From b0942685253c0c97927fff68fb0de1d2446baed8 Mon Sep 17 00:00:00 2001 From: TaherTadpatri Date: Sat, 8 Aug 2026 23:15:00 +0530 Subject: [PATCH 001/105] Added custom _filter_by_metadata for each memory backend --- semantica/vector_store/faiss_store.py | 33 +++++ semantica/vector_store/milvus_store.py | 55 ++++++++ semantica/vector_store/pgvector_store.py | 88 ++++++++++++ semantica/vector_store/pinecone_store.py | 59 ++++++++ semantica/vector_store/qdrant_store.py | 62 +++++++++ semantica/vector_store/sqlite_vec_store.py | 77 ++++++++++ semantica/vector_store/vector_store.py | 103 +++++++------- semantica/vector_store/weaviate_store.py | 41 ++++++ .../test_backend_metadata_filtering.py | 131 ++++++++++++++++++ tests/vector_store/test_vector_store.py | 63 +++++++++ 10 files changed, 665 insertions(+), 47 deletions(-) create mode 100644 tests/vector_store/test_backend_metadata_filtering.py diff --git a/semantica/vector_store/faiss_store.py b/semantica/vector_store/faiss_store.py index 6ca1b83a..3e3bec4d 100644 --- a/semantica/vector_store/faiss_store.py +++ b/semantica/vector_store/faiss_store.py @@ -469,6 +469,39 @@ class FAISSStore: return self.index.get_metadata(vector_id) return None + def filter_by_metadata( + self, filters: Dict[str, Any], limit: int = 10 + ) -> List[Dict[str, Any]]: + """ + Filter stored vectors by metadata. + + Args: + filters: Metadata filter criteria + limit: Maximum number of results + + Returns: + List of matching result dicts with 'id', 'metadata', and 'vector' + """ + if self.index is None or not hasattr(self.index, "metadata"): + return [] + + from .vector_store import _matches_filter + + results = [] + for vector_id, metadata in self.index.metadata.items(): + if _matches_filter(metadata, filters): + results.append( + { + "id": vector_id, + "metadata": metadata, + "vector": self.get_vector(vector_id), + } + ) + if len(results) >= limit: + break + + return results + def get_stats(self) -> Dict[str, Any]: """Get index statistics.""" if self.index is None: diff --git a/semantica/vector_store/milvus_store.py b/semantica/vector_store/milvus_store.py index f188df3f..5271106a 100644 --- a/semantica/vector_store/milvus_store.py +++ b/semantica/vector_store/milvus_store.py @@ -576,6 +576,61 @@ class MilvusStore: except Exception: return None + def filter_by_metadata( + self, filters: Dict[str, Any], limit: int = 10 + ) -> List[Dict[str, Any]]: + """ + Filter vectors by metadata using Milvus expression filtering. + + Args: + filters: Metadata filter criteria + limit: Maximum number of results + + Returns: + List of matching result dicts with 'id', 'metadata', and 'vector' + """ + if self.collection is None or not MILVUS_AVAILABLE: + return [] + + expr_parts = [] + if filters: + for key, value in filters.items(): + if isinstance(value, dict): + if "min" in value and value["min"] is not None: + expr_parts.append(f'metadata["{key}"] >= {value["min"]}') + if "max" in value and value["max"] is not None: + expr_parts.append(f'metadata["{key}"] <= {value["max"]}') + elif isinstance(value, list): + formatted_vals = [f'"{v}"' if isinstance(v, str) else str(v) for v in value] + expr_parts.append(f'metadata["{key}"] in [{", ".join(formatted_vals)}]') + elif isinstance(value, str): + expr_parts.append(f'metadata["{key}"] == "{value}"') + else: + expr_parts.append(f'metadata["{key}"] == {value}') + + expr = " and ".join(expr_parts) if expr_parts else "id != ''" + + try: + query_results = self.collection.collection.query( + expr=expr, + limit=limit, + output_fields=["id", "vector", "metadata"], + ) + results = [] + for item in query_results: + vec = item.get("vector") + results.append( + { + "id": str(item.get("id")), + "metadata": item.get("metadata") or {}, + "vector": np.array(vec) if vec is not None else None, + } + ) + return results + except Exception as e: + self.logger.warning(f"Failed to query Milvus vectors by metadata expression: {e}") + return [] + def get_stats(self, collection_name: Optional[str] = None) -> Dict[str, Any]: """Get collection statistics.""" if self.collection is None and collection_name: diff --git a/semantica/vector_store/pgvector_store.py b/semantica/vector_store/pgvector_store.py index 89a57145..cfc112fd 100644 --- a/semantica/vector_store/pgvector_store.py +++ b/semantica/vector_store/pgvector_store.py @@ -63,6 +63,7 @@ except (ImportError, OSError): except (ImportError, OSError): PSYCOPG2_AVAILABLE = False psycopg2 = None + psycopg_sql = None # Optional pgvector import try: @@ -653,6 +654,93 @@ class PgVectorStore: self.logger.warning(f"Failed to get metadata for {vector_id}: {e}") return None + def filter_by_metadata( + self, filters: Dict[str, Any], limit: int = 10 + ) -> List[Dict[str, Any]]: + """ + Filter stored vectors by metadata using PostgreSQL JSONB queries. + + Args: + filters: Dictionary of metadata filter conditions + limit: Maximum number of results + + Returns: + List of results containing id, metadata, and vector + """ + if not PSYCOPG3_AVAILABLE and not PSYCOPG2_AVAILABLE: + raise ProcessingError( + "Neither psycopg3 nor psycopg2 is available. " + "Install with: pip install psycopg[binary] or psycopg2-binary" + ) + + filter_conditions = [] + filter_values = [] + + if filters: + for key, value in filters.items(): + if not self._is_safe_identifier(key): + raise ValidationError( + f"Invalid filter key: {key!r}. " + "Keys must be alphanumeric with underscores/hyphens only." + ) + if isinstance(value, dict): + if "min" in value and value["min"] is not None: + filter_conditions.append(psycopg_sql.SQL("(metadata->>{})::numeric >= %s").format( + psycopg_sql.Literal(key) + )) + filter_values.append(value["min"]) + if "max" in value and value["max"] is not None: + filter_conditions.append(psycopg_sql.SQL("(metadata->>{})::numeric <= %s").format( + psycopg_sql.Literal(key) + )) + filter_values.append(value["max"]) + elif isinstance(value, list): + filter_conditions.append(psycopg_sql.SQL("metadata->>{} = ANY(%s)").format( + psycopg_sql.Literal(key) + )) + filter_values.append([str(v) for v in value]) + else: + filter_conditions.append(psycopg_sql.SQL("metadata->>{} = %s").format( + psycopg_sql.Literal(key) + )) + filter_values.append(str(value)) + + if filter_conditions: + where_clause = psycopg_sql.SQL(" WHERE ") + psycopg_sql.SQL(" AND ").join(filter_conditions) + else: + where_clause = psycopg_sql.SQL("") + + query_sql = psycopg_sql.SQL(""" + SELECT id, vector, metadata + FROM {table} + {where} + LIMIT %s + """).format( + table=psycopg_sql.Identifier(self.table_name), + where=where_clause + ) + params = filter_values + [limit] + + with self._get_connection() as conn: + try: + cur = conn.cursor() + cur.execute(query_sql, params) + rows = cur.fetchall() + cur.close() + + results = [] + for row in rows: + vec_id, vector_data, meta = row + vec = np.array(vector_data) if vector_data is not None else None + results.append({ + "id": vec_id, + "metadata": meta if isinstance(meta, dict) else json.loads(meta) if meta else {}, + "vector": vec + }) + return results + except Exception as e: + raise ProcessingError(f"Failed to filter vectors by metadata: {str(e)}") from e + def create_index( self, index_type: str = "hnsw", diff --git a/semantica/vector_store/pinecone_store.py b/semantica/vector_store/pinecone_store.py index cc395ee7..18ee7894 100644 --- a/semantica/vector_store/pinecone_store.py +++ b/semantica/vector_store/pinecone_store.py @@ -630,6 +630,65 @@ class PineconeStore: self.logger.warning(f"Failed to get metadata for {vector_id}: {e}") return None + def filter_by_metadata( + self, filters: Dict[str, Any], limit: int = 10, namespace: str = "" + ) -> List[Dict[str, Any]]: + """ + Filter vectors by metadata using Pinecone metadata filters. + + Args: + filters: Metadata filter criteria + limit: Maximum number of results + namespace: Namespace to search in + + Returns: + List of matching result dicts with 'id', 'metadata', and 'vector' + """ + if self.index is None or not PINECONE_AVAILABLE: + return [] + + pinecone_filter = {} + if filters: + for key, value in filters.items(): + if isinstance(value, dict): + cond = {} + if "min" in value and value["min"] is not None: + cond["$gte"] = value["min"] + if "max" in value and value["max"] is not None: + cond["$lte"] = value["max"] + if cond: + pinecone_filter[key] = cond + elif isinstance(value, list): + pinecone_filter[key] = {"$in": value} + else: + pinecone_filter[key] = value + + dimension = getattr(self, "dimension", 768) + dummy_vector = [0.0] * dimension + + try: + response = self.index.index.query( + vector=dummy_vector, + top_k=limit, + filter=pinecone_filter if pinecone_filter else None, + namespace=namespace, + include_metadata=True, + include_values=True, + ) + results = [] + for match in response.matches: + results.append( + { + "id": match.id, + "metadata": match.metadata or {}, + "vector": np.array(match.values) if match.values else None, + } + ) + return results + except Exception as e: + self.logger.warning(f"Failed to filter Pinecone vectors by metadata: {e}") + return [] + def fetch_vectors( self, vector_ids: List[str], namespace: str = "", **options ) -> Dict[str, Any]: diff --git a/semantica/vector_store/qdrant_store.py b/semantica/vector_store/qdrant_store.py index 9cbb7950..318aa973 100644 --- a/semantica/vector_store/qdrant_store.py +++ b/semantica/vector_store/qdrant_store.py @@ -529,6 +529,68 @@ class QdrantStore: self.logger.warning(f"Failed to get metadata for {vector_id}: {e}") return None + def filter_by_metadata( + self, filters: Dict[str, Any], limit: int = 10 + ) -> List[Dict[str, Any]]: + """ + Filter vectors by metadata using Qdrant payload filtering. + + Args: + filters: Metadata filter criteria + limit: Maximum number of results + + Returns: + List of matching result dicts with 'id', 'metadata', and 'vector' + """ + if self.collection is None or self.client is None or not QDRANT_AVAILABLE: + return [] + + conditions = [] + if filters: + for key, value in filters.items(): + if isinstance(value, dict): + cond_kwargs = {} + if "min" in value and value["min"] is not None: + cond_kwargs["gte"] = value["min"] + if "max" in value and value["max"] is not None: + cond_kwargs["lte"] = value["max"] + if cond_kwargs: + conditions.append( + FieldCondition(key=key, range=Range(**cond_kwargs)) + ) + elif isinstance(value, list): + conditions.append( + FieldCondition(key=key, match=MatchAny(any=value)) + ) + else: + conditions.append( + FieldCondition(key=key, match=MatchValue(value=value)) + ) + + query_filter = Filter(must=conditions) if conditions else None + + try: + records, _ = self.client.scroll( + collection_name=self.collection.collection_name, + scroll_filter=query_filter, + limit=limit, + with_payload=True, + with_vectors=True, + ) + results = [] + for rec in records: + results.append( + { + "id": str(rec.id), + "metadata": rec.payload or {}, + "vector": np.array(rec.vector) if rec.vector is not None else None, + } + ) + return results + except Exception as e: + self.logger.warning(f"Failed to scroll Qdrant points by metadata filter: {e}") + return [] + def delete_vectors( self, point_ids: List[Union[str, int]], **options ) -> Dict[str, Any]: diff --git a/semantica/vector_store/sqlite_vec_store.py b/semantica/vector_store/sqlite_vec_store.py index 776eb4a2..fc6dc0f0 100644 --- a/semantica/vector_store/sqlite_vec_store.py +++ b/semantica/vector_store/sqlite_vec_store.py @@ -614,6 +614,83 @@ class SQLiteVecStore: self.logger.warning(f"Failed to get metadata for {vector_id}: {e}") return None + def filter_by_metadata( + self, filters: Dict[str, Any], limit: int = 10 + ) -> List[Dict[str, Any]]: + """ + Filter stored vectors by metadata using SQLite JSON functions. + + Args: + filters: Dictionary of metadata filter conditions + limit: Maximum number of results + + Returns: + List of results containing id, metadata, and vector + """ + filter_conditions = [] + filter_params = [] + + if filters: + for key, value in filters.items(): + if not self._is_safe_identifier(key): + raise ValidationError( + f"Invalid filter key: {key!r}. " + "Keys must start with a letter or underscore and contain " + "only alphanumeric characters and underscores." + ) + if isinstance(value, dict): + if "min" in value and value["min"] is not None: + filter_conditions.append(f"CAST(json_extract(metadata, '$.{key}') AS NUMERIC) >= ?") + filter_params.append(value["min"]) + if "max" in value and value["max"] is not None: + filter_conditions.append(f"CAST(json_extract(metadata, '$.{key}') AS NUMERIC) <= ?") + filter_params.append(value["max"]) + elif isinstance(value, list): + placeholders = ", ".join(["?"] * len(value)) + filter_conditions.append(f"json_extract(metadata, '$.{key}') IN ({placeholders})") + filter_params.extend([str(v) if not isinstance(v, (int, float, bool)) else v for v in value]) + else: + filter_conditions.append(f"json_extract(metadata, '$.{key}') = ?") + if isinstance(value, bool): + filter_params.append(1 if value else 0) + else: + filter_params.append(value) + + where_clause = "" + if filter_conditions: + where_clause = " WHERE " + " AND ".join(filter_conditions) + + query_sql = f""" + SELECT id, embedding, metadata + FROM {self.table_name} + {where_clause} + LIMIT ? + """ + params = filter_params + [limit] + + with self._lock, self._get_connection() as conn: + try: + cur = conn.cursor() + cur.execute(query_sql, params) + rows = cur.fetchall() + cur.close() + + results = [] + for row in rows: + vec_id, embedding_blob, meta_json = row + vec = None + if embedding_blob: + vec = np.frombuffer(embedding_blob, dtype=np.float32).copy() + + results.append({ + "id": vec_id, + "metadata": json.loads(meta_json) if meta_json else {}, + "vector": vec + }) + return results + except Exception as e: + raise ProcessingError(f"Failed to filter by metadata: {str(e)}") from e + def create_index( self, index_type: str = "hnsw", diff --git a/semantica/vector_store/vector_store.py b/semantica/vector_store/vector_store.py index d86e48cb..239f958f 100644 --- a/semantica/vector_store/vector_store.py +++ b/semantica/vector_store/vector_store.py @@ -1119,60 +1119,69 @@ class VectorStore: from datetime import datetime, timedelta cutoff = datetime.now() - timedelta(days=7) filters["timestamp"] = {"min": cutoff.isoformat()} - return filters def _filter_by_metadata(self, filters: Dict[str, Any], limit: int) -> List[Dict[str, Any]]: """Filter decisions by metadata only.""" - results = [] - - for vector_id, metadata in self.metadata.items(): - match = True + if self.backend == "inmemory": + results = [] - for key, value in filters.items(): - if key not in metadata: - match = False - break - - if isinstance(value, dict): - # Handle range filters - metadata_value = metadata[key] - if "min" in value and metadata_value < value["min"]: - match = False - break - if "max" in value and metadata_value > value["max"]: - match = False - break - elif isinstance(value, list): - # Handle list membership - metadata_value = metadata[key] - if isinstance(metadata_value, list): - # Both are lists - check for intersection - if not set(metadata_value) & set(value): - match = False - break - else: - # Metadata value is scalar, check if it's in the filter list - if metadata_value not in value: - match = False - break - else: - # Handle exact match - if metadata[key] != value: - match = False + for vector_id, metadata in self.metadata.items(): + if _matches_filter(metadata, filters): + results.append({ + "id": vector_id, + "metadata": metadata, + "vector": self.vectors.get(vector_id) + }) + + if len(results) >= limit: break - if match: - results.append({ - "id": vector_id, - "metadata": metadata, - "vector": self.vectors.get(vector_id) - }) - - if len(results) >= limit: - break - - return results + return results + elif self._backend_store is not None and hasattr(self._backend_store, "filter_by_metadata"): + return self._backend_store.filter_by_metadata(filters=filters, limit=limit) + else: + raise NotImplementedError( + f"Backend store {type(self._backend_store).__name__ if self._backend_store else self.backend} does not implement filter_by_metadata" + ) + + +def _matches_filter(metadata: Dict[str, Any], filters: Dict[str, Any]) -> bool: + """Check if metadata dictionary matches filter criteria.""" + if not filters: + return True + if metadata is None: + return False + + for key, value in filters.items(): + if key not in metadata: + return False + + metadata_value = metadata[key] + + if isinstance(value, dict): + # Handle range filters + if "min" in value and value["min"] is not None: + if metadata_value is None or metadata_value < value["min"]: + return False + if "max" in value and value["max"] is not None: + if metadata_value is None or metadata_value > value["max"]: + return False + elif isinstance(value, list): + # Handle list membership + if isinstance(metadata_value, list): + if not (set(metadata_value) & set(value)): + return False + else: + if metadata_value not in value: + return False + else: + # Handle exact match + if metadata_value != value: + return False + + return True + class VectorIndexer: diff --git a/semantica/vector_store/weaviate_store.py b/semantica/vector_store/weaviate_store.py index 76605d6f..6ff480f3 100644 --- a/semantica/vector_store/weaviate_store.py +++ b/semantica/vector_store/weaviate_store.py @@ -447,6 +447,47 @@ class WeaviateStore: self.logger.warning(f"Failed to get metadata for {vector_id}: {e}") return None + def filter_by_metadata( + self, filters: Dict[str, Any], limit: int = 10 + ) -> List[Dict[str, Any]]: + """ + Filter stored objects by metadata in Weaviate. + + Args: + filters: Metadata filter criteria + limit: Maximum number of results + + Returns: + List of matching result dicts with 'id', 'metadata', and 'vector' + """ + if self.collection is None or not WEAVIATE_AVAILABLE: + return [] + + from .vector_store import _matches_filter + + try: + objs = self.collection.query.fetch_objects( + limit=limit, + include_vector=True + ) + results = [] + for obj in objs.objects: + properties = obj.properties or {} + if _matches_filter(properties, filters): + results.append( + { + "id": str(obj.uuid), + "metadata": properties, + "vector": np.array(obj.vector) if obj.vector else None, + } + ) + if len(results) >= limit: + break + return results + except Exception as e: + self.logger.warning(f"Failed to fetch Weaviate objects by metadata filter: {e}") + return [] + def query_vectors( self, query_vector: np.ndarray, diff --git a/tests/vector_store/test_backend_metadata_filtering.py b/tests/vector_store/test_backend_metadata_filtering.py new file mode 100644 index 00000000..fc5b1183 --- /dev/null +++ b/tests/vector_store/test_backend_metadata_filtering.py @@ -0,0 +1,131 @@ +import unittest +from unittest.mock import MagicMock, patch +import numpy as np + +from semantica.vector_store.faiss_store import FAISSStore +from semantica.vector_store.qdrant_store import QdrantStore +from semantica.vector_store.pinecone_store import PineconeStore +from semantica.vector_store.milvus_store import MilvusStore +from semantica.vector_store.pgvector_store import PgVectorStore +from semantica.vector_store.weaviate_store import WeaviateStore + + +class TestBackendMetadataFiltering(unittest.TestCase): + + def test_faiss_store_filter_by_metadata(self): + store = FAISSStore(dimension=2) + mock_index = MagicMock() + mock_index.metadata = { + "v1": {"category": "finance", "score": 10}, + "v2": {"category": "tech", "score": 20}, + } + mock_index.get_vector.side_effect = lambda vid: np.array([1.0, 0.0]) if vid == "v1" else np.array([0.0, 1.0]) + store.index = mock_index + + results = store.filter_by_metadata({"category": "finance"}, limit=10) + self.assertEqual(len(results), 1) + self.assertEqual(results[0]["id"], "v1") + self.assertEqual(results[0]["metadata"], {"category": "finance", "score": 10}) + + @patch('semantica.vector_store.qdrant_store.FieldCondition', MagicMock()) + @patch('semantica.vector_store.qdrant_store.MatchValue', MagicMock()) + @patch('semantica.vector_store.qdrant_store.Filter', MagicMock()) + @patch('semantica.vector_store.qdrant_store.QDRANT_AVAILABLE', True) + def test_qdrant_store_filter_by_metadata(self): + store = QdrantStore() + mock_collection = MagicMock() + mock_collection.collection_name = "test_coll" + store.collection = mock_collection + mock_client = MagicMock() + rec = MagicMock() + rec.id = "q1" + rec.payload = {"env": "prod"} + rec.vector = [0.1, 0.2] + mock_client.scroll.return_value = ([rec], None) + store.client = mock_client + + results = store.filter_by_metadata({"env": "prod"}, limit=5) + self.assertEqual(len(results), 1) + self.assertEqual(results[0]["id"], "q1") + self.assertEqual(results[0]["metadata"], {"env": "prod"}) + mock_client.scroll.assert_called_once() + + @patch('semantica.vector_store.pinecone_store.PINECONE_AVAILABLE', True) + def test_pinecone_store_filter_by_metadata(self): + store = PineconeStore() + mock_index_wrapper = MagicMock() + mock_inner_index = MagicMock() + + match_obj = MagicMock() + match_obj.id = "p1" + match_obj.metadata = {"status": "active"} + match_obj.values = [0.1, 0.9] + + response = MagicMock() + response.matches = [match_obj] + mock_inner_index.query.return_value = response + mock_index_wrapper.index = mock_inner_index + store.index = mock_index_wrapper + + results = store.filter_by_metadata({"status": "active"}, limit=5) + self.assertEqual(len(results), 1) + self.assertEqual(results[0]["id"], "p1") + self.assertEqual(results[0]["metadata"], {"status": "active"}) + + @patch('semantica.vector_store.milvus_store.MILVUS_AVAILABLE', True) + def test_milvus_store_filter_by_metadata(self): + store = MilvusStore() + mock_coll_wrapper = MagicMock() + mock_inner_coll = MagicMock() + mock_inner_coll.query.return_value = [ + {"id": "m1", "vector": [0.3, 0.4], "metadata": {"lang": "py"}} + ] + mock_coll_wrapper.collection = mock_inner_coll + store.collection = mock_coll_wrapper + + results = store.filter_by_metadata({"lang": "py"}, limit=5) + self.assertEqual(len(results), 1) + self.assertEqual(results[0]["id"], "m1") + self.assertEqual(results[0]["metadata"], {"lang": "py"}) + + @patch('semantica.vector_store.pgvector_store.PSYCOPG3_AVAILABLE', True) + @patch('semantica.vector_store.pgvector_store.psycopg_sql') + def test_pgvector_store_filter_by_metadata(self, mock_sql): + store = object.__new__(PgVectorStore) + store.table_name = "test_vectors" + store._is_safe_identifier = lambda k: True + + mock_conn = MagicMock() + mock_cur = MagicMock() + mock_cur.fetchall.return_value = [ + ("pg1", [0.1, 0.2], {"org": "acme"}) + ] + mock_conn.cursor.return_value = mock_cur + + with patch.object(PgVectorStore, '_get_connection', return_value=MagicMock(__enter__=MagicMock(return_value=mock_conn), __exit__=MagicMock())): + results = store.filter_by_metadata({"org": "acme"}, limit=10) + self.assertEqual(len(results), 1) + self.assertEqual(results[0]["id"], "pg1") + self.assertEqual(results[0]["metadata"], {"org": "acme"}) + + def test_weaviate_store_filter_by_metadata(self): + store = WeaviateStore() + mock_coll = MagicMock() + obj1 = MagicMock() + obj1.uuid = "w-uuid-1" + obj1.properties = {"dept": "eng"} + obj1.vector = [0.5, 0.5] + objs = MagicMock() + objs.objects = [obj1] + mock_coll.query.fetch_objects.return_value = objs + store.collection = mock_coll + + with patch('semantica.vector_store.weaviate_store.WEAVIATE_AVAILABLE', True): + results = store.filter_by_metadata({"dept": "eng"}, limit=5) + self.assertEqual(len(results), 1) + self.assertEqual(results[0]["id"], "w-uuid-1") + self.assertEqual(results[0]["metadata"], {"dept": "eng"}) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/vector_store/test_vector_store.py b/tests/vector_store/test_vector_store.py index 5badcf24..05ca0239 100644 --- a/tests/vector_store/test_vector_store.py +++ b/tests/vector_store/test_vector_store.py @@ -134,5 +134,68 @@ class TestVectorStore(unittest.TestCase): self.assertTrue(mock_backend.called) + def test_filter_by_metadata_inmemory(self): + """Test _filter_by_metadata on inmemory backend.""" + store = VectorStore(backend="inmemory") + store.metadata = { + "v1": {"category": "finance", "amount": 100, "tags": ["a", "b"]}, + "v2": {"category": "finance", "amount": 500, "tags": ["b", "c"]}, + "v3": {"category": "tech", "amount": 200, "tags": ["c"]}, + } + store.vectors = { + "v1": np.array([0.1]), + "v2": np.array([0.2]), + "v3": np.array([0.3]), + } + + # Exact filter + results = store._filter_by_metadata({"category": "finance"}, limit=10) + self.assertEqual(len(results), 2) + res_ids = {r["id"] for r in results} + self.assertEqual(res_ids, {"v1", "v2"}) + + # Range filter + results = store._filter_by_metadata({"amount": {"min": 150}}, limit=10) + self.assertEqual(len(results), 2) + res_ids = {r["id"] for r in results} + self.assertEqual(res_ids, {"v2", "v3"}) + + # List intersection filter + results = store._filter_by_metadata({"tags": ["a"]}, limit=10) + self.assertEqual(len(results), 1) + self.assertEqual(results[0]["id"], "v1") + + def test_filter_by_metadata_persistent_backend_delegation(self): + """Test that persistent backend delegates filter_by_metadata without AttributeError.""" + store = VectorStore(backend="inmemory") + # Simulate persistent backend by deleting self.metadata attribute if any + if hasattr(store, "metadata"): + delattr(store, "metadata") + + mock_backend = MagicMock() + mock_backend.filter_by_metadata.return_value = [ + {"id": "p1", "metadata": {"category": "test"}, "vector": np.array([0.5])} + ] + store._backend_store = mock_backend + store.backend = "faiss" + + # Should NOT raise AttributeError: 'VectorStore' object has no attribute 'metadata' + results = store._filter_by_metadata({"category": "test"}, limit=5) + self.assertEqual(len(results), 1) + self.assertEqual(results[0]["id"], "p1") + mock_backend.filter_by_metadata.assert_called_once_with(filters={"category": "test"}, limit=5) + + def test_filter_by_metadata_backend_not_implemented(self): + """Test that missing filter_by_metadata method raises NotImplementedError.""" + store = VectorStore(backend="inmemory") + if hasattr(store, "metadata"): + delattr(store, "metadata") + + store.backend = "unknown" + store._backend_store = object() + + with self.assertRaises(NotImplementedError): + store._filter_by_metadata({"key": "val"}, limit=10) + if __name__ == '__main__': unittest.main() From b6497ace41930e7056be37706c6c164b96db3bbb Mon Sep 17 00:00:00 2001 From: TaherTadpatri Date: Sun, 9 Aug 2026 14:50:59 +0530 Subject: [PATCH 002/105] fixed/weavit_store,pinecone_store,milvus_store --- semantica/vector_store/milvus_store.py | 65 +++++-- semantica/vector_store/pinecone_store.py | 36 +++- semantica/vector_store/weaviate_store.py | 162 ++++++++++++++++-- .../test_backend_metadata_filtering.py | 128 +++++++++++++- tests/vector_store/test_pinecone_store.py | 4 + 5 files changed, 360 insertions(+), 35 deletions(-) diff --git a/semantica/vector_store/milvus_store.py b/semantica/vector_store/milvus_store.py index 5271106a..9ac98faf 100644 --- a/semantica/vector_store/milvus_store.py +++ b/semantica/vector_store/milvus_store.py @@ -35,6 +35,7 @@ Author: Semantica Contributors License: MIT """ +import re from typing import Any, Dict, List, Optional, Union import numpy as np @@ -43,6 +44,40 @@ from ..utils.exceptions import ProcessingError, ValidationError from ..utils.logging import get_logger from ..utils.progress_tracker import get_progress_tracker + +def _validate_milvus_key(key: str) -> str: + """Validate and escape a metadata filter key for Milvus queries.""" + if not key or not isinstance(key, str) or not re.match(r"^[a-zA-Z0-9_.-]+$", key): + raise ValidationError(f"Invalid metadata filter key: '{key}'") + return key.replace("\\", "\\\\").replace('"', '\\"') + + +def _format_milvus_value(val: Any) -> str: + """Format and escape a filter value for Milvus expression syntax.""" + if isinstance(val, bool): + return "true" if val else "false" + elif isinstance(val, (int, float)): + return str(val) + elif isinstance(val, str): + escaped = ( + val.replace("\\", "\\\\") + .replace('"', '\\"') + .replace("\n", "\\n") + .replace("\r", "\\r") + ) + return f'"{escaped}"' + elif val is None: + return "null" + else: + escaped = ( + str(val) + .replace("\\", "\\\\") + .replace('"', '\\"') + .replace("\n", "\\n") + .replace("\r", "\\r") + ) + return f'"{escaped}"' + # Optional Milvus import try: from pymilvus import ( @@ -546,11 +581,11 @@ class MilvusStore: """Get vector by ID.""" if not MILVUS_AVAILABLE or not self.collection: return None - + try: - safe_id = vector_id.replace('"', '\\"') + safe_id = vector_id.replace("\\", "\\\\").replace('"', '\\"') res = self.collection.collection.query( - expr=f'id == "{safe_id}"', + expr=f'id == "{safe_id}"', output_fields=["vector"] ) if res and len(res) > 0: @@ -563,11 +598,11 @@ class MilvusStore: """Get metadata by ID.""" if not MILVUS_AVAILABLE or not self.collection: return None - + try: - safe_id = vector_id.replace('"', '\\"') + safe_id = vector_id.replace("\\", "\\\\").replace('"', '\\"') res = self.collection.collection.query( - expr=f'id == "{safe_id}"', + expr=f'id == "{safe_id}"', output_fields=["metadata"] ) if res and len(res) > 0: @@ -595,18 +630,22 @@ class MilvusStore: expr_parts = [] if filters: for key, value in filters.items(): + safe_key = _validate_milvus_key(key) if isinstance(value, dict): if "min" in value and value["min"] is not None: - expr_parts.append(f'metadata["{key}"] >= {value["min"]}') + min_val = _format_milvus_value(value["min"]) + expr_parts.append(f'metadata["{safe_key}"] >= {min_val}') if "max" in value and value["max"] is not None: - expr_parts.append(f'metadata["{key}"] <= {value["max"]}') + max_val = _format_milvus_value(value["max"]) + expr_parts.append(f'metadata["{safe_key}"] <= {max_val}') elif isinstance(value, list): - formatted_vals = [f'"{v}"' if isinstance(v, str) else str(v) for v in value] - expr_parts.append(f'metadata["{key}"] in [{", ".join(formatted_vals)}]') - elif isinstance(value, str): - expr_parts.append(f'metadata["{key}"] == "{value}"') + formatted_vals = [_format_milvus_value(v) for v in value] + expr_parts.append( + f'metadata["{safe_key}"] in [{", ".join(formatted_vals)}]' + ) else: - expr_parts.append(f'metadata["{key}"] == {value}') + formatted_val = _format_milvus_value(value) + expr_parts.append(f'metadata["{safe_key}"] == {formatted_val}') expr = " and ".join(expr_parts) if expr_parts else "id != ''" diff --git a/semantica/vector_store/pinecone_store.py b/semantica/vector_store/pinecone_store.py index 18ee7894..13c04d53 100644 --- a/semantica/vector_store/pinecone_store.py +++ b/semantica/vector_store/pinecone_store.py @@ -75,7 +75,7 @@ class PineconeClient: try: # Default to serverless spec if not provided - if spec is None: + if spec is None and ServerlessSpec is not None: spec = ServerlessSpec(cloud="aws", region="us-east-1") # Map metric names @@ -316,6 +316,7 @@ class PineconeStore: self.api_key = api_key or config.get("api_key") self.environment = environment or config.get("environment") + self.dimension: Optional[int] = config.get("dimension") self.client: Optional[PineconeClient] = None self.index: Optional[PineconeIndex] = None @@ -384,7 +385,7 @@ class PineconeStore: try: # Create index spec if not provided - if spec is None: + if spec is None and ServerlessSpec is not None: spec = ServerlessSpec(cloud="aws", region="us-east-1") self.client.create_index(index_name, dimension, metric, spec, **kwargs) @@ -393,6 +394,7 @@ class PineconeStore: pinecone_index = self.client.get_index(index_name) self.index = PineconeIndex(pinecone_index) self.search_engine = PineconeSearch(self.index) + self.dimension = dimension self.logger.info(f"Created Pinecone index: {index_name}") return self.index @@ -420,6 +422,13 @@ class PineconeStore: pinecone_index = self.client.get_index(index_name) self.index = PineconeIndex(pinecone_index) self.search_engine = PineconeSearch(self.index) + if self.dimension is None: + try: + stats = self.describe_index_stats() + if stats and isinstance(stats, dict) and stats.get("dimension"): + self.dimension = int(stats["dimension"]) + except Exception as e: + self.logger.warning(f"Could not determine index dimension for '{index_name}': {e}") return self.index except Exception as e: raise ProcessingError(f"Failed to get index: {str(e)}") @@ -505,6 +514,9 @@ class PineconeStore: else: vector_list.append(list(vector)) + if self.dimension is None and vector_list: + self.dimension = len(vector_list[0]) + self.progress_tracker.update_tracking( tracking_id, message="Upserting vectors to index..." ) @@ -571,6 +583,9 @@ class PineconeStore: else: query_vector = list(query_vector) + if self.dimension is None and query_vector: + self.dimension = len(query_vector) + results = self.search_engine.similarity_search( np.array(query_vector), k, filter, namespace, **options ) @@ -647,6 +662,22 @@ class PineconeStore: if self.index is None or not PINECONE_AVAILABLE: return [] + dimension = self.dimension + if dimension is None: + try: + stats = self.describe_index_stats() + if stats and isinstance(stats, dict) and stats.get("dimension"): + dimension = int(stats["dimension"]) + self.dimension = dimension + except Exception: + pass + + if not dimension: + raise ProcessingError( + "Index dimension is unknown. Please specify 'dimension' when initializing PineconeStore " + "or call create_index()/get_index() first." + ) + pinecone_filter = {} if filters: for key, value in filters.items(): @@ -663,7 +694,6 @@ class PineconeStore: else: pinecone_filter[key] = value - dimension = getattr(self, "dimension", 768) dummy_vector = [0.0] * dimension try: diff --git a/semantica/vector_store/weaviate_store.py b/semantica/vector_store/weaviate_store.py index 6ff480f3..b5551221 100644 --- a/semantica/vector_store/weaviate_store.py +++ b/semantica/vector_store/weaviate_store.py @@ -447,6 +447,49 @@ class WeaviateStore: self.logger.warning(f"Failed to get metadata for {vector_id}: {e}") return None + def _build_weaviate_filter(self, filters: Dict[str, Any]) -> Any: + """Build native Weaviate Filter object from metadata filter dictionary.""" + if not filters or not WEAVIATE_AVAILABLE: + return None + + Filter = None + try: + from weaviate.classes.query import Filter + except (ImportError, AttributeError): + try: + if weaviate and hasattr(weaviate, "classes") and hasattr(weaviate.classes, "query"): + Filter = getattr(weaviate.classes.query, "Filter", None) + except AttributeError: + Filter = None + + if Filter is None: + return None + + try: + conditions = [] + for key, value in filters.items(): + if isinstance(value, dict): + if "min" in value and value["min"] is not None: + conditions.append(Filter.by_property(key).greater_or_equal(value["min"])) + if "max" in value and value["max"] is not None: + conditions.append(Filter.by_property(key).less_or_equal(value["max"])) + elif isinstance(value, list): + conditions.append(Filter.by_property(key).contains_any(value)) + else: + conditions.append(Filter.by_property(key).equal(value)) + + if not conditions: + return None + + weaviate_filter = conditions[0] + for cond in conditions[1:]: + weaviate_filter = weaviate_filter & cond + + return weaviate_filter + except Exception as e: + self.logger.debug(f"Could not build native Weaviate filter: {e}") + return None + def filter_by_metadata( self, filters: Dict[str, Any], limit: int = 10 ) -> List[Dict[str, Any]]: @@ -465,28 +508,111 @@ class WeaviateStore: from .vector_store import _matches_filter + native_filter = self._build_weaviate_filter(filters) if filters else None + + results = [] + seen_ids = set() + after_cursor = None + scanned_count = 0 + page_size = max(limit, 100) + use_native_filter = native_filter is not None + try: - objs = self.collection.query.fetch_objects( - limit=limit, - include_vector=True - ) - results = [] - for obj in objs.objects: - properties = obj.properties or {} - if _matches_filter(properties, filters): - results.append( - { - "id": str(obj.uuid), - "metadata": properties, - "vector": np.array(obj.vector) if obj.vector else None, - } - ) - if len(results) >= limit: - break + while len(results) < limit: + kwargs = {"limit": page_size, "include_vector": True} + if use_native_filter and native_filter is not None: + kwargs["filters"] = native_filter + if after_cursor is not None: + kwargs["after"] = after_cursor + + try: + objs = self.collection.query.fetch_objects(**kwargs) + except TypeError as te: + # Handle kwargs incompatibility (e.g. mock or client version without filters/after) + if "filters" in kwargs: + use_native_filter = False + kwargs.pop("filters", None) + try: + objs = self.collection.query.fetch_objects(**kwargs) + except TypeError: + if "after" in kwargs: + kwargs.pop("after", None) + kwargs["offset"] = scanned_count + try: + objs = self.collection.query.fetch_objects(**kwargs) + except TypeError: + kwargs.pop("offset", None) + objs = self.collection.query.fetch_objects(**kwargs) + elif "after" in kwargs: + kwargs.pop("after", None) + kwargs["offset"] = scanned_count + try: + objs = self.collection.query.fetch_objects(**kwargs) + except TypeError: + kwargs.pop("offset", None) + objs = self.collection.query.fetch_objects(**kwargs) + else: + raise te + except Exception as fe: + if use_native_filter: + self.logger.warning( + f"Native Weaviate filter query failed, falling back to paginated fetch: {fe}" + ) + use_native_filter = False + kwargs.pop("filters", None) + objs = self.collection.query.fetch_objects(**kwargs) + else: + raise fe + + if not objs or not getattr(objs, "objects", None): + break + + batch_objects = objs.objects + if not batch_objects: + break + + new_objects_found = False + for obj in batch_objects: + obj_id = str(obj.uuid) if hasattr(obj, "uuid") and obj.uuid is not None else None + if obj_id: + if obj_id in seen_ids: + continue + seen_ids.add(obj_id) + new_objects_found = True + + properties = getattr(obj, "properties", None) or {} + if _matches_filter(properties, filters): + vector = None + if hasattr(obj, "vector") and obj.vector: + vector = np.array(obj.vector) + results.append( + { + "id": obj_id, + "metadata": properties, + "vector": vector, + } + ) + if len(results) >= limit: + break + + if not new_objects_found: + break + + scanned_count += len(batch_objects) + if len(batch_objects) < page_size: + break + + last_obj = batch_objects[-1] + if hasattr(last_obj, "uuid") and last_obj.uuid is not None: + after_cursor = str(last_obj.uuid) + else: + break + return results except Exception as e: self.logger.warning(f"Failed to fetch Weaviate objects by metadata filter: {e}") - return [] + return results if results else [] + def query_vectors( self, diff --git a/tests/vector_store/test_backend_metadata_filtering.py b/tests/vector_store/test_backend_metadata_filtering.py index fc5b1183..e86a5763 100644 --- a/tests/vector_store/test_backend_metadata_filtering.py +++ b/tests/vector_store/test_backend_metadata_filtering.py @@ -8,6 +8,7 @@ from semantica.vector_store.pinecone_store import PineconeStore from semantica.vector_store.milvus_store import MilvusStore from semantica.vector_store.pgvector_store import PgVectorStore from semantica.vector_store.weaviate_store import WeaviateStore +from semantica.utils.exceptions import ProcessingError, ValidationError class TestBackendMetadataFiltering(unittest.TestCase): @@ -52,7 +53,7 @@ class TestBackendMetadataFiltering(unittest.TestCase): @patch('semantica.vector_store.pinecone_store.PINECONE_AVAILABLE', True) def test_pinecone_store_filter_by_metadata(self): - store = PineconeStore() + store = PineconeStore(dimension=2) mock_index_wrapper = MagicMock() mock_inner_index = MagicMock() @@ -71,6 +72,19 @@ class TestBackendMetadataFiltering(unittest.TestCase): self.assertEqual(len(results), 1) self.assertEqual(results[0]["id"], "p1") self.assertEqual(results[0]["metadata"], {"status": "active"}) + # Assert query vector dimension matches store.dimension (2) + mock_inner_index.query.assert_called_once() + query_kw = mock_inner_index.query.call_args[1] + self.assertEqual(len(query_kw["vector"]), 2) + + @patch('semantica.vector_store.pinecone_store.PINECONE_AVAILABLE', True) + def test_pinecone_store_filter_by_metadata_unknown_dimension_raises(self): + store = PineconeStore() + mock_index_wrapper = MagicMock() + store.index = mock_index_wrapper + store.describe_index_stats = MagicMock(return_value={}) + with self.assertRaises(ProcessingError): + store.filter_by_metadata({"status": "active"}, limit=5) @patch('semantica.vector_store.milvus_store.MILVUS_AVAILABLE', True) def test_milvus_store_filter_by_metadata(self): @@ -88,6 +102,39 @@ class TestBackendMetadataFiltering(unittest.TestCase): self.assertEqual(results[0]["id"], "m1") self.assertEqual(results[0]["metadata"], {"lang": "py"}) + @patch('semantica.vector_store.milvus_store.MILVUS_AVAILABLE', True) + def test_milvus_store_filter_by_metadata_escaping(self): + store = MilvusStore() + mock_coll_wrapper = MagicMock() + mock_inner_coll = MagicMock() + mock_inner_coll.query.return_value = [] + mock_coll_wrapper.collection = mock_inner_coll + store.collection = mock_coll_wrapper + + store.filter_by_metadata( + { + "title": 'John "Jack" Doe', + "active": True, + "tags": ['python', 'c++ "v"'], + }, + limit=5, + ) + + mock_inner_coll.query.assert_called_once() + expr = mock_inner_coll.query.call_args[1]["expr"] + self.assertIn('metadata["title"] == "John \\"Jack\\" Doe"', expr) + self.assertIn('metadata["active"] == true', expr) + self.assertIn('metadata["tags"] in ["python", "c++ \\"v\\""]', expr) + + @patch('semantica.vector_store.milvus_store.MILVUS_AVAILABLE', True) + def test_milvus_store_filter_by_metadata_invalid_key_raises(self): + store = MilvusStore() + mock_coll_wrapper = MagicMock() + store.collection = mock_coll_wrapper + + with self.assertRaises(ValidationError): + store.filter_by_metadata({'dept" || 1==1 || "': "val"}, limit=5) + @patch('semantica.vector_store.pgvector_store.PSYCOPG3_AVAILABLE', True) @patch('semantica.vector_store.pgvector_store.psycopg_sql') def test_pgvector_store_filter_by_metadata(self, mock_sql): @@ -126,6 +173,85 @@ class TestBackendMetadataFiltering(unittest.TestCase): self.assertEqual(results[0]["id"], "w-uuid-1") self.assertEqual(results[0]["metadata"], {"dept": "eng"}) + def test_weaviate_store_filter_by_metadata_pagination(self): + """Test that WeaviateStore.filter_by_metadata paginates beyond page 1 to find matching items.""" + store = WeaviateStore() + mock_coll = MagicMock() + + # Batch 1: 100 non-matching objects + batch1_objs = [] + for i in range(100): + obj = MagicMock() + obj.uuid = f"batch1-uuid-{i}" + obj.properties = {"dept": "hr"} + obj.vector = [0.1, 0.1] + batch1_objs.append(obj) + + res1 = MagicMock() + res1.objects = batch1_objs + + # Batch 2: 2 matching objects + obj_match1 = MagicMock() + obj_match1.uuid = "match-uuid-1" + obj_match1.properties = {"dept": "eng"} + obj_match1.vector = [0.5, 0.5] + + obj_match2 = MagicMock() + obj_match2.uuid = "match-uuid-2" + obj_match2.properties = {"dept": "eng"} + obj_match2.vector = [0.6, 0.6] + + res2 = MagicMock() + res2.objects = [obj_match1, obj_match2] + + def side_effect(**kwargs): + if kwargs.get("after") == "batch1-uuid-99": + return res2 + return res1 + + mock_coll.query.fetch_objects.side_effect = side_effect + store.collection = mock_coll + + with patch('semantica.vector_store.weaviate_store.WEAVIATE_AVAILABLE', True): + results = store.filter_by_metadata({"dept": "eng"}, limit=5) + self.assertEqual(len(results), 2) + self.assertEqual(results[0]["id"], "match-uuid-1") + self.assertEqual(results[1]["id"], "match-uuid-2") + + def test_weaviate_store_filter_by_metadata_native_filter(self): + """Test building native Weaviate filters for exact, range, and list criteria.""" + store = WeaviateStore() + mock_filter_cls = MagicMock() + mock_filter_prop = MagicMock() + mock_filter_cls.by_property.return_value = mock_filter_prop + + mock_module = MagicMock() + mock_module.classes.query.Filter = mock_filter_cls + + with patch('semantica.vector_store.weaviate_store.WEAVIATE_AVAILABLE', True), \ + patch('semantica.vector_store.weaviate_store.weaviate', mock_module): + + # Test exact match + res = store._build_weaviate_filter({"dept": "eng"}) + mock_filter_cls.by_property.assert_called_with("dept") + mock_filter_prop.equal.assert_called_with("eng") + + # Test range filter + mock_filter_cls.reset_mock() + mock_filter_prop.reset_mock() + res = store._build_weaviate_filter({"age": {"min": 20, "max": 50}}) + mock_filter_cls.by_property.assert_called_with("age") + mock_filter_prop.greater_or_equal.assert_called_with(20) + mock_filter_prop.less_or_equal.assert_called_with(50) + + # Test list filter + mock_filter_cls.reset_mock() + mock_filter_prop.reset_mock() + res = store._build_weaviate_filter({"tags": ["a", "b"]}) + mock_filter_cls.by_property.assert_called_with("tags") + mock_filter_prop.contains_any.assert_called_with(["a", "b"]) + if __name__ == "__main__": unittest.main() + diff --git a/tests/vector_store/test_pinecone_store.py b/tests/vector_store/test_pinecone_store.py index f1a357b5..29fc0345 100644 --- a/tests/vector_store/test_pinecone_store.py +++ b/tests/vector_store/test_pinecone_store.py @@ -82,6 +82,7 @@ class TestPineconeStore(unittest.TestCase): self.assertIsInstance(store.search_engine, PineconeSearch) store.client.create_index.assert_called_once() + @patch('semantica.vector_store.pinecone_store.PINECONE_AVAILABLE', True) @patch('semantica.vector_store.pinecone_store.PineconeClientLib') def test_upsert_vectors(self, mock_pinecone_client): """Test upserting vectors to Pinecone index.""" @@ -105,6 +106,7 @@ class TestPineconeStore(unittest.TestCase): self.assertEqual(result["upserted_count"], 2) store.index.upsert_vectors.assert_called_once() + @patch('semantica.vector_store.pinecone_store.PINECONE_AVAILABLE', True) @patch('semantica.vector_store.pinecone_store.PineconeClientLib') def test_search_vectors(self, mock_pinecone_client): """Test searching vectors in Pinecone index.""" @@ -128,6 +130,7 @@ class TestPineconeStore(unittest.TestCase): self.assertEqual(results[0]["id"], "id1") store.search_engine.similarity_search.assert_called_once() + @patch('semantica.vector_store.pinecone_store.PINECONE_AVAILABLE', True) @patch('semantica.vector_store.pinecone_store.PineconeClientLib') def test_delete_vectors(self, mock_pinecone_client): """Test deleting vectors from Pinecone index.""" @@ -148,6 +151,7 @@ class TestPineconeStore(unittest.TestCase): # Fix: assert called without the empty dict store.index.delete_vectors.assert_called_once_with(["id1", "id2"], "") + @patch('semantica.vector_store.pinecone_store.PINECONE_AVAILABLE', True) @patch('semantica.vector_store.pinecone_store.PineconeClientLib') def test_fetch_vectors(self, mock_pinecone_client): """Test fetching vectors from Pinecone index.""" From 70109133b5115d703d6c6ab7aaa8d29f0274900b Mon Sep 17 00:00:00 2001 From: Sameer6305 Date: Mon, 10 Aug 2026 17:26:39 +0530 Subject: [PATCH 003/105] fix(vector-store): harden metadata filtering across backends --- semantica/vector_store/pgvector_store.py | 8 + semantica/vector_store/qdrant_store.py | 4 + .../test_backend_metadata_filtering.py | 159 ++++++++++++++++++ 3 files changed, 171 insertions(+) diff --git a/semantica/vector_store/pgvector_store.py b/semantica/vector_store/pgvector_store.py index b171ff48..bfed4512 100644 --- a/semantica/vector_store/pgvector_store.py +++ b/semantica/vector_store/pgvector_store.py @@ -701,6 +701,14 @@ class PgVectorStore: psycopg_sql.Literal(key) )) filter_values.append([str(v) for v in value]) + elif isinstance(value, bool): + # PostgreSQL JSONB ->> returns lowercase 'true'/'false' for JSON booleans. + # str(True)='True' and str(False)='False' would never match; use the + # correct lowercase text that ->> actually produces. + filter_conditions.append(psycopg_sql.SQL("metadata->>{} = %s").format( + psycopg_sql.Literal(key) + )) + filter_values.append('true' if value else 'false') else: filter_conditions.append(psycopg_sql.SQL("metadata->>{} = %s").format( psycopg_sql.Literal(key) diff --git a/semantica/vector_store/qdrant_store.py b/semantica/vector_store/qdrant_store.py index d9624842..1779a0bc 100644 --- a/semantica/vector_store/qdrant_store.py +++ b/semantica/vector_store/qdrant_store.py @@ -49,8 +49,10 @@ try: Distance, FieldCondition, Filter, + MatchAny, MatchValue, PointStruct, + Range, VectorParams, ) @@ -63,7 +65,9 @@ except (ImportError, OSError): PointStruct = None Filter = None FieldCondition = None + MatchAny = None MatchValue = None + Range = None CollectionStatus = None diff --git a/tests/vector_store/test_backend_metadata_filtering.py b/tests/vector_store/test_backend_metadata_filtering.py index e86a5763..5401f0fd 100644 --- a/tests/vector_store/test_backend_metadata_filtering.py +++ b/tests/vector_store/test_backend_metadata_filtering.py @@ -51,6 +51,81 @@ class TestBackendMetadataFiltering(unittest.TestCase): self.assertEqual(results[0]["metadata"], {"env": "prod"}) mock_client.scroll.assert_called_once() + @patch('semantica.vector_store.qdrant_store.Range', MagicMock()) + @patch('semantica.vector_store.qdrant_store.FieldCondition', MagicMock()) + @patch('semantica.vector_store.qdrant_store.Filter', MagicMock()) + @patch('semantica.vector_store.qdrant_store.QDRANT_AVAILABLE', True) + def test_qdrant_store_filter_by_metadata_range(self): + """Range filters must construct Range objects and not raise NameError.""" + store = QdrantStore() + mock_collection = MagicMock() + mock_collection.collection_name = "test_coll" + store.collection = mock_collection + mock_client = MagicMock() + rec = MagicMock() + rec.id = "r1" + rec.payload = {"score": 8} + rec.vector = [0.3, 0.4] + mock_client.scroll.return_value = ([rec], None) + store.client = mock_client + + # min-only range + results = store.filter_by_metadata({"score": {"min": 5}}, limit=10) + self.assertEqual(len(results), 1) + self.assertEqual(results[0]["id"], "r1") + mock_client.scroll.assert_called() + + # Verify Range was actually called to build the condition (not skipped) + import semantica.vector_store.qdrant_store as qs_mod + qs_mod.Range.assert_called() + + @patch('semantica.vector_store.qdrant_store.Range', MagicMock()) + @patch('semantica.vector_store.qdrant_store.FieldCondition', MagicMock()) + @patch('semantica.vector_store.qdrant_store.Filter', MagicMock()) + @patch('semantica.vector_store.qdrant_store.QDRANT_AVAILABLE', True) + def test_qdrant_store_filter_by_metadata_range_min_and_max(self): + """Range filters with both min and max must construct Range with both gte and lte.""" + store = QdrantStore() + mock_collection = MagicMock() + mock_collection.collection_name = "test_coll" + store.collection = mock_collection + mock_client = MagicMock() + mock_client.scroll.return_value = ([], None) + store.client = mock_client + + store.filter_by_metadata({"score": {"min": 5, "max": 10}}, limit=10) + + import semantica.vector_store.qdrant_store as qs_mod + # Range must have been called with gte and lte + qs_mod.Range.assert_called_with(gte=5, lte=10) + + @patch('semantica.vector_store.qdrant_store.MatchAny', MagicMock()) + @patch('semantica.vector_store.qdrant_store.FieldCondition', MagicMock()) + @patch('semantica.vector_store.qdrant_store.Filter', MagicMock()) + @patch('semantica.vector_store.qdrant_store.QDRANT_AVAILABLE', True) + def test_qdrant_store_filter_by_metadata_list(self): + """List filters must construct MatchAny objects and not raise NameError.""" + store = QdrantStore() + mock_collection = MagicMock() + mock_collection.collection_name = "test_coll" + store.collection = mock_collection + mock_client = MagicMock() + rec = MagicMock() + rec.id = "l1" + rec.payload = {"tags": "python"} + rec.vector = [0.5, 0.6] + mock_client.scroll.return_value = ([rec], None) + store.client = mock_client + + results = store.filter_by_metadata({"tags": ["python", "ml"]}, limit=10) + self.assertEqual(len(results), 1) + self.assertEqual(results[0]["id"], "l1") + mock_client.scroll.assert_called() + + # Verify MatchAny was actually called with the filter list + import semantica.vector_store.qdrant_store as qs_mod + qs_mod.MatchAny.assert_called_with(any=["python", "ml"]) + @patch('semantica.vector_store.pinecone_store.PINECONE_AVAILABLE', True) def test_pinecone_store_filter_by_metadata(self): store = PineconeStore(dimension=2) @@ -155,6 +230,90 @@ class TestBackendMetadataFiltering(unittest.TestCase): self.assertEqual(results[0]["id"], "pg1") self.assertEqual(results[0]["metadata"], {"org": "acme"}) + @patch('semantica.vector_store.pgvector_store.PSYCOPG3_AVAILABLE', True) + @patch('semantica.vector_store.pgvector_store.psycopg_sql') + def test_pgvector_store_filter_by_metadata_bool_true(self, mock_sql): + """Boolean True must become the string 'true' (lowercase) in the SQL parameter. + + PostgreSQL JSONB ->> returns 'true' for a JSON boolean true. + str(True) == 'True' would never match; this test guards against regression. + """ + store = object.__new__(PgVectorStore) + store.table_name = "test_vectors" + store._is_safe_identifier = lambda k: True + + mock_conn = MagicMock() + mock_cur = MagicMock() + mock_cur.fetchall.return_value = [ + ("pg2", [0.3, 0.4], {"active": True}) + ] + mock_conn.cursor.return_value = mock_cur + + with patch.object( + PgVectorStore, + '_get_connection', + return_value=MagicMock( + __enter__=MagicMock(return_value=mock_conn), + __exit__=MagicMock(), + ), + ): + results = store.filter_by_metadata({"active": True}, limit=10) + + # Result is returned correctly + self.assertEqual(len(results), 1) + self.assertEqual(results[0]["id"], "pg2") + + # The critical assertion: 'true' (not 'True') was passed to execute() + execute_call_args = mock_cur.execute.call_args + self.assertIsNotNone(execute_call_args, "cursor.execute was not called") + params_passed = execute_call_args[0][1] # positional arg 1 is the params list/tuple + self.assertIn('true', params_passed, + "Expected lowercase 'true' in SQL params, got: {}".format(params_passed)) + self.assertNotIn('True', params_passed, + "str(True)='True' must NOT appear in SQL params") + + @patch('semantica.vector_store.pgvector_store.PSYCOPG3_AVAILABLE', True) + @patch('semantica.vector_store.pgvector_store.psycopg_sql') + def test_pgvector_store_filter_by_metadata_bool_false(self, mock_sql): + """Boolean False must become the string 'false' (lowercase) in the SQL parameter. + + PostgreSQL JSONB ->> returns 'false' for a JSON boolean false. + str(False) == 'False' would never match; this test guards against regression. + """ + store = object.__new__(PgVectorStore) + store.table_name = "test_vectors" + store._is_safe_identifier = lambda k: True + + mock_conn = MagicMock() + mock_cur = MagicMock() + mock_cur.fetchall.return_value = [ + ("pg3", [0.5, 0.6], {"active": False}) + ] + mock_conn.cursor.return_value = mock_cur + + with patch.object( + PgVectorStore, + '_get_connection', + return_value=MagicMock( + __enter__=MagicMock(return_value=mock_conn), + __exit__=MagicMock(), + ), + ): + results = store.filter_by_metadata({"active": False}, limit=10) + + # Result is returned correctly + self.assertEqual(len(results), 1) + self.assertEqual(results[0]["id"], "pg3") + + # The critical assertion: 'false' (not 'False') was passed to execute() + execute_call_args = mock_cur.execute.call_args + self.assertIsNotNone(execute_call_args, "cursor.execute was not called") + params_passed = execute_call_args[0][1] # positional arg 1 is the params list/tuple + self.assertIn('false', params_passed, + "Expected lowercase 'false' in SQL params, got: {}".format(params_passed)) + self.assertNotIn('False', params_passed, + "str(False)='False' must NOT appear in SQL params") + def test_weaviate_store_filter_by_metadata(self): store = WeaviateStore() mock_coll = MagicMock() From 00f4e79d3e24730440dc9ef52da721bb0a457ce3 Mon Sep 17 00:00:00 2001 From: "Joey@macstudio" <4296411@qq.com> Date: Sun, 9 Aug 2026 21:03:58 +0800 Subject: [PATCH 004/105] fix(mcp): report package version --- semantica/mcp_server/__init__.py | 11 ++++++++-- tests/test_mcp_server_version.py | 36 ++++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 2 deletions(-) create mode 100644 tests/test_mcp_server_version.py diff --git a/semantica/mcp_server/__init__.py b/semantica/mcp_server/__init__.py index 6f642bab..cb22b434 100644 --- a/semantica/mcp_server/__init__.py +++ b/semantica/mcp_server/__init__.py @@ -45,8 +45,15 @@ import json import logging import os import sys +from importlib.metadata import PackageNotFoundError, version from typing import Any +try: + _SEMANTICA_VERSION = version("semantica") +except PackageNotFoundError: + # Preserve direct source-tree execution when distribution metadata is absent. + from semantica import __version__ as _SEMANTICA_VERSION + # ── logging ──────────────────────────────────────────────────────────────── _log_level = getattr(logging, os.environ.get("SEMANTICA_LOG_LEVEL", "WARNING").upper(), logging.WARNING) logging.basicConfig(stream=sys.stderr, level=_log_level, @@ -477,7 +484,7 @@ def _read_resource(uri: str) -> dict: if uri == "semantica://schema/info": return { "name": "Semantica", - "version": "0.4.0", + "version": _SEMANTICA_VERSION, "tools": [t["name"] for t in TOOLS], "resources": [r["uri"] for r in RESOURCES], } @@ -490,7 +497,7 @@ def _read_resource(uri: str) -> dict: SERVER_INFO = { "name": "semantica", - "version": "0.4.0", + "version": _SEMANTICA_VERSION, } CAPABILITIES = { diff --git a/tests/test_mcp_server_version.py b/tests/test_mcp_server_version.py new file mode 100644 index 00000000..9dd9f9a4 --- /dev/null +++ b/tests/test_mcp_server_version.py @@ -0,0 +1,36 @@ +"""Regression tests for MCP server version reporting.""" + +import unittest +from importlib.metadata import PackageNotFoundError, version + +import semantica +from semantica import mcp_server + + +class TestMCPServerVersion(unittest.TestCase): + def test_server_info_uses_distribution_version(self): + try: + expected = version("semantica") + except PackageNotFoundError: + expected = semantica.__version__ + + self.assertEqual(mcp_server.SERVER_INFO["version"], expected) + + def test_initialize_reports_package_version(self): + response = mcp_server._handle( + {"jsonrpc": "2.0", "id": 1, "method": "initialize"} + ) + + self.assertIsNotNone(response) + self.assertEqual( + response["result"]["serverInfo"]["version"], semantica.__version__ + ) + + def test_schema_info_resource_reports_package_version(self): + resource = mcp_server._read_resource("semantica://schema/info") + + self.assertEqual(resource["version"], semantica.__version__) + + +if __name__ == "__main__": + unittest.main() From 646c70ce6393861c1b7a12a5a111d29db5006cc4 Mon Sep 17 00:00:00 2001 From: KaifAhmad1 Date: Tue, 11 Aug 2026 18:52:26 +0530 Subject: [PATCH 005/105] security: DNS check-then-use pinning for SSRF fetcher, close object-IRI gap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two follow-up hardening items flagged as secondary/deferred during GHSA-8c7v-62gr-hj6g and GHSA-8vgg-8mr4-r236's fixes: 1. DNS check-then-use (TOCTOU) window in the ontology URL fetcher. _validate_fetch_url() resolved and validated a hostname once, but _fetch_url_sync() then let requests resolve the same hostname again independently at connect time — a low-TTL or rebinding DNS answer could differ between the two lookups, reopening the SSRF window the validation exists to close. _validate_fetch_url() now returns the validated IP, and a new _make_pinned_session() builds a per-hop requests.Session whose connection pool is pinned directly to that IP (bypassing DNS resolution for the connection entirely), while explicitly restoring the real hostname as the outgoing HTTP Host header and, for HTTPS, the TLS SNI server_hostname/assert_hostname — so the connection reaches the validated IP but still presents (and is verified against) the real hostname's identity, keeping virtual hosting and certificate validation correct. Note: an earlier version of this fix set `_dns_host` post-construction assuming it was decoupled from `host`, matching some other urllib3 releases; in the installed version (2.7.0), `host` is a property that reads/writes `_dns_host` directly, so that approach silently changed the Host header too. Verified with a real (non-mocked) local HTTP server, a real local HTTPS server with a self-signed cert (proving SNI/cert-hostname verification checks the real hostname, not the pinned IP), and a negative control confirming a hostname/cert mismatch is still correctly rejected — not silently bypassed. 2. Pre-wrapped object IRIs skipped full validation in _format_object_for_sparql/_format_object_for_ntriples (Blazegraph, RDF4J). A triplet object already wrapped in `<...>` only had its inner content checked for a literal space or `>`, not run through sparql_escaping.validate_uri() like the unwrapped-object branch — flagged by automated review during GHSA-8vgg-8mr4-r236's fix. Both branches now validate identically. Tests: tests/explorer/test_ontology_dns_pinning.py (6 tests, including 2 real local-server end-to-end checks and 2 real-TLS checks with a generated self-signed cert, gracefully skipped if `cryptography` isn't installed); updated tests/explorer/test_ontology_ssrf.py for the new per-hop session construction; 4 new tests in tests/triplet_store/test_sparql_injection.py for the object-IRI fix. Full explorer + triplet_store suite: 566 passed. --- semantica/explorer/routes/ontology.py | 143 +++++++--- semantica/triplet_store/blazegraph_store.py | 10 +- semantica/triplet_store/rdf4j_store.py | 10 +- tests/explorer/test_ontology_dns_pinning.py | 281 +++++++++++++++++++ tests/explorer/test_ontology_ssrf.py | 32 ++- tests/triplet_store/test_sparql_injection.py | 30 ++ 6 files changed, 459 insertions(+), 47 deletions(-) create mode 100644 tests/explorer/test_ontology_dns_pinning.py diff --git a/semantica/explorer/routes/ontology.py b/semantica/explorer/routes/ontology.py index 8b0c8aa9..3ac81b0a 100644 --- a/semantica/explorer/routes/ontology.py +++ b/semantica/explorer/routes/ontology.py @@ -978,8 +978,15 @@ def _normalize_format(fmt: Optional[str]) -> str: return _FORMAT_ALIASES.get(lower, lower) -def _validate_fetch_url(url: str) -> None: - """Reject non-HTTP(S) schemes and private/loopback/link-local targets.""" +def _validate_fetch_url(url: str) -> str: + """Reject non-HTTP(S) schemes and private/loopback/link-local targets. + + Returns the first resolved, validated IP address so the caller can pin + the actual connection to it (see _PinnedIPHTTPAdapter) — resolving the + hostname again at connect time would open a DNS check-then-use window + (a low-TTL or rebinding DNS answer could differ between this check and + the client's own lookup). + """ parsed = urlparse(url) if parsed.scheme not in ("http", "https"): raise HTTPException(status_code=422, detail="Only http and https URLs are allowed.") @@ -990,6 +997,7 @@ def _validate_fetch_url(url: str) -> None: addrinfos = socket.getaddrinfo(hostname, None) except socket.gaierror as exc: raise HTTPException(status_code=422, detail=f"Cannot resolve hostname '{hostname}': {exc}") from exc + validated_ip: Optional[str] = None for _family, _type, _proto, _canonname, sockaddr in addrinfos: try: ip = ipaddress.ip_address(sockaddr[0]) @@ -1000,46 +1008,115 @@ def _validate_fetch_url(url: str) -> None: status_code=422, detail="Fetching from private, loopback, or reserved network addresses is not allowed.", ) + if validated_ip is None: + validated_ip = sockaddr[0] + if validated_ip is None: + raise HTTPException(status_code=422, detail=f"Cannot resolve hostname '{hostname}' to a usable address.") + return validated_ip + + +def _make_pinned_session(pinned_ip: str, url: str): + """Build a requests.Session whose connection is pinned to pinned_ip, + regardless of what url's hostname resolves to at connect time. + + _validate_fetch_url() resolves and validates the hostname once; letting + the HTTP client resolve it again independently at connect time reopens + the exact gap that validation exists to close — a low-TTL or rebinding + DNS answer can differ between the two lookups. This pins the pool's + connect target to the already-validated IP directly (bypassing DNS + resolution for the connection entirely), while keeping the original + hostname as the outgoing HTTP Host header and, for HTTPS, the TLS SNI + server_hostname / assert_hostname — otherwise the connection would + reach the right IP but present the wrong identity, breaking name-based + virtual hosting and (for HTTPS) certificate hostname verification. + + Note: urllib3's Connection.host is a property that reads/writes the + same underlying value as `_dns_host` in this version — it is NOT the + separate "presented identity" field it is in some older releases, so + overriding just `_dns_host` post-construction (as an earlier version of + this fix did) actually changes the Host header too. Pinning the pool's + `host` directly and restoring the real hostname via an explicit Host + header (+ SNI params for HTTPS) is the correct mechanism here. + """ + import requests as _req + + parsed = urlparse(url) + hostname = parsed.hostname + port = parsed.port + default_port = 443 if parsed.scheme == "https" else 80 + host_header = hostname if port in (None, default_port) else f"{hostname}:{port}" + + class _PinnedIPHTTPAdapter(_req.adapters.HTTPAdapter): + def get_connection_with_tls_context(self, request, verify, proxies=None, cert=None): + # If an HTTP(S) proxy applies (env-configured or per-request), + # the actual TCP connection target is the proxy, not the + # resolved IP, and proxy tunneling changes the connection model + # enough that pinning doesn't apply cleanly. Fall back to the + # normal (unpinned) path rather than silently bypassing the + # proxy — _validate_fetch_url's destination check still applies + # either way; only this secondary DNS-pinning hardening is + # skipped. + if _req.utils.select_proxy(request.url, proxies): + return super().get_connection_with_tls_context( + request, verify, proxies=proxies, cert=cert + ) + host_params, pool_kwargs = self.build_connection_pool_key_attributes(request, verify, cert) + if host_params.get("scheme") == "https": + pool_kwargs.setdefault("assert_hostname", hostname) + pool_kwargs.setdefault("server_hostname", hostname) + host_params["host"] = pinned_ip + return self.poolmanager.connection_from_host(**host_params, pool_kwargs=pool_kwargs) + + session = _req.Session() + session.headers["Host"] = host_header + adapter = _PinnedIPHTTPAdapter() + session.mount("http://", adapter) + session.mount("https://", adapter) + return session def _fetch_url_sync(url: str) -> bytes: - _validate_fetch_url(url) - import requests as _req + pinned_ip = _validate_fetch_url(url) _MAX_REDIRECTS = 5 current_url = url try: for _ in range(_MAX_REDIRECTS + 1): - resp = _req.get( - current_url, - headers={"Accept": "text/turtle, application/rdf+xml, application/ld+json, */*;q=0.1"}, - timeout=30, - stream=True, - allow_redirects=False, # SECURITY: follow redirects manually - ) - if resp.is_redirect or resp.is_permanent_redirect: - redirect_url = resp.headers.get("Location") - resp.close() # Release the streamed connection before following the redirect - if not redirect_url: - raise HTTPException(status_code=502, detail="Redirect without Location header.") - # Resolve relative redirects (e.g. /ontology.ttl) against the current URL - redirect_url = urljoin(current_url, redirect_url) - # Re-validate the redirect target to prevent SSRF via - # open-redirect to internal/cloud-metadata endpoints. - _validate_fetch_url(redirect_url) - current_url = redirect_url - continue + session = _make_pinned_session(pinned_ip, current_url) try: - resp.raise_for_status() - chunks: List[bytes] = [] - total = 0 - for chunk in resp.iter_content(65536): - total += len(chunk) - if total > _MAX_FETCH_BYTES: - raise HTTPException(status_code=413, detail="Remote resource exceeds 20 MB limit.") - chunks.append(chunk) - return b"".join(chunks) + resp = session.get( + current_url, + headers={"Accept": "text/turtle, application/rdf+xml, application/ld+json, */*;q=0.1"}, + timeout=30, + stream=True, + allow_redirects=False, # SECURITY: follow redirects manually + ) + if resp.is_redirect or resp.is_permanent_redirect: + redirect_url = resp.headers.get("Location") + resp.close() # Release the streamed connection before following the redirect + if not redirect_url: + raise HTTPException(status_code=502, detail="Redirect without Location header.") + # Resolve relative redirects (e.g. /ontology.ttl) against the current URL + redirect_url = urljoin(current_url, redirect_url) + # Re-validate the redirect target to prevent SSRF via + # open-redirect to internal/cloud-metadata endpoints, and + # get a fresh pin for the new host. + pinned_ip = _validate_fetch_url(redirect_url) + current_url = redirect_url + continue + try: + resp.raise_for_status() + chunks: List[bytes] = [] + total = 0 + for chunk in resp.iter_content(65536): + total += len(chunk) + if total > _MAX_FETCH_BYTES: + raise HTTPException(status_code=413, detail="Remote resource exceeds 20 MB limit.") + chunks.append(chunk) + return b"".join(chunks) + finally: + resp.close() # Release the streamed connection once fully read (or on error) finally: - resp.close() # Release the streamed connection once fully read (or on error) + session.close() raise HTTPException(status_code=502, detail=f"Too many redirects (max {_MAX_REDIRECTS}).") except HTTPException: raise diff --git a/semantica/triplet_store/blazegraph_store.py b/semantica/triplet_store/blazegraph_store.py index 4e139a57..21e42b38 100644 --- a/semantica/triplet_store/blazegraph_store.py +++ b/semantica/triplet_store/blazegraph_store.py @@ -391,10 +391,12 @@ class BlazegraphStore: if self._is_uri_value(obj): if obj.startswith("<") and obj.endswith(">"): - inner = obj[1:-1] - if " " in inner or ">" in inner: - raise ValueError(f"IRI contains invalid characters: {obj!r}") - return obj + # Validate the inner IRI with the same disallowed-character + # set as the unwrapped branch below — a narrower ad-hoc + # check here previously let a pre-wrapped object bypass + # validate_uri() entirely (GHSA-8vgg-8mr4-r236 follow-up). + inner = sparql_escaping.validate_uri(obj[1:-1]) + return f"<{inner}>" validated_obj = sparql_escaping.validate_uri(obj) return f"<{validated_obj}>" diff --git a/semantica/triplet_store/rdf4j_store.py b/semantica/triplet_store/rdf4j_store.py index 79a83320..8dad6997 100644 --- a/semantica/triplet_store/rdf4j_store.py +++ b/semantica/triplet_store/rdf4j_store.py @@ -539,10 +539,12 @@ class RDF4JStore: if self._is_uri_value(obj): if obj.startswith("<") and obj.endswith(">"): - inner = obj[1:-1] - if " " in inner or ">" in inner: - raise ValueError(f"IRI contains invalid characters: {obj!r}") - return obj + # Validate the inner IRI with the same disallowed-character + # set as the unwrapped branch below — a narrower ad-hoc + # check here previously let a pre-wrapped object bypass + # validate_uri() entirely (GHSA-8vgg-8mr4-r236 follow-up). + inner = sparql_escaping.validate_uri(obj[1:-1]) + return f"<{inner}>" validated_obj = sparql_escaping.validate_uri(obj) return f"<{validated_obj}>" diff --git a/tests/explorer/test_ontology_dns_pinning.py b/tests/explorer/test_ontology_dns_pinning.py new file mode 100644 index 00000000..cb7791c6 --- /dev/null +++ b/tests/explorer/test_ontology_dns_pinning.py @@ -0,0 +1,281 @@ +"""Regression tests for DNS check-then-use (TOCTOU) hardening in the +ontology URL fetcher (GHSA-8c7v-62gr-hj6g's secondary "smaller" gap). + +`_validate_fetch_url` resolves and validates a hostname once; if the actual +HTTP client resolved it again independently at connect time, a low-TTL or +rebinding DNS answer could differ between the two lookups, reopening the +SSRF window the validation exists to close. `_make_pinned_session` closes +this by pinning the connection pool's `host` directly to the already- +validated IP (bypassing DNS resolution for the connection entirely), while +explicitly restoring the real hostname as the outgoing HTTP `Host` header +and, for HTTPS, the TLS SNI `server_hostname` / `assert_hostname` — so the +connection reaches the pinned IP but still presents (and verifies against) +the original hostname's identity. + +test_ontology_ssrf.py covers the redirect-handling logic around this with +mocks; this file proves the pinning mechanism itself works end-to-end +against real local servers, with no DNS mocking at all — the test hostname +is never resolved, which is exactly the property being verified. It also +includes a negative control (mismatched cert hostname) proving TLS +verification is genuinely enforced against the real hostname, not silently +bypassed or checked against the pinned IP instead. +""" + +import http.server +import socket +import threading + +import pytest + +from semantica.explorer.routes import ontology as ontology_mod + + +def _start_local_server(): + captured = {} + + class Handler(http.server.BaseHTTPRequestHandler): + def do_GET(self): + captured["host_header"] = self.headers.get("Host") + body = b"pinned response" + self.send_response(200) + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, *_args): + pass + + server = http.server.HTTPServer(("127.0.0.1", 0), Handler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + return server, thread, captured + + +def test_pinned_session_connects_to_pinned_ip_without_resolving_hostname(): + """A session built by _make_pinned_session must reach the pinned IP + directly. The request URL uses a hostname that cannot be resolved via + real DNS ('.invalid' is reserved by RFC 2606) — if pinning weren't + working, this request would fail with a name-resolution error instead + of reaching the local server, since nothing else could route it there. + """ + server, thread, captured = _start_local_server() + port = server.server_address[1] + url = f"http://pinned-test.invalid:{port}/resource" + try: + session = ontology_mod._make_pinned_session("127.0.0.1", url) + try: + resp = session.get(url, timeout=5) + assert resp.status_code == 200 + assert resp.content == b"pinned response" + finally: + session.close() + finally: + server.shutdown() + thread.join(timeout=2) + + # Host header must still be the original hostname, not the pinned IP — + # proving connection target and presented identity are decoupled + # correctly (this is what keeps virtual hosting / TLS SNI correct). + assert captured["host_header"] == f"pinned-test.invalid:{port}" + + +def test_pinned_session_ignores_a_different_real_resolution(): + """Even if the hostname *does* resolve to something else via real DNS, + the pinned session must still go to the pinned IP — this is the actual + TOCTOU property: the connection uses what was validated, not whatever + a fresh lookup returns. 'localhost' reliably resolves to a loopback + address, which is deliberately NOT where our test server listens on + (127.0.0.1 specifically) — but since Windows/most stacks map + 'localhost' to 127.0.0.1 too, use a distinct high loopback address + (127.0.0.2) for the server so a real 'localhost' resolution (127.0.0.1) + provably would NOT reach it, isolating the assertion to pinning alone. + """ + captured = {} + + class Handler(http.server.BaseHTTPRequestHandler): + def do_GET(self): + captured["host_header"] = self.headers.get("Host") + body = b"pinned via explicit ip" + self.send_response(200) + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, *_args): + pass + + try: + server = http.server.HTTPServer(("127.0.0.2", 0), Handler) + except OSError: + # 127.0.0.2 isn't bindable in this environment (uncommon, but + # possible in some sandboxes) — skip rather than false-fail. + import pytest + pytest.skip("127.0.0.2 is not bindable in this environment") + + port = server.server_address[1] + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + url = f"http://localhost:{port}/resource" + try: + session = ontology_mod._make_pinned_session("127.0.0.2", url) + try: + resp = session.get(url, timeout=5) + assert resp.status_code == 200 + assert resp.content == b"pinned via explicit ip" + finally: + session.close() + finally: + server.shutdown() + thread.join(timeout=2) + + assert captured["host_header"] == f"localhost:{port}" + + +def test_validate_fetch_url_returns_the_resolved_ip(): + """_validate_fetch_url must return the IP it validated, so callers can + pin the connection to it.""" + def fake_getaddrinfo(host, *_a, **_k): + return [(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("93.184.216.34", 0))] + + import unittest.mock as mock + with mock.patch.object(ontology_mod.socket, "getaddrinfo", side_effect=fake_getaddrinfo): + resolved_ip = ontology_mod._validate_fetch_url("http://example.org/ontology.ttl") + + assert resolved_ip == "93.184.216.34" + + +def test_validate_fetch_url_still_rejects_private_ip(): + """Confirm the pinning refactor didn't loosen the original address + classification — a hostname resolving to a private/internal address + must still be rejected before any IP is returned.""" + import ipaddress + import unittest.mock as mock + + import pytest + from fastapi import HTTPException + + def fake_getaddrinfo(host, *_a, **_k): + return [(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("169.254.169.254", 0))] + + with mock.patch.object(ontology_mod.socket, "getaddrinfo", side_effect=fake_getaddrinfo): + with pytest.raises(HTTPException) as exc_info: + ontology_mod._validate_fetch_url("http://attacker.example/ontology.ttl") + + assert exc_info.value.status_code == 422 + + +# --------------------------------------------------------------------------- +# HTTPS: SNI + certificate hostname verification must use the real hostname, +# not the pinned IP — this is the highest-risk part of pinning to get wrong, +# since a mistake here could silently weaken TLS verification rather than +# just breaking connectivity. Requires the optional `cryptography` package +# to mint a throwaway self-signed cert; skipped gracefully without it. +# --------------------------------------------------------------------------- + +def _make_self_signed_cert(hostname: str, tmp_path): + import datetime + + cryptography = pytest.importorskip("cryptography") + from cryptography import x509 + from cryptography.hazmat.primitives import hashes, serialization + from cryptography.hazmat.primitives.asymmetric import rsa + from cryptography.x509.oid import NameOID + + key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, hostname)]) + now = datetime.datetime.now(datetime.timezone.utc) + cert = ( + x509.CertificateBuilder() + .subject_name(name) + .issuer_name(name) + .public_key(key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(now - datetime.timedelta(days=1)) + .not_valid_after(now + datetime.timedelta(days=1)) + .add_extension(x509.SubjectAlternativeName([x509.DNSName(hostname)]), critical=False) + .sign(key, hashes.SHA256()) + ) + + cert_path = tmp_path / "cert.pem" + key_path = tmp_path / "key.pem" + cert_path.write_bytes(cert.public_bytes(serialization.Encoding.PEM)) + key_path.write_bytes( + key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.TraditionalOpenSSL, + encryption_algorithm=serialization.NoEncryption(), + ) + ) + return str(cert_path), str(key_path) + + +def _start_local_https_server(cert_path, key_path): + import ssl + + captured = {} + + class Handler(http.server.BaseHTTPRequestHandler): + def do_GET(self): + captured["host_header"] = self.headers.get("Host") + body = b"tls pinned response" + self.send_response(200) + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, *_args): + pass + + server = http.server.HTTPServer(("127.0.0.1", 0), Handler) + ssl_ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + ssl_ctx.load_cert_chain(cert_path, key_path) + server.socket = ssl_ctx.wrap_socket(server.socket, server_side=True) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + return server, thread, captured + + +def test_pinned_https_session_verifies_against_real_hostname_not_pinned_ip(tmp_path): + """A pinned HTTPS connection must present + verify SNI/cert against the + real hostname, even though the socket connects to the pinned IP. The + cert's SAN is the hostname, never '127.0.0.1' — if pinning verified + against the IP instead (or against nothing), this would either fail + for the wrong reason or silently succeed with no real verification.""" + cert_path, key_path = _make_self_signed_cert("pinned-tls-test.invalid", tmp_path) + server, thread, captured = _start_local_https_server(cert_path, key_path) + port = server.server_address[1] + url = f"https://pinned-tls-test.invalid:{port}/resource" + try: + session = ontology_mod._make_pinned_session("127.0.0.1", url) + try: + resp = session.get(url, timeout=5, verify=cert_path) + finally: + session.close() + finally: + server.shutdown() + thread.join(timeout=2) + + assert resp.status_code == 200 + assert resp.content == b"tls pinned response" + assert captured["host_header"] == f"pinned-tls-test.invalid:{port}" + + +def test_pinned_https_session_rejects_hostname_mismatch(tmp_path): + """Negative control: requesting a hostname that does NOT match the + cert's SAN must still fail verification — proving pinning doesn't + silently bypass or misdirect certificate hostname checking.""" + cert_path, key_path = _make_self_signed_cert("pinned-tls-test.invalid", tmp_path) + server, thread, _captured = _start_local_https_server(cert_path, key_path) + port = server.server_address[1] + url = f"https://wrong-name.invalid:{port}/resource" + try: + session = ontology_mod._make_pinned_session("127.0.0.1", url) + try: + import requests + with pytest.raises(requests.exceptions.SSLError): + session.get(url, timeout=5, verify=cert_path) + finally: + session.close() + finally: + server.shutdown() + thread.join(timeout=2) diff --git a/tests/explorer/test_ontology_ssrf.py b/tests/explorer/test_ontology_ssrf.py index fe9de35b..8613275c 100644 --- a/tests/explorer/test_ontology_ssrf.py +++ b/tests/explorer/test_ontology_ssrf.py @@ -3,12 +3,17 @@ `_fetch_url_sync` disables `requests`' automatic redirect following and re-validates every hop with `_validate_fetch_url` (see GHSA-8c7v-62gr-hj6g: unvalidated redirect targets previously let a public first hop 302 the -server into fetching cloud metadata / loopback services). +server into fetching cloud metadata / loopback services). It also pins each +hop's connection to the IP `_validate_fetch_url` already resolved and +validated, via `_make_pinned_session`, closing the DNS check-then-use gap +between that validation and the client's own (potentially different) lookup. These tests cover the redirect-handling logic itself: relative `Location` headers must resolve correctly instead of being rejected outright, redirect targets that resolve to private/loopback addresses must still be blocked, and every response must be closed (no leaked connections across hops). +`test_ontology_dns_pinning.py` covers the pinning mechanism +(`_make_pinned_session`, `_validate_fetch_url`'s returned IP) directly. """ import socket @@ -37,6 +42,17 @@ def _make_response(is_redirect=False, is_permanent=False, location=None, body=b" return resp +def _patch_session(responses): + """Patch _make_pinned_session so _fetch_url_sync's session.get(...) + calls return the given responses in order, without touching the real + requests.Session/pinning machinery (that's covered by test_pinning.py). + """ + fake_session = MagicMock() + fake_session.get = MagicMock(side_effect=responses) + fake_session.close = MagicMock() + return patch.object(ontology_mod, "_make_pinned_session", return_value=fake_session), fake_session + + @patch.object(ontology_mod.socket, "getaddrinfo", side_effect=_fake_getaddrinfo) def test_relative_redirect_location_is_resolved(mock_getaddrinfo): """A relative Location header (e.g. '/ontology.ttl') must resolve against @@ -44,11 +60,12 @@ def test_relative_redirect_location_is_resolved(mock_getaddrinfo): redirect_resp = _make_response(is_redirect=True, location="/ontology.ttl") final_resp = _make_response(body=b"final content") - with patch("requests.get", side_effect=[redirect_resp, final_resp]) as mock_get: + patcher, fake_session = _patch_session([redirect_resp, final_resp]) + with patcher: result = ontology_mod._fetch_url_sync("http://example.org/start") assert result == b"final content" - second_call_url = mock_get.call_args_list[1].args[0] + second_call_url = fake_session.get.call_args_list[1].args[0] assert second_call_url == "http://example.org/ontology.ttl" redirect_resp.close.assert_called_once() final_resp.close.assert_called_once() @@ -67,7 +84,8 @@ def test_redirect_to_private_ip_is_rejected(mock_getaddrinfo): mock_getaddrinfo.side_effect = getaddrinfo_side_effect redirect_resp = _make_response(is_redirect=True, location="http://internal.example/latest/meta-data/") - with patch("requests.get", side_effect=[redirect_resp]): + patcher, fake_session = _patch_session([redirect_resp]) + with patcher: with pytest.raises(ontology_mod.HTTPException) as exc_info: ontology_mod._fetch_url_sync("http://example.org/start") @@ -78,7 +96,8 @@ def test_redirect_to_private_ip_is_rejected(mock_getaddrinfo): @patch.object(ontology_mod.socket, "getaddrinfo", side_effect=_fake_getaddrinfo) def test_final_response_is_closed(mock_getaddrinfo): final_resp = _make_response(body=b"content") - with patch("requests.get", side_effect=[final_resp]): + patcher, _fake_session = _patch_session([final_resp]) + with patcher: ontology_mod._fetch_url_sync("http://example.org/start") final_resp.close.assert_called_once() @@ -86,7 +105,8 @@ def test_final_response_is_closed(mock_getaddrinfo): @patch.object(ontology_mod.socket, "getaddrinfo", side_effect=_fake_getaddrinfo) def test_redirect_chain_exceeding_cap_is_rejected(mock_getaddrinfo): responses = [_make_response(is_redirect=True, location=f"/hop{i}") for i in range(10)] - with patch("requests.get", side_effect=responses): + patcher, _fake_session = _patch_session(responses) + with patcher: with pytest.raises(ontology_mod.HTTPException) as exc_info: ontology_mod._fetch_url_sync("http://example.org/start") assert exc_info.value.status_code == 502 diff --git a/tests/triplet_store/test_sparql_injection.py b/tests/triplet_store/test_sparql_injection.py index 30376804..587ba613 100644 --- a/tests/triplet_store/test_sparql_injection.py +++ b/tests/triplet_store/test_sparql_injection.py @@ -82,6 +82,22 @@ class TestBlazegraphSparqlInjection(unittest.TestCase): self.assertIn(" ", insert_data) self.assertNotIn("CLEAR ALL", insert_data) + def test_format_object_rejects_malicious_pre_wrapped_iri(self): + """A caller-supplied object already wrapped in '<...>' must still be + fully validated, not just checked for a literal space/'>' — a + narrower ad-hoc check here previously let this branch bypass + validate_uri() entirely (Codex-flagged follow-up to GHSA-8vgg).""" + store = self._make_store() + evil_object = f"<{EVIL_SUBJECT}>" + triplet = Triplet(subject="http://s", predicate="http://p", object=evil_object) + with self.assertRaises(ValidationError): + store._format_object_for_sparql(triplet) + + def test_format_object_accepts_legitimate_pre_wrapped_iri(self): + store = self._make_store() + triplet = Triplet(subject="http://s", predicate="http://p", object="") + self.assertEqual(store._format_object_for_sparql(triplet), "") + class TestRDF4JSparqlInjection(unittest.TestCase): @patch.object(RDF4JStore, "_connect", autospec=True) @@ -207,6 +223,20 @@ class TestRDF4JSparqlInjection(unittest.TestCase): self.assertIn(" ", ntriples) self.assertNotIn("CLEAR ALL", ntriples) + def test_format_object_rejects_malicious_pre_wrapped_iri(self): + """Same pre-wrapped-object bypass as Blazegraph, fixed in + _format_object_for_ntriples.""" + store = self._make_store() + evil_object = f"<{EVIL_SUBJECT}>" + triplet = Triplet(subject="http://s", predicate="http://p", object=evil_object) + with self.assertRaises(ValidationError): + store._format_object_for_ntriples(triplet) + + def test_format_object_accepts_legitimate_pre_wrapped_iri(self): + store = self._make_store() + triplet = Triplet(subject="http://s", predicate="http://p", object="") + self.assertEqual(store._format_object_for_ntriples(triplet), "") + class TestJenaSparqlInjection(unittest.TestCase): def setUp(self): From f2f1d6787d178be4eedfbf78a636560b51fb633a Mon Sep 17 00:00:00 2001 From: KaifAhmad1 Date: Tue, 11 Aug 2026 18:57:07 +0530 Subject: [PATCH 006/105] docs(changelog): add PR #916 (DNS pinning + object-IRI gap) entry --- CHANGELOG.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index db25f459..1d7bf1ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -244,6 +244,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Security +- **DNS check-then-use hardening for the ontology URL fetcher, and a remaining object-IRI validation gap** (#916, follow-up to GHSA-8c7v-62gr-hj6g and GHSA-8vgg-8mr4-r236) by @KaifAhmad1 + - **DNS check-then-use (TOCTOU) window**: GHSA-8c7v-62gr-hj6g's own fix description flagged this as a secondary gap — `_validate_fetch_url()` resolved and validated a hostname once, but `_fetch_url_sync()` then let `requests` resolve the same hostname again independently at connect time. A low-TTL or rebinding DNS answer could differ between the two lookups, reopening the SSRF window the validation exists to close + - `_validate_fetch_url()` now returns the validated IP, and a new `_make_pinned_session()` builds a per-hop `requests.Session` whose connection pool is pinned directly to that IP — bypassing DNS resolution for the connection entirely — while explicitly restoring the real hostname as the outgoing HTTP `Host` header and, for HTTPS, the TLS SNI `server_hostname`/`assert_hostname`, so the connection reaches the validated IP but still presents (and is verified against) the real hostname's identity, keeping virtual hosting and certificate validation correct + - Caught during implementation: an earlier draft set urllib3's `_dns_host` post-construction, assuming (as in some urllib3 releases) that it was decoupled from `host`. In the version this project installs (2.7.0), `host` is a property that reads/writes `_dns_host` directly, so that approach would have silently changed the Host header too — caught by an end-to-end test against a real local server before landing, rather than shipping. Verified with real (non-mocked) local HTTP and HTTPS servers, the latter using a generated self-signed certificate to prove SNI/cert-hostname verification checks the real hostname rather than the pinned IP, plus a negative control confirming a hostname/cert mismatch is still correctly rejected, not silently bypassed + - **Object-IRI validation gap** (GHSA-8vgg-8mr4-r236 follow-up, distinct from the object-branch fix already shipped in #911): a triplet object already wrapped in `<...>` skipped `sparql_escaping.validate_uri()` in both `blazegraph_store.py` and `rdf4j_store.py`'s `_format_object_for_sparql`/`_format_object_for_ntriples`, only checking the inner content for a literal space or `>` — the pre-wrapped and unwrapped branches now validate identically + - New `tests/explorer/test_ontology_dns_pinning.py` (6 tests, 4 against real local servers including 2 real-TLS checks, gracefully skipped without the optional `cryptography` package); updated `tests/explorer/test_ontology_ssrf.py` for the new per-hop session construction; 4 new tests in `tests/triplet_store/test_sparql_injection.py` for the object-IRI fix. Full `explorer` + `triplet_store` suite: 566 passed + - **SPARQL injection via unvalidated triplet IRIs** (#911, GHSA-8vgg-8mr4-r236) by @KaifAhmad1 - `Triplet.subject`/`.predicate` (and, in some builders, `.object`) were interpolated directly into SPARQL update/query strings in the Blazegraph and RDF4J stores, and into a SELECT filter in the Jena store. A subject containing `>` closes the `<...>` IRI token early, so the rest of the value is parsed as more SPARQL. Entity names are document text in the normal ingest pipeline, so anyone whose content gets processed could append operations like `CLEAR ALL`, running with the application's store credentials - Applied the existing `sparql_escaping.validate_uri` (already used by `anzo_store.py`, the one backend that was already hardened — this generalizes its approach rather than inventing a new one) at every subject/predicate/object interpolation site: `blazegraph_store.py`'s `_build_insert_data`, `_triplets_to_rdf`, `bulk_load`'s `graph` option, `get_triplets`'s filter, and `delete_triplet`; `rdf4j_store.py`'s `_triplets_to_ntriples`, `get_triplets`'s filter, and `delete_triplet`; `jena_store.py`'s `get_triplets`'s filter (the only vulnerable site there — `add_triplets`/`delete_triplet` already use rdflib's native `Graph.add`/`.remove` with `URIRef` rather than building query strings) From 154a7347cdc3bffc0ee5ae33bc9121624da7014f Mon Sep 17 00:00:00 2001 From: KaifAhmad1 Date: Tue, 11 Aug 2026 19:10:01 +0530 Subject: [PATCH 007/105] fix: address CI/review findings on DNS pinning (multi-IP fallback, TLS min version) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four findings from PR #916's automated review, all addressed: - CodeQL (HIGH): the test HTTPS server's SSLContext allowed TLSv1/TLSv1.1 by not setting a minimum version. Added ssl_ctx.minimum_version = ssl.TLSVersion.TLSv1_2. - github-code-quality: unused `cryptography` local in _make_self_signed_cert — importorskip's return value was never used. - Qodo (reliability): _validate_fetch_url() only returned the first validated IP, and _make_pinned_session() pinned to just that one address, so a fetch would fail outright if the first-returned A/AAAA record happened to be unreachable even though a later one would work. _validate_fetch_url() now returns every validated IP (deduplicated, in resolution order); _make_pinned_session() takes the full list and falls back through each one via a custom Connection._new_conn override, matching the fallback behavior a normal DNS-resolving connection would already get for free. Verified with a real test: pin to an unreachable loopback address followed by a real one, confirm the fetch still succeeds by falling back; and a real test confirming it still raises (rather than silently re-resolving the hostname) when every pinned address is unreachable. - Qodo (security): when an HTTP(S) proxy applies, the adapter falls back to the unpinned path rather than pinning. This is a real, but architecturally unavoidable, limitation from the client side: for a forward proxy, the *proxy* performs its own DNS resolution of the target host on the application's behalf, a resolution this process has no visibility into or control over — there's no client-side pin that closes that race. _validate_fetch_url's destination classification still fully applies either way; only the secondary DNS-pinning hardening doesn't extend through a proxy. Added an info log when this fallback path is taken so it's observable rather than silent, and expanded the code comment to make the reasoning explicit for the next reader/reviewer rather than looking like an oversight. Tests: 3 new tests in test_ontology_dns_pinning.py (multi-IP fallback success, all-unreachable failure, deduplicated multi-record resolution). Full explorer + triplet_store suite: 569 passed. --- semantica/explorer/routes/ontology.py | 105 ++++++++++++++------ tests/explorer/test_ontology_dns_pinning.py | 78 +++++++++++++-- 2 files changed, 145 insertions(+), 38 deletions(-) diff --git a/semantica/explorer/routes/ontology.py b/semantica/explorer/routes/ontology.py index 3ac81b0a..005d8a0f 100644 --- a/semantica/explorer/routes/ontology.py +++ b/semantica/explorer/routes/ontology.py @@ -978,14 +978,17 @@ def _normalize_format(fmt: Optional[str]) -> str: return _FORMAT_ALIASES.get(lower, lower) -def _validate_fetch_url(url: str) -> str: +def _validate_fetch_url(url: str) -> List[str]: """Reject non-HTTP(S) schemes and private/loopback/link-local targets. - Returns the first resolved, validated IP address so the caller can pin - the actual connection to it (see _PinnedIPHTTPAdapter) — resolving the - hostname again at connect time would open a DNS check-then-use window - (a low-TTL or rebinding DNS answer could differ between this check and - the client's own lookup). + Returns every resolved, validated IP address (deduplicated, in + resolution order) so the caller can pin the actual connection to them + (see _make_pinned_session) with fallback across all of them — not just + the first — since a hostname can have multiple A/AAAA records and the + first one isn't guaranteed reachable. Resolving the hostname again at + connect time would open a DNS check-then-use window (a low-TTL or + rebinding DNS answer could differ between this check and the client's + own lookup), which is what pinning to these specific addresses avoids. """ parsed = urlparse(url) if parsed.scheme not in ("http", "https"): @@ -997,7 +1000,7 @@ def _validate_fetch_url(url: str) -> str: addrinfos = socket.getaddrinfo(hostname, None) except socket.gaierror as exc: raise HTTPException(status_code=422, detail=f"Cannot resolve hostname '{hostname}': {exc}") from exc - validated_ip: Optional[str] = None + validated_ips: List[str] = [] for _family, _type, _proto, _canonname, sockaddr in addrinfos: try: ip = ipaddress.ip_address(sockaddr[0]) @@ -1008,28 +1011,33 @@ def _validate_fetch_url(url: str) -> str: status_code=422, detail="Fetching from private, loopback, or reserved network addresses is not allowed.", ) - if validated_ip is None: - validated_ip = sockaddr[0] - if validated_ip is None: + if sockaddr[0] not in validated_ips: + validated_ips.append(sockaddr[0]) + if not validated_ips: raise HTTPException(status_code=422, detail=f"Cannot resolve hostname '{hostname}' to a usable address.") - return validated_ip + return validated_ips -def _make_pinned_session(pinned_ip: str, url: str): - """Build a requests.Session whose connection is pinned to pinned_ip, - regardless of what url's hostname resolves to at connect time. +def _make_pinned_session(pinned_ips: List[str], url: str): + """Build a requests.Session whose connection is pinned to pinned_ips + (tried in order, falling back on connection failure), regardless of + what url's hostname resolves to at connect time. _validate_fetch_url() resolves and validates the hostname once; letting the HTTP client resolve it again independently at connect time reopens the exact gap that validation exists to close — a low-TTL or rebinding DNS answer can differ between the two lookups. This pins the pool's - connect target to the already-validated IP directly (bypassing DNS - resolution for the connection entirely), while keeping the original + connect target to the already-validated addresses directly (bypassing + DNS resolution for the connection entirely), while keeping the original hostname as the outgoing HTTP Host header and, for HTTPS, the TLS SNI server_hostname / assert_hostname — otherwise the connection would reach the right IP but present the wrong identity, breaking name-based virtual hosting and (for HTTPS) certificate hostname verification. + Falls back across every validated address (not just the first) so a + hostname with multiple A/AAAA records doesn't fail outright just + because the first-returned address happens to be unreachable. + Note: urllib3's Connection.host is a property that reads/writes the same underlying value as `_dns_host` in this version — it is NOT the separate "presented identity" field it is in some older releases, so @@ -1038,7 +1046,10 @@ def _make_pinned_session(pinned_ip: str, url: str): `host` directly and restoring the real hostname via an explicit Host header (+ SNI params for HTTPS) is the correct mechanism here. """ + import logging as _pin_logging import requests as _req + import urllib3.util.connection as _u3_connection + from urllib3.exceptions import NewConnectionError parsed = urlparse(url) hostname = parsed.hostname @@ -1046,17 +1057,47 @@ def _make_pinned_session(pinned_ip: str, url: str): default_port = 443 if parsed.scheme == "https" else 80 host_header = hostname if port in (None, default_port) else f"{hostname}:{port}" + class _MultiIPConnectionMixin: + """Overrides _new_conn to fall back across every pinned IP in + order, instead of urllib3's default single-host connect.""" + + def _new_conn(self): + last_exc: Optional[BaseException] = None + for ip in pinned_ips: + try: + return _u3_connection.create_connection( + (ip, self.port), + self.timeout, + source_address=self.source_address, + socket_options=self.socket_options, + ) + except OSError as exc: + last_exc = exc + continue + raise NewConnectionError( + self, f"Failed to establish a connection to any of {pinned_ips}: {last_exc}" + ) + class _PinnedIPHTTPAdapter(_req.adapters.HTTPAdapter): def get_connection_with_tls_context(self, request, verify, proxies=None, cert=None): # If an HTTP(S) proxy applies (env-configured or per-request), - # the actual TCP connection target is the proxy, not the - # resolved IP, and proxy tunneling changes the connection model - # enough that pinning doesn't apply cleanly. Fall back to the - # normal (unpinned) path rather than silently bypassing the - # proxy — _validate_fetch_url's destination check still applies - # either way; only this secondary DNS-pinning hardening is - # skipped. + # pinning can't meaningfully apply: the actual TCP connection + # target is the proxy, and for a forward proxy the *proxy* + # performs its own DNS resolution of the target host on our + # behalf — a resolution this process has no visibility into or + # control over, so there is no client-side fix for that + # specific race. Fall back to the normal (unpinned) path rather + # than silently bypassing the configured proxy. + # _validate_fetch_url's destination classification still fully + # applies either way; only this secondary DNS-pinning hardening + # is inherently out of scope when a proxy is in the path. if _req.utils.select_proxy(request.url, proxies): + _pin_logging.getLogger(__name__).info( + "DNS pinning skipped for %s: a proxy is configured for this " + "request, and proxy-side DNS resolution is outside this " + "process's control.", + request.url, + ) return super().get_connection_with_tls_context( request, verify, proxies=proxies, cert=cert ) @@ -1064,8 +1105,14 @@ def _make_pinned_session(pinned_ip: str, url: str): if host_params.get("scheme") == "https": pool_kwargs.setdefault("assert_hostname", hostname) pool_kwargs.setdefault("server_hostname", hostname) - host_params["host"] = pinned_ip - return self.poolmanager.connection_from_host(**host_params, pool_kwargs=pool_kwargs) + host_params["host"] = pinned_ips[0] + pool = self.poolmanager.connection_from_host(**host_params, pool_kwargs=pool_kwargs) + base_connection_cls = pool.ConnectionCls + if not issubclass(base_connection_cls, _MultiIPConnectionMixin): + pool.ConnectionCls = type( + "_PinnedConnection", (_MultiIPConnectionMixin, base_connection_cls), {} + ) + return pool session = _req.Session() session.headers["Host"] = host_header @@ -1076,12 +1123,12 @@ def _make_pinned_session(pinned_ip: str, url: str): def _fetch_url_sync(url: str) -> bytes: - pinned_ip = _validate_fetch_url(url) + pinned_ips = _validate_fetch_url(url) _MAX_REDIRECTS = 5 current_url = url try: for _ in range(_MAX_REDIRECTS + 1): - session = _make_pinned_session(pinned_ip, current_url) + session = _make_pinned_session(pinned_ips, current_url) try: resp = session.get( current_url, @@ -1099,8 +1146,8 @@ def _fetch_url_sync(url: str) -> bytes: redirect_url = urljoin(current_url, redirect_url) # Re-validate the redirect target to prevent SSRF via # open-redirect to internal/cloud-metadata endpoints, and - # get a fresh pin for the new host. - pinned_ip = _validate_fetch_url(redirect_url) + # get fresh pins for the new host. + pinned_ips = _validate_fetch_url(redirect_url) current_url = redirect_url continue try: diff --git a/tests/explorer/test_ontology_dns_pinning.py b/tests/explorer/test_ontology_dns_pinning.py index cb7791c6..90780ba4 100644 --- a/tests/explorer/test_ontology_dns_pinning.py +++ b/tests/explorer/test_ontology_dns_pinning.py @@ -62,7 +62,7 @@ def test_pinned_session_connects_to_pinned_ip_without_resolving_hostname(): port = server.server_address[1] url = f"http://pinned-test.invalid:{port}/resource" try: - session = ontology_mod._make_pinned_session("127.0.0.1", url) + session = ontology_mod._make_pinned_session(["127.0.0.1"], url) try: resp = session.get(url, timeout=5) assert resp.status_code == 200 @@ -117,7 +117,7 @@ def test_pinned_session_ignores_a_different_real_resolution(): thread.start() url = f"http://localhost:{port}/resource" try: - session = ontology_mod._make_pinned_session("127.0.0.2", url) + session = ontology_mod._make_pinned_session(["127.0.0.2"], url) try: resp = session.get(url, timeout=5) assert resp.status_code == 200 @@ -131,17 +131,76 @@ def test_pinned_session_ignores_a_different_real_resolution(): assert captured["host_header"] == f"localhost:{port}" +def test_pinned_session_falls_back_across_multiple_pinned_ips(): + """A hostname can have multiple A/AAAA records; pinning to only the + first-returned address means a fetch fails outright if that specific + address happens to be unreachable even though a later one would work. + _make_pinned_session must fall back through every pinned IP in order. + """ + server, thread, captured = _start_local_server() + port = server.server_address[1] + url = f"http://pinned-test.invalid:{port}/resource" + # 127.0.0.3 has nothing listening on this port — connection refused, + # forcing a fallback to the second (real) address. + unreachable_ip = "127.0.0.3" + try: + session = ontology_mod._make_pinned_session([unreachable_ip, "127.0.0.1"], url) + try: + resp = session.get(url, timeout=5) + assert resp.status_code == 200 + assert resp.content == b"pinned response" + finally: + session.close() + finally: + server.shutdown() + thread.join(timeout=2) + + +def test_pinned_session_raises_when_every_pinned_ip_is_unreachable(): + """If none of the pinned IPs are reachable, the session must raise + rather than silently falling back to resolving the hostname itself + (which would reopen the exact TOCTOU window pinning exists to close).""" + import requests + + url = "http://pinned-test.invalid:9/resource" # port 9 (discard) — nothing listens + session = ontology_mod._make_pinned_session(["127.0.0.3", "127.0.0.4"], url) + try: + with pytest.raises(requests.exceptions.ConnectionError): + session.get(url, timeout=5) + finally: + session.close() + + def test_validate_fetch_url_returns_the_resolved_ip(): - """_validate_fetch_url must return the IP it validated, so callers can - pin the connection to it.""" + """_validate_fetch_url must return every IP it validated, so callers can + pin the connection to them (with fallback across all of them).""" def fake_getaddrinfo(host, *_a, **_k): return [(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("93.184.216.34", 0))] import unittest.mock as mock with mock.patch.object(ontology_mod.socket, "getaddrinfo", side_effect=fake_getaddrinfo): - resolved_ip = ontology_mod._validate_fetch_url("http://example.org/ontology.ttl") + resolved_ips = ontology_mod._validate_fetch_url("http://example.org/ontology.ttl") - assert resolved_ip == "93.184.216.34" + assert resolved_ips == ["93.184.216.34"] + + +def test_validate_fetch_url_returns_all_validated_ips_deduplicated(): + """A hostname with multiple A/AAAA records must return every distinct + validated address, in resolution order, so the caller can fall back + across all of them rather than failing if only the first is + unreachable.""" + def fake_getaddrinfo(host, *_a, **_k): + return [ + (socket.AF_INET, socket.SOCK_STREAM, 6, "", ("93.184.216.34", 0)), + (socket.AF_INET, socket.SOCK_DGRAM, 17, "", ("93.184.216.34", 0)), # duplicate, different socktype + (socket.AF_INET, socket.SOCK_STREAM, 6, "", ("93.184.216.35", 0)), + ] + + import unittest.mock as mock + with mock.patch.object(ontology_mod.socket, "getaddrinfo", side_effect=fake_getaddrinfo): + resolved_ips = ontology_mod._validate_fetch_url("http://example.org/ontology.ttl") + + assert resolved_ips == ["93.184.216.34", "93.184.216.35"] def test_validate_fetch_url_still_rejects_private_ip(): @@ -175,7 +234,7 @@ def test_validate_fetch_url_still_rejects_private_ip(): def _make_self_signed_cert(hostname: str, tmp_path): import datetime - cryptography = pytest.importorskip("cryptography") + pytest.importorskip("cryptography") from cryptography import x509 from cryptography.hazmat.primitives import hashes, serialization from cryptography.hazmat.primitives.asymmetric import rsa @@ -228,6 +287,7 @@ def _start_local_https_server(cert_path, key_path): server = http.server.HTTPServer(("127.0.0.1", 0), Handler) ssl_ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + ssl_ctx.minimum_version = ssl.TLSVersion.TLSv1_2 ssl_ctx.load_cert_chain(cert_path, key_path) server.socket = ssl_ctx.wrap_socket(server.socket, server_side=True) thread = threading.Thread(target=server.serve_forever, daemon=True) @@ -246,7 +306,7 @@ def test_pinned_https_session_verifies_against_real_hostname_not_pinned_ip(tmp_p port = server.server_address[1] url = f"https://pinned-tls-test.invalid:{port}/resource" try: - session = ontology_mod._make_pinned_session("127.0.0.1", url) + session = ontology_mod._make_pinned_session(["127.0.0.1"], url) try: resp = session.get(url, timeout=5, verify=cert_path) finally: @@ -269,7 +329,7 @@ def test_pinned_https_session_rejects_hostname_mismatch(tmp_path): port = server.server_address[1] url = f"https://wrong-name.invalid:{port}/resource" try: - session = ontology_mod._make_pinned_session("127.0.0.1", url) + session = ontology_mod._make_pinned_session(["127.0.0.1"], url) try: import requests with pytest.raises(requests.exceptions.SSLError): From ea3416ed32d8355b999497568fc983a7a07be011 Mon Sep 17 00:00:00 2001 From: KaifAhmad1 Date: Tue, 11 Aug 2026 19:16:29 +0530 Subject: [PATCH 008/105] fix: enforce a definitive no-proxy policy for the pinned SSRF fetcher MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Qodo's re-review confirmed the multi-IP fallback fix but kept the proxy finding open: logging-and-falling-back when a proxy applies still let the DNS-pinning protection be silently skipped under proxy configuration, rather than enforcing a clear policy either way. Implemented Qodo's preferred option: proxies are now disabled outright for this SSRF-sensitive fetcher via session.trust_env = False, so HTTP_PROXY/HTTPS_PROXY/NO_PROXY env vars are never consulted in the first place (a configured proxy would perform its own DNS resolution of the target host outside this process's control, reopening the DNS check-then-use race pinning exists to close). The adapter also keeps a fail-closed backstop: if a proxy is somehow still configured despite trust_env=False (e.g. set explicitly by future code), it now raises a clear 502 instead of silently connecting through the proxy unpinned. _validate_fetch_url's destination classification (blocking private/ internal targets) is unaffected either way — it runs before any of this and doesn't depend on proxy configuration. 4 new tests: trust_env is disabled on every pinned session; an HTTP_PROXY env var pointed at an address that would fail if contacted is confirmed genuinely unused (real local-server fetch still succeeds directly); and the fail-closed backstop actually raises when a proxy is forced onto the session. Full explorer + triplet_store suite: 572 passed. --- semantica/explorer/routes/ontology.py | 41 +++++++++-------- tests/explorer/test_ontology_dns_pinning.py | 51 +++++++++++++++++++++ 2 files changed, 72 insertions(+), 20 deletions(-) diff --git a/semantica/explorer/routes/ontology.py b/semantica/explorer/routes/ontology.py index 005d8a0f..b50eb206 100644 --- a/semantica/explorer/routes/ontology.py +++ b/semantica/explorer/routes/ontology.py @@ -1046,7 +1046,6 @@ def _make_pinned_session(pinned_ips: List[str], url: str): `host` directly and restoring the real hostname via an explicit Host header (+ SNI params for HTTPS) is the correct mechanism here. """ - import logging as _pin_logging import requests as _req import urllib3.util.connection as _u3_connection from urllib3.exceptions import NewConnectionError @@ -1080,26 +1079,21 @@ def _make_pinned_session(pinned_ips: List[str], url: str): class _PinnedIPHTTPAdapter(_req.adapters.HTTPAdapter): def get_connection_with_tls_context(self, request, verify, proxies=None, cert=None): - # If an HTTP(S) proxy applies (env-configured or per-request), - # pinning can't meaningfully apply: the actual TCP connection - # target is the proxy, and for a forward proxy the *proxy* - # performs its own DNS resolution of the target host on our - # behalf — a resolution this process has no visibility into or - # control over, so there is no client-side fix for that - # specific race. Fall back to the normal (unpinned) path rather - # than silently bypassing the configured proxy. - # _validate_fetch_url's destination classification still fully - # applies either way; only this secondary DNS-pinning hardening - # is inherently out of scope when a proxy is in the path. + # A proxy would perform its own DNS resolution of the target + # host on this process's behalf — a resolution outside this + # process's visibility or control, so there is no client-side + # pin that closes that race. Proxies are disabled outright for + # this SSRF-sensitive fetcher (session.trust_env=False below), + # so this should be unreachable via environment proxies; fail + # closed rather than silently skip pinning if a proxy is + # somehow still configured (e.g. passed explicitly in the + # future). _validate_fetch_url's destination classification is + # a separate, always-enforced check — this only guards the + # secondary DNS-pinning hardening. if _req.utils.select_proxy(request.url, proxies): - _pin_logging.getLogger(__name__).info( - "DNS pinning skipped for %s: a proxy is configured for this " - "request, and proxy-side DNS resolution is outside this " - "process's control.", - request.url, - ) - return super().get_connection_with_tls_context( - request, verify, proxies=proxies, cert=cert + raise HTTPException( + status_code=502, + detail="Proxied requests are not supported for ontology URL fetching.", ) host_params, pool_kwargs = self.build_connection_pool_key_attributes(request, verify, cert) if host_params.get("scheme") == "https": @@ -1115,6 +1109,13 @@ def _make_pinned_session(pinned_ips: List[str], url: str): return pool session = _req.Session() + # Never honor HTTP_PROXY/HTTPS_PROXY/NO_PROXY env vars for this + # SSRF-sensitive fetcher: a configured proxy would perform its own DNS + # resolution of the target host outside this process's control, + # silently reopening the DNS check-then-use race pinning exists to + # close. See _PinnedIPHTTPAdapter.get_connection_with_tls_context for + # the fail-closed backstop if a proxy is somehow still configured. + session.trust_env = False session.headers["Host"] = host_header adapter = _PinnedIPHTTPAdapter() session.mount("http://", adapter) diff --git a/tests/explorer/test_ontology_dns_pinning.py b/tests/explorer/test_ontology_dns_pinning.py index 90780ba4..a68ff192 100644 --- a/tests/explorer/test_ontology_dns_pinning.py +++ b/tests/explorer/test_ontology_dns_pinning.py @@ -171,6 +171,57 @@ def test_pinned_session_raises_when_every_pinned_ip_is_unreachable(): session.close() +def test_pinned_session_disables_environment_proxy_trust(): + """A pinned session must never honor HTTP_PROXY/HTTPS_PROXY env vars — + a proxy would perform its own DNS resolution of the target host outside + this process's control, reopening the exact TOCTOU window pinning + exists to close.""" + session = ontology_mod._make_pinned_session(["127.0.0.1"], "http://example.org/") + try: + assert session.trust_env is False + finally: + session.close() + + +def test_pinned_session_ignores_env_proxy_and_connects_directly(monkeypatch): + """End-to-end: even with HTTP_PROXY pointed at an address that would + fail if contacted, a pinned session must reach the real local server + directly — proving the env var is genuinely not consulted, not just + that the trust_env flag is set.""" + monkeypatch.setenv("HTTP_PROXY", "http://127.0.0.5:1/") # would fail if ever used + server, thread, _captured = _start_local_server() + port = server.server_address[1] + url = f"http://pinned-test.invalid:{port}/resource" + try: + session = ontology_mod._make_pinned_session(["127.0.0.1"], url) + try: + resp = session.get(url, timeout=5) + assert resp.status_code == 200 + assert resp.content == b"pinned response" + finally: + session.close() + finally: + server.shutdown() + thread.join(timeout=2) + + +def test_pinned_session_fails_closed_if_a_proxy_is_explicitly_forced(): + """Backstop: if a proxy is somehow still configured on the session + despite trust_env=False (e.g. set explicitly, as a future code path + might), the adapter must fail closed with a clear error rather than + silently connecting through the proxy unpinned.""" + from fastapi import HTTPException + + session = ontology_mod._make_pinned_session(["127.0.0.1"], "http://example.org/") + session.proxies = {"http": "http://127.0.0.5:1"} + try: + with pytest.raises(HTTPException) as exc_info: + session.get("http://example.org/", timeout=5) + assert exc_info.value.status_code == 502 + finally: + session.close() + + def test_validate_fetch_url_returns_the_resolved_ip(): """_validate_fetch_url must return every IP it validated, so callers can pin the connection to them (with fallback across all of them).""" From f29c4310a11c3481c7d56454761d9de654a28793 Mon Sep 17 00:00:00 2001 From: Mohd Kaif <98801504+KaifAhmad1@users.noreply.github.com> Date: Tue, 11 Aug 2026 22:11:17 +0530 Subject: [PATCH 009/105] security: validate WebSocket Origin against the CORS allowlist (GHSA-4643) (#917) CORSMiddleware doesn't cover WebSocket handshakes at all (Starlette's CORS support only wraps HTTP), so under SEMANTICA_ALLOW_ANONYMOUS=true -- the mode docker-compose.dev.yml ships -- is_valid_api_key's anonymous bypass accepted a /ws/graph-updates connection from any origin. Loopback binding isn't a boundary against a browser: any page the operator has open can still reach ws://localhost:8000/ws/graph-updates directly, and ConnectionManager.broadcast sends every graph_mutation to every connected socket with no per-connection scoping. Combined with /api/import accepting multipart/form-data (a CORS-safelisted content type that skips preflight), a hostile page could write to the graph over REST and read the result back over the unauthenticated WebSocket -- demonstrated end-to-end in the report with a real client. Not affected: any deployment with SEMANTICA_API_KEY configured -- the handshake already rejects without a valid key in that mode. This is an anonymous-mode-only, development-configuration exposure. Fix: check the handshake's Origin header against app.state.explorer_settings['allowed_origins'], the same list CORSMiddleware already enforces for HTTP, before the key check. A missing Origin (native/CLI clients, which never set the header -- only browsers do) is still allowed through, since the browser is the only threat this closes. 4 new tests in test_explorer_auth.py: hostile Origin rejected under anonymous mode; hostile Origin rejected even with a correct key (Origin is checked before the key, so a leaked key alone can't hijack the socket); an allowlisted Origin still connects under anonymous mode; a missing Origin still connects under anonymous mode (native clients keep working). Full explorer suite: 226 passed. Co-authored-by: Sameer Kadam --- semantica/explorer/app.py | 17 ++++++++ tests/explorer/test_explorer_auth.py | 59 ++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+) diff --git a/semantica/explorer/app.py b/semantica/explorer/app.py index 68bab729..bd9ab042 100644 --- a/semantica/explorer/app.py +++ b/semantica/explorer/app.py @@ -193,6 +193,23 @@ def create_app( @app.websocket("/ws/graph-updates") async def websocket_endpoint(websocket: WebSocket): + # CORSMiddleware doesn't cover WebSocket handshakes (Starlette's + # CORS support only wraps HTTP), so under SEMANTICA_ALLOW_ANONYMOUS + # the key check below accepts any origin — loopback binding isn't a + # boundary against a browser, since any page the operator has open + # can still reach ws://localhost:.../ws/graph-updates directly. + # Reject a foreign Origin explicitly here, against the same + # allowlist CORSMiddleware already enforces for HTTP + # (GHSA-4643-wpgq-w329). Browsers always send Origin on a + # cross-origin WebSocket handshake; native/CLI clients omit it + # entirely, so a missing Origin is allowed through — the browser is + # the only threat this check is closing. + origin = websocket.headers.get("origin") + allowed_origins = app.state.explorer_settings["allowed_origins"] + if origin is not None and origin not in allowed_origins: + await websocket.close(code=4403) # forbidden + return + # Browsers can't set custom headers on a WebSocket handshake, so # accept the key via header (non-browser clients) or query param # (browser clients), same SEMANTICA_API_KEY the REST routes check. diff --git a/tests/explorer/test_explorer_auth.py b/tests/explorer/test_explorer_auth.py index 4b7b9c6f..e29461d2 100644 --- a/tests/explorer/test_explorer_auth.py +++ b/tests/explorer/test_explorer_auth.py @@ -150,3 +150,62 @@ def test_websocket_accepts_connection_with_header_key(client, monkeypatch): ) as websocket: ack = websocket.receive_json() assert ack["event"] == "connection_ack" + + +# --------------------------------------------------------------------------- +# WebSocket Origin validation (GHSA-4643-wpgq-w329): CORSMiddleware doesn't +# cover WebSocket handshakes at all, so under SEMANTICA_ALLOW_ANONYMOUS the +# key check alone accepted a handshake from any origin — loopback binding is +# not a boundary against a browser, since any page the operator has open can +# still reach ws://localhost:.../ws/graph-updates. These pin the fix: a +# hostile Origin is refused even in anonymous mode (and even with a correct +# key), a same-origin/allowlisted Origin still works, and a missing Origin +# (native/CLI clients, which never set the header) is still allowed through. +# --------------------------------------------------------------------------- + +def test_websocket_rejects_hostile_origin_under_anonymous_mode(client, monkeypatch): + monkeypatch.setenv("SEMANTICA_ALLOW_ANONYMOUS", "true") + monkeypatch.delenv("SEMANTICA_API_KEY", raising=False) + + with pytest.raises(Exception): + with client.websocket_connect( + "/ws/graph-updates", headers={"Origin": "https://evil.example"} + ): + pass + + +def test_websocket_rejects_hostile_origin_even_with_correct_key(client, monkeypatch): + """Defense in depth: Origin is checked before the API key, so a hostile + page that somehow obtained a valid key still can't hijack the socket.""" + monkeypatch.delenv("SEMANTICA_ALLOW_ANONYMOUS", raising=False) + monkeypatch.setenv("SEMANTICA_API_KEY", "correct-key") + + with pytest.raises(Exception): + with client.websocket_connect( + "/ws/graph-updates", + headers={"Origin": "https://evil.example", "X-API-Key": "correct-key"}, + ): + pass + + +def test_websocket_accepts_allowlisted_origin_under_anonymous_mode(client, monkeypatch): + monkeypatch.setenv("SEMANTICA_ALLOW_ANONYMOUS", "true") + monkeypatch.delenv("SEMANTICA_API_KEY", raising=False) + + with client.websocket_connect( + "/ws/graph-updates", headers={"Origin": "http://localhost:5173"} + ) as websocket: + ack = websocket.receive_json() + assert ack["event"] == "connection_ack" + + +def test_websocket_accepts_missing_origin_under_anonymous_mode(client, monkeypatch): + """Native/CLI clients never send an Origin header — only browsers do — + so a missing Origin must still be allowed through; the browser is the + only threat this check closes.""" + monkeypatch.setenv("SEMANTICA_ALLOW_ANONYMOUS", "true") + monkeypatch.delenv("SEMANTICA_API_KEY", raising=False) + + with client.websocket_connect("/ws/graph-updates") as websocket: + ack = websocket.receive_json() + assert ack["event"] == "connection_ack" From 5b319560fb0b8403644b70bc592864418cdcc740 Mon Sep 17 00:00:00 2001 From: Mohd Kaif <98801504+KaifAhmad1@users.noreply.github.com> Date: Tue, 11 Aug 2026 22:41:19 +0530 Subject: [PATCH 010/105] chore: bump version to 0.6.5 (#918) Security release bundling fixes for GHSA-j4mq (missing auth), GHSA-8c7v (SSRF via redirect bypass), GHSA-482h (Cypher injection), GHSA-8vgg (SPARQL injection), GHSA-4643 (WebSocket Origin validation), and a CodeQL-flagged ReDoS in the SPARQL route validator. --- CHANGELOG.md | 18 +++++++++++++++++- README.md | 18 ++++++++++++------ docs/citation.md | 16 ++++++++-------- docs/faq.md | 2 +- docs/getting-started.md | 2 +- pyproject.toml | 2 +- semantica/__init__.py | 2 +- 7 files changed, 41 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d7bf1ba..34d400d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.6.5] - 2026-08-11 + ### Added - **Embedded Oxigraph backend for `TripletStore`** (#838, closes #834) by @Linxiushen @@ -249,7 +251,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `_validate_fetch_url()` now returns the validated IP, and a new `_make_pinned_session()` builds a per-hop `requests.Session` whose connection pool is pinned directly to that IP — bypassing DNS resolution for the connection entirely — while explicitly restoring the real hostname as the outgoing HTTP `Host` header and, for HTTPS, the TLS SNI `server_hostname`/`assert_hostname`, so the connection reaches the validated IP but still presents (and is verified against) the real hostname's identity, keeping virtual hosting and certificate validation correct - Caught during implementation: an earlier draft set urllib3's `_dns_host` post-construction, assuming (as in some urllib3 releases) that it was decoupled from `host`. In the version this project installs (2.7.0), `host` is a property that reads/writes `_dns_host` directly, so that approach would have silently changed the Host header too — caught by an end-to-end test against a real local server before landing, rather than shipping. Verified with real (non-mocked) local HTTP and HTTPS servers, the latter using a generated self-signed certificate to prove SNI/cert-hostname verification checks the real hostname rather than the pinned IP, plus a negative control confirming a hostname/cert mismatch is still correctly rejected, not silently bypassed - **Object-IRI validation gap** (GHSA-8vgg-8mr4-r236 follow-up, distinct from the object-branch fix already shipped in #911): a triplet object already wrapped in `<...>` skipped `sparql_escaping.validate_uri()` in both `blazegraph_store.py` and `rdf4j_store.py`'s `_format_object_for_sparql`/`_format_object_for_ntriples`, only checking the inner content for a literal space or `>` — the pre-wrapped and unwrapped branches now validate identically - - New `tests/explorer/test_ontology_dns_pinning.py` (6 tests, 4 against real local servers including 2 real-TLS checks, gracefully skipped without the optional `cryptography` package); updated `tests/explorer/test_ontology_ssrf.py` for the new per-hop session construction; 4 new tests in `tests/triplet_store/test_sparql_injection.py` for the object-IRI fix. Full `explorer` + `triplet_store` suite: 566 passed + - **Fixed along the way** (caught in automated review across two follow-up rounds): `_validate_fetch_url()` originally pinned to only the first resolved IP, so a hostname with multiple A/AAAA records would fail outright if that specific address was unreachable — it now returns every validated IP and `_make_pinned_session()` falls back through all of them, verified by pinning to a genuinely unreachable address followed by a working one and confirming the fetch still succeeds; the test HTTPS server allowed TLSv1/TLSv1.1 by not setting a minimum version, now pinned to TLSv1.2; and when an HTTP(S) proxy applied, pinning was silently skipped in favor of the unpinned path — proxies are now disabled outright for this fetcher (`session.trust_env = False`, so `HTTP_PROXY`/`HTTPS_PROXY` env vars are never consulted) with a fail-closed 502 backstop if a proxy is ever forced onto the session some other way, verified by pointing `HTTP_PROXY` at an address that would fail if actually used and confirming the fetch still succeeds directly + - New `tests/explorer/test_ontology_dns_pinning.py` (12 tests: real local HTTP/HTTPS servers including 2 real-TLS checks, multi-IP fallback success/failure, and no-proxy-trust verification — gracefully skipped without the optional `cryptography` package where applicable); updated `tests/explorer/test_ontology_ssrf.py` for the new per-hop session construction; 4 new tests in `tests/triplet_store/test_sparql_injection.py` for the object-IRI fix. Full `explorer` + `triplet_store` suite: 572 passed + +- **Missing Origin validation on the `/ws/graph-updates` WebSocket handshake** (#917, GHSA-4643-wpgq-w329) by @KaifAhmad1 + - `CORSMiddleware` doesn't cover WebSocket handshakes at all (Starlette's CORS support only wraps HTTP), so under `SEMANTICA_ALLOW_ANONYMOUS=true` — the mode `docker-compose.dev.yml` ships — the anonymous-mode key bypass accepted a `/ws/graph-updates` connection from any origin. Loopback binding isn't a boundary against a browser: any page the operator has open can still reach `ws://localhost:8000/ws/graph-updates` directly, and `ConnectionManager.broadcast` sends every `graph_mutation` to every connected socket with no per-connection scoping. Combined with `/api/import` accepting `multipart/form-data` (a CORS-safelisted content type that skips preflight), a hostile page could write to the graph over REST and read the result back over the unauthenticated WebSocket + - Not affected: any deployment with `SEMANTICA_API_KEY` configured — the handshake already rejects without a valid key in that mode. This was an anonymous-mode-only, development-configuration exposure + - Fix: check the handshake's `Origin` header against `app.state.explorer_settings['allowed_origins']` — the same list `CORSMiddleware` already enforces for HTTP — before the key check. A missing `Origin` (native/CLI clients, which never set the header) is still allowed through, since the browser is the only threat this closes + - 4 new tests in `tests/explorer/test_explorer_auth.py`: hostile Origin rejected under anonymous mode; hostile Origin rejected even with a correct key (Origin is checked first, so a leaked key alone can't hijack the socket); an allowlisted Origin still connects; a missing Origin still connects. Full `explorer` suite: 226 passed + +- **Polynomial-time ReDoS in the SPARQL route's `_PREFIX_DECL` regex** (#915, CodeQL `py/polynomial-redos`) by @Sameer6305 + - The prior pattern's trailing `\s*` overlapped with the preceding `<[^>]*>` IRI-body match on inputs containing no closing `>` (e.g. `base<` followed by thousands of `!<` repetitions), forcing the regex engine to explore every possible split between the two quantifiers — O(n²) backtracking reachable from `req.query` via `_is_read_only_query()` + - Fixed by making the two quantifiers character-disjoint: horizontal whitespace only (`[ \t]`, never overlapping the IRI body) instead of `\s*`, and excluding CR/LF from the IRI body (`[^>\r\n]*`) so it can never span a line boundary. Independently verified: the exact pathological payload (`base<` + `!<` × 5,000/20,000) scales linearly (0.238ms → 0.841ms for 4x input, not the ~16x a surviving quadratic blowup would show) + - Added `_SPARQL_MAX_QUERY_LEN = 10_000` as defense-in-depth, checked in `execute_sparql()` before any regex work so a future pattern regression stays bounded regardless + - Two correctness regressions raised in review were checked and did not reproduce: comment-then-prefix stripping order means an inline comment after a `PREFIX` line (`PREFIX ex: <...> # comment`) is already gone by the time `_PREFIX_DECL` runs, verified directly against the pipeline; and the allowlist's `.sub()`-based cleaning only ever affects the yes/no decision, never the query actually sent to `graph.query()` — so even the narrow case of a multi-line string literal that happens to start a line with the literal text `PREFIX` or `BASE` can only cause a legitimate query to be wrongly rejected, never let something malicious through, since rdflib's parser still gates whatever actually executes + - 20 new/updated tests in `tests/explorer/test_sparql_route.py` and `tests/test_security_regression.py` (inline prologues, CRLF line endings, multi-line CRLF prefix chains, oversized-query rejection). 225 `explorer` + 82 SPARQL-specific tests passing - **SPARQL injection via unvalidated triplet IRIs** (#911, GHSA-8vgg-8mr4-r236) by @KaifAhmad1 - `Triplet.subject`/`.predicate` (and, in some builders, `.object`) were interpolated directly into SPARQL update/query strings in the Blazegraph and RDF4J stores, and into a SELECT filter in the Jena store. A subject containing `>` closes the `<...>` IRI token early, so the rest of the value is parsed as more SPARQL. Entity names are document text in the normal ingest pipeline, so anyone whose content gets processed could append operations like `CLEAR ALL`, running with the application's store credentials diff --git a/README.md b/README.md index 745c54e4..fbc20781 100644 --- a/README.md +++ b/README.md @@ -132,7 +132,7 @@ compliant = graph.check_decision_rules({"category": "vendor_selection"}) # poli ```bash semantica doctor # Python 3.11.9 pass -# semantica 0.6.0 pass +# semantica 0.6.5 pass # faiss vector store pass # Config file pass ~/.semantica/config.yaml ``` @@ -1474,12 +1474,18 @@ For contributor / dev-server setup: **[explorer/README.md: Local Setup Guide](ex --- -## What's New in v0.6.0 +## What's New in v0.6.5 -- **Named-Graph Support for `JenaStore`:** Migrated onto `rdflib.Dataset(default_union=False)`, completing cross-backend named-graph parity across Blazegraph, RDF4J, and Jena; `add_triplets()` gains a `graph=` option -- **SPARQL CONSTRUCT Query Templates:** Parameterized, injection-safe `CONSTRUCT` templates extended from Blazegraph-only to RDF4J and Jena, plus pipeline integration via the `construct_template` step type -- **Databricks Connector:** `DatabricksIngestor` for Unity Catalog + Delta Lake ingestion, with PAT/OAuth M2M auth, table/query ingestion, and catalog/schema/table/lineage introspection. Install with `pip install "semantica[db-databricks]"` -- **SQLite Vector Store Backend:** `SQLiteVecStore`, a disk-backed local vector store on `sqlite-vec`'s `vec0` virtual tables, with Cosine/L2 metrics, metadata filtering, and WAL mode. Install with `pip install semantica[vectorstore-sqlite]` +**Security release — upgrading is strongly recommended.** Fixes for 5 externally-reported vulnerabilities in the Explorer API and graph/triplet store backends, plus a CodeQL-flagged ReDoS: + +- **Missing authentication on all Explorer API routes** (GHSA-j4mq-hprp-987v, Critical): every route now requires `SEMANTICA_API_KEY`, fails closed (503) rather than open when unconfigured +- **SSRF via redirect bypass in ontology URL fetching** (GHSA-8c7v-62gr-hj6g, High): redirect targets are now re-validated at every hop and the connection is pinned to the validated address, closing a DNS check-then-use race +- **Cypher injection via unvalidated node labels and property keys** (GHSA-482h-hw99-h62p, Critical): Neptune, Neo4j, and FalkorDB now sanitize every label/relationship-type/property-key interpolation site +- **SPARQL injection via unvalidated triplet IRIs** (GHSA-8vgg-8mr4-r236, Critical): Blazegraph, RDF4J, and Jena now validate subject/predicate/object IRIs before interpolation +- **Missing Origin validation on the WebSocket handshake** (GHSA-4643-wpgq-w329, Moderate, anonymous-mode only): `/ws/graph-updates` now checks `Origin` against the same allowlist `CORSMiddleware` enforces for HTTP +- **Polynomial ReDoS in SPARQL query validation** (CodeQL `py/polynomial-redos`): fixed a backtracking regex in the Explorer's SPARQL route + +Also includes: embedded Oxigraph backend for `TripletStore`, PROV-O trust/spec completeness for `ProvenanceManager`, and the Altair Anzo triplet store backend. → [Full release notes](RELEASE_NOTES.md) · [Changelog](CHANGELOG.md) diff --git a/docs/citation.md b/docs/citation.md index e33c566f..d1077887 100644 --- a/docs/citation.md +++ b/docs/citation.md @@ -13,33 +13,33 @@ icon: "quote-left" ```bibtex @software{semantica2026, - title = {Semantica: An Open Source Framework for Semantic Layers and Knowledge Engineering}, - author = {Hawksight AI}, + title = {Semantica: Graph-Native Infrastructure for Context and Accountable AI Systems}, + author = {Semantica}, year = {2026}, url = {https://github.com/semantica-agi/semantica}, - version = {0.6.0}, + version = {0.6.5}, doi = {10.5281/zenodo.XXXXXXX} } ``` - Hawksight AI. (2026). *Semantica: An Open Source Framework for Semantic Layers and Knowledge Engineering* (Version 0.6.0) \[Computer software\]. https://github.com/semantica-agi/semantica + Semantica. (2026). *Semantica: Graph-Native Infrastructure for Context and Accountable AI Systems* (Version 0.6.5) \[Computer software\]. https://github.com/semantica-agi/semantica - Hawksight AI. *Semantica: An Open Source Framework for Semantic Layers and Knowledge Engineering*. Version 0.6.0, GitHub, 2026, https://github.com/semantica-agi/semantica. + Semantica. *Semantica: Graph-Native Infrastructure for Context and Accountable AI Systems*. Version 0.6.5, GitHub, 2026, https://github.com/semantica-agi/semantica. - Hawksight AI. *Semantica: An Open Source Framework for Semantic Layers and Knowledge Engineering*. Version 0.6.0. GitHub, 2026. https://github.com/semantica-agi/semantica. + Semantica. *Semantica: Graph-Native Infrastructure for Context and Accountable AI Systems*. Version 0.6.5. GitHub, 2026. https://github.com/semantica-agi/semantica. - Hawksight AI, "Semantica: An Open Source Framework for Semantic Layers and Knowledge Engineering," Version 0.6.0, GitHub, 2026. \[Online\]. Available: https://github.com/semantica-agi/semantica + Semantica, "Semantica: Graph-Native Infrastructure for Context and Accountable AI Systems," Version 0.6.5, GitHub, 2026. \[Online\]. Available: https://github.com/semantica-agi/semantica ## Acknowledgment Text -> "This work uses Semantica (Hawksight AI, 2026), an open-source framework for semantic layer construction and knowledge engineering." +> "This work uses Semantica (2026), an open-source graph-native infrastructure framework for context and accountable AI systems, providing Context Graphs, knowledge graphs, and full decision provenance." ## Share Your Research diff --git a/docs/faq.md b/docs/faq.md index 8cead87c..e050df4c 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -17,7 +17,7 @@ icon: "circle-question" | API key required? | Optional: pattern extraction works with no keys | | Works with LangChain / LlamaIndex? | Yes: Semantica is a layer on top, not a replacement | | Production-ready? | Yes: 1,000+ tests, v0.5.0 ships with 12 security fixes | -| Latest version? | **v0.6.0** (July 2026) | +| Latest version? | **v0.6.5** (August 2026) | | Local LLMs? | Yes: Ollama via LiteLLM, HuggingFaceLLM for air-gapped | diff --git a/docs/getting-started.md b/docs/getting-started.md index 20aeffef..ee442fed 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -42,7 +42,7 @@ icon: "rocket" Verify installation: ```python import semantica - print(semantica.__version__) # 0.6.0 + print(semantica.__version__) # 0.6.5 ``` diff --git a/pyproject.toml b/pyproject.toml index d6f9f3bd..7a65ae3c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "semantica" -version = "0.6.0" +version = "0.6.5" description = "Accountability and context layer for AI agents. Context graphs, decision intelligence, full provenance tracking, and explainable reasoning engines — every AI decision traceable, every output auditable." readme = "README.md" license = { text = "MIT" } diff --git a/semantica/__init__.py b/semantica/__init__.py index e5f0ead9..79f83236 100644 --- a/semantica/__init__.py +++ b/semantica/__init__.py @@ -10,7 +10,7 @@ Main exports: - Config: Configuration management """ -__version__ = "0.6.0" +__version__ = "0.6.5" __author__ = "Semantica Contributors" __license__ = "MIT" From 918830a82181cd4547e72da3e1f08d3a141b0270 Mon Sep 17 00:00:00 2001 From: Karunasagar Mohansundar <52268863+Karunasagar12@users.noreply.github.com> Date: Wed, 12 Aug 2026 05:19:54 +0530 Subject: [PATCH 011/105] fix(pipeline): resolve broken import and missing `run()` in `PipelineWithProvenance` (#862) * fix(pipeline): resolve broken import and missing run() in PipelineWithProvenance Fix two bugs in pipeline_provenance.py: 1. Wrong import path: `from .pipeline import Pipeline` fails because `semantica/pipeline/pipeline.py` does not exist. Pipeline lives in `pipeline_builder.py`. Fixed to `from .pipeline_builder import Pipeline`. 2. Pipeline dataclass has no run() method. PipelineWithProvenance.run() now delegates to ExecutionEngine.execute_pipeline(), which is the intended execution path for built pipelines. Additional changes: - Constructor now accepts a built Pipeline instance (breaking the previous unusable API that tried to instantiate a dataclass with **config). - Replace deprecated datetime.utcnow() with datetime.now(timezone.utc). - Add test suite covering import, instantiation, execution, attribute delegation, and provenance graceful degradation. Fixes #858 * test: address Qodo review findings - Remove redundant test_import_succeeds (module-level import already guards against import regression at collection time). - Fix test_provenance_disabled_when_import_fails to deterministically simulate ImportError via sys.modules patch and assert provenance is actually toggled off (runner.provenance is False). * fix(pipeline): update provenance callers for Pipeline API --------- Co-authored-by: Sameer Kadam Co-authored-by: Russell Jurney --- CHANGELOG.md | 6 ++ semantica/pipeline/pipeline_provenance.py | 61 ++++++++++++------ semantica/provenance/provenance_usage.md | 10 ++- tests/pipeline/test_pipeline_provenance.py | 64 +++++++++++++++++++ .../provenance/test_all_provenance_modules.py | 25 +++++++- .../provenance/test_provenance_edge_cases.py | 7 +- .../test_real_module_integration.py | 15 +++-- 7 files changed, 156 insertions(+), 32 deletions(-) create mode 100644 tests/pipeline/test_pipeline_provenance.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 34d400d0..8efdcd18 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -71,6 +71,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **`PipelineWithProvenance` raised `ModuleNotFoundError` on import and `AttributeError` on `.run()`** (#858, closes #858) by @Karunasagar12 + - `from .pipeline import Pipeline` failed because `semantica/pipeline/pipeline.py` does not exist; corrected to `from .pipeline_builder import Pipeline` + - `.run()` called `self._pipeline.run()` on the `Pipeline` dataclass, which has no such method; replaced with `self._engine.execute_pipeline(self._pipeline, ...)` delegating to `ExecutionEngine` + - Constructor now accepts a built `Pipeline` instance (from `PipelineBuilder.build()`) instead of `**config`; the old `Pipeline(**config)` internal construction was invalid and never functional + - Replaced deprecated `datetime.utcnow()` with `datetime.now(timezone.utc)` in `run()` + - **`VectorStore.search_vectors()` returned inconsistent result shapes across backend implementations** (#853, closes #845) by @Sameer6305, reviewed by @KaifAhmad1 - Every built-in backend (FAISS, Milvus, pgvector, Pinecone, Qdrant, SQLite-vec, Weaviate, in-memory) now returns the same canonical `SearchResult` shape (`id`, `score`, `metadata`, `vector`, `distance`), instead of some backends omitting `vector`/`metadata`/`distance` or, for Weaviate, returning a backend-specific `properties` key instead of `metadata` - Added a `SearchResult` `TypedDict` (`semantica/vector_store/vector_store.py`, exported from `semantica.vector_store`) documenting the contract; `metadata` now always defaults to `{}` rather than being absent, and `id` accepts `Union[str, int]` to accommodate Milvus/Qdrant's native integer IDs without casting diff --git a/semantica/pipeline/pipeline_provenance.py b/semantica/pipeline/pipeline_provenance.py index b5c7c99b..a77d7c9a 100644 --- a/semantica/pipeline/pipeline_provenance.py +++ b/semantica/pipeline/pipeline_provenance.py @@ -6,9 +6,14 @@ capturing all steps, inputs, outputs, and transformations. Usage: from semantica.pipeline.pipeline_provenance import PipelineWithProvenance - - pipeline = PipelineWithProvenance(provenance=True) - result = pipeline.run(data) + from semantica.pipeline import PipelineBuilder + + builder = PipelineBuilder() + builder.add_step("ingest", "file_ingest") + pipeline = builder.build("my_pipeline") + + runner = PipelineWithProvenance(pipeline, provenance=True) + result = runner.run(data) # Tracks all pipeline steps with complete lineage Author: Semantica Contributors @@ -16,26 +21,37 @@ License: MIT """ from typing import Optional, Any, Dict, List -from datetime import datetime +from datetime import datetime, timezone import uuid import time +from .pipeline_builder import Pipeline +from .execution_engine import ExecutionEngine + class PipelineWithProvenance: """Pipeline executor with complete provenance tracking.""" - + def __init__( self, + pipeline: Pipeline, provenance: bool = False, agent_id: Optional[str] = None, is_automated: bool = True, - **config, + **engine_config, ): - """Initialize pipeline with optional provenance.""" - from .pipeline import Pipeline + """Initialize provenance-tracked pipeline runner. + Args: + pipeline: A built Pipeline instance (from PipelineBuilder.build()). + provenance: Whether to record provenance metadata. + agent_id: Identifier for the agent running the pipeline. + is_automated: Whether the execution is automated (vs. human-triggered). + **engine_config: Extra keyword arguments forwarded to ExecutionEngine. + """ + self._pipeline = pipeline + self._engine = ExecutionEngine(**engine_config) self.provenance = provenance - self._pipeline = Pipeline(**config) self._prov_manager = None self._agent_id = agent_id or self.__class__.__name__ self._is_automated = is_automated @@ -47,15 +63,24 @@ class PipelineWithProvenance: except ImportError: self.provenance = False - def run(self, data: Any, source: Optional[str] = None, **kwargs): - """Run pipeline with provenance tracking.""" + def run(self, data: Any = None, source: Optional[str] = None, **kwargs): + """Run pipeline with provenance tracking. + + Args: + data: Input data to feed into the pipeline. + source: Provenance source label (defaults to "pipeline_execution"). + **kwargs: Extra options forwarded to ExecutionEngine.execute_pipeline(). + + Returns: + ExecutionResult from the engine. + """ pipeline_id = f"pipeline_{uuid.uuid4().hex[:8]}" start_time = time.time() - activity_started_at_time = datetime.utcnow().isoformat() + activity_started_at_time = datetime.now(timezone.utc).isoformat() - result = self._pipeline.run(data, **kwargs) + result = self._engine.execute_pipeline(self._pipeline, data=data, **kwargs) elapsed = time.time() - start_time - activity_ended_at_time = datetime.utcnow().isoformat() + activity_ended_at_time = datetime.now(timezone.utc).isoformat() if self.provenance and self._prov_manager: self._prov_manager.track_entity( @@ -69,14 +94,14 @@ class PipelineWithProvenance: activity_started_at_time=activity_started_at_time, activity_ended_at_time=activity_ended_at_time, metadata={ - "steps": len(self._pipeline.steps) if hasattr(self._pipeline, 'steps') else 0, + "steps": len(self._pipeline.steps), "duration_seconds": elapsed, - "status": "completed" + "status": "completed" if result.success else "failed", } ) - + return result - + def __getattr__(self, name): return getattr(self._pipeline, name) diff --git a/semantica/provenance/provenance_usage.md b/semantica/provenance/provenance_usage.md index db667038..7113826f 100644 --- a/semantica/provenance/provenance_usage.md +++ b/semantica/provenance/provenance_usage.md @@ -399,12 +399,16 @@ response = llm.generate("What is artificial intelligence?") ```python from semantica.pipeline.pipeline_provenance import PipelineWithProvenance +from semantica.pipeline import PipelineBuilder -# Create pipeline with provenance -pipeline = PipelineWithProvenance(provenance=True) +builder = PipelineBuilder() +builder.add_step("ingest", "file_ingest") +pipeline = builder.build("my_pipeline") + +runner = PipelineWithProvenance(pipeline, provenance=True) # Run pipeline - all steps tracked -result = pipeline.run( +result = runner.run( data=input_data, source="input_file.json" ) diff --git a/tests/pipeline/test_pipeline_provenance.py b/tests/pipeline/test_pipeline_provenance.py new file mode 100644 index 00000000..1756df08 --- /dev/null +++ b/tests/pipeline/test_pipeline_provenance.py @@ -0,0 +1,64 @@ +"""Tests for PipelineWithProvenance. + +Verifies that: +1. The import path is correct (no ModuleNotFoundError). +2. PipelineWithProvenance accepts a built Pipeline and runs it via ExecutionEngine. +3. Provenance tracking gracefully degrades when the provenance package is absent. +""" + +import sys +from unittest.mock import patch + +import pytest + +from semantica.pipeline import PipelineBuilder +from semantica.pipeline.pipeline_provenance import PipelineWithProvenance +from semantica.pipeline.execution_engine import ExecutionResult + + +class TestPipelineWithProvenance: + """Tests for PipelineWithProvenance.""" + + @pytest.fixture + def simple_pipeline(self): + """Build a minimal two-step pipeline for testing.""" + builder = PipelineBuilder() + builder.add_step("ingest", "file_ingest") + builder.add_step("parse", "document_parse") + return builder.build("test_provenance_pipeline") + + def test_instantiation_with_pipeline(self, simple_pipeline): + """Should accept a built Pipeline instance.""" + runner = PipelineWithProvenance(simple_pipeline, provenance=False) + assert runner._pipeline is simple_pipeline + + def test_run_returns_execution_result(self, simple_pipeline): + """run() should delegate to ExecutionEngine and return an ExecutionResult.""" + runner = PipelineWithProvenance(simple_pipeline, provenance=False) + result = runner.run() + assert isinstance(result, ExecutionResult) + assert result.success is True + + def test_getattr_delegates_to_pipeline(self, simple_pipeline): + """Attribute access should fall through to the wrapped Pipeline.""" + runner = PipelineWithProvenance(simple_pipeline, provenance=False) + assert runner.name == "test_provenance_pipeline" + assert len(runner.steps) == 2 + + def test_provenance_disabled_when_import_fails(self, simple_pipeline): + """When semantica.provenance is unavailable, provenance should be disabled.""" + # Force the provenance import to raise ImportError + with patch.dict(sys.modules, {"semantica.provenance": None}): + runner = PipelineWithProvenance(simple_pipeline, provenance=True) + assert runner.provenance is False + assert runner._prov_manager is None + # Should still execute successfully without provenance + result = runner.run() + assert isinstance(result, ExecutionResult) + assert result.success is True + + def test_run_with_data(self, simple_pipeline): + """run() should accept data and kwargs without error.""" + runner = PipelineWithProvenance(simple_pipeline, provenance=False) + result = runner.run(data={"key": "value"}) + assert isinstance(result, ExecutionResult) diff --git a/tests/provenance/test_all_provenance_modules.py b/tests/provenance/test_all_provenance_modules.py index 0d5e5bd4..f0dc25e9 100644 --- a/tests/provenance/test_all_provenance_modules.py +++ b/tests/provenance/test_all_provenance_modules.py @@ -167,7 +167,6 @@ class TestProvenanceEnabledDisabled: """Test all modules work with provenance=False.""" modules_to_test = [ ('semantica.context.context_provenance', 'ContextManagerWithProvenance'), - ('semantica.pipeline.pipeline_provenance', 'PipelineWithProvenance'), ] for module_path, class_name in modules_to_test: @@ -183,7 +182,6 @@ class TestProvenanceEnabledDisabled: """Test all modules work with provenance=True.""" modules_to_test = [ ('semantica.context.context_provenance', 'ContextManagerWithProvenance'), - ('semantica.pipeline.pipeline_provenance', 'PipelineWithProvenance'), ] for module_path, class_name in modules_to_test: @@ -195,6 +193,23 @@ class TestProvenanceEnabledDisabled: except ImportError: pytest.skip(f"{module_path} not available") + def test_pipeline_with_provenance_supports_provenance_flag(self): + """PipelineWithProvenance accepts provenance=True/False. + + PipelineWithProvenance requires a built Pipeline instance (unlike + other *WithProvenance wrappers that own their internal state), so it + cannot participate in the generic no-argument constructor loop above. + """ + try: + from semantica.pipeline.pipeline_builder import Pipeline + from semantica.pipeline.pipeline_provenance import PipelineWithProvenance + + pipeline = Pipeline(name="compat_test") + assert PipelineWithProvenance(pipeline, provenance=False).provenance is False + assert PipelineWithProvenance(pipeline, provenance=True).provenance is True + except ImportError: + pytest.skip("pipeline_provenance not available") + class TestAllModulesEdgeCases: """Test edge cases across all provenance modules.""" @@ -219,10 +234,14 @@ class TestAllModulesEdgeCases: """Test each module has independent provenance manager.""" try: from semantica.context.context_provenance import ContextManagerWithProvenance + from semantica.pipeline.pipeline_builder import Pipeline from semantica.pipeline.pipeline_provenance import PipelineWithProvenance ctx = ContextManagerWithProvenance(provenance=True) - pipe = PipelineWithProvenance(provenance=True) + pipe = PipelineWithProvenance( + Pipeline(name="independence_test"), + provenance=True, + ) # Each should have its own manager assert ctx._prov_manager is not None diff --git a/tests/provenance/test_provenance_edge_cases.py b/tests/provenance/test_provenance_edge_cases.py index 9d0c1f0f..92329482 100644 --- a/tests/provenance/test_provenance_edge_cases.py +++ b/tests/provenance/test_provenance_edge_cases.py @@ -154,10 +154,13 @@ class TestModuleSpecificEdgeCases: def test_pipeline_with_empty_data(self): """Test pipeline with empty data.""" try: + from semantica.pipeline.pipeline_builder import Pipeline from semantica.pipeline.pipeline_provenance import PipelineWithProvenance - pipeline = PipelineWithProvenance(provenance=True) + + pipeline = Pipeline(name="edge_case_test") + runner = PipelineWithProvenance(pipeline, provenance=True) # Should handle empty data - assert pipeline is not None + assert runner is not None except ImportError: pytest.skip("Pipeline not available") diff --git a/tests/provenance/test_real_module_integration.py b/tests/provenance/test_real_module_integration.py index 5dceebed..cd177438 100644 --- a/tests/provenance/test_real_module_integration.py +++ b/tests/provenance/test_real_module_integration.py @@ -29,14 +29,17 @@ class TestRealModuleIntegration: def test_pipeline_real_execution_tracking(self): """Test pipeline tracks execution with provenance.""" try: + from semantica.pipeline.pipeline_builder import Pipeline from semantica.pipeline.pipeline_provenance import PipelineWithProvenance - - pipeline = PipelineWithProvenance(provenance=True) - + + pipeline = Pipeline(name="provenance_tracking_test") + runner = PipelineWithProvenance(pipeline, provenance=True) + # Verify provenance setup - assert pipeline.provenance is True - assert pipeline._prov_manager is not None - + assert runner.provenance is True + assert runner._prov_manager is not None + assert isinstance(runner._prov_manager, ProvenanceManager) + except ImportError: pytest.skip("Pipeline not available") From cab995dc9770bc1cd3e6756e0ccabc4b0f109b6e Mon Sep 17 00:00:00 2001 From: KaifAhmad1 Date: Wed, 12 Aug 2026 12:46:23 +0530 Subject: [PATCH 012/105] fix: address code review findings in backend metadata filtering - pinecone_store: call self.index.describe_index_stats() instead of the nonexistent self.describe_index_stats(), and use a unit query vector instead of an all-zero vector so filter_by_metadata() works on cosine-metric indexes (the library's own default) - pgvector_store: apply the existing lowercase true/false bool handling to the list-filter branch too, and use the jsonb ?| operator so list-valued metadata fields match on intersection instead of being compared as a single JSON-text blob - sqlite_vec_store: use json_each() with a json_type guard so list-valued metadata fields match on intersection, mirroring the in-memory backend's set-intersection semantics - faiss_store: filter_by_metadata(limit=0) now returns [] instead of one result - milvus_store: reject NaN/Infinity filter values up front with a clear ValidationError instead of building an invalid expression that gets silently swallowed - update the #848 FAISS NotImplementedError test to reflect that FAISS now implements real filter_by_metadata() (this PR's whole point) - add regression tests for each fix; sqlite tests run against the real sqlite-vec extension --- CHANGELOG.md | 12 +++ semantica/vector_store/faiss_store.py | 3 + semantica/vector_store/milvus_store.py | 6 ++ semantica/vector_store/pgvector_store.py | 23 ++++- semantica/vector_store/pinecone_store.py | 11 ++- semantica/vector_store/sqlite_vec_store.py | 15 +++- .../test_backend_metadata_filtering.py | 86 ++++++++++++++++++- .../test_decision_embedding_pipeline.py | 54 ++++++------ tests/vector_store/test_sqlite_vec_store.py | 48 +++++++++++ 9 files changed, 219 insertions(+), 39 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8efdcd18..c5c574b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- **`VectorStore._filter_by_metadata()` `AttributeError` on all persistent backends** (#857, closes #849) by @TaherTadpatri + - `_filter_by_metadata()` iterated `self.metadata` directly, which only exists on the `inmemory` backend — any persistent backend (`faiss`, `qdrant`, `pinecone`, `milvus`, `pgvector`, `sqlite`, `weaviate`) crashed with `AttributeError` on `filter_decisions(query=None, ...)` / metadata-only filtering. Filtering is now delegated to a native `filter_by_metadata()` implemented on each backend store, using backend-native payload/SQL/JSON filtering (Qdrant `scroll()`, Pinecone `query()`, Milvus expression filters, PostgreSQL JSONB, SQLite `json_extract()`, Weaviate collection filters) + - **Fixed along the way**: `PineconeStore.get_index()` and `filter_by_metadata()` called a nonexistent `self.describe_index_stats()` on the store itself (the method only exists on the `PineconeIndex` wrapper returned by `self.index`); the resulting `AttributeError` was silently swallowed, so dimension auto-detection always failed quietly. Now correctly calls `self.index.describe_index_stats()` + - **Fixed along the way**: `PineconeStore.filter_by_metadata()` probed for filter-only matches using an all-zero dummy query vector, which Pinecone rejects for cosine-metric indexes — the library's own default — making metadata-only filtering silently non-functional out of the box. Now uses a unit vector instead + - **Fixed along the way**: `PgVectorStore.filter_by_metadata()`'s list-filter branch formatted boolean values with `str(v)` (`'True'`/`'False'`), never matching PostgreSQL JSONB's lowercase `'true'`/`'false'` text rendering, even though the equivalent scalar-filter branch already handled this correctly + - **Fixed along the way**: list-valued metadata fields (e.g. `{"tags": ["python", "js"]}`) could never match a list filter on the SQLite or PostgreSQL backends, because both extracted the whole array as its JSON/text representation instead of matching individual elements — silently diverging from the in-memory backend's set-intersection semantics. SQLite now uses `json_each()` over a `json_type`-guarded array/scalar wrapper; PostgreSQL now uses the `?|` "any array element" operator alongside the existing scalar `= ANY(...)` path + - **Fixed along the way**: `FAISSStore.filter_by_metadata(limit=0)` returned one result instead of zero, because the limit check ran after appending the current match + - **Fixed along the way**: `MilvusStore`'s metadata expression builder rendered `NaN`/`Infinity` filter values as bare unquoted tokens, producing an invalid Milvus expression whose server-side rejection was then swallowed by a broad `except`, indistinguishable from "no matches"; these values are now rejected up front with a clear `ValidationError` + - New/expanded test coverage in `tests/vector_store/test_backend_metadata_filtering.py` (all 7 backends, including the Pinecone dimension/zero-vector, PgVector boolean-list, FAISS `limit=0`, and Milvus `NaN` regressions) and `tests/vector_store/test_sqlite_vec_store.py` (new `TestSQLiteVecStoreFilterByMetadata`, run against the real `sqlite-vec` extension, including the array-vs-scalar intersection case) + ## [0.6.5] - 2026-08-11 ### Added diff --git a/semantica/vector_store/faiss_store.py b/semantica/vector_store/faiss_store.py index 2e39266f..54df70a4 100644 --- a/semantica/vector_store/faiss_store.py +++ b/semantica/vector_store/faiss_store.py @@ -508,6 +508,9 @@ class FAISSStore: from .vector_store import _matches_filter + if limit <= 0: + return [] + results = [] for vector_id, metadata in self.index.metadata.items(): if _matches_filter(metadata, filters): diff --git a/semantica/vector_store/milvus_store.py b/semantica/vector_store/milvus_store.py index 2a213859..f467eeb4 100644 --- a/semantica/vector_store/milvus_store.py +++ b/semantica/vector_store/milvus_store.py @@ -35,6 +35,7 @@ Author: Semantica Contributors License: MIT """ +import math import re from typing import Any, Dict, List, Optional, Union @@ -57,6 +58,11 @@ def _format_milvus_value(val: Any) -> str: if isinstance(val, bool): return "true" if val else "false" elif isinstance(val, (int, float)): + if isinstance(val, float) and not math.isfinite(val): + raise ValidationError( + f"Invalid metadata filter value: {val!r}. NaN/Infinity are not " + "valid Milvus expression literals." + ) return str(val) elif isinstance(val, str): escaped = ( diff --git a/semantica/vector_store/pgvector_store.py b/semantica/vector_store/pgvector_store.py index bfed4512..e1ce8ed0 100644 --- a/semantica/vector_store/pgvector_store.py +++ b/semantica/vector_store/pgvector_store.py @@ -697,10 +697,25 @@ class PgVectorStore: )) filter_values.append(value["max"]) elif isinstance(value, list): - filter_conditions.append(psycopg_sql.SQL("metadata->>{} = ANY(%s)").format( - psycopg_sql.Literal(key) - )) - filter_values.append([str(v) for v in value]) + # Same lowercase-bool rule as the scalar branch below: ->> renders + # JSON booleans as 'true'/'false', not str()'s 'True'/'False'. + str_values = [ + ('true' if v else 'false') if isinstance(v, bool) else str(v) + for v in value + ] + # If the metadata value at this key is itself a JSON array, match on + # intersection (mirrors the in-memory backend's set-intersection + # semantics) via the jsonb `?|` "any array element matches" operator; + # otherwise fall back to plain scalar membership. `->>` renders an + # array as its whole text representation, so it cannot be reused for + # the array case. + filter_conditions.append(psycopg_sql.SQL( + "(CASE WHEN jsonb_typeof(metadata->{0}) = 'array' " + "THEN metadata->{0} ?| %s " + "ELSE metadata->>{0} = ANY(%s) END)" + ).format(psycopg_sql.Literal(key))) + filter_values.append(str_values) + filter_values.append(str_values) elif isinstance(value, bool): # PostgreSQL JSONB ->> returns lowercase 'true'/'false' for JSON booleans. # str(True)='True' and str(False)='False' would never match; use the diff --git a/semantica/vector_store/pinecone_store.py b/semantica/vector_store/pinecone_store.py index f58f61dc..068cb4bb 100644 --- a/semantica/vector_store/pinecone_store.py +++ b/semantica/vector_store/pinecone_store.py @@ -435,7 +435,7 @@ class PineconeStore: self.search_engine = PineconeSearch(self.index) if self.dimension is None: try: - stats = self.describe_index_stats() + stats = self.index.describe_index_stats() if stats and isinstance(stats, dict) and stats.get("dimension"): self.dimension = int(stats["dimension"]) except Exception as e: @@ -676,7 +676,7 @@ class PineconeStore: dimension = self.dimension if dimension is None: try: - stats = self.describe_index_stats() + stats = self.index.describe_index_stats() if stats and isinstance(stats, dict) and stats.get("dimension"): dimension = int(stats["dimension"]) self.dimension = dimension @@ -705,7 +705,12 @@ class PineconeStore: else: pinecone_filter[key] = value - dummy_vector = [0.0] * dimension + # A literal zero vector is rejected by Pinecone for cosine-metric indexes + # ("Query vector must not be the zero vector"). Use a unit vector instead so + # this works regardless of the index's distance metric; since this call only + # cares about which vectors match `filter`, not similarity ranking, any + # fixed non-zero query vector is an equally valid probe. + dummy_vector = [1.0 / (dimension ** 0.5)] * dimension try: response = self.index.index.query( diff --git a/semantica/vector_store/sqlite_vec_store.py b/semantica/vector_store/sqlite_vec_store.py index 646353ec..9fbdaed5 100644 --- a/semantica/vector_store/sqlite_vec_store.py +++ b/semantica/vector_store/sqlite_vec_store.py @@ -648,8 +648,21 @@ class SQLiteVecStore: filter_conditions.append(f"CAST(json_extract(metadata, '$.{key}') AS NUMERIC) <= ?") filter_params.append(value["max"]) elif isinstance(value, list): + # If the metadata value at this key is itself a JSON array, match on + # intersection (mirrors the in-memory backend's set-intersection + # semantics); otherwise fall back to plain scalar membership. Both + # cases are handled uniformly via json_each: a non-array value is + # wrapped in a one-element array first so json_each always sees a + # valid JSON array to iterate. placeholders = ", ".join(["?"] * len(value)) - filter_conditions.append(f"json_extract(metadata, '$.{key}') IN ({placeholders})") + filter_conditions.append( + f"EXISTS (SELECT 1 FROM json_each(" + f" CASE WHEN json_type(metadata, '$.{key}') = 'array'" + f" THEN json_extract(metadata, '$.{key}')" + f" ELSE json_array(json_extract(metadata, '$.{key}'))" + f" END" + f") je WHERE je.value IN ({placeholders}))" + ) filter_params.extend([str(v) if not isinstance(v, (int, float, bool)) else v for v in value]) else: filter_conditions.append(f"json_extract(metadata, '$.{key}') = ?") diff --git a/tests/vector_store/test_backend_metadata_filtering.py b/tests/vector_store/test_backend_metadata_filtering.py index 5401f0fd..cffe422c 100644 --- a/tests/vector_store/test_backend_metadata_filtering.py +++ b/tests/vector_store/test_backend_metadata_filtering.py @@ -156,8 +156,8 @@ class TestBackendMetadataFiltering(unittest.TestCase): def test_pinecone_store_filter_by_metadata_unknown_dimension_raises(self): store = PineconeStore() mock_index_wrapper = MagicMock() + mock_index_wrapper.describe_index_stats = MagicMock(return_value={}) store.index = mock_index_wrapper - store.describe_index_stats = MagicMock(return_value={}) with self.assertRaises(ProcessingError): store.filter_by_metadata({"status": "active"}, limit=5) @@ -314,6 +314,90 @@ class TestBackendMetadataFiltering(unittest.TestCase): self.assertNotIn('False', params_passed, "str(False)='False' must NOT appear in SQL params") + @patch('semantica.vector_store.pgvector_store.PSYCOPG3_AVAILABLE', True) + @patch('semantica.vector_store.pgvector_store.psycopg_sql') + def test_pgvector_store_filter_by_metadata_bool_list(self, mock_sql): + """List-valued boolean filters must use lowercase 'true'/'false', not + str(True)/str(False), matching the scalar branch's handling. + """ + store = object.__new__(PgVectorStore) + store.table_name = "test_vectors" + store._is_safe_identifier = lambda k: True + + mock_conn = MagicMock() + mock_cur = MagicMock() + mock_cur.fetchall.return_value = [ + ("pg4", [0.7, 0.8], {"active": True}) + ] + mock_conn.cursor.return_value = mock_cur + + with patch.object( + PgVectorStore, + '_get_connection', + return_value=MagicMock( + __enter__=MagicMock(return_value=mock_conn), + __exit__=MagicMock(), + ), + ): + results = store.filter_by_metadata({"active": [True, False]}, limit=10) + + self.assertEqual(len(results), 1) + execute_call_args = mock_cur.execute.call_args + params_passed = execute_call_args[0][1] + flat_params = [v for p in params_passed for v in (p if isinstance(p, list) else [p])] + self.assertIn('true', flat_params) + self.assertIn('false', flat_params) + self.assertNotIn('True', flat_params) + self.assertNotIn('False', flat_params) + + def test_faiss_store_filter_by_metadata_limit_zero(self): + """limit=0 must return no results, not the first match.""" + store = FAISSStore(dimension=2) + mock_index = MagicMock() + mock_index.metadata = { + "v1": {"category": "finance", "score": 10}, + } + mock_index.get_vector.return_value = np.array([1.0, 0.0]) + store.index = mock_index + + results = store.filter_by_metadata({"category": "finance"}, limit=0) + self.assertEqual(results, []) + + @patch('semantica.vector_store.milvus_store.MILVUS_AVAILABLE', True) + def test_milvus_store_filter_by_metadata_nan_raises(self): + """NaN/Infinity are not valid Milvus expression literals and must be + rejected up front rather than silently producing an invalid expression + that gets swallowed by the broad except around the query() call. + """ + store = MilvusStore() + mock_coll_wrapper = MagicMock() + store.collection = mock_coll_wrapper + + with self.assertRaises(ValidationError): + store.filter_by_metadata({"score": {"min": float("nan")}}, limit=5) + + @patch('semantica.vector_store.pinecone_store.PINECONE_AVAILABLE', True) + def test_pinecone_store_get_index_sets_dimension_from_stats(self): + """get_index() must read stats from the returned PineconeIndex wrapper + (self.index), not from a nonexistent method on the store itself. + """ + store = PineconeStore() + mock_client = MagicMock() + mock_pinecone_index = MagicMock() + mock_client.get_index.return_value = mock_pinecone_index + store.client = mock_client + + with patch( + 'semantica.vector_store.pinecone_store.PineconeIndex' + ) as mock_index_cls: + mock_index_instance = MagicMock() + mock_index_instance.describe_index_stats.return_value = {"dimension": 42} + mock_index_cls.return_value = mock_index_instance + + store.get_index("my-index") + + self.assertEqual(store.dimension, 42) + def test_weaviate_store_filter_by_metadata(self): store = WeaviateStore() mock_coll = MagicMock() diff --git a/tests/vector_store/test_decision_embedding_pipeline.py b/tests/vector_store/test_decision_embedding_pipeline.py index ed65236d..9293f726 100644 --- a/tests/vector_store/test_decision_embedding_pipeline.py +++ b/tests/vector_store/test_decision_embedding_pipeline.py @@ -773,19 +773,21 @@ class TestBuildDecisionContextFAISSBackend: class TestFilterByMetadataBackendBehavior: """ - Requirement (issue #848 follow-up): verify the chosen behavior of + Requirement (issue #848, superseded by #857): verify the behavior of _filter_by_metadata when a non-inmemory backend is active. - The decision: raise NotImplementedError (matching get_vector / get_metadata - from #843) rather than silently returning []. + #848's original decision was to raise NotImplementedError (matching + get_vector / get_metadata from #843) rather than silently return [], + because at the time zero backend wrappers implemented + filter_by_metadata(filters, limit). - Rationale documented in the production comment: - - Zero backend wrappers implement filter_by_metadata(filters, limit). - - The only codebase hit (HybridSearch.filter_by_metadata) has a completely - different signature and is never stored in _backend_store. - - Returning [] would make filter_decisions(query=None, category="loan") - report "zero matches" when the truth is "capability not available" — - indistinguishable from a real empty result and therefore wrong. + #857 gave every persistent backend (FAISS, Qdrant, Pinecone, Milvus, + PgVector, SQLiteVec, Weaviate) a real filter_by_metadata() implementation, + so FAISS-backed filter_decisions(query=None, ...) now returns actual + filtered results instead of raising. The NotImplementedError path itself + is still correct and still covered (see + test_filter_by_metadata_backend_not_implemented in test_vector_store.py) + for a backend that genuinely lacks the method. """ def _make_faiss_store(self): @@ -809,34 +811,26 @@ class TestFilterByMetadataBackendBehavior: ) return vs, ids - # ── FAISS backend: NotImplementedError, not AttributeError, not [] ── # + # ── FAISS backend: real results, not AttributeError, not [] ── # - def test_filter_by_metadata_faiss_raises_not_implemented(self): + def test_filter_by_metadata_faiss_returns_real_results(self): """ filter_decisions(query=None, category='loan') on a FAISS-backed store - must raise NotImplementedError, not AttributeError (old crash) and not - silently return [] (the wrong silent-failure fix). - - This test pins the chosen behavior: explicit NotImplementedError matching - the get_vector/get_metadata precedent set by issue #843. + must return the actual matching decisions, not raise AttributeError + (old crash) and not silently return [] (the old NotImplementedError + stand-in from #848, superseded once #857 gave FAISSStore a real + filter_by_metadata()). """ vs, _ids = self._make_faiss_store() - with pytest.raises(NotImplementedError) as exc_info: - vs.filter_decisions(query=None, category="loan") + results = vs.filter_decisions(query=None, category="loan") - # Message must name the backend and point to the correct alternative - msg = str(exc_info.value) - assert "FAISSStore" in msg, ( - f"Error message should name the backend class, got: {msg!r}" - ) - assert "filter_decisions" in msg or "filter_by_metadata" in msg, ( - f"Error message should mention the failing method, got: {msg!r}" - ) - assert "search_decisions" in msg, ( - f"Error message should suggest search_decisions() as the alternative, " - f"got: {msg!r}" + assert isinstance(results, list) + assert len(results) == 2, ( + f"Expected 2 loan decisions, got {len(results)}: {results}" ) + for r in results: + assert r["metadata"]["category"] == "loan" def test_filter_by_metadata_faiss_not_attribute_error(self): """ diff --git a/tests/vector_store/test_sqlite_vec_store.py b/tests/vector_store/test_sqlite_vec_store.py index 786257f0..0c72888e 100644 --- a/tests/vector_store/test_sqlite_vec_store.py +++ b/tests/vector_store/test_sqlite_vec_store.py @@ -413,3 +413,51 @@ class TestSQLiteVecStoreStats: stats = store.get_stats() assert stats["vector_count"] == 4 + + +class TestSQLiteVecStoreFilterByMetadata: + """Test filter_by_metadata, including list-valued metadata handling.""" + + def test_filter_exact_match(self, store): + vectors = [np.random.rand(128).astype(np.float32) for _ in range(2)] + metadata = [{"category": "finance"}, {"category": "tech"}] + ids = store.add(vectors, metadata, ids=["v1", "v2"]) + + results = store.filter_by_metadata({"category": "finance"}, limit=10) + + assert [r["id"] for r in results] == ["v1"] + + def test_filter_scalar_field_against_list_filter(self, store): + """A scalar metadata value should match via plain IN-list membership.""" + vectors = [np.random.rand(128).astype(np.float32) for _ in range(2)] + metadata = [{"category": "finance"}, {"category": "tech"}] + store.add(vectors, metadata, ids=["v1", "v2"]) + + results = store.filter_by_metadata({"category": ["finance", "ops"]}, limit=10) + + assert [r["id"] for r in results] == ["v1"] + + def test_filter_array_field_intersects_list_filter(self, store): + """A list-valued metadata field must match on set intersection with the + filter list, mirroring the in-memory backend's semantics -- not on a + literal comparison of the whole array's JSON text against each candidate. + """ + vectors = [np.random.rand(128).astype(np.float32) for _ in range(3)] + metadata = [ + {"tags": ["python", "js"]}, + {"tags": ["go"]}, + {"tags": ["python", "ml"]}, + ] + store.add(vectors, metadata, ids=["v1", "v2", "v3"]) + + results = store.filter_by_metadata({"tags": ["python", "ml"]}, limit=10) + + assert {r["id"] for r in results} == {"v1", "v3"} + + def test_filter_limit_zero_returns_empty(self, store): + vectors = [np.random.rand(128).astype(np.float32)] + store.add(vectors, [{"category": "finance"}], ids=["v1"]) + + results = store.filter_by_metadata({"category": "finance"}, limit=0) + + assert results == [] From b8e8b2f227811480fba505d32ee8611e098b40f8 Mon Sep 17 00:00:00 2001 From: Sameer6305 Date: Wed, 12 Aug 2026 13:45:46 +0530 Subject: [PATCH 013/105] fix(mcp): use semantica.__version__ as authoritative MCP version source The previous implementation used importlib.metadata.version('semantica') as the primary version source with a PackageNotFoundError fallback to semantica.__version__. This caused two of the three new regression tests to fail in editable/development installs, where dist-info (egg-info) is written at install time and is not automatically updated on subsequent version bumps. In this repo, pyproject.toml declares version as a static field (not dynamic), and semantica/__init__.py maintains __version__ in sync with it by convention. semantica.__version__ is therefore the authoritative source of truth and is always present whenever semantica.mcp_server is importable -- the importlib .metadata indirection adds no value and can return a stale value. Changes: - semantica/mcp_server/__init__.py: replace the importlib.metadata try/except block with a direct 'from semantica import __version__ as _SEMANTICA_VERSION' - tests/test_mcp_server_version.py: rewrite tests to assert both MCP version surfaces (SERVER_INFO['version'] and semantica://schema/info) against semantica.__version__ as the single ground truth; add 0.4.0 regression canaries and a cross-surface consistency assertion; remove the mirrored importlib.metadata resolution that masked the staleness problem The root-level mcp/ directory (a separate unpublished companion implementation not included in the built package) is intentionally left unchanged -- it is outside the scope of issue #863 which targets the semantica-mcp entry point. --- semantica/mcp_server/__init__.py | 14 +++--- tests/test_mcp_server_version.py | 86 ++++++++++++++++++++++++++------ 2 files changed, 80 insertions(+), 20 deletions(-) diff --git a/semantica/mcp_server/__init__.py b/semantica/mcp_server/__init__.py index cb22b434..19fe0bf4 100644 --- a/semantica/mcp_server/__init__.py +++ b/semantica/mcp_server/__init__.py @@ -45,14 +45,16 @@ import json import logging import os import sys -from importlib.metadata import PackageNotFoundError, version from typing import Any -try: - _SEMANTICA_VERSION = version("semantica") -except PackageNotFoundError: - # Preserve direct source-tree execution when distribution metadata is absent. - from semantica import __version__ as _SEMANTICA_VERSION +# `semantica.__version__` is the authoritative package version — it is kept in +# sync with pyproject.toml's static `version` field by the release process and +# is always present whenever this submodule is importable. Using it directly +# is simpler and more reliable than `importlib.metadata.version("semantica")`, +# which reads dist-info written at install time and can lag the source in +# editable installs (egg-info / dist-info is not regenerated on every version +# bump, so it can reflect a stale value). +from semantica import __version__ as _SEMANTICA_VERSION # ── logging ──────────────────────────────────────────────────────────────── _log_level = getattr(logging, os.environ.get("SEMANTICA_LOG_LEVEL", "WARNING").upper(), logging.WARNING) diff --git a/tests/test_mcp_server_version.py b/tests/test_mcp_server_version.py index 9dd9f9a4..fbe03d1d 100644 --- a/tests/test_mcp_server_version.py +++ b/tests/test_mcp_server_version.py @@ -1,35 +1,93 @@ -"""Regression tests for MCP server version reporting.""" +"""Regression tests for MCP server version reporting (issue #863). + +Both public MCP version surfaces must derive from the same authoritative +package version rather than a hardcoded stale literal: + 1. MCP ``initialize`` → ``serverInfo.version`` + 2. ``semantica://schema/info`` → ``version`` + +The authoritative source of truth is ``semantica.__version__``, which is +maintained in sync with ``pyproject.toml``'s static ``version`` field by +the release process. We assert equality against that value rather than +duplicating the version-resolution logic here, so the tests remain valid +through future version bumps without modification. + +The ``assertNotEqual(..., "0.4.0")`` canaries guard against regression to +the original stale literal that triggered issue #863. +""" import unittest -from importlib.metadata import PackageNotFoundError, version import semantica from semantica import mcp_server +_EXPECTED = semantica.__version__ + class TestMCPServerVersion(unittest.TestCase): - def test_server_info_uses_distribution_version(self): - try: - expected = version("semantica") - except PackageNotFoundError: - expected = semantica.__version__ - self.assertEqual(mcp_server.SERVER_INFO["version"], expected) + # ------------------------------------------------------------------ # + # SERVER_INFO (used directly in the initialize response) + # ------------------------------------------------------------------ # - def test_initialize_reports_package_version(self): + def test_server_info_version_matches_package(self): + """SERVER_INFO['version'] must equal the authoritative package version.""" + self.assertEqual(mcp_server.SERVER_INFO["version"], _EXPECTED) + + def test_server_info_version_is_not_stale_literal(self): + """Guard: SERVER_INFO must not report the original hardcoded 0.4.0.""" + self.assertNotEqual(mcp_server.SERVER_INFO["version"], "0.4.0") + + # ------------------------------------------------------------------ # + # MCP initialize → serverInfo.version + # ------------------------------------------------------------------ # + + def test_initialize_server_info_version_matches_package(self): + """The MCP initialize response must report the authoritative package version.""" response = mcp_server._handle( {"jsonrpc": "2.0", "id": 1, "method": "initialize"} ) - self.assertIsNotNone(response) self.assertEqual( - response["result"]["serverInfo"]["version"], semantica.__version__ + response["result"]["serverInfo"]["version"], + _EXPECTED, ) - def test_schema_info_resource_reports_package_version(self): - resource = mcp_server._read_resource("semantica://schema/info") + def test_initialize_server_info_version_is_not_stale_literal(self): + """Guard: initialize must not report the original hardcoded 0.4.0.""" + response = mcp_server._handle( + {"jsonrpc": "2.0", "id": 1, "method": "initialize"} + ) + self.assertNotEqual(response["result"]["serverInfo"]["version"], "0.4.0") - self.assertEqual(resource["version"], semantica.__version__) + # ------------------------------------------------------------------ # + # semantica://schema/info → version + # ------------------------------------------------------------------ # + + def test_schema_info_resource_version_matches_package(self): + """The semantica://schema/info resource must report the authoritative package version.""" + resource = mcp_server._read_resource("semantica://schema/info") + self.assertEqual(resource["version"], _EXPECTED) + + def test_schema_info_resource_version_is_not_stale_literal(self): + """Guard: schema/info must not report the original hardcoded 0.4.0.""" + resource = mcp_server._read_resource("semantica://schema/info") + self.assertNotEqual(resource["version"], "0.4.0") + + # ------------------------------------------------------------------ # + # Both surfaces must agree + # ------------------------------------------------------------------ # + + def test_both_version_surfaces_are_identical(self): + """SERVER_INFO and schema/info must report the exact same version string, + confirming both surfaces derive from a single authoritative value.""" + init_response = mcp_server._handle( + {"jsonrpc": "2.0", "id": 1, "method": "initialize"} + ) + schema_resource = mcp_server._read_resource("semantica://schema/info") + self.assertEqual( + init_response["result"]["serverInfo"]["version"], + schema_resource["version"], + ) if __name__ == "__main__": From 81bb5f2ed89063e1a3c067489020e6f919e016fe Mon Sep 17 00:00:00 2001 From: KaifAhmad1 Date: Wed, 12 Aug 2026 14:05:25 +0530 Subject: [PATCH 014/105] fix(mcp): report package version in standalone mcp/ server too semantica/mcp_server/__init__.py was fixed to stop hardcoding 0.4.0, but the separate top-level mcp/ package (run via `python -m mcp.server`, documented in mcp/__init__.py as a supported way to configure Claude Desktop/Windsurf/etc. from a source checkout) still hardcoded 0.4.0 in three places: mcp/__init__.py, mcp/server.py, and mcp/resources/registry.py. Reuses semantica.__version__ directly, matching the pattern just adopted in semantica/mcp_server/__init__.py, so both implementations stay in sync with the package version going forward. --- mcp/__init__.py | 8 +++-- mcp/resources/registry.py | 3 +- mcp/server.py | 3 +- tests/test_mcp_package_version.py | 54 +++++++++++++++++++++++++++++++ 4 files changed, 64 insertions(+), 4 deletions(-) create mode 100644 tests/test_mcp_package_version.py diff --git a/mcp/__init__.py b/mcp/__init__.py index 5867cde4..6154f804 100644 --- a/mcp/__init__.py +++ b/mcp/__init__.py @@ -21,7 +21,11 @@ Configure in Claude Desktop, Windsurf, Cline, Continue, VS Code: } """ +# `semantica.__version__` is the authoritative package version — see +# semantica/mcp_server/__init__.py for why it is used directly rather than +# importlib.metadata.version("semantica"). +from semantica import __version__ + from .server import SemanticaMCPServer, main -__all__ = ["SemanticaMCPServer", "main"] -__version__ = "0.4.0" +__all__ = ["SemanticaMCPServer", "main", "__version__"] diff --git a/mcp/resources/registry.py b/mcp/resources/registry.py index 052ee8c4..0f1d5ac4 100644 --- a/mcp/resources/registry.py +++ b/mcp/resources/registry.py @@ -10,6 +10,7 @@ from __future__ import annotations import json import logging +from mcp import __version__ from mcp.session import get_graph log = logging.getLogger("semantica.mcp.resources") @@ -60,7 +61,7 @@ def _read_decisions_list(uri: str) -> dict: def _read_schema_info(uri: str) -> dict: info = { - "version": "0.4.0", + "version": __version__, "node_types": [ "Entity", "decision", "Decision", "Event", "Concept", "Person", "Organisation", "Location", diff --git a/mcp/server.py b/mcp/server.py index 371343a5..3cd935be 100644 --- a/mcp/server.py +++ b/mcp/server.py @@ -17,6 +17,7 @@ import logging import sys from typing import Any +from mcp import __version__ from mcp.resources import RESOURCE_DEFINITIONS, handle_resource_read from mcp.tools import TOOL_DEFINITIONS @@ -63,7 +64,7 @@ def _handle_initialize(req_id: Any, params: dict) -> dict: }, "serverInfo": { "name": "semantica-mcp", - "version": "0.4.0", + "version": __version__, }, }) diff --git a/tests/test_mcp_package_version.py b/tests/test_mcp_package_version.py new file mode 100644 index 00000000..72e4aa55 --- /dev/null +++ b/tests/test_mcp_package_version.py @@ -0,0 +1,54 @@ +"""Regression tests for version reporting in the top-level `mcp` package +(issue #863). + +Covers the same stale-version bug as `test_mcp_server_version.py` for the +standalone `mcp/` server (run via `python -m mcp.server`), which is a +separate implementation from `semantica.mcp_server` and was not covered +by that fix. `semantica.__version__` is the authoritative package +version (see semantica/mcp_server/__init__.py), so all three surfaces +are asserted against it directly. +""" + +import json +import os +import sys +import unittest + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + +import mcp +import semantica +from mcp.resources.registry import _read_schema_info +from mcp.server import _handle_initialize + +_EXPECTED = semantica.__version__ + + +class TestMCPPackageVersion(unittest.TestCase): + def test_package_version_matches_authoritative_source(self): + self.assertEqual(mcp.__version__, _EXPECTED) + + def test_package_version_is_not_stale_literal(self): + self.assertNotEqual(mcp.__version__, "0.4.0") + + def test_initialize_server_info_version_matches_package(self): + response = _handle_initialize(1, {}) + self.assertEqual(response["result"]["serverInfo"]["version"], _EXPECTED) + + def test_initialize_server_info_version_is_not_stale_literal(self): + response = _handle_initialize(1, {}) + self.assertNotEqual(response["result"]["serverInfo"]["version"], "0.4.0") + + def test_schema_info_resource_version_matches_package(self): + resource = _read_schema_info("semantica://schema/info") + info = json.loads(resource["text"]) + self.assertEqual(info["version"], _EXPECTED) + + def test_schema_info_resource_version_is_not_stale_literal(self): + resource = _read_schema_info("semantica://schema/info") + info = json.loads(resource["text"]) + self.assertNotEqual(info["version"], "0.4.0") + + +if __name__ == "__main__": + unittest.main() From 6328bfe52d7723f6094db2443c7f5465fe8f77b5 Mon Sep 17 00:00:00 2001 From: KaifAhmad1 Date: Wed, 12 Aug 2026 14:09:40 +0530 Subject: [PATCH 015/105] docs(changelog): add entry for MCP server version fix (#870, closes #863) --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c5c574b5..c43ab8ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **MCP server reported a stale `0.4.0` version instead of the installed package version** (#870, closes #863) by @oiahoon + - `semantica/mcp_server/__init__.py` hardcoded `"version": "0.4.0"` in both the MCP `initialize` response (`SERVER_INFO`) and the `semantica://schema/info` resource, regardless of the actual installed `semantica` version — every MCP client (Claude Desktop, Windsurf, Cline, Continue, VS Code Copilot, etc.) showed the wrong server version. Both surfaces now derive from `semantica.__version__`, the package's authoritative version source, so they can no longer drift from `pyproject.toml` + - New regression coverage in `tests/test_mcp_server_version.py`, including `!= "0.4.0"` canaries and a cross-surface consistency check + - **Fixed along the way**: the separate root-level `mcp/` package (`mcp/__init__.py`, `mcp/server.py`, `mcp/resources/registry.py`) — a companion MCP server implementation not included in the built distribution, but documented in `mcp/__init__.py` as a supported way to run against Claude Desktop/Windsurf/etc. from a source checkout — had the same three hardcoded `0.4.0` literals; fixed the same way, with matching regression tests in `tests/test_mcp_package_version.py` + - **`VectorStore._filter_by_metadata()` `AttributeError` on all persistent backends** (#857, closes #849) by @TaherTadpatri - `_filter_by_metadata()` iterated `self.metadata` directly, which only exists on the `inmemory` backend — any persistent backend (`faiss`, `qdrant`, `pinecone`, `milvus`, `pgvector`, `sqlite`, `weaviate`) crashed with `AttributeError` on `filter_decisions(query=None, ...)` / metadata-only filtering. Filtering is now delegated to a native `filter_by_metadata()` implemented on each backend store, using backend-native payload/SQL/JSON filtering (Qdrant `scroll()`, Pinecone `query()`, Milvus expression filters, PostgreSQL JSONB, SQLite `json_extract()`, Weaviate collection filters) - **Fixed along the way**: `PineconeStore.get_index()` and `filter_by_metadata()` called a nonexistent `self.describe_index_stats()` on the store itself (the method only exists on the `PineconeIndex` wrapper returned by `self.index`); the resulting `AttributeError` was silently swallowed, so dimension auto-detection always failed quietly. Now correctly calls `self.index.describe_index_stats()` From 9ec7959899127d3159b7672e1f3dee9415edd8bd Mon Sep 17 00:00:00 2001 From: agu2347 <94227848+agu2347@users.noreply.github.com> Date: Wed, 12 Aug 2026 16:32:06 +0530 Subject: [PATCH 016/105] Bump fastapi minimum version to fix PYSEC-2024-38 (starlette DoS) (#871) * security(deps): bump fastapi floor to >=0.109.1 (PYSEC-2024-38) The [explorer] extra declared fastapi>=0.100.0, which allows the vulnerable 0.109.0 (PYSEC-2024-38, HTTP response splitting). Raise the floor to 0.109.1, the patched release. One-line change, no functional impact -- the 0.109.x API is stable and backward-compatible. Fixes #869 * fix(deps): bump fastapi to >=0.109.2 and python-multipart to >=0.0.7 for PYSEC-2024-38 PYSEC-2024-38 (CVE-2024-24762 / GHSA-2jv5-9r88-3w3p) is a ReDoS in python-multipart < 0.0.7: an attacker sends a crafted Content-Type header that causes catastrophic backtracking in the multipart regex, stalling the event loop and causing a DoS on any endpoint that parses form data. The original PR bumped fastapi to >=0.109.1, but that version pins starlette<0.36.0,>=0.35.0 and cannot install starlette 0.36.2+ (which contains the fix via python-multipart>=0.0.7). FastAPI 0.109.2 is the first version that pins starlette>=0.36.3 (verified against PyPI metadata). Two changes are necessary: 1. fastapi>=0.109.1 -> fastapi>=0.109.2: ensures starlette>=0.36.3 is installed as a transitive dependency, which in turn pulls the fixed python-multipart>=0.0.7. 2. python-multipart>=0.0.6 -> python-multipart>=0.0.7: closes the direct dependency path. python-multipart is listed explicitly in the explorer extra, so without this floor a resolver could still install 0.0.6 and leave the vulnerability present even with the fastapi bump. The fix targets only the 'explorer' optional dependency group, which is the only code surface where FastAPI and form-data parsing are used. No functional API changes between 0.109.1 and 0.109.2; 239 Explorer tests pass without modification. * ci(security): gate pip-audit on explorer-extra dependency PRs, add changelog entry for PYSEC-2024-38 The Security workflow's pip-audit job ran weekly against a bare Python env with none of Semantica's optional extras installed, and always continue-on-error'd -- it would never have flagged the vulnerable fastapi/python-multipart floors this PR fixes, or the first attempt at the fix that left python-multipart>=0.0.6 in place. security-scan.yml's Safety check has the same blind spot (only installs [llm-litellm]). pip-audit now also runs on pull_request when pyproject.toml changes, installs semantica[all] so it can actually see extras like [explorer], and fails the build on findings for that trigger. Scheduled/dispatch runs stay non-blocking pending a full pass over the [all] tree. Also documents the fix (#871, closes #869) in CHANGELOG.md, including the correction made during review after the original fastapi-only bump turned out not to close the vulnerability. * fix(deps): raise setuptools floor to >=83.0.0 (CVE-2026-59890), harden audit env The new pull_request pip-audit gate (previous commit) caught this on its first run: pip install -e ".[all]" resolved setuptools==79.0.1, vulnerable to CVE-2026-59890 / GHSA-h35f-9h28-mq5c / PYSEC-2026-3447 (Unicode normalization lets a MANIFEST.in exclude/prune pattern be bypassed on macOS APFS/HFS+, leaking excluded files into a built sdist). Fixed in setuptools 83.0.0. [build-system] requires had the same too-permissive floor this whole PR is about (setuptools>=61.0). Raised to >=83.0.0. Also upgrade pip/ setuptools explicitly in the Security workflow before running pip-audit, since [build-system] requires only governs isolated build environments, not the ambient one actions/setup-python provisions and pip-audit scans. --------- Co-authored-by: Sameer Kadam Co-authored-by: KaifAhmad1 --- .github/workflows/security.yml | 24 +++++++++++++++++++++++- CHANGELOG.md | 9 +++++++++ pyproject.toml | 6 +++--- 3 files changed, 35 insertions(+), 4 deletions(-) diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 9f093bc7..5fb86cde 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -4,6 +4,11 @@ on: schedule: - cron: '0 0 * * 1' workflow_dispatch: + pull_request: + branches: [main] + paths: + - 'pyproject.toml' + - '.github/workflows/security.yml' permissions: contents: read @@ -16,6 +21,23 @@ jobs: - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7 with: python-version: '3.11' + # Upgrade first: actions/setup-python's baked-in setuptools has been + # behind known-vulnerable floors before (e.g. PYSEC-2026-3447 / + # CVE-2026-59890, fixed in 83.0.0) regardless of what this project's + # own [build-system] requires -- that only governs isolated build + # environments, not the ambient one pip-audit scans here. + - run: python -m pip install --upgrade pip setuptools - run: pip install pip-audit + # Install the [all] extra so pip-audit sees every optional dependency + # group (fastapi, python-multipart, etc.), not just pip-audit's own + # deps. PYSEC-2024-38 (#869) shipped in the first place because + # neither this job (bare env, no extras) nor security-scan.yml's + # Safety check (installs only [llm-litellm]) ever had fastapi or + # python-multipart installed to look at. + - run: pip install -e ".[all]" + # PR runs gate on findings, since they're scoped to actual + # pyproject.toml changes under review. The schedule/workflow_dispatch + # runs stay non-blocking until a full pass over pre-existing findings + # across the whole [all] tree has been done. - run: pip-audit - continue-on-error: true + continue-on-error: ${{ github.event_name != 'pull_request' }} diff --git a/CHANGELOG.md b/CHANGELOG.md index c43ab8ab..74333a4c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Fixed along the way**: `MilvusStore`'s metadata expression builder rendered `NaN`/`Infinity` filter values as bare unquoted tokens, producing an invalid Milvus expression whose server-side rejection was then swallowed by a broad `except`, indistinguishable from "no matches"; these values are now rejected up front with a clear `ValidationError` - New/expanded test coverage in `tests/vector_store/test_backend_metadata_filtering.py` (all 7 backends, including the Pinecone dimension/zero-vector, PgVector boolean-list, FAISS `limit=0`, and Milvus `NaN` regressions) and `tests/vector_store/test_sqlite_vec_store.py` (new `TestSQLiteVecStoreFilterByMetadata`, run against the real `sqlite-vec` extension, including the array-vs-scalar intersection case) +### Security + +- **`fastapi`/`python-multipart` floors in the `explorer` extra allowed PYSEC-2024-38 (CVE-2024-24762 / GHSA-2jv5-9r88-3w3p, `python-multipart` ReDoS)** (#871, closes #869) by @agu2347 + - `explorer` declared `fastapi>=0.100.0` and `python-multipart>=0.0.6`; both floors resolve to versions carrying a ReDoS in `python-multipart`'s `Content-Type` header option parser (`parse_options_header`), reachable by any endpoint that accepts form/multipart data — an attacker-crafted header option can stall the event loop for minutes + - **Corrected during review**: the original fix raised only `fastapi>=0.109.1`, leaving `python-multipart>=0.0.6` unchanged. `python-multipart` is declared as its own direct dependency in the `explorer` extra rather than pulled in transitively via `fastapi[all]`, so a bare `fastapi` install enforces no `python-multipart` floor at all — the vulnerable `0.0.6` could still resolve with `fastapi>=0.109.1` in place. Floors raised to `fastapi>=0.109.2` / `python-multipart>=0.0.7`, the first versions of each that exclude the vulnerable range + - **Fixed along the way**: the `Security` workflow's `pip-audit` job ran only on a weekly schedule with `continue-on-error: true`, against a bare Python environment with none of Semantica's optional extras installed — it would never have seen `fastapi`/`python-multipart` regardless of which floor was pinned. `security-scan.yml`'s Safety check has the same blind spot (`pip install -e ".[llm-litellm]"` only, never `[explorer]`). `pip-audit` now also runs on `pull_request` when `pyproject.toml` changes, installs `semantica[all]`, and fails the build on any finding for that trigger; the schedule/`workflow_dispatch` runs stay non-blocking pending a full pass over any pre-existing findings across the whole `[all]` tree + - **Caught by the new gate on its first run**: `python -m pip install -e ".[all]"` pulled in `setuptools==79.0.1`, vulnerable to CVE-2026-59890/GHSA-h35f-9h28-mq5c/PYSEC-2026-3447 (Unicode-normalization bypass of `MANIFEST.in` exclude/prune patterns on macOS APFS/HFS+, letting excluded files leak into a built sdist), fixed in `83.0.0`. `[build-system] requires` had the exact same too-permissive-floor pattern this whole entry is about (`setuptools>=61.0`), and `actions/setup-python`'s baked-in `setuptools` isn't governed by that pin at all since it's outside any isolated build. Bumped `[build-system] requires` to `setuptools>=83.0.0`, and the `Security` workflow now runs `pip install --upgrade pip setuptools` before auditing so the scanned environment can't have a stale ambient copy regardless of what governs it + - Full `explorer` suite: 241 passed + ## [0.6.5] - 2026-08-11 ### Added diff --git a/pyproject.toml b/pyproject.toml index 7a65ae3c..d7fc2899 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [build-system] -requires = ["setuptools>=61.0", "wheel"] +requires = ["setuptools>=83.0.0", "wheel"] build-backend = "setuptools.build_meta" [project] @@ -230,10 +230,10 @@ dev = [ # Explorer Dashboard explorer = [ - "fastapi>=0.100.0", + "fastapi>=0.109.2", "uvicorn[standard]>=0.22.0", "websockets>=15.0.1", - "python-multipart>=0.0.6", + "python-multipart>=0.0.7", "defusedxml>=0.7.1" ] explorer-lite = [ From bc63e962c9818111d4a951b0d08f5e1e53a3ba39 Mon Sep 17 00:00:00 2001 From: nightcityblade Date: Wed, 12 Aug 2026 20:13:10 +0800 Subject: [PATCH 017/105] test(seed): use a real file for CSV loading (#873) Co-authored-by: nightcityblade Co-authored-by: Sameer Kadam Co-authored-by: Mohd Kaif --- tests/seed/test_seed_manager.py | 25 ++++++++++++++----------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/tests/seed/test_seed_manager.py b/tests/seed/test_seed_manager.py index c84f14e2..b2422c99 100644 --- a/tests/seed/test_seed_manager.py +++ b/tests/seed/test_seed_manager.py @@ -1,9 +1,12 @@ +import tempfile import unittest -from unittest.mock import MagicMock, patch, mock_open from pathlib import Path -from semantica.seed.seed_manager import SeedDataManager, SeedDataSource, SeedData +from unittest.mock import patch + +from semantica.seed.seed_manager import SeedData, SeedDataManager, SeedDataSource from semantica.utils.exceptions import ProcessingError + class TestSeedDataManager(unittest.TestCase): def setUp(self): @@ -32,20 +35,20 @@ class TestSeedDataManager(unittest.TestCase): self.assertEqual(source.entity_type, "Person") self.assertIn(name, self.manager.versions) - @patch("pathlib.Path.exists") - @patch("builtins.open", new_callable=mock_open, read_data="name,age\nAlice,30\nBob,25") - def test_load_from_csv(self, mock_file, mock_exists): - mock_exists.return_value = True - - records = self.manager.load_from_csv("test.csv", entity_type="Person", source_name="test_source") - + def test_load_from_csv(self): + with tempfile.TemporaryDirectory() as tmp_dir: + csv_file = Path(tmp_dir) / "test.csv" + csv_file.write_text("name,age\nAlice,30\nBob,25", encoding="utf-8") + + records = self.manager.load_from_csv( + csv_file, entity_type="Person", source_name="test_source" + ) + self.assertEqual(len(records), 2) self.assertEqual(records[0]["name"], "Alice") self.assertEqual(records[0]["age"], "30") self.assertEqual(records[0]["entity_type"], "Person") self.assertEqual(records[0]["source"], "test_source") - - mock_file.assert_called_once_with(Path("test.csv"), "r", encoding="utf-8") @patch("pathlib.Path.exists") def test_load_from_csv_file_not_found(self, mock_exists): From 687a180721b24f88edd72e1260ecf38073c9b849 Mon Sep 17 00:00:00 2001 From: pravit-amp <43916793+pravit-amp@users.noreply.github.com> Date: Wed, 12 Aug 2026 08:02:45 -0700 Subject: [PATCH 018/105] fix(context): take the lock in ContextGraph.to_dict() (#929) * fix(context): take the lock in ContextGraph.to_dict() to_dict() iterated self.nodes.values() and self.edges without holding self._lock, so a concurrent writer raised "RuntimeError: dictionary changed size during iteration". It was the only reader on the class that did not take the lock -- stats(), density(), find_nodes(), find_edges(), get_neighbors(), get_nodes_by_label(), state_at() and save_to_file() all hold it. Commit 1d1ae398 introduced the RLock and added 26 "with self._lock:" blocks; to_dict already existed and was not among them. save_to_file is safe only incidentally -- it holds the lock and builds its payload inline rather than delegating to to_dict, so it never reaches the unguarded loops. Beyond the RuntimeError, the unguarded body could also return a torn snapshot: the statistics block reads len(self.nodes)/len(self.edges) after building the node and edge lists, so a write landing in between yields counts that contradict the payload they describe. self._lock is an RLock, so this composes with the callers that already hold it (build_from_conversation and build_from_documents both return self.to_dict() from inside a locked block). Neither external caller -- agent_context's _capture_checkpoint_state nor triplet_store's knowledge-graph conversion -- defines a lock of its own, so there is no ordering inversion. Add tests/context/test_context_graph_thread_safety.py: a deterministic check that to_dict() blocks while another thread holds _lock (no race window needed), a reentrancy check, and three checks under concurrent writes covering the RuntimeError, statistics/payload agreement, and duplicate node ids. Four of the five fail against the unfixed method. Closes #923 * test(context): make to_dict lock tests deterministic and hang-proof Wait for the worker thread to actually start before asserting to_dict() blocks on _lock, and run the reentrancy check in a joined worker so a non-reentrant lock fails the test instead of hanging CI. * test(context): assert worker threads actually stopped after timed joins A join(timeout=...) on a daemon thread returns even if the thread is still running, so a deadlock would leak a live thread into subsequent tests instead of failing. Assert not is_alive() after each timed join. --------- Co-authored-by: Pravit Ampapathini Co-authored-by: Zohaib Hassnain <109234410+ZohaibHassan16@users.noreply.github.com> --- semantica/context/context_graph.py | 79 ++++---- .../test_context_graph_thread_safety.py | 168 ++++++++++++++++++ 2 files changed, 208 insertions(+), 39 deletions(-) create mode 100644 tests/context/test_context_graph_thread_safety.py diff --git a/semantica/context/context_graph.py b/semantica/context/context_graph.py index 06419759..944e1f6a 100644 --- a/semantica/context/context_graph.py +++ b/semantica/context/context_graph.py @@ -1769,47 +1769,48 @@ class ContextGraph: def to_dict(self) -> Dict[str, Any]: """Export graph to dictionary format.""" - nodes_out = [] - for n in self.nodes.values(): - entry: Dict[str, Any] = { - "id": n.node_id, - "type": n.node_type, - "content": n.content, - "properties": n.properties, - "metadata": n.metadata, - } - if n.valid_from is not None: - entry["valid_from"] = n.valid_from - if n.valid_until is not None: - entry["valid_until"] = n.valid_until - nodes_out.append(entry) + with self._lock: + nodes_out = [] + for n in self.nodes.values(): + entry: Dict[str, Any] = { + "id": n.node_id, + "type": n.node_type, + "content": n.content, + "properties": n.properties, + "metadata": n.metadata, + } + if n.valid_from is not None: + entry["valid_from"] = n.valid_from + if n.valid_until is not None: + entry["valid_until"] = n.valid_until + nodes_out.append(entry) - edges_out = [] - for e in self.edges: - entry = { - "id": e.edge_id, - "familyId": e.family_id or e.edge_id, - "source": e.source_id, - "target": e.target_id, - "type": e.edge_type, - "weight": e.weight, - } - if e.metadata: - entry["metadata"] = e.metadata - if e.valid_from is not None: - entry["valid_from"] = e.valid_from - if e.valid_until is not None: - entry["valid_until"] = e.valid_until - edges_out.append(entry) + edges_out = [] + for e in self.edges: + entry = { + "id": e.edge_id, + "familyId": e.family_id or e.edge_id, + "source": e.source_id, + "target": e.target_id, + "type": e.edge_type, + "weight": e.weight, + } + if e.metadata: + entry["metadata"] = e.metadata + if e.valid_from is not None: + entry["valid_from"] = e.valid_from + if e.valid_until is not None: + entry["valid_until"] = e.valid_until + edges_out.append(entry) - return { - "nodes": nodes_out, - "edges": edges_out, - "statistics": { - "node_count": len(self.nodes), - "edge_count": len(self.edges), - }, - } + return { + "nodes": nodes_out, + "edges": edges_out, + "statistics": { + "node_count": len(self.nodes), + "edge_count": len(self.edges), + }, + } def from_dict(self, graph_dict: Dict[str, Any]) -> None: """Load graph from dictionary format.""" diff --git a/tests/context/test_context_graph_thread_safety.py b/tests/context/test_context_graph_thread_safety.py new file mode 100644 index 00000000..0d521d7e --- /dev/null +++ b/tests/context/test_context_graph_thread_safety.py @@ -0,0 +1,168 @@ +#!/usr/bin/env python3 +"""Regression tests for ``ContextGraph.to_dict()`` thread safety. + +``ContextGraph`` guards its state with ``self._lock`` (an ``RLock``), and every +reader on the class takes it -- ``stats``, ``density``, ``find_nodes``, +``find_edges``, ``get_neighbors``, ``get_nodes_by_label``, ``state_at`` and +``save_to_file`` all do. ``to_dict`` was the one exception: it iterated +``self.nodes.values()`` and ``self.edges`` unguarded, so a concurrent writer +raised ``RuntimeError: dictionary changed size during iteration``. + +``save_to_file`` was safe only incidentally -- it holds the lock and builds its +payload inline rather than delegating to ``to_dict``. +""" + +import threading +import time + +from semantica.context.context_graph import ContextGraph + + +def _seeded_graph(node_count: int = 200) -> ContextGraph: + graph = ContextGraph(advanced_analytics=False) + for i in range(node_count): + graph.add_node(f"seed{i}", "seed") + return graph + + +class TestToDictHoldsTheLock: + """``to_dict`` must take ``_lock``, like every sibling reader.""" + + def test_to_dict_waits_for_the_lock(self): + """Deterministic proof the lock is held -- no race window needed. + + With the lock held elsewhere, ``to_dict`` must block. Without the fix it + returns immediately, since it never asks for the lock at all. + """ + graph = _seeded_graph(10) + started = threading.Event() + finished = threading.Event() + + def snapshot(): + started.set() + graph.to_dict() + finished.set() + + with graph._lock: + worker = threading.Thread(target=snapshot, daemon=True) + worker.start() + assert started.wait(timeout=5.0), "the worker thread never started running" + # The worker is now running and cannot finish while this thread + # owns the lock. + assert not finished.wait(timeout=0.5), ( + "to_dict() completed while another thread held _lock, so it is " + "reading graph state unguarded" + ) + + assert finished.wait(timeout=5.0), "to_dict() did not complete after _lock was released" + worker.join(timeout=5.0) + assert not worker.is_alive(), "the worker thread is still running after to_dict() finished" + + def test_to_dict_is_reentrant_for_a_caller_holding_the_lock(self): + """``_lock`` is an ``RLock``, so lock-holding callers must not deadlock. + + The nested acquisition runs in a daemon worker joined with a timeout so + that a non-reentrant lock fails the test instead of hanging it. + """ + graph = _seeded_graph(10) + result = {} + + def nested_snapshot(): + with graph._lock: + result["snapshot"] = graph.to_dict() + + worker = threading.Thread(target=nested_snapshot, daemon=True) + worker.start() + worker.join(timeout=5.0) + + assert not worker.is_alive(), ( + "to_dict() deadlocked when called by a thread already holding " + "_lock -- the lock is no longer reentrant" + ) + assert len(result["snapshot"]["nodes"]) == 10 + + +class TestToDictUnderConcurrentWrites: + """The reported race: snapshot one thread, mutate from another.""" + + def _run_race(self, graph: ContextGraph, reader, duration: float = 1.0): + """Hammer ``reader`` while a writer adds nodes. Returns (errors, reads).""" + stop = threading.Event() + errors = [] + reads = [] + + def writer(): + i = 0 + while not stop.is_set(): + try: + graph.add_node(f"w{i}", "written") + except Exception as exc: # pragma: no cover - writer must stay healthy + errors.append(exc) + return + i += 1 + + def reader_loop(): + while not stop.is_set(): + try: + reads.append(reader()) + except Exception as exc: + errors.append(exc) + stop.set() + return + + threads = [ + threading.Thread(target=writer, daemon=True), + threading.Thread(target=reader_loop, daemon=True), + ] + for thread in threads: + thread.start() + time.sleep(duration) + stop.set() + for thread in threads: + thread.join(timeout=5.0) + assert not thread.is_alive(), ( + "a worker thread was still running 5s after the stop signal -- " + "a hang here would otherwise leak into subsequent tests" + ) + + return errors, reads + + def test_to_dict_does_not_raise_during_concurrent_writes(self): + graph = _seeded_graph() + errors, reads = self._run_race(graph, graph.to_dict) + + assert not errors, f"to_dict() raised under concurrent writes: {errors[0]!r}" + assert reads, "the reader thread never completed a to_dict() call" + + def test_to_dict_snapshot_is_internally_consistent(self): + """The reported statistics must describe the payload actually emitted. + + ``to_dict`` builds ``nodes``/``edges`` and then reads ``len(self.nodes)`` + and ``len(self.edges)`` for its ``statistics`` block. Unguarded, a write + landing between those steps yields counts that contradict the lists. + """ + graph = _seeded_graph() + errors, reads = self._run_race(graph, graph.to_dict) + + assert not errors, f"to_dict() raised under concurrent writes: {errors[0]!r}" + assert reads, "the reader thread never completed a to_dict() call" + for snapshot in reads: + stats = snapshot["statistics"] + assert stats["node_count"] == len(snapshot["nodes"]), ( + f"statistics.node_count={stats['node_count']} contradicts the " + f"{len(snapshot['nodes'])} nodes in the same snapshot" + ) + assert stats["edge_count"] == len(snapshot["edges"]), ( + f"statistics.edge_count={stats['edge_count']} contradicts the " + f"{len(snapshot['edges'])} edges in the same snapshot" + ) + + def test_snapshot_node_ids_are_unique(self): + """A torn read can emit the same node twice; a locked one cannot.""" + graph = _seeded_graph() + errors, reads = self._run_race(graph, graph.to_dict) + + assert not errors, f"to_dict() raised under concurrent writes: {errors[0]!r}" + for snapshot in reads: + ids = [node["id"] for node in snapshot["nodes"]] + assert len(ids) == len(set(ids)), "to_dict() emitted duplicate node ids" From c1154b6ed6e14f927f4ba8accd0ec131b3853f1f Mon Sep 17 00:00:00 2001 From: Sunil Date: Wed, 12 Aug 2026 21:14:55 +0530 Subject: [PATCH 019/105] fix(security): header injection, link-prediction DoS, import ID sanitization (#912) * fix(security): sanitize node_id in Content-Disposition to prevent header injection (CWE-113) * fix(security): cap link prediction at 10k nodes with semaphore to prevent OOM DoS (CWE-770) * fix(security): sanitize imported node IDs to prevent stored header injection chain (CWE-20) * test(security): add self-contained PoC runner with real measured output * test(security): add regression tests for header injection, DoS cap, import sanitization * fix(security): comprehensive fix for header injection, DoS, and import ID sanitization * fix: move semaphore to wrap entire data-load+scoring region, use node-specific edge queries (Qodo #2, #3) * fix: sanitize edge source/target IDs to match sanitized node IDs (Qodo #4) * fix: scope 999_999 check to predict_links function via AST (Qodo #1) * fix: add explicit None guard to _sanitize_import_node_id * fix(security): close import-sanitizer bypass, enforce link-prediction cap before the expensive scan Follow-up to the fixes in this PR, found in review: - export_import.py's "properties" in raw_node fast path stored the id verbatim, completely skipping _sanitize_import_node_id() -- a node payload of {"id": "", "properties": {}} (the shape this app's own /api/export produces) bypassed the VULN-3 fix entirely. That branch now sanitizes id before storing. - The link-prediction 10k-node cap checked `total` only after calling session.get_nodes()/get_edges(), which normalize the graph's entire matching set before applying `limit` -- so the DoS guard ran after the expensive work it exists to prevent had already happened, on every request regardless of graph size. Added GraphSession.get_raw_counts(), an O(1) check against the raw len(graph.nodes)/len(graph.edges), and moved the size check ahead of the normalizing calls (also added an edge-count cap). - 5 of the existing regression tests asserted that literal words like "Set-Cookie"/"Content-Type" disappear from the sanitized value -- the sanitizer strips \r\n\x00"\ , not letters, so those assertions failed against this PR's own fix as submitted. Corrected to assert on the actual security property (no \r/\n survives), and added end-to-end tests that exercise the real /api/import -> /api/provenance/report route chain so the properties-key bypass has regression coverage. Full explorer suite: 241 passed. tests/test_security_regression_pr2.py: 30 passed. --------- Co-authored-by: Zohaib Hassnain <109234410+ZohaibHassan16@users.noreply.github.com> Co-authored-by: Mohd Kaif <98801504+KaifAhmad1@users.noreply.github.com> Co-authored-by: KaifAhmad1 --- CHANGELOG.md | 9 + poc_runner.py | 279 ++++++++++++++ semantica/explorer/routes/enrich.py | 121 ++++-- semantica/explorer/routes/export_import.py | 51 ++- semantica/explorer/routes/provenance.py | 23 +- semantica/explorer/session.py | 13 + tests/test_security_regression_pr2.py | 408 +++++++++++++++++++++ 7 files changed, 862 insertions(+), 42 deletions(-) create mode 100644 poc_runner.py create mode 100644 tests/test_security_regression_pr2.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 74333a4c..6aff20a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Security +- **HTTP response header injection via `node_id`, unbounded-memory DoS in link prediction, and unsanitized imported node IDs in the Explorer** (#912) by @Sunil56224972 + - `semantica/explorer/routes/provenance.py`'s `GET /api/provenance/report` f-string-interpolated the `node_id` query parameter directly into the `Content-Disposition` response header; a `\r\n`-bearing `node_id` could inject arbitrary response headers (`Set-Cookie` session fixation, `Content-Type` override for reflected XSS). Fixed with `_safe_content_disposition_filename()`, which strips `\r`, `\n`, `\x00`, `"`, `\` and length-caps the value before interpolation + - `POST /api/enrich/links` (link prediction) loaded up to 999,999 nodes with no cap or concurrency guard, then scored every candidate — a single request could consume ~1.6 GB RAM, and concurrent requests compounded that with no limit. Capped the candidate pool at 10,000 nodes (`413` if exceeded) and added an `asyncio.Semaphore(2)`, mirroring the SPARQL DoS fix in #898 + - `POST /api/import` stored uploaded JSON/CSV node IDs verbatim; since provenance reports reflect `node_id` into `Content-Disposition`, an attacker could upload a node with a CRLF-bearing ID once and trigger the header-injection chain above for every subsequent viewer. Added `_sanitize_import_node_id()`, applied to node and edge `source_id`/`target_id` fields on both the JSON and CSV import paths + - **Corrected during review**: the JSON import path had a second, unsanitized branch — any uploaded node object already carrying a `"properties"` key (the shape this app's own `/api/export` produces, and already used elsewhere in the test suite) was appended to the graph as-is, bypassing `_sanitize_import_node_id()` entirely and leaving the stored-header-injection chain open via a one-line payload (`{"id": "", "properties": {}}`). That branch now sanitizes `id` before storing + - **Corrected during review**: the link-prediction cap checked `total` only *after* calling `session.get_nodes()`/`get_edges()`, which normalize the graph's *entire* matching node/edge set before applying `limit` — so the guard ran after the expensive work it was meant to prevent had already happened, on every request regardless of graph size. Added `GraphSession.get_raw_counts()`, an O(1) check against the raw `len(graph.nodes)`/`len(graph.edges)` collections, and moved the size check ahead of the normalizing calls + - **Corrected during review**: 5 of the original PR's 22 regression tests asserted that literal words like `"Set-Cookie"`/`"Content-Type"` disappeared from the sanitized value — the sanitizer only strips `\r\n\x00"\\`, not letters, so those assertions failed against the PR's own fix as submitted. Corrected to assert on the property that actually blocks header injection (no `\r`/`\n` survives), and added end-to-end tests that exercise the real `/api/import` → `/api/provenance/report` route chain (not just the standalone sanitizer function) so the `properties`-key bypass has regression coverage + - Full `explorer` suite: 241 passed; `tests/test_security_regression_pr2.py`: 30 passed + - **`fastapi`/`python-multipart` floors in the `explorer` extra allowed PYSEC-2024-38 (CVE-2024-24762 / GHSA-2jv5-9r88-3w3p, `python-multipart` ReDoS)** (#871, closes #869) by @agu2347 - `explorer` declared `fastapi>=0.100.0` and `python-multipart>=0.0.6`; both floors resolve to versions carrying a ReDoS in `python-multipart`'s `Content-Type` header option parser (`parse_options_header`), reachable by any endpoint that accepts form/multipart data — an attacker-crafted header option can stall the event loop for minutes - **Corrected during review**: the original fix raised only `fastapi>=0.109.1`, leaving `python-multipart>=0.0.6` unchanged. `python-multipart` is declared as its own direct dependency in the `explorer` extra rather than pulled in transitively via `fastapi[all]`, so a bare `fastapi` install enforces no `python-multipart` floor at all — the vulnerable `0.0.6` could still resolve with `fastapi>=0.109.1` in place. Floors raised to `fastapi>=0.109.2` / `python-multipart>=0.0.7`, the first versions of each that exclude the vulnerable range diff --git a/poc_runner.py b/poc_runner.py new file mode 100644 index 00000000..49b9a952 --- /dev/null +++ b/poc_runner.py @@ -0,0 +1,279 @@ +""" +Standalone PoC runner for 3 security vulnerabilities in semantica. + +Spins up the FastAPI app in-process using httpx.AsyncClient + ASGITransport, +so no external server is needed. Run with: + + pip install httpx fastapi + python poc_runner.py + +Each PoC prints the actual captured evidence (headers/status/timing/memory). +""" + +import asyncio +import io +import json +import re +import sys +import time +import tracemalloc + +# ───────────────────────────────────────────────────────────────────────────── +# VULN-1: HTTP Header Injection via node_id in Content-Disposition +# ───────────────────────────────────────────────────────────────────────────── + +# Reproduce the vulnerable code path directly — no server needed. +def _vulnerable_provenance_response(node_id: str, fmt: str) -> dict: + """Mirrors the exact logic from provenance.py lines 332-344.""" + suffix = "_provenance.md" if fmt in {"md", "markdown"} else "_provenance.json" + header_value = f'attachment; filename="{node_id}{suffix}"' + return {"Content-Disposition": header_value} + + +def poc_vuln1(): + print("\n" + "="*70) + print("VULN-1: HTTP Header Injection via node_id in Content-Disposition") + print("="*70) + print("Source: semantica/explorer/routes/provenance.py lines 332-344") + print() + + # PoC 1a: Inject a second header via CRLF + node_id_crlf = 'legit-node"\r\nX-Injected-Header: PWNED\r\nX-Extra: yes' + headers = _vulnerable_provenance_response(node_id_crlf, "json") + raw = headers["Content-Disposition"] + + print("[PoC 1a] Payload: node_id with CRLF injection") + print(f"[PoC 1a] Raw Content-Disposition value:") + print(f" {repr(raw)}") + print() + print("[PoC 1a] Parsed as headers by an HTTP parser:") + for line in raw.split("\r\n"): + print(f" {line}") + print() + print("[PoC 1a] RESULT: X-Injected-Header: PWNED is a REAL injected header") + + # PoC 1b: Override Content-Type to text/html for reflected XSS + node_id_xss = 'x"\r\nContent-Type: text/html\r\n\r\n' + headers2 = _vulnerable_provenance_response(node_id_xss, "json") + raw2 = headers2["Content-Disposition"] + + print() + print("[PoC 1b] Payload: override Content-Type to text/html") + print(f"[PoC 1b] Raw Content-Disposition value:") + print(f" {repr(raw2)}") + print() + print("[PoC 1b] Lines injected after Content-Disposition:") + for line in raw2.split("\r\n")[1:]: + print(f" {line}") + print() + print("[PoC 1b] RESULT: Body now served as text/html → XSS in any browser") + + # PoC 1c: Session fixation via Set-Cookie injection + node_id_cookie = 'x"\r\nSet-Cookie: session=ATTACKER_VALUE; Path=/; HttpOnly' + headers3 = _vulnerable_provenance_response(node_id_cookie, "json") + raw3 = headers3["Content-Disposition"] + + print() + print("[PoC 1c] Payload: inject Set-Cookie for session fixation") + print(f"[PoC 1c] Raw Content-Disposition value:") + print(f" {repr(raw3)}") + injected_cookie = raw3.split("\r\n")[1] if "\r\n" in raw3 else "" + print(f"[PoC 1c] Injected: {injected_cookie}") + print() + print("[PoC 1c] RESULT: Victim's browser receives attacker-set cookie") + + # Verify the fix works + print() + print("[FIX verification]") + _SAFE = re.compile(r"[^\w\-.]") + for bad_id in [node_id_crlf, node_id_xss, node_id_cookie]: + safe = _SAFE.sub("_", bad_id)[:64] + print(f" Input: {repr(bad_id[:50])}...") + print(f" Fixed: {repr(safe)}") + assert "\r" not in safe and "\n" not in safe, "Fix failed!" + print("[FIX] All sanitized — no CRLF sequences remain ✓") + + +# ───────────────────────────────────────────────────────────────────────────── +# VULN-2: Unbounded Memory DoS in /api/enrich/links +# ───────────────────────────────────────────────────────────────────────────── + +def poc_vuln2(): + print("\n" + "="*70) + print("VULN-2: Unbounded Memory DoS via /api/enrich/links") + print("="*70) + print("Source: semantica/explorer/routes/enrich.py lines 197-198") + print() + print("Vulnerable code:") + print(" nodes, _ = await asyncio.to_thread(session.get_nodes, skip=0, limit=999_999)") + print(" edges, _ = await asyncio.to_thread(session.get_edges, skip=0, limit=999_999)") + print() + + # Measure actual memory for building a graph of N nodes in-process + SIZES = [1_000, 5_000, 10_000, 50_000] + + print(f"{'Nodes':>10} {'Edges':>10} {'RAM (MB)':>10} {'Time (ms)':>12} {'Extrapolated 999k (GB)':>25}") + print("-" * 75) + + for n in SIZES: + tracemalloc.start() + t0 = time.perf_counter() + + # Simulate exactly what get_nodes + get_edges returns and _score_all iterates + nodes = [ + {"id": f"node_{i}", "type": "entity", "content": f"content {i}", "embedding": [0.1] * 128} + for i in range(n) + ] + edges = [ + {"source": f"node_{i}", "target": f"node_{i+1}", "type": "related_to", "weight": 1.0} + for i in range(min(n - 1, n)) + ] + + # Simulate _score_all: O(N^2) comparisons + query_node = "node_0" + existing_neighbors = {e["target"] for e in edges if e["source"] == query_node} + scores = [] + for candidate in nodes: + cid = candidate.get("id") + if cid and cid != query_node and cid not in existing_neighbors: + # Simulate score_link (dot product of 128-dim vectors) + score = sum(a * b for a, b in zip(candidate["embedding"], candidate["embedding"])) + scores.append((cid, score)) + + elapsed_ms = (time.perf_counter() - t0) * 1000 + _, peak = tracemalloc.get_traced_memory() + tracemalloc.stop() + + peak_mb = peak / 1024 / 1024 + extrapolated_gb = (peak_mb / n) * 999_999 / 1024 + + print(f"{n:>10,} {len(edges):>10,} {peak_mb:>10.1f} {elapsed_ms:>12.0f} {extrapolated_gb:>25.1f}") + + print() + print("[PoC 2] RESULT: Memory scales linearly with node count.") + print("[PoC 2] At the hardcoded limit=999_999, a 128-dim embedding graph") + print("[PoC 2] consumes multiple GB per request. 4 concurrent = OOM on any server.") + print() + print("[PoC 2] Concurrency amplifier — the endpoint has NO semaphore:") + print(" # enrich.py has no equivalent of the SPARQL semaphore added in PR #898") + print(" # Any number of concurrent requests pile up in the thread pool") + print() + print("[FIX] Cap: limit=10_000, semaphore(2), return 413 if graph > cap") + + +# ───────────────────────────────────────────────────────────────────────────── +# VULN-3: Unsanitized node_id from import flows into HTTP headers (CWE-20/113) +# (Narrowed: no filesystem write sink in the Explorer — claim is header injection chain) +# ───────────────────────────────────────────────────────────────────────────── + +def poc_vuln3(): + print("\n" + "="*70) + print("VULN-3: Unsanitized Import ID → Header Injection Chain (CWE-20 + CWE-113)") + print("="*70) + print("Source: export_import.py line 85 → provenance.py lines 336, 344") + print() + + # Simulate the import parser — mirrors export_import.py lines 77-92 + def parse_import_json(data: dict) -> list: + """Mirrors export_import.py node parsing (no sanitization).""" + raw_nodes = data.get("nodes", data.get("entities", [])) + nodes = [] + for raw_node in raw_nodes: + node_id = str(raw_node.get("id", raw_node.get("_id", raw_node.get("node_id", "")))) + nodes.append({ + "id": node_id, # ← UNSANITIZED + "type": raw_node.get("type", "entity"), + "properties": {"content": raw_node.get("content", node_id)}, + }) + return nodes + + # Simulate the CSV parser — mirrors export_import.py lines 131-133 + def parse_import_csv_row(row: dict) -> dict: + """Mirrors export_import.py CSV node ID extraction (no sanitization).""" + node_id = row.get("id") or row.get("node_id") or row.get(":ID") or row.get("_id") + return { + "id": str(node_id), # ← UNSANITIZED + "type": row.get("type", "entity"), + } + + # Attack payloads + payloads = [ + # Header injection payload (chained with VULN-1) + 'evil"\r\nSet-Cookie: session=HIJACKED; Path=/\r\n\r\n', + # Content-Type override + 'x"\r\nContent-Type: text/html\r\nX-XSS: ', + # Null byte to truncate filenames on some systems + 'node\x00.json', + # Long ID causing buffer issues in some loggers + "A" * 512, + ] + + print("[Step 1] Upload JSON with malicious node IDs via POST /api/import:") + malicious_json = { + "nodes": [{"id": p, "type": "entity", "content": "pwned"} for p in payloads] + } + imported_nodes = parse_import_json(malicious_json) + + print(f" Imported {len(imported_nodes)} nodes. IDs stored verbatim:") + for node in imported_nodes: + preview = repr(node["id"][:60]) + ("..." if len(node["id"]) > 60 else "") + print(f" {preview}") + + print() + print("[Step 2] IDs flow into Content-Disposition when caller requests provenance report:") + print(" GET /api/provenance/report?node_id=&format=json") + print() + + for node in imported_nodes[:2]: # show first two + node_id = node["id"] + # Exact code from provenance.py line 344 + raw_header = f'attachment; filename="{node_id}_provenance.json"' + print(f" node_id input: {repr(node_id[:60])}") + print(f" Content-Disposition output:") + print(f" {repr(raw_header[:120])}") + if "\r\n" in raw_header: + print(f" >>> CRLF INJECTION CONFIRMED — headers after split:") + for line in raw_header.split("\r\n"): + print(f" {line}") + print() + + print("[Step 3] Verify the full attack chain works:") + attack_id = 'node"\r\nContent-Type: text/html\r\n\r\n

XSS

' + + # Step 1: import stores it + stored = parse_import_json({"nodes": [{"id": attack_id, "type": "entity"}]})[0] + assert stored["id"] == attack_id, "ID not stored verbatim" + print(f" ✓ ID stored verbatim: {repr(stored['id'][:60])}") + + # Step 2: provenance endpoint reflects it into header + raw = f'attachment; filename="{stored["id"]}_provenance.json"' + assert "Content-Type: text/html" in raw, "Content-Type not injected" + print(f" ✓ Content-Type: text/html injected via stored ID") + print(f" ✓ Full attack chain: import → store → provenance → header injection CONFIRMED") + + print() + print("[PoC 3] RESULT: Any user who can POST /api/import can plant a malicious node ID") + print("[PoC 3] that — when provenance is requested — injects HTTP response headers.") + print("[PoC 3] Impact: XSS (Content-Type override), session fixation (Set-Cookie).") + print() + print("[NOTE] Narrowing from file-overwrite: no direct file-write sink found in Explorer.") + print("[NOTE] Real impact is header injection chain with VULN-1 (both need the same fix).") + print() + print("[FIX] Sanitize node IDs on import (strip CRLF, null bytes, length-cap):") + print(" node_id = re.sub(r'[\\r\\n\\x00]', '', raw_id)[:256]") + + +# ───────────────────────────────────────────────────────────────────────────── + +if __name__ == "__main__": + print("semantica Security PoC Runner") + print("Demonstrates VULN-1, VULN-2, VULN-3 with real captured output") + print("No external server required — all evidence captured in-process") + + poc_vuln1() + poc_vuln2() + poc_vuln3() + + print("\n" + "="*70) + print("ALL PoCs COMPLETED — see output above for reproducible evidence") + print("="*70) diff --git a/semantica/explorer/routes/enrich.py b/semantica/explorer/routes/enrich.py index e6498d9c..699875fa 100644 --- a/semantica/explorer/routes/enrich.py +++ b/semantica/explorer/routes/enrich.py @@ -1,4 +1,4 @@ -""" +""" Enrichment and reasoning routes. """ @@ -26,6 +26,23 @@ from ..session import GraphSession router = APIRouter(tags=["Enrichment"]) _FACT_RE = re.compile(r"^(?P[A-Za-z_][\w:-]*)\((?P.*)\)$") +# SECURITY: Cap the candidate pool loaded by link prediction to prevent a +# single request from exhausting server memory (CWE-770). Without a cap the +# endpoint calls session.get_nodes(limit=999_999) and scores every node in +# O(N^2), consuming ~1.6 GB RAM at the maximum limit (measured via +# tracemalloc at 1.7 KB/node with 128-dim embeddings; see poc_runner.py). +# Mirrors the SPARQL DoS fix from PR #898 (50k cap + semaphore). +# +# NOTE: session.get_nodes()/get_edges() (paginate_nodes/paginate_edges) +# normalize the *entire* matching set before applying `limit` -- passing +# limit=_LINK_PREDICTION_MAX_NODES does not bound that work. The `total` +# they return can only be checked *after* paying that full cost. To actually +# reject an oversized graph before doing that work, check session.get_raw_counts() +# (O(1) collection lengths) first -- see predict_links() below. +_LINK_PREDICTION_MAX_NODES = 10_000 +_LINK_PREDICTION_MAX_EDGES = 50_000 +_link_prediction_semaphore = asyncio.Semaphore(2) + def _safe_dict(obj) -> dict: if isinstance(obj, dict): @@ -194,40 +211,80 @@ async def predict_links( if node is None: raise HTTPException(status_code=404, detail=f"Node '{body.node_id}' not found") - nodes, _ = await asyncio.to_thread(session.get_nodes, skip=0, limit=999_999) - edges, _ = await asyncio.to_thread(session.get_edges, skip=0, limit=999_999) + # SECURITY: Acquire semaphore BEFORE loading data so concurrent requests + # cannot pile up expensive threadpool work and memory pressure (Qodo #2). + async with _link_prediction_semaphore: + # SECURITY: Reject an oversized graph using the O(1) raw collection + # lengths BEFORE calling get_nodes()/get_edges(), which normalize the + # *entire* matching set before applying `limit` -- checking `total` + # only after that call still pays the full O(graph size) cost the cap + # is meant to avoid. + total_nodes, total_edges = await asyncio.to_thread(session.get_raw_counts) + if total_nodes > _LINK_PREDICTION_MAX_NODES: + raise HTTPException( + status_code=413, + detail=( + f"Graph has {total_nodes:,} nodes; link prediction is capped at " + f"{_LINK_PREDICTION_MAX_NODES:,} nodes to prevent memory exhaustion. " + "Use the graph search endpoint for large graphs." + ), + ) + if total_edges > _LINK_PREDICTION_MAX_EDGES: + raise HTTPException( + status_code=413, + detail=( + f"Graph has {total_edges:,} edges; link prediction is capped at " + f"{_LINK_PREDICTION_MAX_EDGES:,} edges to prevent memory exhaustion. " + "Use the graph search endpoint for large graphs." + ), + ) - existing_neighbors = { - edge.get("target") for edge in edges if edge.get("source") == body.node_id - } | { - edge.get("source") for edge in edges if edge.get("target") == body.node_id - } + # SECURITY: Load at most _LINK_PREDICTION_MAX_NODES candidates. + # The hardcoded limit in the original code consumed ~1.6 GB RAM + # per request and had no concurrency guard, making it trivially DoS-able. + nodes, _ = await asyncio.to_thread(session.get_nodes, skip=0, limit=_LINK_PREDICTION_MAX_NODES) - def _score_all() -> list: - results = [] - for candidate_node in nodes: - candidate_id = candidate_node.get("id") - if not candidate_id or candidate_id == body.node_id or candidate_id in existing_neighbors: - continue - if body.candidate_type and candidate_node.get("type") != body.candidate_type: - continue - try: - score = predictor.score_link(session.graph, body.node_id, candidate_id) - except Exception: - continue - if score >= body.min_score: - results.append( - { - "target": candidate_id, - "score": score, - "type": candidate_node.get("type", "entity"), - "label": candidate_node.get("content", candidate_id), - } - ) - results.sort(key=lambda item: item["score"], reverse=True) - return results + # Load edges specific to the queried node rather than a globally + # truncated page — avoids missing neighbours when the node's edges + # fall outside the first page (Qodo #3). + edges_out, _ = await asyncio.to_thread( + session.get_edges, source=body.node_id, skip=0, limit=_LINK_PREDICTION_MAX_NODES, + ) + edges_in, _ = await asyncio.to_thread( + session.get_edges, target=body.node_id, skip=0, limit=_LINK_PREDICTION_MAX_NODES, + ) - scored = await asyncio.to_thread(_score_all) + existing_neighbors = { + edge.get("target") for edge in edges_out + } | { + edge.get("source") for edge in edges_in + } + + def _score_all() -> list: + results = [] + for candidate_node in nodes: + candidate_id = candidate_node.get("id") + if not candidate_id or candidate_id == body.node_id or candidate_id in existing_neighbors: + continue + if body.candidate_type and candidate_node.get("type") != body.candidate_type: + continue + try: + score = predictor.score_link(session.graph, body.node_id, candidate_id) + except Exception: + continue + if score >= body.min_score: + results.append( + { + "target": candidate_id, + "score": score, + "type": candidate_node.get("type", "entity"), + "label": candidate_node.get("content", candidate_id), + } + ) + results.sort(key=lambda item: item["score"], reverse=True) + return results + + scored = await asyncio.to_thread(_score_all) return LinkPredictionResponse(node_id=body.node_id, predictions=scored[: body.top_n]) diff --git a/semantica/explorer/routes/export_import.py b/semantica/explorer/routes/export_import.py index 6beda930..5ae464f5 100644 --- a/semantica/explorer/routes/export_import.py +++ b/semantica/explorer/routes/export_import.py @@ -1,4 +1,4 @@ -""" +""" Import and export routes for graph datasets. """ @@ -6,6 +6,7 @@ import csv import io import json import logging +import re from fastapi import APIRouter, Depends, File, HTTPException, UploadFile from fastapi.responses import Response @@ -22,6 +23,33 @@ _IMPORT_MAX_BYTES = 50 * 1024 * 1024 # 50 MB # Do not add extensions here unless a corresponding parsing branch exists below. _ALLOWED_IMPORT_EXTENSIONS = frozenset({".json", ".csv"}) +# SECURITY: Strip characters from imported node IDs that would enable stored +# HTTP response header injection (CWE-20 / CWE-113). These IDs are later +# reflected verbatim into Content-Disposition filename= headers by the +# provenance report endpoint -- CRLF sequences in an ID can split the HTTP +# response and inject arbitrary headers (Set-Cookie, Content-Type, etc.). +# NUL bytes truncate filenames on POSIX and some Windows APIs. +_UNSAFE_ID_CHARS = re.compile(r'[\r\n\x00"\\]') +_MAX_IMPORT_NODE_ID_LEN = 512 + + +def _sanitize_import_node_id(raw: object) -> str: + """Sanitize a node ID arriving from an uploaded CSV or JSON file. + + Strips CR, LF, NUL, double-quotes, and backslashes, then length-caps the + result. These are the characters that enable CRLF header injection when + the ID is later used in a Content-Disposition filename= parameter. + """ + if raw is None: + return "" + cleaned = _UNSAFE_ID_CHARS.sub("_", str(raw).strip()) + if len(cleaned) > _MAX_IMPORT_NODE_ID_LEN: + raise HTTPException( + status_code=422, + detail=f"Node ID exceeds maximum length of {_MAX_IMPORT_NODE_ID_LEN} characters.", + ) + return cleaned + def _import_response(nodes_added: int, edges_added: int, message: str = "Import successful") -> ImportResponse: return ImportResponse( @@ -77,12 +105,19 @@ async def import_file( nodes = [] for raw_node in raw_nodes: if "properties" in raw_node: - nodes.append(raw_node) + # SECURITY: this pre-built-node path bypasses the id/type/properties + # construction below entirely, so it must sanitize the id itself -- + # otherwise a payload like {"id": "", "properties": {}} skips + # _sanitize_import_node_id() completely (CWE-20/CWE-113 bypass). + safe_node_id = _sanitize_import_node_id( + raw_node.get("id", raw_node.get("_id", raw_node.get("node_id", ""))) + ) + nodes.append({**raw_node, "id": safe_node_id}) continue metadata = raw_node.get("metadata", {}) or {} nodes.append( { - "id": str(raw_node.get("id", raw_node.get("_id", raw_node.get("node_id", "")))), + "id": _sanitize_import_node_id(raw_node.get("id", raw_node.get("_id", raw_node.get("node_id", "")))), "type": raw_node.get("type", "entity"), "properties": { "content": raw_node.get("text", raw_node.get("content", raw_node.get("id", ""))), @@ -102,8 +137,8 @@ async def import_file( { "id": raw_edge.get("id", raw_edge.get("edge_id")), "familyId": raw_edge.get("familyId", raw_edge.get("family_id")), - "source_id": str(source), - "target_id": str(target), + "source_id": _sanitize_import_node_id(source), + "target_id": _sanitize_import_node_id(target), "type": raw_edge.get("type", raw_edge.get("relationship", "related_to")), "weight": float(raw_edge.get("weight", 1.0)), "properties": edge_properties, @@ -159,8 +194,8 @@ async def import_file( { "id": row.get("id") or row.get("edge_id"), "familyId": row.get("familyId") or row.get("family_id"), - "source_id": str(source), - "target_id": str(target), + "source_id": _sanitize_import_node_id(source), + "target_id": _sanitize_import_node_id(target), "type": row.get("type") or row.get("relationship") or row.get(":TYPE") or "related_to", "weight": float(row.get("weight", 1.0) or 1.0), "properties": edge_props, @@ -174,7 +209,7 @@ async def import_file( } nodes.append( { - "id": str(node_id), + "id": _sanitize_import_node_id(node_id), "type": row.get("type") or row.get("label") or row.get(":LABEL") or "entity", "properties": node_props, } diff --git a/semantica/explorer/routes/provenance.py b/semantica/explorer/routes/provenance.py index daa52842..a7861135 100644 --- a/semantica/explorer/routes/provenance.py +++ b/semantica/explorer/routes/provenance.py @@ -5,6 +5,7 @@ Provenance routes for lineage visualization and exportable reports. import asyncio import json import logging +import re from typing import Any, Dict, List, Optional import networkx as nx @@ -19,6 +20,24 @@ from ...provenance.integrity import verify_checksum logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/provenance", tags=["Power User Tools"]) +# SECURITY: Strip characters that could break out of a Content-Disposition +# filename= value and inject new HTTP response headers (CWE-113 / CRLF injection). +# \r, \n, \x00 are the primary header-splitting vectors; " and \ would close +# or escape the filename attribute. +_UNSAFE_FILENAME_CHARS = re.compile(r'[\r\n\x00"\\]') +_MAX_FILENAME_ID_LEN = 128 + + +def _safe_content_disposition_filename(node_id: str, suffix: str) -> str: + """Return a sanitized Content-Disposition filename for the given node_id. + + Strips CR, LF, NUL, double-quotes, and backslashes that could split HTTP + response headers or escape the filename attribute, then length-caps the + result so it never produces an excessively long header value. + """ + sanitized = _UNSAFE_FILENAME_CHARS.sub("_", str(node_id))[:_MAX_FILENAME_ID_LEN] + return f"{sanitized}{suffix}" + _AGENT_TYPES = {"person", "organization", "system", "agent"} _ACTIVITY_TYPES = {"action", "event", "process", "activity", "decision", "publication"} @@ -333,12 +352,12 @@ async def export_provenance_report( content = _render_markdown(report) return PlainTextResponse( content, - headers={"Content-Disposition": f'attachment; filename="{node_id}_provenance.md"'}, + headers={"Content-Disposition": f'attachment; filename="{_safe_content_disposition_filename(node_id, "_provenance.md")}"'}, ) content = json.dumps(report, indent=2, default=str) return Response( content=content, media_type="application/json", - headers={"Content-Disposition": f'attachment; filename="{node_id}_provenance.json"'}, + headers={"Content-Disposition": f'attachment; filename="{_safe_content_disposition_filename(node_id, "_provenance.json")}"'}, ) diff --git a/semantica/explorer/session.py b/semantica/explorer/session.py index 1d0a60f9..abdc1711 100644 --- a/semantica/explorer/session.py +++ b/semantica/explorer/session.py @@ -375,6 +375,19 @@ class GraphSession: ) return page, total + def get_raw_counts(self) -> tuple[int, int]: + """O(1) node/edge counts from the raw collections, with no per-item + normalization. + + ``paginate_nodes``/``paginate_edges`` always normalize the *entire* + matching set before applying ``limit``, so callers that need to reject + an oversized graph before paying that cost (e.g. link prediction's DoS + guard) should check this first rather than inspecting the ``total`` + returned by ``get_nodes``/``get_edges`` after the fact. + """ + with self._lock: + return len(self.graph.nodes), len(self.graph.edges) + def paginate_edges( self, edge_type: Optional[str] = None, diff --git a/tests/test_security_regression_pr2.py b/tests/test_security_regression_pr2.py new file mode 100644 index 00000000..f20f4557 --- /dev/null +++ b/tests/test_security_regression_pr2.py @@ -0,0 +1,408 @@ +""" +Regression tests for security fixes introduced in follow-on to PR #898. + +Covers three vulnerabilities found by security audit: + - VULN-1: CWE-113 Header injection via node_id in Content-Disposition + - VULN-2: CWE-770 Unbounded memory DoS in /api/enrich/links + - VULN-3: CWE-20+113 Stored header injection via unsanitized import node IDs + +All tests are self-contained; no running server required. +""" +import json +import re +import pytest + + +# =================================================================== +# Helper: replicate the sanitization functions under test +# =================================================================== + +# --- provenance.py --- +_UNSAFE_FILENAME_CHARS_PROV = re.compile(r'[\r\n\x00"\\]') +_MAX_FILENAME_ID_LEN = 128 + + +def _safe_content_disposition_filename(node_id: str, suffix: str) -> str: + sanitized = _UNSAFE_FILENAME_CHARS_PROV.sub("_", str(node_id))[:_MAX_FILENAME_ID_LEN] + return f"{sanitized}{suffix}" + + +# --- export_import.py --- +_UNSAFE_ID_CHARS_IMPORT = re.compile(r'[\r\n\x00"\\]') +_MAX_IMPORT_NODE_ID_LEN = 512 + + +def _sanitize_import_node_id(raw: object) -> str: + cleaned = _UNSAFE_ID_CHARS_IMPORT.sub("_", str(raw).strip()) + if len(cleaned) > _MAX_IMPORT_NODE_ID_LEN: + raise ValueError(f"Node ID exceeds {_MAX_IMPORT_NODE_ID_LEN} chars") + return cleaned + + +# =================================================================== +# VULN-1: Header injection via node_id in Content-Disposition +# =================================================================== + +class TestVuln1HeaderInjection: + """Regression: CWE-113 — provenance.py lines 332, 344.""" + + def _make_header(self, node_id: str, fmt: str = "json") -> str: + """Reproduce the pre-fix vulnerable code path.""" + suffix = "_provenance.md" if fmt in {"md", "markdown"} else "_provenance.json" + return f'attachment; filename="{node_id}{suffix}"' + + def _make_safe_header(self, node_id: str, fmt: str = "json") -> str: + """Post-fix sanitized path.""" + suffix = "_provenance.md" if fmt in {"md", "markdown"} else "_provenance.json" + return f'attachment; filename="{_safe_content_disposition_filename(node_id, suffix)}"' + + # --- Confirm the old code WAS vulnerable --- + + def test_vulnerable_path_crlf(self): + """Without the fix, CRLF injects new headers.""" + raw = self._make_header('x"\r\nX-Evil: pwned') + assert "\r\n" in raw, "Vulnerable: CRLF in header value" + assert "X-Evil: pwned" in raw + + def test_vulnerable_path_content_type_override(self): + raw = self._make_header('x"\r\nContent-Type: text/html\r\n\r\n' + result = _sanitize_import_node_id(bad) + assert "\r\n" not in result + assert "\r" not in result + assert "\n" not in result + + # --- End-to-end: sanitized ID cannot trigger header injection --- + + def test_chain_sanitized_id_cannot_inject(self): + """After sanitization, stored ID must not split Content-Disposition.""" + bad_id = 'evil"\r\nSet-Cookie: session=HIJACKED' + stored_id = _sanitize_import_node_id(bad_id) + # Simulate provenance report header construction + header = _safe_content_disposition_filename(stored_id, "_provenance.json") + assert "\r\n" not in header + assert "\r" not in header + assert "\n" not in header + + def test_import_sanitizer_applied_json(self): + """_sanitize_import_node_id must be called in the JSON import path.""" + import ast, pathlib + src = pathlib.Path( + "semantica/explorer/routes/export_import.py" + ).read_text(encoding="utf-8") + assert "_sanitize_import_node_id" in src + # Must appear at least twice: JSON path + CSV path + assert src.count("_sanitize_import_node_id") >= 2, ( + "Sanitizer only applied in one import path — CSV or JSON path is still vulnerable" + ) + + def test_import_sanitizer_applied_csv(self): + """The CSV import path must also call _sanitize_import_node_id.""" + import pathlib + src = pathlib.Path( + "semantica/explorer/routes/export_import.py" + ).read_text(encoding="utf-8") + # Find both occurrences with their surrounding context + lines = src.splitlines() + sanitizer_lines = [i for i, l in enumerate(lines) if "_sanitize_import_node_id" in l] + assert len(sanitizer_lines) >= 2, ( + f"Expected >= 2 calls to _sanitize_import_node_id, found {len(sanitizer_lines)}" + ) + + +# =================================================================== +# VULN-3 bypass fix: the `"properties" in raw_node` fast path in the JSON +# import loop stored the id verbatim, completely skipping +# _sanitize_import_node_id(). Exercised end-to-end via the real FastAPI +# route (not the standalone sanitizer copy above) since that's exactly how +# the bypass went unnoticed by the original test suite in this PR. +# =================================================================== + + +@pytest.fixture +def _real_client(monkeypatch): + pytest.importorskip("starlette") + # These tests exercise route logic, not the API-key auth layer (see + # tests/explorer/conftest.py, which does the same for that directory). + monkeypatch.setenv("SEMANTICA_ALLOW_ANONYMOUS", "true") + monkeypatch.delenv("SEMANTICA_API_KEY", raising=False) + from starlette.testclient import TestClient + from semantica.context.context_graph import ContextGraph + from semantica.explorer.app import create_app + from semantica.explorer.session import GraphSession + + session = GraphSession(ContextGraph(advanced_analytics=False)) + app = create_app(session=session) + with TestClient(app) as test_client: + yield test_client + + +class TestVuln3PropertiesBypassFix: + """Regression: export_import.py's `"properties" in raw_node` fast path.""" + + def test_properties_shaped_node_id_is_sanitized(self, _real_client): + """A node object carrying its own "properties" key -- the shape this + app's own /api/export produces, and what + test_import_json_with_edge_metadata in test_explorer_api.py already + uses -- must still have its id sanitized on import.""" + malicious_id = 'evil"\r\nSet-Cookie: session=HIJACKED; Path=/' + payload = json.dumps( + {"nodes": [{"id": malicious_id, "type": "entity", "properties": {"content": "pwned"}}]} + ) + response = _real_client.post( + "/api/import", + files={"file": ("evil.json", payload, "application/json")}, + ) + assert response.status_code == 200 + assert response.json()["nodes_added"] == 1 + + expected_id = _sanitize_import_node_id(malicious_id) + assert "\r" not in expected_id and "\n" not in expected_id + + listing = _real_client.get("/api/graph/nodes", params={"limit": 100}) + ids = {n["id"] for n in listing.json()["nodes"]} + assert malicious_id not in ids, "Raw malicious id was stored verbatim -- bypass not fixed" + assert expected_id in ids, "Sanitized id was not what got stored" + + def test_properties_bypass_blocks_header_injection_e2e(self, _real_client): + """Full chain: import a "properties"-shaped node with a CRLF id, then + request its provenance report and confirm no header injection.""" + malicious_id = 'evil"\r\nContent-Type: text/html\r\nX-Evil: pwned' + payload = json.dumps({"nodes": [{"id": malicious_id, "type": "entity", "properties": {}}]}) + response = _real_client.post( + "/api/import", + files={"file": ("evil.json", payload, "application/json")}, + ) + assert response.status_code == 200 + + expected_id = _sanitize_import_node_id(malicious_id) + report = _real_client.get( + "/api/provenance/report", params={"node_id": expected_id, "format": "json"} + ) + assert report.status_code == 200 + disposition = report.headers.get("content-disposition", "") + # No \r or \n surviving is the necessary and sufficient condition for + # blocking header injection -- the sanitizer strips those characters + # but not letters, so "X-Evil"/"Content-Type" as literal substrings + # surviving is expected and harmless. + assert "\r\n" not in disposition + assert "\r" not in disposition + assert "\n" not in disposition + # And confirm no attacker-controlled header actually landed as a + # distinct response header (would only happen if splitting occurred). + assert "x-evil" not in report.headers + assert report.headers.get("content-type", "").startswith("application/json") + + +# =================================================================== +# VULN-2 fix follow-up: the 10k/50k cap must be enforced BEFORE the +# expensive get_nodes()/get_edges() calls, not after. paginate_nodes()/ +# paginate_edges() normalize the *entire* matching set before applying +# `limit`, so checking `total` only after calling them still pays the full +# O(graph size) cost the cap exists to avoid. +# =================================================================== + + +class TestVuln2CapEnforcedBeforeExpensiveWork: + def test_get_raw_counts_matches_graph(self): + from semantica.context.context_graph import ContextGraph + from semantica.explorer.session import GraphSession + + graph = ContextGraph(advanced_analytics=False) + graph.add_node("a", node_type="entity", content="A") + graph.add_node("b", node_type="entity", content="B") + graph.add_edge("a", "b", edge_type="related_to") + session = GraphSession(graph) + + total_nodes, total_edges = session.get_raw_counts() + assert total_nodes == 2 + assert total_edges == 1 + + def test_predict_links_checks_raw_counts_before_get_nodes(self): + import pathlib + + src = pathlib.Path("semantica/explorer/routes/enrich.py").read_text(encoding="utf-8") + raw_counts_pos = src.index("session.get_raw_counts") + get_nodes_pos = src.index("session.get_nodes,") + assert raw_counts_pos < get_nodes_pos, ( + "get_raw_counts() must run before get_nodes() so the cap is enforced " + "before paying the full O(graph size) normalization cost" + ) + + def test_predict_links_rejects_oversized_graph_e2e(self, monkeypatch): + """Exercise the real route: with the cap patched low, an oversized + graph must 413 instead of scoring the full candidate pool.""" + pytest.importorskip("starlette") + monkeypatch.setenv("SEMANTICA_ALLOW_ANONYMOUS", "true") + monkeypatch.delenv("SEMANTICA_API_KEY", raising=False) + from starlette.testclient import TestClient + from semantica.context.context_graph import ContextGraph + from semantica.explorer.app import create_app + from semantica.explorer.session import GraphSession + import semantica.explorer.routes.enrich as enrich_module + + graph = ContextGraph(advanced_analytics=False) + for i in range(5): + graph.add_node(f"n{i}", node_type="entity", content=f"node {i}") + session = GraphSession(graph) + if session.link_predictor is None: + pytest.skip("LinkPredictor not available; KG extras not installed.") + + monkeypatch.setattr(enrich_module, "_LINK_PREDICTION_MAX_NODES", 2) + + app = create_app(session=session) + with TestClient(app) as client: + response = client.post("/api/enrich/links", json={"node_id": "n0"}) + assert response.status_code == 413 + assert "nodes" in response.json()["detail"].lower() + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) From 1a3dd5038ace29b52651e4722fc127f79d1a88a4 Mon Sep 17 00:00:00 2001 From: cakeni <2150015994@qq.com> Date: Thu, 13 Aug 2026 01:35:58 +0800 Subject: [PATCH 020/105] docs(kg): document GraphBuilder public methods (#878) * docs(kg): document GraphBuilder public methods * test(kg): skip module-level doctest to fix suite run * docs(kg): restore GraphBuilder option documentation * docs(kg): document default values for build() extraction options extract_relations, extract_triplets, ner_method, relation_method, and triplet_method all have concrete defaults in _extract_from_text(), but the build() docstring only stated a default for extract, inconsistent with CONTRIBUTING.md's docstring convention of noting parameter defaults. * docs: add changelog entry for GraphBuilder docstrings (#878, #876) --------- Co-authored-by: Zohaib Hassnain <109234410+ZohaibHassan16@users.noreply.github.com> Co-authored-by: Sameer Kadam Co-authored-by: Mohd Kaif <98801504+KaifAhmad1@users.noreply.github.com> Co-authored-by: KaifAhmad1 --- CHANGELOG.md | 10 ++ semantica/kg/graph_builder.py | 195 +++++++++++++++++++++++++--------- 2 files changed, 156 insertions(+), 49 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6aff20a7..d263f9d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- **`GraphBuilder`'s 6 public methods now have Google-style docstrings** (#878, closes #876) by @cakeni + - `semantica/kg/graph_builder.py`'s `build`, `build_single_source`, `add_temporal_edge`, `create_temporal_snapshot`, `query_temporal`, and `load_from_neo4j` — the core knowledge-graph construction API, imported directly by callers — previously had zero docstrings across all 6 methods, the only file in a 10-file audit sample with that gap, despite CONTRIBUTING.md requiring Google-style `Args`/`Returns`/`Raises`/`Example` docs for public methods. Added full docstrings for all 6, plus the previously undocumented `build_single_source`, with runnable (`# doctest: +SKIP`) usage examples + - **Corrected during review**: `query_temporal`'s docstring claimed the query text was used to filter the graph; the implementation only records it in the result (`results = {"query": query, ...}`) with no interpretation or filtering. Corrected to state that explicitly + - **Corrected during review**: `create_temporal_snapshot`'s docstring implied entities were filtered for validity at the snapshot timestamp like relationships are; the implementation copies all entities unfiltered and only filters `relationships` by `valid_from`/`valid_until`. Docstring now distinguishes the two + - **Corrected during review**: `add_temporal_edge`/`create_temporal_snapshot` docstrings overclaimed numeric-timestamp support; `_parse_time()` only special-cases `str` and `datetime`, falling back to a bare `str()` cast for anything else (not true numeric parsing). Narrowed to "datetime or ISO-formatted string" + - **Fixed along the way**: `build()`'s `**options` documented a default only for `extract`; `extract_relations`, `extract_triplets`, `ner_method`, `relation_method`, and `triplet_method` all have concrete defaults in `_extract_from_text()` (`True`, `True`, `"llm"`, `"llm"`, `"llm"`) that were left unstated, inconsistent with CONTRIBUTING.md's own docstring example of noting defaults inline + - `python -m pytest tests/kg/test_kg.py tests/kg/test_graph_builder_external.py -q`: 45 passed + ### Fixed - **MCP server reported a stale `0.4.0` version instead of the installed package version** (#870, closes #863) by @oiahoon diff --git a/semantica/kg/graph_builder.py b/semantica/kg/graph_builder.py index b3b54dc6..6ed783f9 100644 --- a/semantica/kg/graph_builder.py +++ b/semantica/kg/graph_builder.py @@ -16,7 +16,7 @@ Key Features: Example Usage: >>> from semantica.kg import GraphBuilder >>> builder = GraphBuilder(merge_entities=True, resolve_conflicts=True) - >>> graph = builder.build(sources=[{"entities": [...], "relationships": [...]}]) + >>> graph = builder.build(sources=[{"entities": [...], "relationships": [...]}]) # doctest: +SKIP Author: Semantica Contributors License: MIT @@ -291,21 +291,47 @@ class GraphBuilder: pipeline_id: Optional[str] = None, **options, ) -> Dict[str, Any]: - """ - Build knowledge graph from sources. + """Build a knowledge graph from one or more sources. Args: - sources: Entities or sources list - second_arg: Optional relationships list or entity_resolver (for backward compatibility) - pipeline_id: Optional pipeline ID for progress tracking - **options: Additional build options - - extract: Whether to extract entities from text (default: True) - - extract_relations: Whether to extract relations from text (default: False) - - ner_method: NER method to use (default: "ml") - - triplet_method: Triplet extraction method (default: "pattern") + sources: A source or list of sources. Sources may be text, + pre-extracted objects, or dictionaries containing ``entities`` + and ``relationships``. + second_arg: An optional relationship list or entity resolver kept + for backward compatibility. + pipeline_id: Optional pipeline identifier used for progress + tracking. + **options: Additional graph-building options: + + - ``extract`` (bool): Whether to run text extraction when a + raw string or ``{"text": ...}`` dict is passed as a source + (default: ``True``). + - ``extract_relations`` (bool): Whether to extract relations + during text extraction (default: ``True``). + - ``extract_triplets`` (bool): Whether to extract triplets + during text extraction (default: ``True``). + - ``ner_method`` (str): NER backend used for text extraction + (e.g. ``"ml"``, ``"pattern"``, ``"llm"``; default: ``"llm"``). + - ``relation_method`` (str): Relation-extraction backend + (e.g. ``"pattern"``, ``"llm"``; default: ``"llm"``). + - ``triplet_method`` (str): Triplet-extraction backend + (e.g. ``"pattern"``, ``"llm"``; default: ``"llm"``). + - ``entity_resolver``: An :class:`EntityResolver` instance + that overrides the one configured on the builder. + - ``relationships`` (list): An explicit list of relationships + to include in addition to those found in *sources*. Returns: - Dictionary containing entities and relationships + A dictionary containing the graph's ``entities``, + ``relationships``, and build ``metadata``. + + Example: + >>> builder = GraphBuilder(resolve_conflicts=False) + >>> graph = builder.build( # doctest: +SKIP + ... {"entities": [{"id": "ada"}], "relationships": []} + ... ) + >>> graph["metadata"]["num_entities"] # doctest: +SKIP + 1 """ # Handle arguments entity_resolver = None @@ -730,6 +756,26 @@ class GraphBuilder: pipeline_id: Optional[str] = None, **options, ) -> Dict[str, Any]: + """Build a knowledge graph from a single source dictionary. + + Args: + kg_data: Source data containing entities, relationships, or both. + pipeline_id: Optional pipeline identifier used for progress + tracking. + **options: Additional options forwarded to :meth:`build`. + + Returns: + A dictionary containing the graph's ``entities``, + ``relationships``, and build ``metadata``. + + Example: + >>> builder = GraphBuilder(resolve_conflicts=False) + >>> graph = builder.build_single_source( # doctest: +SKIP + ... {"entities": [{"id": "ada"}], "relationships": []} + ... ) + >>> len(graph["entities"]) # doctest: +SKIP + 1 + """ return self.build(kg_data, pipeline_id=pipeline_id, **options) def add_temporal_edge( @@ -743,21 +789,33 @@ class GraphBuilder: temporal_metadata=None, **kwargs, ): - """ - Add edge with temporal validity information. + """Add an edge with temporal validity information to a graph. Args: - graph: Knowledge graph to add edge to - source: Source entity/node - target: Target entity/node - relationship: Relationship type - valid_from: Start time for relationship validity (datetime, timestamp, or ISO string) - valid_until: End time for relationship validity (None for ongoing) - temporal_metadata: Additional temporal metadata (timezone, precision, etc.) - **kwargs: Additional edge properties + graph: Mutable knowledge-graph dictionary to update. + source: Identifier of the source entity or node. + target: Identifier of the target entity or node. + relationship: Relationship type for the edge. + valid_from: Start of the validity period. Accepts a datetime or + ISO-formatted string; defaults to the current time. + valid_until: End of the validity period, or ``None`` for an + ongoing relationship. + temporal_metadata: Optional metadata such as timezone or + precision information. + **kwargs: Additional properties to include on the edge. Returns: - Edge object with temporal annotations + The temporal edge dictionary appended to the graph's + ``relationships`` list. + + Example: + >>> builder = GraphBuilder(resolve_conflicts=False) + >>> graph = {"entities": [], "relationships": []} + >>> edge = builder.add_temporal_edge( # doctest: +SKIP + ... graph, "ada", "analytical-engine", "DESIGNED" + ... ) + >>> edge["type"] # doctest: +SKIP + 'DESIGNED' """ tracking_id = self.progress_tracker.start_tracking( module="kg", @@ -806,17 +864,29 @@ class GraphBuilder: def create_temporal_snapshot( self, graph, timestamp=None, snapshot_name=None, **options ): - """ - Create temporal snapshot of graph at specific time point. + """Create a snapshot of a graph at a specific point in time. Args: - graph: Knowledge graph to snapshot - timestamp: Time point for snapshot (None for current time) - snapshot_name: Optional name for snapshot - **options: Additional snapshot options + graph: Knowledge graph whose entities and relationships will be + copied into the snapshot. + timestamp: Snapshot time, or ``None`` to use the current time. + snapshot_name: Optional human-readable snapshot name. + **options: Additional snapshot options reserved for extensions. Returns: - Temporal snapshot object + A snapshot dictionary containing the name, timestamp, all copied + entities, relationships valid at the timestamp, and summary + metadata. + + Example: + >>> builder = GraphBuilder(resolve_conflicts=False) + >>> snapshot = builder.create_temporal_snapshot( # doctest: +SKIP + ... {"entities": [{"id": "ada"}], "relationships": []}, + ... timestamp="2026-01-01T00:00:00", + ... snapshot_name="new-year", + ... ) + >>> snapshot["name"] # doctest: +SKIP + 'new-year' """ tracking_id = self.progress_tracker.start_tracking( module="kg", @@ -897,19 +967,32 @@ class GraphBuilder: temporal_window=None, **options, ): - """ - Query graph at specific time point or time range. + """Query a graph at a specific time or over a time range. Args: - graph: Knowledge graph to query - query: Query (Cypher, SPARQL, or natural language) - at_time: Query at specific time point - time_range: Query within time range (start, end) - temporal_window: Temporal window size - **options: Additional query options + graph: Knowledge graph to query. + query: Query text to record in the result. The current + implementation does not interpret it or filter the graph. + at_time: Optional point in time at which to query the graph. + time_range: Optional ``(start, end)`` time range. The graph is + evaluated at the end of the range. + temporal_window: Optional temporal-window value reserved for + query-engine integrations. + **options: Additional query options reserved for extensions. Returns: - Query results with temporal context + A dictionary containing the query, temporal context, entities and + relationships from the selected graph or snapshot, and graph + metadata. + + Example: + >>> builder = GraphBuilder(resolve_conflicts=False) + >>> result = builder.query_temporal( # doctest: +SKIP + ... {"entities": [{"id": "ada"}], "relationships": []}, + ... "MATCH (n) RETURN n", + ... ) + >>> result["entities"][0]["id"] # doctest: +SKIP + 'ada' """ tracking_id = self.progress_tracker.start_tracking( module="kg", @@ -972,20 +1055,34 @@ class GraphBuilder: temporal_property="valid_time", **kwargs, ): - """ - Load graph from Neo4j database. + """Load a knowledge graph from a Neo4j database. Args: - uri: Neo4j connection URI - username: Neo4j username - password: Neo4j password - database: Neo4j database name - enable_temporal: Enable temporal features for loaded graph - temporal_property: Property name for temporal data - **kwargs: Additional connection options + uri: Neo4j connection URI. + username: Neo4j username. + password: + Authentication credential supplied for the Neo4j user. + database: Neo4j database name. + enable_temporal: Whether to read temporal relationship data. + temporal_property: Relationship property containing temporal + data. + **kwargs: Additional connection options reserved for extensions. Returns: - Knowledge graph loaded from Neo4j + A dictionary containing loaded entities, relationships, and + source metadata. + + Raises: + ImportError: If the Neo4j driver is unavailable. + + Example: + >>> import os + >>> builder = GraphBuilder(resolve_conflicts=False) + >>> graph = builder.load_from_neo4j( # doctest: +SKIP + ... "bolt://localhost:7687", + ... "neo4j", + ... os.environ["NEO4J_PASSWORD"], + ... ) """ tracking_id = self.progress_tracker.start_tracking( module="kg", From 1ee3f2f214cc1d3ac923e1d3d86cf19a4056b66c Mon Sep 17 00:00:00 2001 From: Shubham Srivastava Date: Wed, 12 Aug 2026 19:07:09 +0100 Subject: [PATCH 021/105] fix(kg): align GraphBuilder raw-text extraction defaults with the documented contract (#941) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(kg): align GraphBuilder raw-text extraction defaults with the documented contract _extract_from_text() defaulted ner_method, relation_method and triplet_method to "llm" and ran relation extraction unconditionally, contradicting the build() docstring ("ml"/"pattern"/False) and the standalone extractor defaults. Any raw-text build() therefore required a provider, an API key, and network access without saying so. Defaults are now ml/pattern/pattern with extract_relations=False. LLM extraction is unchanged and now opt-in via explicit kwargs. Also documents relation_method and extract_triplets, which the docstring never listed, and drops the stale "Default to LLM methods as per requirement" comment. Closes #930 * perf(kg): reuse extractors across texts instead of rebuilding per source Addresses review feedback on #941. NERExtractor.__init__ loads its spaCy model eagerly when the method includes "ml", so switching the default from "llm" to "ml" made _extract_from_text() reload the model once per source in a multi-document build. Extractors are now cached per (kind, method) on the builder. Adds tests asserting single construction across repeated texts, that distinct methods still get distinct extractors, and that the default path runs end to end without any provider call. * fix(kg): keep fallback method lists working with the extractor cache The extractor cache keyed directly on `method`, but all three extractors accept a list for fallback ordering (e.g. ner_method=["pattern", "ml"]), so a list argument raised TypeError: unhashable type: 'list' before extraction started. Lists are now converted to tuples for the cache key only; the extractor still receives the original value. Also seeds _extraction_stats in __init__. It was previously created only in build(), so calling _extract_from_text() directly — as the report's repro does — raised an AttributeError that the broad except swallowed and logged as "Entity extraction failed". Adds coverage for list methods on all three extractors, cache reuse for equal lists, and distinct entries for different orderings. * fix(kg): forward extracted relations into triplet extraction _extract_from_text() passed only entities= to extract_triplets(), so TripletExtractor re-derived relations itself whenever relations is None, using a method taken from triplet_method rather than relation_method. That duplicated work and could yield triplets inconsistent with the relations already extracted. relations is now initialized to None, holds the extracted list when extract_relations=True succeeds, and is forwarded to extract_triplets(). When extraction is disabled or fails, None is passed and TripletExtractor's existing self-derivation is unchanged. Folded in at maintainer request rather than tracked as #944. * docs(changelog): note that #878 documented the LLM defaults before this landed #878 merged while this was in review and resolved the same code/docstring mismatch in the opposite direction. Records that #930's decision makes the code the side that changes, and that #878's docstring formatting is retained. --- CHANGELOG.md | 20 ++ semantica/kg/graph_builder.py | 80 +++++- .../test_graph_builder_extraction_defaults.py | 262 ++++++++++++++++++ 3 files changed, 348 insertions(+), 14 deletions(-) create mode 100644 tests/kg/test_graph_builder_extraction_defaults.py diff --git a/CHANGELOG.md b/CHANGELOG.md index d263f9d5..b23ed156 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Corrected during review**: `add_temporal_edge`/`create_temporal_snapshot` docstrings overclaimed numeric-timestamp support; `_parse_time()` only special-cases `str` and `datetime`, falling back to a bare `str()` cast for anything else (not true numeric parsing). Narrowed to "datetime or ISO-formatted string" - **Fixed along the way**: `build()`'s `**options` documented a default only for `extract`; `extract_relations`, `extract_triplets`, `ner_method`, `relation_method`, and `triplet_method` all have concrete defaults in `_extract_from_text()` (`True`, `True`, `"llm"`, `"llm"`, `"llm"`) that were left unstated, inconsistent with CONTRIBUTING.md's own docstring example of noting defaults inline - `python -m pytest tests/kg/test_kg.py tests/kg/test_graph_builder_external.py -q`: 45 passed +- **`GraphBuilder` raw-text extraction now defaults to local extractors instead of LLM extraction** (closes #930) by @dex0shubham + - `GraphBuilder._extract_from_text()` defaulted `ner_method`, `relation_method`, and `triplet_method` to `"llm"`, and ran relation extraction unconditionally (`extract_relations` defaulted to `True`) — all four contradicting the defaults documented in the `build()` docstring at the time (`"ml"` / `"pattern"` / `False`), and diverging from the standalone extractors (`NERExtractor` defaults to `method="ml"`, `RelationExtractor` and `TripletExtractor` to `method="pattern"`). The practical effect was that any raw-text `build()` call silently required a configured provider, an API key, and network access + - Defaults are now `ner_method="ml"`, `relation_method="pattern"`, `triplet_method="pattern"`, and `extract_relations=False`, matching the docstring. LLM extraction remains fully available and is now opt-in + - **To restore the previous behaviour**, pass the methods explicitly: + ```python + builder.build( + sources, + ner_method="llm", + relation_method="llm", + triplet_method="llm", + extract_relations=True, + ) + ``` + - #878 landed in the meantime and resolved the same mismatch in the opposite direction, documenting the LLM values (`"llm"` / `"llm"` / `"llm"`, `extract_relations: True`) as the contract. Per the decision on #930 the code is the side that changes, so those docstring defaults are corrected here to `"ml"` / `"pattern"` / `"pattern"` / `False`, keeping #878's formatting + - Removed the stale `# Default to LLM methods as per requirement` comment, which read as an intentional decision but did not match the documented contract + - **Fixed along the way**: `_extract_from_text()` constructed a fresh extractor for every text, and `NERExtractor.__init__` loads its spaCy model eagerly when the method includes `"ml"` — so with the new default, a multi-document build would have reloaded the model once per source. Extractors are now built once per `(kind, method)` and reused for the lifetime of the builder, via `GraphBuilder._get_extractor()`. This path was previously unreachable by default because the old `"llm"` default never touched spaCy + - **Fixed along the way**: `_extract_from_text()` never forwarded its extracted relations to triplet extraction — it passed only `entities=`, so `TripletExtractor` re-derived relations itself (via a method taken from `triplet_method`) whenever `relations is None`, duplicating work and producing triplets that could disagree with the relations already extracted using `relation_method`. Relations are now passed through as `relations=`; when relation extraction is disabled or fails, `None` is forwarded and `TripletExtractor` keeps its existing self-derivation behaviour + - **Fixed along the way**: `GraphBuilder._extraction_stats` was only initialised inside `build()`, so calling `_extract_from_text()` directly raised an `AttributeError` that the extraction path's broad `except` swallowed and reported as `"Entity extraction failed"`. It is now seeded in `__init__` as well; `build()` still resets it per run + - New regression coverage in `tests/kg/test_graph_builder_extraction_defaults.py` pinning all four defaults, verifying that no default resolves to `"llm"`, confirming explicit LLM opt-in still routes correctly, asserting extractors are constructed once across repeated texts, covering fallback method lists (e.g. `ner_method=["pattern", "ml"]`) for all three extractors, asserting relations are forwarded to triplet extraction (and that `None` is forwarded when relation extraction is disabled or fails), and running the real default path end to end with no provider mocked. Verified to fail against the pre-fix code + - Full `kg` suite: 473 passed ### Fixed diff --git a/semantica/kg/graph_builder.py b/semantica/kg/graph_builder.py index 6ed783f9..314b923d 100644 --- a/semantica/kg/graph_builder.py +++ b/semantica/kg/graph_builder.py @@ -23,7 +23,7 @@ License: MIT """ from datetime import datetime -from typing import Any, Dict, List, Optional, Union +from typing import Any, Dict, List, Optional, Tuple, Union import time @@ -90,6 +90,18 @@ class GraphBuilder: self.version_snapshots = version_snapshots self.graph_store = graph_store self.config = kwargs # Store additional config for extractors + # Extractors are reused across texts: NERExtractor loads its spaCy model + # eagerly in __init__, so constructing one per text would reload the + # model on every source in a multi-document build. + self._extractor_cache: Dict[Tuple[str, Any], Any] = {} + # build() resets these per run; seed them here so _extract_from_text + # is usable on its own instead of raising an AttributeError that the + # broad except in the extraction path silently swallows. + self._extraction_stats: Dict[str, int] = { + "extracted_entities": 0, + "extracted_relations": 0, + "extracted_triplets": 0, + } # Initialize logging from ..utils.logging import get_logger @@ -228,6 +240,28 @@ class GraphBuilder: # Unknown type pass + def _get_extractor( + self, kind: str, extractor_cls, method: Union[str, List[str]] + ): + """Return a cached extractor for this method, building it on first use. + + Extractors hold no per-text state but are expensive to construct — + ``NERExtractor(method="ml")`` loads a spaCy model in ``__init__``. + Keying on kind and method is enough because ``self.config`` is fixed + for the lifetime of the builder. + + Args: + kind: Extractor role, one of ``"ner"``, ``"relation"``, ``"triplet"``. + extractor_cls: Extractor class to construct on a cache miss. + method: A method name, or a list of them for fallback ordering. + Lists are converted to tuples for the cache key only; the + extractor still receives the original value. + """ + key = (kind, tuple(method) if isinstance(method, list) else method) + if key not in self._extractor_cache: + self._extractor_cache[key] = extractor_cls(method=method, **self.config) + return self._extractor_cache[key] + def _extract_from_text(self, text: str, all_entities: List[Any], all_relationships: List[Any], **options): """Helper to extract knowledge from text using configured methods.""" if not options.get("extract", True): @@ -237,15 +271,17 @@ class GraphBuilder: from ..semantic_extract.relation_extractor import RelationExtractor from ..semantic_extract.triplet_extractor import TripletExtractor - # Default to LLM methods as per requirement - ner_method = options.get("ner_method", "llm") - relation_method = options.get("relation_method", "llm") - triplet_method = options.get("triplet_method", "llm") + # Local extractors by default — raw-text build() must not require a + # provider, API key, or network access. Pass ner_method="llm" (and the + # relation/triplet equivalents) to opt into LLM extraction. + ner_method = options.get("ner_method", "ml") + relation_method = options.get("relation_method", "pattern") + triplet_method = options.get("triplet_method", "pattern") self.logger.info(f"Extracting knowledge from text ({len(text)} chars) using {ner_method}...") # 1. Extract Entities - ner = NERExtractor(method=ner_method, **self.config) + ner = self._get_extractor("ner", NERExtractor, ner_method) try: entities = ner.extract_entities(text, **options) extracted_count = len(entities) @@ -258,8 +294,16 @@ class GraphBuilder: entities = [] # 2. Extract Relations (if requested) - if options.get("extract_relations", True): - rel_extractor = RelationExtractor(method=relation_method, **self.config) + # Stays None when relation extraction is skipped or fails, which lets + # TripletExtractor derive its own relations as before. When we do have + # them, they are forwarded below so triplets reuse the relations + # extracted with relation_method rather than re-deriving via + # triplet_method. + relations = None + if options.get("extract_relations", False): + rel_extractor = self._get_extractor( + "relation", RelationExtractor, relation_method + ) try: # Pass entities if available to help relation extraction relations = rel_extractor.extract_relations(text, entities=entities, **options) @@ -273,9 +317,13 @@ class GraphBuilder: # 3. Extract Triplets (if requested) if options.get("extract_triplets", True): - trip_extractor = TripletExtractor(method=triplet_method, **self.config) + trip_extractor = self._get_extractor( + "triplet", TripletExtractor, triplet_method + ) try: - triplets = trip_extractor.extract_triplets(text, entities=entities, **options) + triplets = trip_extractor.extract_triplets( + text, entities=entities, relations=relations, **options + ) extracted_count = len(triplets) self._extraction_stats["extracted_triplets"] += extracted_count self.logger.info(f"Extracted {extracted_count} triplets") @@ -307,20 +355,24 @@ class GraphBuilder: raw string or ``{"text": ...}`` dict is passed as a source (default: ``True``). - ``extract_relations`` (bool): Whether to extract relations - during text extraction (default: ``True``). + during text extraction (default: ``False``). - ``extract_triplets`` (bool): Whether to extract triplets during text extraction (default: ``True``). - ``ner_method`` (str): NER backend used for text extraction - (e.g. ``"ml"``, ``"pattern"``, ``"llm"``; default: ``"llm"``). + (e.g. ``"ml"``, ``"pattern"``, ``"llm"``; default: ``"ml"``). - ``relation_method`` (str): Relation-extraction backend - (e.g. ``"pattern"``, ``"llm"``; default: ``"llm"``). + (e.g. ``"pattern"``, ``"llm"``; default: ``"pattern"``). - ``triplet_method`` (str): Triplet-extraction backend - (e.g. ``"pattern"``, ``"llm"``; default: ``"llm"``). + (e.g. ``"pattern"``, ``"llm"``; default: ``"pattern"``). - ``entity_resolver``: An :class:`EntityResolver` instance that overrides the one configured on the builder. - ``relationships`` (list): An explicit list of relationships to include in addition to those found in *sources*. + Raw-text extraction uses local extractors by default and needs no + provider or API key. To use LLM extraction, pass the methods + explicitly, e.g. ``ner_method="llm"``. + Returns: A dictionary containing the graph's ``entities``, ``relationships``, and build ``metadata``. diff --git a/tests/kg/test_graph_builder_extraction_defaults.py b/tests/kg/test_graph_builder_extraction_defaults.py new file mode 100644 index 00000000..fdc2a0e2 --- /dev/null +++ b/tests/kg/test_graph_builder_extraction_defaults.py @@ -0,0 +1,262 @@ +"""Pins GraphBuilder's raw-text extraction defaults to the documented values. + +Regression guard for #930: `_extract_from_text` defaulted to LLM extraction for +all three methods and ran relation extraction unconditionally, both of which +contradicted the `build()` docstring and silently required a provider and API +key for any raw-text build. +""" + +import unittest +from unittest.mock import patch + +from semantica.kg.graph_builder import GraphBuilder + + +class TestGraphBuilderExtractionDefaults(unittest.TestCase): + + def setUp(self): + self.ner_patcher = patch( + "semantica.semantic_extract.ner_extractor.NERExtractor" + ) + self.rel_patcher = patch( + "semantica.semantic_extract.relation_extractor.RelationExtractor" + ) + self.trip_patcher = patch( + "semantica.semantic_extract.triplet_extractor.TripletExtractor" + ) + self.NER = self.ner_patcher.start() + self.Rel = self.rel_patcher.start() + self.Trip = self.trip_patcher.start() + self.addCleanup(self.ner_patcher.stop) + self.addCleanup(self.rel_patcher.stop) + self.addCleanup(self.trip_patcher.stop) + + self.NER.return_value.extract_entities.return_value = [] + self.Rel.return_value.extract_relations.return_value = [] + self.Trip.return_value.extract_triplets.return_value = [] + + self.builder = GraphBuilder(merge_entities=False, resolve_conflicts=False) + + def _extract(self, **options): + self.builder._extract_from_text( + "Apple Inc. was founded in 1976.", [], [], **options + ) + + def test_ner_method_defaults_to_ml(self): + self._extract() + self.assertEqual(self.NER.call_args.kwargs["method"], "ml") + + def test_triplet_method_defaults_to_pattern(self): + self._extract() + self.assertEqual(self.Trip.call_args.kwargs["method"], "pattern") + + def test_relation_extraction_is_off_by_default(self): + self._extract() + self.Rel.assert_not_called() + + def test_relation_method_defaults_to_pattern_when_enabled(self): + self._extract(extract_relations=True) + self.assertEqual(self.Rel.call_args.kwargs["method"], "pattern") + + def test_no_extractor_defaults_to_llm(self): + """No raw-text default may require a provider or API key.""" + self._extract(extract_relations=True) + extractors = ( + ("ner", self.NER), + ("relation", self.Rel), + ("triplet", self.Trip), + ) + for name, mock_cls in extractors: + with self.subTest(extractor=name): + self.assertNotEqual(mock_cls.call_args.kwargs["method"], "llm") + + def test_llm_extraction_is_still_available_explicitly(self): + self._extract( + ner_method="llm", + relation_method="llm", + triplet_method="llm", + extract_relations=True, + ) + self.assertEqual(self.NER.call_args.kwargs["method"], "llm") + self.assertEqual(self.Rel.call_args.kwargs["method"], "llm") + self.assertEqual(self.Trip.call_args.kwargs["method"], "llm") + + +class TestGraphBuilderExtractorReuse(unittest.TestCase): + """Extractors must be built once per method, not once per text. + + `NERExtractor.__init__` loads its spaCy model eagerly, so with the `"ml"` + default a per-text construction would reload the model for every source in + a multi-document build. + """ + + def setUp(self): + self.ner_patcher = patch( + "semantica.semantic_extract.ner_extractor.NERExtractor" + ) + self.NER = self.ner_patcher.start() + self.addCleanup(self.ner_patcher.stop) + self.NER.return_value.extract_entities.return_value = [] + + self.builder = GraphBuilder(merge_entities=False, resolve_conflicts=False) + + def test_ner_extractor_built_once_across_texts(self): + for i in range(5): + self.builder._extract_from_text(f"Document {i}.", [], []) + self.assertEqual(self.NER.call_count, 1) + + def test_distinct_methods_get_distinct_extractors(self): + self.builder._extract_from_text("a", [], []) + self.builder._extract_from_text("b", [], [], ner_method="pattern") + self.builder._extract_from_text("c", [], []) + self.assertEqual(self.NER.call_count, 2) + + +class TestGraphBuilderForwardsRelationsToTriplets(unittest.TestCase): + """Relations extracted with relation_method must reach triplet extraction. + + `TripletExtractor` re-derives relations itself when `relations is None`, + using a method derived from `triplet_method` — so not forwarding them both + duplicates work and can produce triplets inconsistent with the relations + already extracted. + """ + + def setUp(self): + self.ner_patcher = patch( + "semantica.semantic_extract.ner_extractor.NERExtractor" + ) + self.rel_patcher = patch( + "semantica.semantic_extract.relation_extractor.RelationExtractor" + ) + self.trip_patcher = patch( + "semantica.semantic_extract.triplet_extractor.TripletExtractor" + ) + self.NER = self.ner_patcher.start() + self.Rel = self.rel_patcher.start() + self.Trip = self.trip_patcher.start() + self.addCleanup(self.ner_patcher.stop) + self.addCleanup(self.rel_patcher.stop) + self.addCleanup(self.trip_patcher.stop) + + self.NER.return_value.extract_entities.return_value = [] + self.Trip.return_value.extract_triplets.return_value = [] + + self.builder = GraphBuilder(merge_entities=False, resolve_conflicts=False) + + def _triplet_kwargs(self): + return self.Trip.return_value.extract_triplets.call_args.kwargs + + def test_extracted_relations_are_forwarded(self): + sentinel = [object()] + self.Rel.return_value.extract_relations.return_value = sentinel + + self.builder._extract_from_text("x", [], [], extract_relations=True) + + self.assertIs(self._triplet_kwargs()["relations"], sentinel) + + def test_relations_is_none_when_extraction_disabled(self): + """Default path keeps TripletExtractor's own relation derivation.""" + self.builder._extract_from_text("x", [], []) + + self.assertIsNone(self._triplet_kwargs()["relations"]) + self.Rel.assert_not_called() + + def test_relations_is_none_when_extraction_fails(self): + self.Rel.return_value.extract_relations.side_effect = RuntimeError("boom") + + self.builder._extract_from_text("x", [], [], extract_relations=True) + + self.assertIsNone(self._triplet_kwargs()["relations"]) + + +class TestGraphBuilderFallbackMethodLists(unittest.TestCase): + """All three extractors accept a list of methods for fallback ordering. + + The extractor cache must key on something hashable, or passing a list + raises `TypeError: unhashable type: 'list'` before extraction even starts. + """ + + def setUp(self): + self.ner_patcher = patch( + "semantica.semantic_extract.ner_extractor.NERExtractor" + ) + self.rel_patcher = patch( + "semantica.semantic_extract.relation_extractor.RelationExtractor" + ) + self.trip_patcher = patch( + "semantica.semantic_extract.triplet_extractor.TripletExtractor" + ) + self.NER = self.ner_patcher.start() + self.Rel = self.rel_patcher.start() + self.Trip = self.trip_patcher.start() + self.addCleanup(self.ner_patcher.stop) + self.addCleanup(self.rel_patcher.stop) + self.addCleanup(self.trip_patcher.stop) + + self.NER.return_value.extract_entities.return_value = [] + self.Rel.return_value.extract_relations.return_value = [] + self.Trip.return_value.extract_triplets.return_value = [] + + self.builder = GraphBuilder(merge_entities=False, resolve_conflicts=False) + + def test_list_method_does_not_raise(self): + self.builder._extract_from_text( + "x", [], [], ner_method=["pattern", "ml"], extract_triplets=False + ) + self.assertEqual(self.NER.call_args.kwargs["method"], ["pattern", "ml"]) + + def test_list_methods_accepted_for_every_extractor(self): + self.builder._extract_from_text( + "x", + [], + [], + ner_method=["pattern", "ml"], + relation_method=["pattern", "cooccurrence"], + triplet_method=["pattern", "rules"], + extract_relations=True, + ) + self.assertEqual(self.NER.call_args.kwargs["method"], ["pattern", "ml"]) + self.assertEqual( + self.Rel.call_args.kwargs["method"], ["pattern", "cooccurrence"] + ) + self.assertEqual(self.Trip.call_args.kwargs["method"], ["pattern", "rules"]) + + def test_equal_lists_reuse_one_extractor(self): + for _ in range(3): + self.builder._extract_from_text( + "x", [], [], ner_method=["pattern", "ml"], extract_triplets=False + ) + self.assertEqual(self.NER.call_count, 1) + + def test_different_lists_get_different_extractors(self): + self.builder._extract_from_text( + "x", [], [], ner_method=["pattern", "ml"], extract_triplets=False + ) + self.builder._extract_from_text( + "x", [], [], ner_method=["ml", "pattern"], extract_triplets=False + ) + self.assertEqual(self.NER.call_count, 2) + + +class TestGraphBuilderDefaultsRunOffline(unittest.TestCase): + """The default raw-text path must work with no provider and no network.""" + + def test_default_build_needs_no_provider(self): + builder = GraphBuilder(merge_entities=False, resolve_conflicts=False) + entities, relationships = [], [] + + # No mocks: this runs the real ml/pattern extractors end to end. If any + # default resolved to "llm", this would attempt a provider call. + with patch("semantica.semantic_extract.providers.create_provider") as provider: + builder._extract_from_text( + "Apple Inc. was founded by Steve Jobs in 1976.", + entities, + relationships, + ) + + provider.assert_not_called() + self.assertIsInstance(entities, list) + + +if __name__ == "__main__": + unittest.main() From b0080c3602dc5d447cb55062bd707f9972d11308 Mon Sep 17 00:00:00 2001 From: Amir Fathi Date: Wed, 12 Aug 2026 12:44:25 -0600 Subject: [PATCH 022/105] fix(export): log DistanceExporter metric computation failures instead of swallowing them (#879) * fix(export): log DistanceExporter metric computation failures instead of swallowing them The four private metric helpers in DistanceExporter (_betweenness, _hop_distance, _weighted_distance, _semantic_similarity) each catch a bare Exception and return None/{} with no signal. That makes an exported None indistinguishable from a legitimate "no path exists" result, corrupting downstream CSV/JSONL/DataFrame exports with no way to tell a real gap from a swallowed error. Log each caught exception at warning level with the offending source/target before returning the existing sentinel. The exported row shape and values are unchanged; only the observability of the failure changes. Fixes #874 * fix(export): route DistanceExporter warnings through the semantica logger tree get_logger(__name__) doubled the semantica. prefix (__name__ is already semantica.export.distance_exporter), so the warnings this PR adds landed on semantica.semantica.export.distance_exporter, a branch setup_logging() never configures and does not reach the app's log handler. Also reworded the three except-Exception log messages: they said "recording as no path", which overclaims what a generic exception means. Addresses review feedback from @KaifAhmad1 on #879. * docs(changelog): add DistanceExporter logging fix entry --------- Co-authored-by: Mohd Kaif <98801504+KaifAhmad1@users.noreply.github.com> Co-authored-by: KaifAhmad1 --- CHANGELOG.md | 6 ++ semantica/export/distance_exporter.py | 6 +- tests/export/test_distance_exporter.py | 107 +++++++++++++++++++++++++ 3 files changed, 118 insertions(+), 1 deletion(-) create mode 100644 tests/export/test_distance_exporter.py diff --git a/CHANGELOG.md b/CHANGELOG.md index b23ed156..1107d74b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -56,6 +56,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Fixed along the way**: `MilvusStore`'s metadata expression builder rendered `NaN`/`Infinity` filter values as bare unquoted tokens, producing an invalid Milvus expression whose server-side rejection was then swallowed by a broad `except`, indistinguishable from "no matches"; these values are now rejected up front with a clear `ValidationError` - New/expanded test coverage in `tests/vector_store/test_backend_metadata_filtering.py` (all 7 backends, including the Pinecone dimension/zero-vector, PgVector boolean-list, FAISS `limit=0`, and Milvus `NaN` regressions) and `tests/vector_store/test_sqlite_vec_store.py` (new `TestSQLiteVecStoreFilterByMetadata`, run against the real `sqlite-vec` extension, including the array-vs-scalar intersection case) +- **`DistanceExporter` silently swallowed metric computation failures, exporting `None` values indistinguishable from a legitimate "no path" result** (#879, closes #874) by @AmirF194 + - `_betweenness`, `_hop_distance`, `_weighted_distance`, and `_semantic_similarity` each caught `Exception` and returned their sentinel (`None`/`{}`) with no logging; a failed computation and a real "no path exists" looked identical in exported CSV/JSONL/DataFrame data. All four now log a `warning` with `exc_info=True` before returning the sentinel; exported row shape and values are unchanged + - **Fixed along the way**: the module logger was built with `get_logger(__name__)`, which double-prefixed it to `semantica.semantica.export.distance_exporter` — a name `setup_logging()` never configures — so this module's logging (including a pre-existing `logger.debug` call) was silent regardless. Now uses `get_logger("export.distance_exporter")`, matching every other exporter in the module + - New regression coverage in `tests/export/test_distance_exporter.py`: warnings fire on exception for all four helpers, exported sentinel values/shape stay unchanged, and the legitimate "no KG backend" `None` path still logs nothing + - Full `tests/export/` suite: 71 passed + ### Security - **HTTP response header injection via `node_id`, unbounded-memory DoS in link prediction, and unsanitized imported node IDs in the Explorer** (#912) by @Sunil56224972 diff --git a/semantica/export/distance_exporter.py b/semantica/export/distance_exporter.py index be78637d..060f041a 100644 --- a/semantica/export/distance_exporter.py +++ b/semantica/export/distance_exporter.py @@ -21,7 +21,7 @@ from typing import Any, Dict, List, Optional from ..utils.helpers import classify_path_distance from ..utils.logging import get_logger -logger = get_logger(__name__) +logger = get_logger("export.distance_exporter") _KG_AVAILABLE = False try: @@ -72,6 +72,7 @@ class DistanceExporter: result = self._centrality.calculate_betweenness_centrality(graph_dict) return result.get("betweenness", {}) if isinstance(result, dict) else {} except Exception: + logger.warning("Betweenness centrality computation failed; omitting from export", exc_info=True) return {} def _hop_distance(self, graph_dict: Dict[str, Any], src: str, tgt: str) -> Optional[int]: @@ -82,6 +83,7 @@ class DistanceExporter: path = result.get("path", []) if isinstance(result, dict) else (result or []) return len(path) - 1 if path else None except Exception: + logger.warning("Hop distance computation failed for %s -> %s; returning None sentinel", src, tgt, exc_info=True) return None def _weighted_distance(self, graph_dict: Dict[str, Any], src: str, tgt: str) -> Optional[float]: @@ -93,6 +95,7 @@ class DistanceExporter: return float(result.get("total_weight", len(result.get("path", [])) - 1)) return None except Exception: + logger.warning("Weighted distance computation failed for %s -> %s; returning None sentinel", src, tgt, exc_info=True) return None def _semantic_similarity(self, graph_dict: Dict[str, Any], src: str, tgt: str) -> Optional[float]: @@ -102,6 +105,7 @@ class DistanceExporter: sim = self._similarity.cosine_similarity(graph_dict, src, tgt) return float(sim) if isinstance(sim, (int, float)) else None except Exception: + logger.warning("Semantic similarity computation failed for %s -> %s; returning None sentinel", src, tgt, exc_info=True) return None def compute_pairs( diff --git a/tests/export/test_distance_exporter.py b/tests/export/test_distance_exporter.py new file mode 100644 index 00000000..e9d20b67 --- /dev/null +++ b/tests/export/test_distance_exporter.py @@ -0,0 +1,107 @@ +"""Tests for DistanceExporter's silent-exception handling (issue #874). + +Each of the four private metric helpers (_betweenness, _hop_distance, +_weighted_distance, _semantic_similarity) wraps its computation in a bare +except Exception and returns None/{} with no signal, so a raised exception is +indistinguishable in the exported data from a legitimate "no path" result. +""" + +import logging + +import pytest + +from semantica.export.distance_exporter import DistanceExporter + + +class _Node: + def __init__(self, node_id): + self.node_id = node_id + self.node_type = "t" + self.content = "" + self.properties = {} + + +class _Graph: + def __init__(self): + self.nodes = {"a": _Node("a"), "b": _Node("b")} + self.edges = [] + + +class _RaisingPathFinder: + def bfs_shortest_path(self, graph_dict, src, tgt): + raise RuntimeError("bfs boom") + + def dijkstra_shortest_path(self, graph_dict, src, tgt): + raise RuntimeError("dijkstra boom") + + +class _RaisingSimilarity: + def cosine_similarity(self, graph_dict, src, tgt): + raise RuntimeError("cosine boom") + + +class _RaisingCentrality: + def calculate_betweenness_centrality(self, graph_dict): + raise RuntimeError("betweenness boom") + + +@pytest.fixture +def exporter(): + exp = DistanceExporter(_Graph()) + exp._path_finder = _RaisingPathFinder() + exp._similarity = _RaisingSimilarity() + exp._centrality = _RaisingCentrality() + return exp + + +def test_hop_distance_logs_warning_on_exception(exporter, caplog): + with caplog.at_level(logging.WARNING, logger="semantica.export.distance_exporter"): + result = exporter._hop_distance({}, "a", "b") + assert result is None + assert any("Hop distance" in rec.message for rec in caplog.records) + + +def test_weighted_distance_logs_warning_on_exception(exporter, caplog): + with caplog.at_level(logging.WARNING, logger="semantica.export.distance_exporter"): + result = exporter._weighted_distance({}, "a", "b") + assert result is None + assert any("Weighted distance" in rec.message for rec in caplog.records) + + +def test_semantic_similarity_logs_warning_on_exception(exporter, caplog): + with caplog.at_level(logging.WARNING, logger="semantica.export.distance_exporter"): + result = exporter._semantic_similarity({}, "a", "b") + assert result is None + assert any("Semantic similarity" in rec.message for rec in caplog.records) + + +def test_betweenness_logs_warning_on_exception(exporter, caplog): + with caplog.at_level(logging.WARNING, logger="semantica.export.distance_exporter"): + result = exporter._betweenness({}) + assert result == {} + assert any("Betweenness" in rec.message for rec in caplog.records) + + +def test_compute_pairs_still_produces_none_sentinels_when_metrics_raise(exporter, caplog): + """The exported row shape is unchanged: a raised exception still yields + None/"distant", it is just no longer silent.""" + with caplog.at_level(logging.WARNING, logger="semantica.export.distance_exporter"): + rows = exporter.compute_pairs() + assert len(rows) == 2 + for row in rows: + assert row["hop_count"] is None + assert row["weighted_distance"] is None + assert row["semantic_similarity"] is None + assert row["distance_band"] == "distant" + assert len(caplog.records) >= 4 + + +def test_hop_distance_no_warning_when_kg_unavailable(caplog): + """A legitimate 'no KG backend' None (the pre-existing early-return path) + must not be confused with an exception; nothing to log there.""" + exp = DistanceExporter(_Graph()) + exp._path_finder = None + with caplog.at_level(logging.WARNING, logger="semantica.export.distance_exporter"): + result = exp._hop_distance({}, "a", "b") + assert result is None + assert len(caplog.records) == 0 From 18f1d55d77e43272a14a2cd8f5e18caa1e4c3796 Mon Sep 17 00:00:00 2001 From: aoright <102943475+aoright@users.noreply.github.com> Date: Thu, 13 Aug 2026 03:14:10 +0800 Subject: [PATCH 023/105] test(normalize): make optional tests deterministic (#881) * test(normalize): make optional tests deterministic Signed-off-by: aoright <102943475+aoright@users.noreply.github.com> * docs(changelog): add entry for #881 / #860 normalize test determinism fixes --------- Signed-off-by: aoright <102943475+aoright@users.noreply.github.com> Co-authored-by: Mohd Kaif <98801504+KaifAhmad1@users.noreply.github.com> Co-authored-by: KaifAhmad1 --- CHANGELOG.md | 6 +++ tests/normalize/test_date_normalizer.py | 47 ++++++++++++++--------- tests/normalize/test_encoding_handler.py | 25 +++++++----- tests/normalize/test_language_detector.py | 26 ++++++++++--- 4 files changed, 71 insertions(+), 33 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1107d74b..1c46543b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,6 +41,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **Three `tests/normalize/` tests failed for reasons unrelated to the normalize implementations: a missing optional-dependency skip guard, an incomplete chardet allowlist, and a UTC/local timezone mismatch** (#881, closes #860) by @aoright + - `test_detect_language`/`test_detect_with_confidence` in `tests/normalize/test_language_detector.py` asserted on real `langdetect` output with no skip guard, even though `langdetect` is an optional dependency absent from `pyproject.toml` that `LanguageDetector` already degrades gracefully without (`LANGDETECT_AVAILABLE = False`, falls back to `default_language`) — any environment without it failed both tests unconditionally, including a fresh CI run without optional extras installed. Both are now gated with `@unittest.skipUnless(LANGDETECT_AVAILABLE, ...)` + - `test_detect_encoding` in `tests/normalize/test_encoding_handler.py` asserted `chardet.detect()`'s result against a 3-name allowlist (`iso-8859-1`/`windows-1252`/`latin-1`); on a short Latin-1 sample, chardet is free to return other compatible single-byte codepages (e.g. `windows-1253`), which fails the allowlist and then cascades into `test_convert_to_utf8` decoding the bytes as Greek instead of the original text. The test now uses a longer, unambiguous Latin-1 corpus and asserts that the detected encoding round-trip-decodes the original text instead of matching a fixed name list; `test_convert_to_utf8` now passes `source_encoding="latin-1"` explicitly rather than relying on chardet's heuristic auto-detection + - `test_normalize_date_relative` in `tests/normalize/test_date_normalizer.py` compared `RelativeDateProcessor`'s local-clock-based `"today"` (`datetime.now()`, naive, UTC-normalized after the fact by `convert_to_utc()`) against a separately-computed UTC reference date — failing intermittently in any timezone east of UTC whenever the local and UTC dates diverge for part of the day. The test now patches `datetime.now()` to a fixed reference time, making the assertion independent of host timezone + - `pytest tests/normalize`: 77 passed, 2 skipped (`langdetect` not installed); `black`/`isort`/`flake8 --max-line-length=88` clean on all three changed files. Test-only change; no production code touched + - **MCP server reported a stale `0.4.0` version instead of the installed package version** (#870, closes #863) by @oiahoon - `semantica/mcp_server/__init__.py` hardcoded `"version": "0.4.0"` in both the MCP `initialize` response (`SERVER_INFO`) and the `semantica://schema/info` resource, regardless of the actual installed `semantica` version — every MCP client (Claude Desktop, Windsurf, Cline, Continue, VS Code Copilot, etc.) showed the wrong server version. Both surfaces now derive from `semantica.__version__`, the package's authoritative version source, so they can no longer drift from `pyproject.toml` - New regression coverage in `tests/test_mcp_server_version.py`, including `!= "0.4.0"` canaries and a cross-surface consistency check diff --git a/tests/normalize/test_date_normalizer.py b/tests/normalize/test_date_normalizer.py index 412d8a5b..9c1bb889 100644 --- a/tests/normalize/test_date_normalizer.py +++ b/tests/normalize/test_date_normalizer.py @@ -1,12 +1,14 @@ import unittest -from datetime import datetime, date, timedelta, timezone +from datetime import datetime, timedelta, timezone +from unittest.mock import patch + from semantica.normalize.date_normalizer import ( DateNormalizer, - TimeZoneNormalizer, RelativeDateProcessor, - TemporalExpressionParser + TimeZoneNormalizer, ) + class TestDateNormalizer(unittest.TestCase): def setUp(self): self.normalizer = DateNormalizer() @@ -14,39 +16,42 @@ class TestDateNormalizer(unittest.TestCase): def test_normalize_date_iso(self): # Test ISO8601 parsing self.assertEqual( - self.normalizer.normalize_date("2023-01-01", format="date"), - "2023-01-01" + self.normalizer.normalize_date("2023-01-01", format="date"), "2023-01-01" ) self.assertEqual( self.normalizer.normalize_date("2023-01-01T12:00:00", format="ISO8601"), - "2023-01-01T12:00:00+00:00" + "2023-01-01T12:00:00+00:00", ) def test_normalize_date_relative(self): - # Test relative date parsing (e.g., "today", "yesterday") - # Note: These depend on current date, so we might need to mock datetime if strictly testing logic, - # but for now we'll assume the relative processor uses current time. - # We can check if it returns a valid ISO date string. - today = datetime.now(timezone.utc).date().isoformat() - self.assertEqual( - self.normalizer.normalize_date("today", format="date"), - today - ) + class FixedDateTime(datetime): + @classmethod + def now(cls, tz=None): + fixed = cls(2024, 1, 2, 0, 30) + return fixed if tz is None else fixed.replace(tzinfo=tz) + + with patch("semantica.normalize.date_normalizer.datetime", FixedDateTime): + self.assertEqual( + self.normalizer.normalize_date("today", format="date"), + "2024-01-02", + ) def test_normalize_timezone(self): # Test timezone conversion # "2023-01-01T12:00:00+01:00" -> UTC should be "2023-01-01T11:00:00+00:00" normalized = self.normalizer.normalize_date( - "2023-01-01T12:00:00+01:00", - timezone="UTC" + "2023-01-01T12:00:00+01:00", timezone="UTC" ) self.assertEqual(normalized, "2023-01-01T11:00:00+00:00") def test_parse_temporal_expression(self): # Test range parsing - result = self.normalizer.parse_temporal_expression("from 2023-01-01 to 2023-01-31") + result = self.normalizer.parse_temporal_expression( + "from 2023-01-01 to 2023-01-31" + ) self.assertIsNotNone(result.get("range")) + class TestTimeZoneNormalizer(unittest.TestCase): def setUp(self): self.tz_normalizer = TimeZoneNormalizer() @@ -56,7 +61,10 @@ class TestTimeZoneNormalizer(unittest.TestCase): # Assuming default is UTC if not specified or naive normalized = self.tz_normalizer.normalize_timezone(dt, "UTC") # Check offset instead of object identity - self.assertEqual(normalized.tzinfo.utcoffset(normalized), timezone.utc.utcoffset(None)) + self.assertEqual( + normalized.tzinfo.utcoffset(normalized), timezone.utc.utcoffset(None) + ) + class TestRelativeDateProcessor(unittest.TestCase): def setUp(self): @@ -71,5 +79,6 @@ class TestRelativeDateProcessor(unittest.TestCase): diff = datetime.now() - dt self.assertTrue(timedelta(days=2, hours=23) < diff < timedelta(days=3, hours=1)) + if __name__ == "__main__": unittest.main() diff --git a/tests/normalize/test_encoding_handler.py b/tests/normalize/test_encoding_handler.py index 4d99198a..ac123bdb 100644 --- a/tests/normalize/test_encoding_handler.py +++ b/tests/normalize/test_encoding_handler.py @@ -1,35 +1,41 @@ import unittest -import os + from semantica.normalize.encoding_handler import EncodingHandler + class TestEncodingHandler(unittest.TestCase): def setUp(self): self.handler = EncodingHandler() def test_detect_encoding(self): # UTF-8 - text = "Héllò Wörld" + text = ( + "C'était déjà l'été à Montréal; François dégustait un café près " + "de l'hôtel. " + ) * 4 utf8_bytes = text.encode("utf-8") - encoding, conf = self.handler.detect(utf8_bytes) + encoding, _ = self.handler.detect(utf8_bytes) self.assertEqual(encoding.lower(), "utf-8") - + # Latin-1 latin1_bytes = text.encode("latin-1") - encoding, conf = self.handler.detect(latin1_bytes) - # chardet might return ISO-8859-1 or Windows-1252 which are compatible - self.assertIn(encoding.lower(), ["iso-8859-1", "windows-1252", "latin-1"]) + encoding, _ = self.handler.detect(latin1_bytes) + # Different chardet versions may choose different compatible codecs. + self.assertEqual(latin1_bytes.decode(encoding), text) def test_convert_to_utf8(self): text = "Héllò Wörld" latin1_bytes = text.encode("latin-1") - converted = self.handler.convert_to_utf8(latin1_bytes) + converted = self.handler.convert_to_utf8( + latin1_bytes, source_encoding="latin-1" + ) self.assertEqual(converted, text) def test_remove_bom(self): # UTF-8 BOM bom_bytes = b"\xef\xbb\xbfHello" self.assertEqual(self.handler.remove_bom(bom_bytes), b"Hello") - + # String BOM bom_str = "\ufeffHello" self.assertEqual(self.handler.remove_bom(bom_str), "Hello") @@ -39,5 +45,6 @@ class TestEncodingHandler(unittest.TestCase): # Invalid sequence for ascii self.assertFalse(self.handler.validate_encoding("Héllò", "ascii")) + if __name__ == "__main__": unittest.main() diff --git a/tests/normalize/test_language_detector.py b/tests/normalize/test_language_detector.py index ffee337c..2f10fc29 100644 --- a/tests/normalize/test_language_detector.py +++ b/tests/normalize/test_language_detector.py @@ -1,24 +1,39 @@ import unittest -from semantica.normalize.language_detector import LanguageDetector + +from semantica.normalize.language_detector import ( + LANGDETECT_AVAILABLE, + LanguageDetector, +) + class TestLanguageDetector(unittest.TestCase): def setUp(self): self.detector = LanguageDetector() + @unittest.skipUnless(LANGDETECT_AVAILABLE, "langdetect is not installed") def test_detect_language(self): # English - self.assertEqual(self.detector.detect("This is a simple English sentence."), "en") + self.assertEqual( + self.detector.detect("This is a simple English sentence."), "en" + ) # French - self.assertEqual(self.detector.detect("Ceci est une phrase française simple."), "fr") + self.assertEqual( + self.detector.detect("Ceci est une phrase française simple."), "fr" + ) # German - self.assertEqual(self.detector.detect("Dies ist ein einfacher deutscher Satz."), "de") + self.assertEqual( + self.detector.detect("Dies ist ein einfacher deutscher Satz."), "de" + ) def test_detect_short_text(self): # Should return default for very short text self.assertEqual(self.detector.detect("Hi"), "en") + @unittest.skipUnless(LANGDETECT_AVAILABLE, "langdetect is not installed") def test_detect_with_confidence(self): - lang, conf = self.detector.detect_with_confidence("This is definitely an English sentence.") + lang, conf = self.detector.detect_with_confidence( + "This is definitely an English sentence." + ) self.assertEqual(lang, "en") self.assertGreater(conf, 0.5) @@ -27,5 +42,6 @@ class TestLanguageDetector(unittest.TestCase): self.assertEqual(self.detector.get_language_name("fr"), "French") self.assertEqual(self.detector.get_language_name("xx"), "XX") + if __name__ == "__main__": unittest.main() From 0fa3483b966a34194bb1aa24b3383cd0ccd03be9 Mon Sep 17 00:00:00 2001 From: Zohaib Hassnain <109234410+ZohaibHassan16@users.noreply.github.com> Date: Thu, 13 Aug 2026 12:10:30 +0500 Subject: [PATCH 024/105] fix(context): clarify get_node_property not-found contract (#877) (#882) * fix(context): clarify get_node_property not-found contract (#877) Add default= param to get_node_property and get_node_attributes so callers can distinguish node-missing from property-missing using a sentinel. Fix add_node_attribute calling mutation_callback outside the lock. Tests added for all cases. * fix(context): address Qodo review findings (#877) * fix(context): wrap add_node_attribute mutation_callback in try/except (#877) The PR claimed to move the callback back inside `with self._lock`, but the diff only dropped a stray blank line -- the call stayed outside the lock, unchanged. That's actually correct: self._lock is an RLock, and _add_internal_node/_add_internal_edge deliberately release the lock before invoking the callback too, so a slow/misbehaving callback never holds up other threads. The real gap was that, unlike those two siblings, this call site didn't catch exceptions from the callback. Wrapped it the same way, with a regression test. --------- Co-authored-by: Mohd Kaif <98801504+KaifAhmad1@users.noreply.github.com> Co-authored-by: KaifAhmad1 --- CHANGELOG.md | 7 ++ semantica/context/context_graph.py | 105 +++++++++++++++++++++++++---- tests/context/test_context.py | 82 ++++++++++++++++++++++ 3 files changed, 182 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c46543b..b2103772 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,6 +41,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **`ContextGraph.get_node_property`/`get_node_attributes` "not found" contract clarified; `add_node_attribute` mutation-callback exception safety fixed** (#882, closes #877) by @ZohaibHassan16 + - `get_node_property` returned `None` for both "node missing" and "property missing" with no way to distinguish them, and `get_node_attributes` returned `{}` for a missing node while its siblings disagreed on the not-found signal (`get_node_property`/`find_node` → `None`, `get_edge_data` → `{}`). Both now accept a `default=` parameter matching `dict.get()`'s convention, defaulting to their historical return values (`None` and `{}` respectively) for backward compatibility. Callers that need to disambiguate "node missing" from "value legitimately absent" can pass a private sentinel as `default` + - Added Google-style docstrings to `get_node_property`, `get_node_attributes`, `get_edge_data`, and `find_node` documenting each method's not-found contract, addressing #877's "sibling not-found contract undocumented" gap + - **Corrected during review**: the PR as submitted claimed to fix `add_node_attribute` firing its `mutation_callback` "outside `with self._lock`, without holding the lock," but the diff only removed a stray blank line — the callback call remained outside the lock, unchanged. Further investigation found this was not actually a bug: `self._lock` is a `threading.RLock`, and the same release-the-lock-before-invoking-the-callback pattern is used deliberately in `_add_internal_node`/`_add_internal_edge` elsewhere in this class, avoiding holding the lock for the duration of an arbitrary user-supplied callback. The real inconsistency was that, unlike those two siblings, `add_node_attribute`'s callback call wasn't wrapped in `try/except` — a raising callback propagated uncaught here but was caught and logged there. Now wrapped the same way (`except Exception as e: self.logger.warning(...)`) + - 13 tests covering happy path, missing node, missing property, sentinel disambiguation, falsy-zero, callback firing/non-firing, and (added during review) a raising callback no longer propagating out of `add_node_attribute` + - `pytest tests/context/test_context.py -q`: 27 passed + - **Three `tests/normalize/` tests failed for reasons unrelated to the normalize implementations: a missing optional-dependency skip guard, an incomplete chardet allowlist, and a UTC/local timezone mismatch** (#881, closes #860) by @aoright - `test_detect_language`/`test_detect_with_confidence` in `tests/normalize/test_language_detector.py` asserted on real `langdetect` output with no skip guard, even though `langdetect` is an optional dependency absent from `pyproject.toml` that `LanguageDetector` already degrades gracefully without (`LANGDETECT_AVAILABLE = False`, falls back to `default_language`) — any environment without it failed both tests unconditionally, including a fresh CI run without optional extras installed. Both are now gated with `@unittest.skipUnless(LANGDETECT_AVAILABLE, ...)` - `test_detect_encoding` in `tests/normalize/test_encoding_handler.py` asserted `chardet.detect()`'s result against a 3-name allowlist (`iso-8859-1`/`windows-1252`/`latin-1`); on a short Latin-1 sample, chardet is free to return other compatible single-byte codepages (e.g. `windows-1253`), which fails the allowlist and then cascades into `test_convert_to_utf8` decoding the bytes as Greek instead of the original text. The test now uses a longer, unambiguous Latin-1 corpus and asserts that the detected encoding round-trip-decodes the original text instead of matching a fixed name list; `test_convert_to_utf8` now passes `source_encoding="latin-1"` explicitly rather than relying on chardet's heuristic auto-detection diff --git a/semantica/context/context_graph.py b/semantica/context/context_graph.py index 944e1f6a..da252b84 100644 --- a/semantica/context/context_graph.py +++ b/semantica/context/context_graph.py @@ -410,6 +410,9 @@ class ContextEdge: return d +_ATTRS_MISSING = object() + + class ContextGraph: """ Easy-to-Use Context Graph with All Advanced Features. @@ -708,18 +711,71 @@ class ContextGraph: }) return result - def get_node_property(self, node_id: str, property_name: str) -> Any: - with self._lock: - node = self.nodes.get(node_id) - if not node: - return None - return node.properties.get(property_name) + def get_node_property( + self, + node_id: str, + property_name: str, + default: Any = None, + ) -> Any: + """Return the value of *property_name* on *node_id*. - def get_node_attributes(self, node_id: str) -> Dict[str, Any]: + Returns *default* when the node does not exist or when the property is + not set on the node. Both failure modes return the same *default*, so + a sentinel can identify *any not-found result* as distinct from a + property whose value is legitimately ``None``:: + + _MISSING = object() + val = graph.get_node_property(node_id, "score", default=_MISSING) + if val is _MISSING: + ... # node absent or property not set + + To distinguish a missing node from a missing property specifically, + call ``find_node()`` first to check node existence. + + Args: + node_id: ID of the node to look up. + property_name: Name of the property to retrieve. + default: Value returned when the node or property is absent. + Defaults to ``None`` (backward-compatible). + + Returns: + The property value, or *default* if not found. + """ with self._lock: node = self.nodes.get(node_id) - if not node: - return {} + if node is None: + return default + return node.properties.get(property_name, default) + + def get_node_attributes( + self, + node_id: str, + default: Any = _ATTRS_MISSING, + ) -> Any: + """Return a copy of all properties on *node_id*. + + Returns *default* when the node does not exist. The historical + default is ``{}`` (an empty dict), preserved for backward + compatibility. Pass a private sentinel as *default* to detect a + missing node unambiguously:: + + _MISSING = object() + attrs = graph.get_node_attributes(node_id, default=_MISSING) + if attrs is _MISSING: + ... # node does not exist + + Args: + node_id: ID of the node to look up. + default: Value returned when the node is absent. + Defaults to ``{}`` (backward-compatible). + + Returns: + A shallow copy of the node's properties dict, or *default*. + """ + with self._lock: + node = self.nodes.get(node_id) + if node is None: + return {} if default is _ATTRS_MISSING else default return node.properties.copy() def add_node_attribute(self, node_id: str, attributes: Dict[str, Any]) -> None: @@ -730,13 +786,28 @@ class ContextGraph: node.properties.update(attributes) node.metadata.update(attributes) - if getattr(self, "mutation_callback", None) and not getattr( self, "_suspend_mutation_callback", False ): - self.mutation_callback("UPDATE_NODE", node_id, node.to_dict()) + try: + self.mutation_callback("UPDATE_NODE", node_id, node.to_dict()) + except Exception as e: + self.logger.warning(f"Audit trail callback failed for node {node_id}: {e}") def get_edge_data(self, source_id: str, target_id: str) -> Dict[str, Any]: + """Return metadata for the edge between *source_id* and *target_id*. + + Returns an empty dict ``{}`` when no edge exists between the two nodes + or when either node is absent. + + Args: + source_id: ID of the source node. + target_id: ID of the target node. + + Returns: + A dict containing edge metadata (``id``, ``familyId``, ``type``, + ``weight``, plus any custom metadata), or ``{}`` if not found. + """ with self._lock: for edge in self._adjacency.get(source_id, []): if edge.target_id == target_id: @@ -1080,7 +1151,17 @@ class ContextGraph: self.logger.info(f"Loaded context graph from {path}") def find_node(self, node_id: str) -> Optional[Dict[str, Any]]: - """Find a node by ID.""" + """Return a dict representation of the node identified by *node_id*. + + Returns ``None`` when the node does not exist. + + Args: + node_id: ID of the node to look up. + + Returns: + A dict with keys ``id``, ``type``, ``content``, and ``metadata``, + or ``None`` if the node is not found. + """ with self._lock: node = self.nodes.get(node_id) if node: diff --git a/tests/context/test_context.py b/tests/context/test_context.py index 73edaa4e..dd9a706f 100644 --- a/tests/context/test_context.py +++ b/tests/context/test_context.py @@ -252,5 +252,87 @@ class TestContextModule(unittest.TestCase): self.assertIsNotNone(ctx._memory) self.assertEqual(len(ctx._memory.short_term_memory), 1) +class TestContextGraphNodePropertyContract(unittest.TestCase): + + _MISSING = object() + + def _graph_with_node(self): + graph = ContextGraph() + graph.add_node("n1", "person", "Alice", role="engineer", score=0) + return graph + + def test_get_node_property_existing_node_existing_prop(self): + graph = self._graph_with_node() + self.assertEqual(graph.get_node_property("n1", "role"), "engineer") + + def test_get_node_property_existing_node_missing_prop(self): + graph = self._graph_with_node() + self.assertIsNone(graph.get_node_property("n1", "nonexistent")) + + def test_get_node_property_missing_node_returns_default_none(self): + graph = self._graph_with_node() + self.assertIsNone(graph.get_node_property("ghost", "role")) + + def test_get_node_property_returns_default_on_missing_node(self): + graph = self._graph_with_node() + result = graph.get_node_property("ghost", "role", default=self._MISSING) + self.assertIs(result, self._MISSING) + + def test_get_node_property_returns_default_on_missing_prop(self): + graph = self._graph_with_node() + result = graph.get_node_property("n1", "nonexistent", default=self._MISSING) + self.assertIs(result, self._MISSING) + + def test_get_node_property_explicit_default_returned_for_absent_node(self): + graph = self._graph_with_node() + self.assertEqual(graph.get_node_property("ghost", "role", default="fallback"), "fallback") + + def test_get_node_property_prop_value_of_zero_not_swallowed(self): + graph = self._graph_with_node() + self.assertEqual(graph.get_node_property("n1", "score"), 0) + + def test_get_node_attributes_existing_node_returns_copy(self): + graph = self._graph_with_node() + attrs = graph.get_node_attributes("n1") + self.assertIsInstance(attrs, dict) + self.assertEqual(attrs.get("role"), "engineer") + + def test_get_node_attributes_missing_node_returns_empty_dict_by_default(self): + graph = self._graph_with_node() + self.assertEqual(graph.get_node_attributes("ghost"), {}) + + def test_get_node_attributes_missing_node_explicit_default(self): + graph = self._graph_with_node() + result = graph.get_node_attributes("ghost", default={}) + self.assertEqual(result, {}) + + def test_add_node_attribute_mutation_callback_fires_on_update(self): + graph = self._graph_with_node() + fired = [] + graph.mutation_callback = lambda op, nid, data: fired.append((op, nid)) + graph.add_node_attribute("n1", {"extra": "value"}) + self.assertEqual(len(fired), 1) + self.assertEqual(fired[0], ("UPDATE_NODE", "n1")) + + def test_add_node_attribute_missing_node_no_callback(self): + graph = self._graph_with_node() + fired = [] + graph.mutation_callback = lambda op, nid, data: fired.append((op, nid)) + graph.add_node_attribute("ghost", {"extra": "value"}) + self.assertEqual(len(fired), 0) + + def test_add_node_attribute_raising_callback_does_not_propagate(self): + graph = self._graph_with_node() + + def _boom(op, nid, data): + raise RuntimeError("audit sink unavailable") + + graph.mutation_callback = _boom + # Should not raise, matching _add_internal_node/_add_internal_edge, + # which already catch and log mutation_callback exceptions. + graph.add_node_attribute("n1", {"extra": "value"}) + self.assertEqual(graph.get_node_property("n1", "extra"), "value") + + if __name__ == '__main__': unittest.main() From 2cfb5de43dfbf4cdb809de2e571e20b6505deefd Mon Sep 17 00:00:00 2001 From: Karunasagar Mohansundar <52268863+Karunasagar12@users.noreply.github.com> Date: Thu, 13 Aug 2026 15:54:42 +0530 Subject: [PATCH 025/105] feat(export): add opt-in metric_errors column to DistanceExporter (#960) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(export): add opt-in metric_errors column to DistanceExporter Add a 'metric_errors' field to compute_pairs() output that lets downstream consumers programmatically distinguish legitimate 'no path' (None) from computation failures (None + error name). Usage: rows = exporter.compute_pairs(include=[..., 'metric_errors']) # row['metric_errors'] == '' → all metrics succeeded # row['metric_errors'] == 'hop_count,weighted_distance' → those failed Design decisions: - Opt-in: column only appears when explicitly requested via include= - Default export schema unchanged (backward compatible) - Comma-separated metric names (not exception messages) — stable for programmatic filtering without exposing internal error details - Helpers now return (value, error_name | None) tuples internally Follow-up to #879, as discussed in its review thread. * fix: address Qodo findings — track betweenness errors and remove unused constant 1. _betweenness() now returns (dict, error) tuple like the other helpers, so betweenness computation failures appear in metric_errors. 2. Removed unused _ERROR_COLUMNS constant (dead code). All 77 tests in tests/export/ pass. * docs(changelog): add entry for opt-in metric_errors column (#960) --------- Co-authored-by: Mohd Kaif <98801504+KaifAhmad1@users.noreply.github.com> Co-authored-by: KaifAhmad1 --- CHANGELOG.md | 11 ++ semantica/export/distance_exporter.py | 81 ++++++++--- tests/export/test_distance_exporter.py | 20 ++- .../test_distance_exporter_metric_errors.py | 132 ++++++++++++++++++ 4 files changed, 216 insertions(+), 28 deletions(-) create mode 100644 tests/export/test_distance_exporter_metric_errors.py diff --git a/CHANGELOG.md b/CHANGELOG.md index b2103772..91887706 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **`DistanceExporter.compute_pairs()` gains an opt-in `metric_errors` column to distinguish legitimate `None` results from computation failures** (#960, follow-up to #879) by @Karunasagar12 + - Previously, a `None` in `hop_count`/`weighted_distance`/`semantic_similarity`/betweenness could mean either "no path exists" or "the underlying computation raised" — logged as a warning per #879, but not otherwise surfaced, so the two cases were indistinguishable in exported CSV/JSONL/DataFrame data. `include=["metric_errors"]` now adds a `metric_errors` field per row: `""` when all requested metrics succeeded, or a comma-separated list of metric names that raised (e.g. `"hop_count,weighted_distance"`) + - Opt-in only — default `compute_pairs()`/`to_csv()`/`to_dataframe()`/`to_jsonl()` schema is unchanged unless `"metric_errors"` is explicitly requested + - The four metric helpers (`_betweenness`, `_hop_distance`, `_weighted_distance`, `_semantic_similarity`) now return `(value, error_name | None)` tuples internally; `compute_pairs()` aggregates the error names per row + - **Fixed during review** (Qodo): `_betweenness()` failures weren't tracked into `metric_errors` in the initial version — centrality computation could raise and the column would still report `""`. Now returns its error tuple like the other three helpers + - **Known limitation**: `include=["metric_errors"]` with no other metric names computes nothing, so the column is always `""` in that case — pass it alongside the metrics you want tracked, e.g. `include=["hop_count", "metric_errors"]` + - New `tests/export/test_distance_exporter_metric_errors.py`: 6 tests covering success, single/multiple failures, opt-out, the no-path-vs-error distinction, and default-schema stability; existing `tests/export/test_distance_exporter.py` updated for the new tuple return type + - Full `tests/export/` suite: 77 passed + ### Changed - **`GraphBuilder`'s 6 public methods now have Google-style docstrings** (#878, closes #876) by @cakeni diff --git a/semantica/export/distance_exporter.py b/semantica/export/distance_exporter.py index 060f041a..3d2d250d 100644 --- a/semantica/export/distance_exporter.py +++ b/semantica/export/distance_exporter.py @@ -11,12 +11,15 @@ Python API: df = exporter.to_dataframe(include=["hops", "semantic_similarity", "distance_band"]) exporter.to_csv("distances.csv") exporter.to_jsonl("distances.jsonl") + + # Include error status columns for auditable exports: + df = exporter.to_dataframe(include=["hop_count", "metric_errors"]) """ import csv import io import json -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List, Optional, Tuple from ..utils.helpers import classify_path_distance from ..utils.logging import get_logger @@ -36,6 +39,9 @@ _ALL_COLUMNS = [ "distance_band", "source_betweenness", "target_betweenness", ] +# Error status columns — opt-in via include=["metric_errors"] +# (used by compute_pairs when "metric_errors" is in include set) + class DistanceExporter: """Compute and export pairwise distance metrics for a ContextGraph.""" @@ -65,63 +71,77 @@ class DistanceExporter: node = getattr(self.graph, "nodes", {}).get(node_id) return getattr(node, "node_type", "") if node else "" - def _betweenness(self, graph_dict: Dict[str, Any]) -> Dict[str, float]: + def _betweenness(self, graph_dict: Dict[str, Any]) -> Tuple[Dict[str, float], Optional[str]]: + """Return (betweenness_dict, error). error is None on success.""" if self._centrality is None: - return {} + return {}, None try: result = self._centrality.calculate_betweenness_centrality(graph_dict) - return result.get("betweenness", {}) if isinstance(result, dict) else {} + return (result.get("betweenness", {}) if isinstance(result, dict) else {}), None except Exception: logger.warning("Betweenness centrality computation failed; omitting from export", exc_info=True) - return {} + return {}, "betweenness" - def _hop_distance(self, graph_dict: Dict[str, Any], src: str, tgt: str) -> Optional[int]: + def _hop_distance(self, graph_dict: Dict[str, Any], src: str, tgt: str) -> Tuple[Optional[int], Optional[str]]: + """Return (hop_count, error). error is None on success or a short description on failure.""" if self._path_finder is None: - return None + return None, None # KG unavailable — not an error, just no data try: result = self._path_finder.bfs_shortest_path(graph_dict, src, tgt) path = result.get("path", []) if isinstance(result, dict) else (result or []) - return len(path) - 1 if path else None + return (len(path) - 1 if path else None), None except Exception: logger.warning("Hop distance computation failed for %s -> %s; returning None sentinel", src, tgt, exc_info=True) - return None + return None, "hop_count" - def _weighted_distance(self, graph_dict: Dict[str, Any], src: str, tgt: str) -> Optional[float]: + def _weighted_distance(self, graph_dict: Dict[str, Any], src: str, tgt: str) -> Tuple[Optional[float], Optional[str]]: + """Return (weighted_distance, error). error is None on success.""" if self._path_finder is None: - return None + return None, None try: result = self._path_finder.dijkstra_shortest_path(graph_dict, src, tgt) if isinstance(result, dict): - return float(result.get("total_weight", len(result.get("path", [])) - 1)) - return None + return float(result.get("total_weight", len(result.get("path", [])) - 1)), None + return None, None except Exception: logger.warning("Weighted distance computation failed for %s -> %s; returning None sentinel", src, tgt, exc_info=True) - return None + return None, "weighted_distance" - def _semantic_similarity(self, graph_dict: Dict[str, Any], src: str, tgt: str) -> Optional[float]: + def _semantic_similarity(self, graph_dict: Dict[str, Any], src: str, tgt: str) -> Tuple[Optional[float], Optional[str]]: + """Return (similarity, error). error is None on success.""" if self._similarity is None: - return None + return None, None try: sim = self._similarity.cosine_similarity(graph_dict, src, tgt) - return float(sim) if isinstance(sim, (int, float)) else None + return (float(sim) if isinstance(sim, (int, float)) else None), None except Exception: logger.warning("Semantic similarity computation failed for %s -> %s; returning None sentinel", src, tgt, exc_info=True) - return None + return None, "semantic_similarity" def compute_pairs( self, include: Optional[List[str]] = None, node_subset: Optional[List[str]] = None, ) -> List[Dict[str, Any]]: - """Compute all pairwise distance metrics and return as a list of dicts.""" + """Compute all pairwise distance metrics and return as a list of dicts. + + When ``include`` contains ``"metric_errors"``, each row gains a + ``metric_errors`` field: an empty string when all metrics succeeded, or + a comma-separated list of metric names that raised during computation + (e.g. ``"hop_count,weighted_distance"``). This lets downstream consumers + distinguish legitimate ``None`` (no path) from computation failure. + """ include_set = set(include or _ALL_COLUMNS) + track_errors = "metric_errors" in include_set + include_set.discard("metric_errors") # not a real metric to compute graph_dict = self._build_graph_dict() node_ids = node_subset or list(self.graph.nodes.keys()) betweenness: Dict[str, float] = {} + betweenness_err: Optional[str] = None if "source_betweenness" in include_set or "target_betweenness" in include_set: - betweenness = self._betweenness(graph_dict) + betweenness, betweenness_err = self._betweenness(graph_dict) rows = [] for i, src in enumerate(node_ids): @@ -129,6 +149,10 @@ class DistanceExporter: if src == tgt: continue row: Dict[str, Any] = {} + errors: List[str] = [] + if betweenness_err: + errors.append(betweenness_err) + if "source_id" in include_set: row["source_id"] = src if "source_type" in include_set: @@ -140,15 +164,23 @@ class DistanceExporter: hop_count: Optional[int] = None if "hop_count" in include_set or "distance_band" in include_set: - hop_count = self._hop_distance(graph_dict, src, tgt) + hop_count, hop_err = self._hop_distance(graph_dict, src, tgt) + if hop_err: + errors.append(hop_err) if "hop_count" in include_set: row["hop_count"] = hop_count if "weighted_distance" in include_set: - row["weighted_distance"] = self._weighted_distance(graph_dict, src, tgt) + wd_val, wd_err = self._weighted_distance(graph_dict, src, tgt) + row["weighted_distance"] = wd_val + if wd_err: + errors.append(wd_err) if "semantic_similarity" in include_set: - row["semantic_similarity"] = self._semantic_similarity(graph_dict, src, tgt) + ss_val, ss_err = self._semantic_similarity(graph_dict, src, tgt) + row["semantic_similarity"] = ss_val + if ss_err: + errors.append(ss_err) if "distance_band" in include_set: row["distance_band"] = classify_path_distance(hop_count) if hop_count is not None else "distant" @@ -158,6 +190,9 @@ class DistanceExporter: if "target_betweenness" in include_set: row["target_betweenness"] = betweenness.get(tgt) + if track_errors: + row["metric_errors"] = ",".join(errors) if errors else "" + rows.append(row) return rows diff --git a/tests/export/test_distance_exporter.py b/tests/export/test_distance_exporter.py index e9d20b67..2066743e 100644 --- a/tests/export/test_distance_exporter.py +++ b/tests/export/test_distance_exporter.py @@ -57,28 +57,36 @@ def exporter(): def test_hop_distance_logs_warning_on_exception(exporter, caplog): with caplog.at_level(logging.WARNING, logger="semantica.export.distance_exporter"): result = exporter._hop_distance({}, "a", "b") - assert result is None + value, error = result + assert value is None + assert error == "hop_count" assert any("Hop distance" in rec.message for rec in caplog.records) def test_weighted_distance_logs_warning_on_exception(exporter, caplog): with caplog.at_level(logging.WARNING, logger="semantica.export.distance_exporter"): result = exporter._weighted_distance({}, "a", "b") - assert result is None + value, error = result + assert value is None + assert error == "weighted_distance" assert any("Weighted distance" in rec.message for rec in caplog.records) def test_semantic_similarity_logs_warning_on_exception(exporter, caplog): with caplog.at_level(logging.WARNING, logger="semantica.export.distance_exporter"): result = exporter._semantic_similarity({}, "a", "b") - assert result is None + value, error = result + assert value is None + assert error == "semantic_similarity" assert any("Semantic similarity" in rec.message for rec in caplog.records) def test_betweenness_logs_warning_on_exception(exporter, caplog): with caplog.at_level(logging.WARNING, logger="semantica.export.distance_exporter"): result = exporter._betweenness({}) - assert result == {} + value, error = result + assert value == {} + assert error == "betweenness" assert any("Betweenness" in rec.message for rec in caplog.records) @@ -103,5 +111,7 @@ def test_hop_distance_no_warning_when_kg_unavailable(caplog): exp._path_finder = None with caplog.at_level(logging.WARNING, logger="semantica.export.distance_exporter"): result = exp._hop_distance({}, "a", "b") - assert result is None + value, error = result + assert value is None + assert error is None assert len(caplog.records) == 0 diff --git a/tests/export/test_distance_exporter_metric_errors.py b/tests/export/test_distance_exporter_metric_errors.py new file mode 100644 index 00000000..e8c8e351 --- /dev/null +++ b/tests/export/test_distance_exporter_metric_errors.py @@ -0,0 +1,132 @@ +"""Tests for DistanceExporter metric_errors column. + +Verifies that when ``include=["metric_errors"]`` is passed to +``compute_pairs()``, the exported rows contain a ``metric_errors`` field +that distinguishes computation failures from legitimate None results. +""" + +import logging +from unittest.mock import MagicMock + +import pytest + +from semantica.export.distance_exporter import DistanceExporter + + +@pytest.fixture +def mock_graph(): + """Minimal graph mock with two nodes.""" + graph = MagicMock() + node_a = MagicMock(node_id="a", node_type="entity", content="A", properties={}) + node_b = MagicMock(node_id="b", node_type="entity", content="B", properties={}) + graph.nodes = {"a": node_a, "b": node_b} + graph.edges = [] + return graph + + +@pytest.fixture +def exporter(mock_graph): + """DistanceExporter with mocked KG components.""" + exp = DistanceExporter(mock_graph) + exp._path_finder = MagicMock() + exp._similarity = MagicMock() + exp._centrality = MagicMock() + return exp + + +class TestMetricErrorsColumn: + """Tests for the opt-in metric_errors export column.""" + + def test_metric_errors_empty_on_success(self, exporter): + """When all metrics succeed, metric_errors is an empty string.""" + exporter._path_finder.bfs_shortest_path.return_value = {"path": ["a", "x", "b"]} + exporter._path_finder.dijkstra_shortest_path.return_value = {"total_weight": 2.5, "path": ["a", "b"]} + exporter._similarity.cosine_similarity.return_value = 0.87 + + rows = exporter.compute_pairs(include=["hop_count", "weighted_distance", "semantic_similarity", "metric_errors"]) + + assert len(rows) == 2 # a->b and b->a + for row in rows: + assert "metric_errors" in row + assert row["metric_errors"] == "" + + def test_metric_errors_records_single_failure(self, exporter): + """When one metric fails, its name appears in metric_errors.""" + exporter._path_finder.bfs_shortest_path.return_value = {"path": ["a", "b"]} + exporter._path_finder.dijkstra_shortest_path.side_effect = RuntimeError("negative cycle") + exporter._similarity.cosine_similarity.return_value = 0.5 + + rows = exporter.compute_pairs(include=["hop_count", "weighted_distance", "semantic_similarity", "metric_errors"]) + + for row in rows: + assert row["metric_errors"] == "weighted_distance" + assert row["hop_count"] == 1 # still computed + assert row["weighted_distance"] is None # failed + assert row["semantic_similarity"] == 0.5 # still computed + + def test_metric_errors_records_multiple_failures(self, exporter): + """When multiple metrics fail, all names appear comma-separated.""" + exporter._path_finder.bfs_shortest_path.side_effect = RuntimeError("fail") + exporter._path_finder.dijkstra_shortest_path.side_effect = RuntimeError("fail") + exporter._similarity.cosine_similarity.side_effect = TypeError("fail") + exporter._centrality.calculate_betweenness_centrality.side_effect = RuntimeError("fail") + + rows = exporter.compute_pairs(include=[ + "hop_count", "weighted_distance", "semantic_similarity", + "source_betweenness", "metric_errors", + ]) + + for row in rows: + errors = row["metric_errors"].split(",") + assert "hop_count" in errors + assert "weighted_distance" in errors + assert "semantic_similarity" in errors + assert "betweenness" in errors + assert row["hop_count"] is None + assert row["weighted_distance"] is None + assert row["semantic_similarity"] is None + + def test_metric_errors_absent_when_not_requested(self, exporter): + """When metric_errors is not in include, it doesn't appear in rows.""" + exporter._path_finder.bfs_shortest_path.side_effect = RuntimeError("fail") + exporter._path_finder.dijkstra_shortest_path.return_value = {"total_weight": 1.0, "path": ["a", "b"]} + exporter._similarity.cosine_similarity.return_value = 0.9 + + rows = exporter.compute_pairs(include=["hop_count", "weighted_distance", "semantic_similarity"]) + + for row in rows: + assert "metric_errors" not in row + + def test_metric_errors_distinguishes_no_path_from_error(self, exporter): + """Core distinction: None from 'no path' has empty error; None from exception has the metric name.""" + # bfs returns empty path (legitimate "no path") — NOT an error + exporter._path_finder.bfs_shortest_path.return_value = {"path": []} + # dijkstra raises (computation error) + exporter._path_finder.dijkstra_shortest_path.side_effect = ValueError("bad weight") + exporter._similarity.cosine_similarity.return_value = 0.3 + + rows = exporter.compute_pairs(include=["hop_count", "weighted_distance", "semantic_similarity", "metric_errors"]) + + for row in rows: + # Both are None, but only weighted_distance is an error + assert row["hop_count"] is None + assert row["weighted_distance"] is None + assert row["metric_errors"] == "weighted_distance" + + def test_default_columns_unchanged_without_metric_errors(self, exporter): + """Default column set (no metric_errors) produces the same schema as before.""" + exporter._path_finder.bfs_shortest_path.return_value = {"path": ["a", "b"]} + exporter._path_finder.dijkstra_shortest_path.return_value = {"total_weight": 1.0, "path": ["a", "b"]} + exporter._similarity.cosine_similarity.return_value = 0.5 + exporter._centrality.calculate_betweenness_centrality.return_value = {"betweenness": {"a": 0.5, "b": 0.3}} + + rows = exporter.compute_pairs() + + assert len(rows) == 2 + expected_keys = { + "source_id", "source_type", "target_id", "target_type", + "hop_count", "weighted_distance", "semantic_similarity", + "distance_band", "source_betweenness", "target_betweenness", + } + assert set(rows[0].keys()) == expected_keys + assert "metric_errors" not in rows[0] From 7c3372c062e8b6d8c13d9694e43cb9c6a072a7b5 Mon Sep 17 00:00:00 2001 From: le-czs Date: Thu, 13 Aug 2026 20:21:54 +0800 Subject: [PATCH 026/105] fix(explorer): align dev esbuild target (#966) Co-authored-by: le-czs <243511553+le-czs@users.noreply.github.com> Co-authored-by: Mohd Kaif <98801504+KaifAhmad1@users.noreply.github.com> --- explorer/vite.config.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/explorer/vite.config.ts b/explorer/vite.config.ts index bdc253f0..e36eec50 100644 --- a/explorer/vite.config.ts +++ b/explorer/vite.config.ts @@ -57,6 +57,13 @@ export default defineConfig({ }, }, }, + optimizeDeps: { + // Keep dependency pre-bundling aligned with the production build target. + // esbuild >=0.28 no longer lowers destructuring for Vite's default target. + esbuildOptions: { + target: 'esnext', + }, + }, server: { proxy: { '/api': { From 91d02a0f2933c8388022763823e54bbba090e9b7 Mon Sep 17 00:00:00 2001 From: pravit-amp <43916793+pravit-amp@users.noreply.github.com> Date: Thu, 13 Aug 2026 09:47:25 -0700 Subject: [PATCH 027/105] fix(ingest): harden RepoIngestor GitPython clone surface (#868) (#905) * fix(ingest): harden RepoIngestor against GitPython URL and option injection Bump GitPython to >=3.1.58, allowlist clone kwargs, and validate repo URLs before clone_from to close env-var exfiltration and option-injection paths. * fix(ingest): accept scp-like SSH remotes in RepoIngestor URL validation * fix(ingest): resolve repo hostnames to block SSRF via private IPs * fix(ingest): map malformed repo URL parse errors to ValidationError * fix(ingest): bound and prune repo host resolve cache Cap the repository host DNS cache, prune expired entries on access, and evict the oldest entries so long-running processes cannot accumulate unbounded host lookups from user-supplied repo URLs. * fix(ingest): cap host resolve cache and tighten env-var token checks Bound the repo host DNS cache with pruning and oldest-entry eviction, and narrow URL env-var blocking to actual $VAR/${VAR} tokens so literal dollar signs are not rejected. * fix(ingest): preserve repo path compatibility and NAT64 support * docs(changelog): document RepoIngestor GitPython hardening (#905, closes #868) Records the clone-surface hardening (GitPython floor, clone-option allowlist, URL/SSRF validation), the two fixes made during review (NAT64 false-positive, local-path regression), and a known residual gap: the SSRF host check doesn't classify RFC 6598 CGNAT space (100.64.0.0/10) as blocked since ipaddress.is_private doesn't cover it. --------- Co-authored-by: Pravit Ampapathini Co-authored-by: Pravit Ampapathini Co-authored-by: Sameer Kadam Co-authored-by: KaifAhmad1 --- CHANGELOG.md | 10 + pyproject.toml | 2 +- semantica/ingest/methods.py | 16 +- semantica/ingest/repo_ingestor.py | 316 ++++++++++++- tests/ingest/test_repo_ingestor_security.py | 482 ++++++++++++++++++++ 5 files changed, 808 insertions(+), 18 deletions(-) create mode 100644 tests/ingest/test_repo_ingestor_security.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 91887706..d38a1901 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -88,6 +88,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Security +- **`RepoIngestor` clone surface hardened against GitPython URL/option injection** (#905, closes #868) by @pravit-amp + - `RepoIngestor.ingest_repository()` passed the caller-supplied repository URL and arbitrary `**options` straight through to `git.Repo.clone_from()` on a `GitPython>=3.1.50` floor predating hardening for `ext::`-style transport helpers and `$VAR`/`${VAR}` environment-variable expansion in clone URLs — unvalidated clone options (`upload_pack`, `multi_options`, `template`, `config`, `env`, ...) could be abused for command execution, and unvalidated hostnames allowed SSRF against internal services (e.g. cloud metadata endpoints) + - `GitPython` floor raised to `>=3.1.58` + - Clone options passed to `clone_from()` are now allowlisted to `{depth, branch, single_branch, no_tags}`; anything else raises `ValidationError` before the clone is attempted + - Repository URLs are validated before cloning: scheme allowlist (`https`, `http`, `git`, `ssh`), rejection of `$VAR`/`${VAR}` tokens, and hostname resolution with every returned address screened against private/loopback/link-local/unspecified ranges. scp-like SSH remotes (`user@host:path`) are recognized and normalized to `ssh://` before the clone call + - **Fixed during review** (@Sameer6305): the SSRF check originally used `ip.is_reserved`, which flags the NAT64 Well-Known Prefix (`64:ff9b::/96`, RFC 6052) as reserved — falsely blocking `github.com` and other public hosts on IPv6-only/dual-stack networks using NAT64. Narrowed the block list to private/loopback/link-local/unspecified only + - **Fixed during review** (@Sameer6305): local filesystem repository paths (`git clone /path/to/local/repo`) were being treated as remote URLs and rejected outright; local paths now bypass network validation entirely since they make no network requests and carry no SSRF risk + - **Known limitation**: the SSRF host check does not classify RFC 6598 Carrier-Grade NAT space (`100.64.0.0/10`) as blocked — Python's `ipaddress.IPv4Address.is_private` does not cover that range, so a hostname resolving into it (e.g. some Kubernetes/CNI pod networks) would not be caught. Follow-up recommended to add it explicitly alongside the existing private/loopback/link-local checks + - `pytest tests/ingest/test_repo_ingestor_security.py -v`: 44 passed + - **HTTP response header injection via `node_id`, unbounded-memory DoS in link prediction, and unsanitized imported node IDs in the Explorer** (#912) by @Sunil56224972 - `semantica/explorer/routes/provenance.py`'s `GET /api/provenance/report` f-string-interpolated the `node_id` query parameter directly into the `Content-Disposition` response header; a `\r\n`-bearing `node_id` could inject arbitrary response headers (`Set-Cookie` session fixation, `Content-Type` override for reflected XSS). Fixed with `_safe_content_disposition_filename()`, which strips `\r`, `\n`, `\x00`, `"`, `\` and length-caps the value before interpolation - `POST /api/enrich/links` (link prediction) loaded up to 999,999 nodes with no cap or concurrency guard, then scored every candidate — a single request could consume ~1.6 GB RAM, and concurrent requests compounded that with no limit. Capped the candidate pool at 10,000 nodes (`413` if exceeded) and added an `asyncio.Semaphore(2)`, mirroring the SPARQL DoS fix in #898 diff --git a/pyproject.toml b/pyproject.toml index d7fc2899..577cb94f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -60,7 +60,7 @@ dependencies = [ "plotly>=6.8.0", "ipywidgets>=8.0.0", "requests>=2.34.2", - "GitPython>=3.1.50", + "GitPython>=3.1.58", "chardet>=7.4.3", "protobuf>=5.29.1,<8.0", "grpcio>=1.81.1", diff --git a/semantica/ingest/methods.py b/semantica/ingest/methods.py index ac9775a2..586eb827 100644 --- a/semantica/ingest/methods.py +++ b/semantica/ingest/methods.py @@ -174,6 +174,7 @@ Example Usage: from __future__ import annotations +import re from pathlib import Path from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Union @@ -183,6 +184,14 @@ from .config import ingest_config from .file_ingestor import FileIngestor, FileObject from .registry import method_registry +# SCP-like SSH remotes (user@host:path) — keep in sync with repo_ingestor +_SCP_LIKE_REPO_URL_RE = re.compile(r"^[^@\s]+@[^:\s]+:.+$") + + +def _is_scp_like_repo_source(source: str) -> bool: + """Return True for scp-like SSH remotes (``user@host:path``).""" + return bool(_SCP_LIKE_REPO_URL_RE.match(source.strip())) + if TYPE_CHECKING: from .api_ingestor import APIData from .arrow_ingestor import ArrowData @@ -880,7 +889,10 @@ def ingest_repository( if method == "clone" or ( isinstance(source, str) - and source.startswith(("http://", "https://", "git@")) + and ( + source.startswith(("http://", "https://")) + or _is_scp_like_repo_source(source) + ) ): return ingestor.ingest_repository(source, **kwargs) elif method == "analyze": @@ -1336,7 +1348,7 @@ def ingest( ("postgresql://", "mysql://", "sqlite://", "oracle://", "mssql://") ): source_type = "db" - elif source_str.startswith("git@") or source_str_lower.startswith( + elif _is_scp_like_repo_source(source_str) or source_str_lower.startswith( ("https://github.com", "https://gitlab.com") ): source_type = "repo" diff --git a/semantica/ingest/repo_ingestor.py b/semantica/ingest/repo_ingestor.py index 0cadb0d9..4b4f88c1 100644 --- a/semantica/ingest/repo_ingestor.py +++ b/semantica/ingest/repo_ingestor.py @@ -29,14 +29,19 @@ Author: Semantica Contributors License: MIT """ +import ipaddress import os import re import shutil +import socket import tempfile +import time +from collections import OrderedDict from dataclasses import dataclass, field from datetime import datetime from pathlib import Path -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List, Optional, Set, Tuple, Union +from urllib.parse import urlparse import git @@ -44,6 +49,25 @@ from ..utils.exceptions import ProcessingError, ValidationError from ..utils.logging import get_logger from ..utils.progress_tracker import get_progress_tracker +# Safe subset of GitPython clone_from kwargs. Broader kwargs (multi_options, +# upload_pack, template, config, env, …) have been used in denylist-bypass +# attacks against older GitPython releases — keep them out of the call surface. +ALLOWED_CLONE_OPTIONS: Set[str] = {"depth", "branch", "single_branch", "no_tags"} +ALLOWED_REPO_URL_SCHEMES = frozenset({"https", "http", "git", "ssh"}) +# SCP-like SSH remotes: user@host:path/to/repo.git (no scheme) +_SCP_LIKE_REPO_URL_RE = re.compile(r"^[^@\s]+@[^:\s]+:.+$") +_ENV_VAR_TOKEN_RE = re.compile( + r"\$(\{[A-Za-z_][A-Za-z0-9_]*\}|[A-Za-z_][A-Za-z0-9_]*)" +) +# Short-lived DNS cache for host validation. This reduces repeated lookups but +# does not eliminate DNS-rebinding / TOCTOU races between validate and clone — +# network egress controls remain recommended. +_REPO_HOST_RESOLVE_CACHE: "OrderedDict[str, Tuple[float, Tuple[str, ...]]]" = ( + OrderedDict() +) +_REPO_HOST_RESOLVE_CACHE_TTL_SECONDS = 60.0 +_REPO_HOST_RESOLVE_CACHE_MAX_ENTRIES = 1024 + @dataclass class CodeFile: @@ -509,6 +533,268 @@ class RepoIngestor: self.logger.debug("Repo ingestor initialized") + @staticmethod + def _is_scp_like_repo_url(repo_url: str) -> bool: + """Return True for scp-like SSH remotes (``user@host:path``).""" + url = repo_url.strip() + # Avoid treating scheme URLs with userinfo as scp-like (e.g. https://u@h/...) + if "://" in url: + return False + return bool(_SCP_LIKE_REPO_URL_RE.match(url)) + + @staticmethod + def _scp_like_host(repo_url: str) -> str: + """Extract the hostname from an scp-like remote (``user@host:path``).""" + _, rest = repo_url.strip().split("@", 1) + host, _ = rest.split(":", 1) + return host + + @staticmethod + def _normalize_repo_url(repo_url: str) -> str: + """Normalize scp-like remotes to ``ssh://`` URLs; leave others unchanged. + + ``git@host:org/repo.git`` → ``ssh://git@host/org/repo.git`` + """ + url = repo_url.strip() + if not RepoIngestor._is_scp_like_repo_url(url): + return url + user_host, path = url.split(":", 1) + if not path.startswith("/"): + path = f"/{path}" + return f"ssh://{user_host}{path}" + + @staticmethod + def _is_blocked_ip( + ip: Union[ipaddress.IPv4Address, ipaddress.IPv6Address], + ) -> bool: + """Return True if *ip* is an SSRF-sensitive address. + + Blocks private (RFC1918/ULA), loopback, link-local (including + 169.254.x.x / fe80::/10 cloud-metadata ranges), and unspecified + addresses. + + Intentionally does **not** use ``ip.is_reserved``: Python's + ``ipaddress`` module marks the NAT64 Well-Known Prefix + (64:ff9b::/96, RFC 6052) as reserved, which causes false positives + on IPv6-only and dual-stack networks that use NAT64 for public + Internet access (e.g., github.com resolves to 64:ff9b::… on such + networks). Those addresses are not SSRF-sensitive. + """ + return bool( + ip.is_private + or ip.is_loopback + or ip.is_link_local + or ip.is_unspecified + ) + + @staticmethod + def _resolve_repo_host_ips(host: str) -> Tuple[str, ...]: + """Resolve *host* to IP strings via ``socket.getaddrinfo``, with TTL cache. + + Note: caching and pre-clone resolution mitigate repeated lookups but + cannot fully prevent DNS rebinding between validation and clone. + Prefer network-layer egress controls for defense in depth. + """ + cache_key = host.lower().rstrip(".") + now = time.monotonic() + RepoIngestor._prune_repo_host_resolve_cache(now) + cached = _REPO_HOST_RESOLVE_CACHE.get(cache_key) + if cached is not None: + expires_at, ips = cached + if now < expires_at: + _REPO_HOST_RESOLVE_CACHE.move_to_end(cache_key) + return ips + _REPO_HOST_RESOLVE_CACHE.pop(cache_key, None) + + try: + addrinfos = socket.getaddrinfo( + host, None, type=socket.SOCK_STREAM + ) + except socket.gaierror as exc: + raise ValidationError( + f"Cannot resolve repository host {host!r}: {exc}" + ) from exc + + ips: List[str] = [] + seen: Set[str] = set() + for _family, _type, _proto, _canonname, sockaddr in addrinfos: + addr = sockaddr[0] + if addr not in seen: + seen.add(addr) + ips.append(addr) + + if not ips: + raise ValidationError( + f"Cannot resolve repository host {host!r}: no addresses" + ) + + result = tuple(ips) + _REPO_HOST_RESOLVE_CACHE[cache_key] = ( + now + _REPO_HOST_RESOLVE_CACHE_TTL_SECONDS, + result, + ) + _REPO_HOST_RESOLVE_CACHE.move_to_end(cache_key) + RepoIngestor._prune_repo_host_resolve_cache(now) + return result + + @staticmethod + def _prune_repo_host_resolve_cache(now: Optional[float] = None) -> None: + """Remove expired host entries and enforce a hard cache size cap.""" + if now is None: + now = time.monotonic() + + expired_keys = [ + cache_key + for cache_key, (expires_at, _ips) in _REPO_HOST_RESOLVE_CACHE.items() + if expires_at <= now + ] + for cache_key in expired_keys: + _REPO_HOST_RESOLVE_CACHE.pop(cache_key, None) + + while len(_REPO_HOST_RESOLVE_CACHE) > _REPO_HOST_RESOLVE_CACHE_MAX_ENTRIES: + _REPO_HOST_RESOLVE_CACHE.popitem(last=False) + + @staticmethod + def _validate_repo_host(host: str) -> None: + """Reject localhost names and hosts resolving to blocked addresses. + + Literal IPs are checked directly. Hostnames are resolved with + ``socket.getaddrinfo`` and **every** returned address is screened. + """ + if not host: + raise ValidationError("Repository URL must include a host") + + lowered = host.lower().rstrip(".") + if lowered == "localhost" or lowered.endswith(".localhost"): + raise ValidationError(f"Repository host is not allowed: {host}") + + try: + ip = ipaddress.ip_address(host) + except ValueError: + # Hostname: resolve and validate all returned addresses + for addr in RepoIngestor._resolve_repo_host_ips(host): + try: + resolved = ipaddress.ip_address(addr) + except ValueError: + continue + if RepoIngestor._is_blocked_ip(resolved): + raise ValidationError( + f"Repository host resolves to a blocked address: " + f"{host} -> {addr}" + ) + return + + if RepoIngestor._is_blocked_ip(ip): + raise ValidationError( + f"Repository host resolves to a blocked address: {host}" + ) + + @staticmethod + def _is_local_repo_path(repo_url: str) -> bool: + """Return True if *repo_url* looks like a local filesystem path. + + Matches absolute paths (``/…``, ``C:\\…``), relative paths + (``./…``, ``../…``), and bare names without a scheme or ``@host:`` + pattern that would be interpreted as a local path by git. + """ + url = repo_url.strip() + if "://" in url: + return False + if RepoIngestor._is_scp_like_repo_url(url): + return False + # Absolute POSIX or Windows paths, or relative paths + p = Path(url) + if p.is_absolute(): + return True + # ./ or ../ + if url.startswith(("./", "../", ".\\", "..\\")): + return True + # Existing local directory (best-effort; may not exist yet during tests) + if p.exists(): + return True + return False + + @staticmethod + def _validate_repo_url(repo_url: str) -> None: + """Validate a repository URL before cloning. + + Accepts http(s)/git/ssh URLs, scp-like SSH remotes + (``user@host:path``), and local filesystem paths. Rejects empty + values, unsupported schemes, missing hosts, environment variable + expansion tokens (``$VAR`` / ``${VAR}``), and hosts that are or + resolve to private / loopback / link-local addresses. + + Local filesystem paths bypass network validation because + ``git clone /path/to/local/repo`` makes no network requests and + carries no SSRF risk. + + DNS resolution is TOCTOU-sensitive (rebinding); pair with egress + controls in production deployments. + """ + if not isinstance(repo_url, str) or not repo_url.strip(): + raise ValidationError("Repository URL must be a non-empty string") + + # Defense-in-depth against GitPython env-var expansion in clone URLs + # (GHSA-2f96-g7mh-g2hx / related). Prefer rejecting before clone_from. + if _ENV_VAR_TOKEN_RE.search(repo_url): + raise ValidationError( + "Repository URL must not contain environment variable " + "references ($VAR / ${VAR})" + ) + + url = repo_url.strip() + + # Local filesystem paths: no network, no SSRF risk — skip host checks. + if RepoIngestor._is_local_repo_path(url): + return + + # scp-like syntax has no URL scheme; validate host then accept. + if RepoIngestor._is_scp_like_repo_url(url): + RepoIngestor._validate_repo_host(RepoIngestor._scp_like_host(url)) + return + + try: + parsed = urlparse(url) + # ``hostname`` can raise ValueError for malformed netloc (e.g. bad IPv6) + host = parsed.hostname + except ValueError as e: + raise ValidationError(f"Invalid repository URL: {e}") from e + + scheme = (parsed.scheme or "").lower() + if scheme not in ALLOWED_REPO_URL_SCHEMES: + raise ValidationError( + f"Unsupported repository URL scheme {scheme!r}. " + f"Allowed schemes: {sorted(ALLOWED_REPO_URL_SCHEMES)}" + ) + if not parsed.netloc or not host: + raise ValidationError( + f"Repository URL must include a host: {repo_url}" + ) + + RepoIngestor._validate_repo_host(host) + + @staticmethod + def _filter_clone_options(options: Dict[str, Any]) -> Dict[str, Any]: + """Return only allowlisted git clone kwargs; reject anything else.""" + # Semantica processing options — never forwarded to clone_from + non_git_options = { + "include_history", + "file_filters", + "commit_filters", + "include_extensions", + "max_depth", + } + candidate = { + k: v for k, v in options.items() if k not in non_git_options + } + unsafe = set(candidate) - ALLOWED_CLONE_OPTIONS + if unsafe: + raise ValidationError( + f"Clone option(s) not permitted: {sorted(unsafe)}. " + f"Allowed options: {sorted(ALLOWED_CLONE_OPTIONS)}" + ) + return candidate + def ingest_repository(self, repo_url: str, **options) -> Dict[str, Any]: """ Ingest and process a Git repository. @@ -518,6 +804,8 @@ class RepoIngestor: **options: Processing options: - branch: Specific branch to checkout - depth: Clone depth (for shallow clones) + - single_branch: Clone only a single branch + - no_tags: Skip cloning tags - include_history: Whether to include commit history - include_extensions: List of file extensions to include (e.g., ["py", "md"]) @@ -533,27 +821,19 @@ class RepoIngestor: ) try: + # Validate repository URL before any clone attempt + self._validate_repo_url(repo_url) + clone_url = self._normalize_repo_url(repo_url) + # Handle option aliases and filters if "max_depth" in options and "depth" not in options: options["depth"] = options["max_depth"] - # Separate git clone options from processing options - # We filter out known non-git options to avoid passing invalid flags to git clone - non_git_options = { - "include_history", - "file_filters", - "commit_filters", - "include_extensions", - "max_depth", - } - clone_options = { - k: v for k, v in options.items() if k not in non_git_options - } + clone_options = self._filter_clone_options(options) - # Validate repository URL try: parsed = git.Repo.clone_from( - repo_url, self._get_temp_dir(), **clone_options + clone_url, self._get_temp_dir(), **clone_options ) except Exception as e: self.progress_tracker.update_tracking( @@ -627,6 +907,12 @@ class RepoIngestor: "temp_path": str(repo_path), } + except ValidationError as e: + # Keep validation failures typed for callers; do not wrap as ProcessingError + self.progress_tracker.update_tracking( + tracking_id, status="failed", message=str(e) + ) + raise except Exception as e: self.progress_tracker.update_tracking( tracking_id, status="failed", message=str(e) diff --git a/tests/ingest/test_repo_ingestor_security.py b/tests/ingest/test_repo_ingestor_security.py new file mode 100644 index 00000000..738c0f76 --- /dev/null +++ b/tests/ingest/test_repo_ingestor_security.py @@ -0,0 +1,482 @@ +"""Security-focused tests for RepoIngestor (issue #868).""" + +import socket +from unittest.mock import MagicMock, patch + +import pytest + +from semantica.ingest import repo_ingestor as repo_ingestor_mod +from semantica.ingest.repo_ingestor import ( + ALLOWED_CLONE_OPTIONS, + RepoIngestor, +) +from semantica.utils.exceptions import ValidationError + + +def _fake_addrinfo(*addrs: str): + """Build a getaddrinfo-shaped result list for the given IP strings.""" + results = [] + for addr in addrs: + family = socket.AF_INET6 if ":" in addr else socket.AF_INET + results.append( + (family, socket.SOCK_STREAM, 0, "", (addr, 0)) + ) + return results + + +@pytest.fixture(autouse=True) +def _clear_repo_host_resolve_cache(): + repo_ingestor_mod._REPO_HOST_RESOLVE_CACHE.clear() + yield + repo_ingestor_mod._REPO_HOST_RESOLVE_CACHE.clear() + + +class TestRepoUrlValidation: + def test_accepts_https_github_url(self): + with patch( + "semantica.ingest.repo_ingestor.socket.getaddrinfo", + return_value=_fake_addrinfo("140.82.112.3"), + ): + RepoIngestor._validate_repo_url("https://github.com/user/repo.git") + + def test_accepts_ssh_scheme(self): + with patch( + "semantica.ingest.repo_ingestor.socket.getaddrinfo", + return_value=_fake_addrinfo("140.82.112.3"), + ): + RepoIngestor._validate_repo_url("ssh://git@github.com/user/repo.git") + + def test_accepts_scp_like_ssh_remote(self): + with patch( + "semantica.ingest.repo_ingestor.socket.getaddrinfo", + return_value=_fake_addrinfo("140.82.112.3"), + ): + RepoIngestor._validate_repo_url("git@github.com:user/repo.git") + RepoIngestor._validate_repo_url( + "deploy@gitlab.example.com:team/app.git" + ) + + def test_normalizes_scp_like_to_ssh_url(self): + assert ( + RepoIngestor._normalize_repo_url("git@github.com:user/repo.git") + == "ssh://git@github.com/user/repo.git" + ) + assert ( + RepoIngestor._normalize_repo_url( + "https://github.com/user/repo.git" + ) + == "https://github.com/user/repo.git" + ) + + def test_rejects_empty(self): + with pytest.raises(ValidationError, match="non-empty"): + RepoIngestor._validate_repo_url("") + + def test_rejects_file_scheme(self): + with pytest.raises(ValidationError, match="Unsupported repository URL scheme"): + RepoIngestor._validate_repo_url("file:///tmp/repo.git") + + def test_rejects_env_var_tokens(self): + with pytest.raises(ValidationError, match="environment variable"): + RepoIngestor._validate_repo_url( + "https://attacker.example/${AWS_SECRET_ACCESS_KEY}/repo.git" + ) + with pytest.raises(ValidationError, match="environment variable"): + RepoIngestor._validate_repo_url( + "https://$GITHUB_TOKEN@attacker.example/repo.git" + ) + with pytest.raises(ValidationError, match="environment variable"): + RepoIngestor._validate_repo_url( + "git@github.com:org/${AWS_SECRET_ACCESS_KEY}.git" + ) + + def test_accepts_literal_dollar_without_env_var_token(self): + with patch( + "semantica.ingest.repo_ingestor.socket.getaddrinfo", + return_value=_fake_addrinfo("140.82.112.3"), + ): + RepoIngestor._validate_repo_url("https://example.com/repo$1.git") + RepoIngestor._validate_repo_url("git@example.com:team/repo$1.git") + + def test_rejects_localhost_and_loopback(self): + with pytest.raises(ValidationError, match="not allowed|blocked"): + RepoIngestor._validate_repo_url("https://localhost/repo.git") + with pytest.raises(ValidationError, match="blocked"): + RepoIngestor._validate_repo_url("https://127.0.0.1/repo.git") + with pytest.raises(ValidationError, match="not allowed|blocked"): + RepoIngestor._validate_repo_url("git@localhost:repo.git") + with pytest.raises(ValidationError, match="blocked"): + RepoIngestor._validate_repo_url("git@127.0.0.1:repo.git") + + def test_rejects_private_and_metadata_ips(self): + for url in ( + "https://10.0.0.1/repo.git", + "https://192.168.1.1/repo.git", + "https://172.16.5.5/repo.git", + "http://169.254.169.254/latest/meta-data/", + "git@10.0.0.1:repo.git", + "git@169.254.169.254:repo.git", + ): + with pytest.raises(ValidationError, match="blocked"): + RepoIngestor._validate_repo_url(url) + + def test_rejects_hostname_resolving_to_private_ip(self): + with patch( + "semantica.ingest.repo_ingestor.socket.getaddrinfo", + return_value=_fake_addrinfo("10.0.0.5"), + ): + with pytest.raises(ValidationError, match="blocked"): + RepoIngestor._validate_repo_url( + "https://internal.example/repo.git" + ) + + def test_rejects_hostname_if_any_resolved_ip_is_blocked(self): + with patch( + "semantica.ingest.repo_ingestor.socket.getaddrinfo", + return_value=_fake_addrinfo("8.8.8.8", "127.0.0.1"), + ): + with pytest.raises(ValidationError, match="blocked"): + RepoIngestor._validate_repo_url( + "https://mixed.example/repo.git" + ) + + def test_rejects_unresolvable_hostname(self): + with patch( + "semantica.ingest.repo_ingestor.socket.getaddrinfo", + side_effect=socket.gaierror(8, "Name or service not known"), + ): + with pytest.raises(ValidationError, match="Cannot resolve"): + RepoIngestor._validate_repo_url( + "https://does-not-resolve.invalid/repo.git" + ) + + def test_hostname_resolution_is_cached(self): + with patch( + "semantica.ingest.repo_ingestor.socket.getaddrinfo", + return_value=_fake_addrinfo("1.2.3.4"), + ) as mock_gai: + RepoIngestor._validate_repo_url("https://cached.example/repo.git") + RepoIngestor._validate_repo_url("https://cached.example/other.git") + assert mock_gai.call_count == 1 + + def test_rejects_malformed_netloc_as_validation_error(self): + with pytest.raises(ValidationError, match="Invalid repository URL"): + RepoIngestor._validate_repo_url("http://[::1") + with pytest.raises(ValidationError, match="Invalid repository URL"): + RepoIngestor._validate_repo_url("http://[") + with pytest.raises(ValidationError, match="Invalid repository URL"): + RepoIngestor._validate_repo_url("https://user@[::1/repo.git") + + def test_malformed_url_surfaces_as_validation_error_from_ingest(self): + with patch("semantica.ingest.repo_ingestor.git.Repo") as MockRepo, patch( + "semantica.ingest.repo_ingestor.get_progress_tracker" + ) as mock_get_tracker: + mock_get_tracker.return_value = MagicMock() + ingestor = RepoIngestor() + with pytest.raises(ValidationError, match="Invalid repository URL"): + ingestor.ingest_repository("http://[::1") + MockRepo.clone_from.assert_not_called() + + +class TestCloneOptionAllowlist: + def test_allows_safe_options(self): + filtered = RepoIngestor._filter_clone_options( + {"depth": 1, "branch": "main", "single_branch": True, "no_tags": True} + ) + assert filtered == { + "depth": 1, + "branch": "main", + "single_branch": True, + "no_tags": True, + } + + def test_strips_processing_options_without_error(self): + filtered = RepoIngestor._filter_clone_options( + { + "depth": 1, + "include_history": True, + "include_extensions": ["py"], + "file_filters": {}, + "commit_filters": {}, + "max_depth": 5, + } + ) + assert filtered == {"depth": 1} + + def test_rejects_multi_options(self): + with pytest.raises(ValidationError, match="not permitted"): + RepoIngestor._filter_clone_options( + {"multi_options": ["--template=/tmp/evil"]} + ) + + def test_rejects_upload_pack_and_template(self): + for key in ("upload_pack", "template", "config", "env"): + with pytest.raises(ValidationError, match="not permitted"): + RepoIngestor._filter_clone_options({key: "x"}) + + def test_allowlist_matches_documented_safe_set(self): + assert ALLOWED_CLONE_OPTIONS == { + "depth", + "branch", + "single_branch", + "no_tags", + } + + +class TestIngestRepositoryGuards: + def test_unsafe_url_never_reaches_clone_from(self): + with patch("semantica.ingest.repo_ingestor.git.Repo") as MockRepo, patch( + "semantica.ingest.repo_ingestor.get_progress_tracker" + ) as mock_get_tracker: + mock_get_tracker.return_value = MagicMock() + ingestor = RepoIngestor() + with pytest.raises(ValidationError, match="environment variable"): + ingestor.ingest_repository( + "https://evil.example/${AWS_SECRET_ACCESS_KEY}/r.git" + ) + MockRepo.clone_from.assert_not_called() + + def test_unsafe_clone_option_never_reaches_clone_from(self): + with patch("semantica.ingest.repo_ingestor.git.Repo") as MockRepo, patch( + "semantica.ingest.repo_ingestor.get_progress_tracker" + ) as mock_get_tracker, patch( + "semantica.ingest.repo_ingestor.socket.getaddrinfo", + return_value=_fake_addrinfo("140.82.112.3"), + ): + mock_get_tracker.return_value = MagicMock() + ingestor = RepoIngestor() + with pytest.raises(ValidationError, match="not permitted"): + ingestor.ingest_repository( + "https://github.com/user/repo.git", + multi_options=["--template=/tmp/evil"], + ) + MockRepo.clone_from.assert_not_called() + + def test_hostname_resolving_private_never_reaches_clone_from(self): + with patch("semantica.ingest.repo_ingestor.git.Repo") as MockRepo, patch( + "semantica.ingest.repo_ingestor.get_progress_tracker" + ) as mock_get_tracker, patch( + "semantica.ingest.repo_ingestor.socket.getaddrinfo", + return_value=_fake_addrinfo("192.168.1.50"), + ): + mock_get_tracker.return_value = MagicMock() + ingestor = RepoIngestor() + with pytest.raises(ValidationError, match="blocked"): + ingestor.ingest_repository( + "https://ssrf.example/internal/repo.git" + ) + MockRepo.clone_from.assert_not_called() + + def test_safe_options_forwarded_to_clone_from(self): + with patch("semantica.ingest.repo_ingestor.git.Repo") as MockRepo, patch( + "semantica.ingest.repo_ingestor.tempfile.mkdtemp", + return_value="/tmp/fake-repo", + ), patch("semantica.ingest.repo_ingestor.shutil.rmtree"), patch( + "semantica.ingest.repo_ingestor.get_progress_tracker" + ) as mock_get_tracker, patch( + "semantica.ingest.repo_ingestor.socket.getaddrinfo", + return_value=_fake_addrinfo("140.82.112.3"), + ), patch.object( + RepoIngestor, "extract_code_files", return_value=[] + ), patch.object( + RepoIngestor, "get_repository_info", return_value={"url": "x"} + ), patch.object(RepoIngestor, "analyze_commits", return_value=[]): + mock_get_tracker.return_value = MagicMock() + mock_repo = MagicMock() + MockRepo.clone_from.return_value = mock_repo + MockRepo.return_value = mock_repo + + ingestor = RepoIngestor() + with patch.object( + ingestor.analyzer, "analyze_structure", return_value={} + ), patch.object( + ingestor.analyzer, "calculate_metrics", return_value={} + ): + ingestor.ingest_repository( + "https://github.com/user/repo.git", + depth=1, + branch="main", + include_history=False, + ) + + kwargs = MockRepo.clone_from.call_args.kwargs + assert kwargs.get("depth") == 1 + assert kwargs.get("branch") == "main" + assert "include_history" not in kwargs + assert "multi_options" not in kwargs + + def test_scp_like_remote_normalized_before_clone(self): + with patch("semantica.ingest.repo_ingestor.git.Repo") as MockRepo, patch( + "semantica.ingest.repo_ingestor.tempfile.mkdtemp", + return_value="/tmp/fake-repo", + ), patch("semantica.ingest.repo_ingestor.shutil.rmtree"), patch( + "semantica.ingest.repo_ingestor.get_progress_tracker" + ) as mock_get_tracker, patch( + "semantica.ingest.repo_ingestor.socket.getaddrinfo", + return_value=_fake_addrinfo("140.82.112.3"), + ), patch.object( + RepoIngestor, "extract_code_files", return_value=[] + ), patch.object( + RepoIngestor, "get_repository_info", return_value={"url": "x"} + ), patch.object(RepoIngestor, "analyze_commits", return_value=[]): + mock_get_tracker.return_value = MagicMock() + mock_repo = MagicMock() + MockRepo.clone_from.return_value = mock_repo + MockRepo.return_value = mock_repo + + ingestor = RepoIngestor() + with patch.object( + ingestor.analyzer, "analyze_structure", return_value={} + ), patch.object( + ingestor.analyzer, "calculate_metrics", return_value={} + ): + ingestor.ingest_repository("git@github.com:user/repo.git") + + assert MockRepo.clone_from.call_args.args[0] == ( + "ssh://git@github.com/user/repo.git" + ) + +class TestIsReservedNAT64Regression: + """Regression tests for the is_reserved / NAT64 false-positive fix. + + Python's ipaddress.is_reserved marks 64:ff9b::/96 (NAT64 Well-Known + Prefix, RFC 6052) as reserved=True, which caused github.com to be + falsely blocked on IPv6-only / dual-stack networks that use NAT64. + """ + + def test_nat64_prefix_not_blocked(self): + """64:ff9b::/96 addresses must not be blocked by _is_blocked_ip.""" + import ipaddress + + # Typical NAT64 translation of 140.82.112.3 (github.com) + addr = ipaddress.ip_address("64:ff9b::8c52:7003") + assert not RepoIngestor._is_blocked_ip(addr), ( + "NAT64 WKP address should not be blocked; " + "it is a legitimate public IPv6 address on NAT64 networks." + ) + + def test_nat64_local_prefix_not_blocked(self): + """64:ff9b:1::/48 (RFC 8215 local NAT64) is private by Python 3.12 + definition and IS correctly blocked — it's a locally-assigned range, + not globally routable. + """ + import ipaddress + + addr = ipaddress.ip_address("64:ff9b:1::1") + # is_private=True in Python 3.12 — legitimately blocked + assert RepoIngestor._is_blocked_ip(addr) + + def test_private_ipv6_still_blocked(self): + """ULA (fc00::/7) must still be blocked.""" + import ipaddress + + assert RepoIngestor._is_blocked_ip(ipaddress.ip_address("fc00::1")) + assert RepoIngestor._is_blocked_ip(ipaddress.ip_address("fd12:3456::1")) + + def test_ipv6_loopback_still_blocked(self): + import ipaddress + + assert RepoIngestor._is_blocked_ip(ipaddress.ip_address("::1")) + + def test_ipv6_link_local_still_blocked(self): + import ipaddress + + assert RepoIngestor._is_blocked_ip(ipaddress.ip_address("fe80::1")) + + def test_documentation_prefix_blocked(self): + """2001:db8::/32 is documentation-only and classified as + is_private=True in Python 3.12. It is correctly blocked. + """ + import ipaddress + + addr = ipaddress.ip_address("2001:db8::1") + assert RepoIngestor._is_blocked_ip(addr) + + def test_public_ipv4_not_blocked(self): + import ipaddress + + assert not RepoIngestor._is_blocked_ip(ipaddress.ip_address("140.82.112.3")) + + def test_public_ipv6_not_blocked(self): + import ipaddress + + assert not RepoIngestor._is_blocked_ip( + ipaddress.ip_address("2001:4860:4860::8888") + ) + + def test_host_resolving_to_nat64_address_is_allowed(self): + """A hostname that resolves to a NAT64 address (plus a public IPv4) + must not be blocked — this was the real-world failure mode. + """ + # Simulate github.com on a NAT64 network + with patch( + "semantica.ingest.repo_ingestor.socket.getaddrinfo", + return_value=_fake_addrinfo("64:ff9b::8c52:7003", "140.82.112.3"), + ): + # Should not raise + RepoIngestor._validate_repo_url("https://github.com/user/repo.git") + + def test_host_resolving_only_to_nat64_is_allowed(self): + """Even if the only resolved address is a NAT64 address, it is allowed + because it is a valid public address. + """ + with patch( + "semantica.ingest.repo_ingestor.socket.getaddrinfo", + return_value=_fake_addrinfo("64:ff9b::8c52:7003"), + ): + RepoIngestor._validate_repo_url("https://github.com/user/repo.git") + + +class TestLocalPathSupport: + """Regression tests for local repository path backward compatibility.""" + + def test_is_local_repo_path_absolute(self, tmp_path): + """Absolute paths are recognised as local.""" + assert RepoIngestor._is_local_repo_path(str(tmp_path)) + + def test_is_local_repo_path_relative(self): + """./… and ../… are recognised as local.""" + assert RepoIngestor._is_local_repo_path("./repo") + assert RepoIngestor._is_local_repo_path("../sibling-repo") + + def test_is_local_repo_path_not_remote(self): + """Remote URLs are not local.""" + assert not RepoIngestor._is_local_repo_path("https://github.com/u/r.git") + assert not RepoIngestor._is_local_repo_path("git@github.com:u/r.git") + assert not RepoIngestor._is_local_repo_path("ssh://git@github.com/r.git") + + def test_validate_repo_url_accepts_absolute_local_path(self, tmp_path): + """_validate_repo_url must not raise for an absolute local path.""" + RepoIngestor._validate_repo_url(str(tmp_path)) + + def test_validate_repo_url_accepts_relative_local_path(self): + """_validate_repo_url must not raise for ./… paths.""" + RepoIngestor._validate_repo_url("./repo") + + def test_validate_repo_url_env_var_still_blocked_in_local_path(self): + """Env-var tokens in local paths are still rejected.""" + with pytest.raises(ValidationError, match="environment variable"): + RepoIngestor._validate_repo_url("./$SECRET_KEY/repo") + + def test_local_path_never_reaches_dns_resolution(self, tmp_path): + """Local paths must not trigger DNS lookups.""" + with patch( + "semantica.ingest.repo_ingestor.socket.getaddrinfo" + ) as mock_gai: + RepoIngestor._validate_repo_url(str(tmp_path)) + mock_gai.assert_not_called() + + def test_ingest_repository_local_path_passes_validation(self, tmp_path): + """ingest_repository with a local path must not fail at URL validation.""" + with patch("semantica.ingest.repo_ingestor.git.Repo") as MockRepo, patch( + "semantica.ingest.repo_ingestor.get_progress_tracker" + ) as mock_get_tracker: + mock_get_tracker.return_value = MagicMock() + ingestor = RepoIngestor() + # Expect clone to fail (temp_dir logic), but NOT a ValidationError + try: + ingestor.ingest_repository(str(tmp_path)) + except Exception as exc: + assert not isinstance(exc, ValidationError), ( + f"Local path must not raise ValidationError; got: {exc}" + ) From 43bac6170cd61b3ca4ba9f6f9aae9b229805b385 Mon Sep 17 00:00:00 2001 From: Yunare Maia Date: Thu, 13 Aug 2026 14:12:34 -0300 Subject: [PATCH 028/105] fix(vector_store): make VectorManager methods work on persistent backends (#855) (#914) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(vector_store): make VectorManager methods work on persistent backends (#855) maintain_store() and collect_statistics() reached into VectorStore internals (.vectors/.metadata), which only exist for the inmemory backend — any persistent backend (FAISS, Qdrant, Pinecone, Milvus, ...) crashed with AttributeError. Add a public backend-agnostic VectorStore.count() accessor following the get_vector()/get_metadata() precedent (#843) and the NotImplementedError-on-unsupported-capability precedent of _filter_by_metadata() (#848): inmemory counts its dict, persistent backends delegate to count() when available, and raise NotImplementedError otherwise. VectorManager methods now go through count(); maintain_store() keeps the exact inmemory semantics (separate vector/metadata dict counts) and reports a 1:1 count for persistent backends, where metadata is stored alongside each vector. Tests: 10 hermetic unit tests covering inmemory, delegation and the NotImplementedError path. Core vector_store suite: 40 passed. * fix(vector_store): raise NotImplementedError when count() unavailable Address Qodo review findings on #914: - Persistent backend with no wrapped store no longer silently returns 0 (which masked a missing initialization as an empty, healthy store); it now raises NotImplementedError like get_vector()/get_metadata(). - A mis-shaped adapter exposing a non-callable 'count' attribute now surfaces a clean NotImplementedError instead of a TypeError, via a getattr + callable() capability check. Adds regression tests for both cases. * fix(vector_store): implement count() on FAISS/SQLite/PgVector backends (#914) - FAISSStore.count(): returns len(index.vector_ids); 0 when no index exists yet - SQLiteVecStore.count(): delegates to get_stats()[vector_count] (SELECT COUNT(*)) - PgVectorStore.count(): delegates to get_stats()[vector_count] (SELECT COUNT(*)) - VectorStore.count(): fix misleading NotImplementedError message; now describes how to add count() support to a backend adapter rather than claiming only the inmemory backend can ever support counting - VectorManager.maintain_store(): split inmemory and persistent paths: * inmemory: independently reads len(vectors) and len(metadata) and compares them as an integrity check (original semantics preserved) * persistent: calls store.count(); returns metadata_count=None because metadata is co-located with vectors in the backend and cannot be counted independently; never manufactures metadata_count=vector_count as a vacuous tautology (#914 Qodo review) - Tests: rewrite test_vector_manager_persistent.py with 31 tests covering dispatch logic, inmemory divergence detection, persistent metadata_count=None invariant, FAISSStore/PgVectorStore via mocks, and SQLiteVecStore via real in-memory SQLite (skipped when sqlite-vec absent) * docs(changelog): document VectorManager persistent-backend count fix (#914, closes #855) Records the VectorStore.count() accessor, the FAISS/SQLite/PgVector implementations added during review, and the maintain_store() metadata_count fix (no longer fabricates equality for persistent backends). --------- Co-authored-by: Sameer6305 Co-authored-by: KaifAhmad1 Co-authored-by: Mohd Kaif <98801504+KaifAhmad1@users.noreply.github.com> --- CHANGELOG.md | 9 + semantica/vector_store/faiss_store.py | 12 + semantica/vector_store/pgvector_store.py | 9 + semantica/vector_store/sqlite_vec_store.py | 9 + semantica/vector_store/vector_store.py | 71 ++- .../test_vector_manager_persistent.py | 457 ++++++++++++++++++ 6 files changed, 559 insertions(+), 8 deletions(-) create mode 100644 tests/vector_store/test_vector_manager_persistent.py diff --git a/CHANGELOG.md b/CHANGELOG.md index d38a1901..717cc1d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,6 +52,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **`VectorManager.maintain_store()`/`collect_statistics()` crashed with `AttributeError` on persistent `VectorStore` backends** (#914, closes #855) by @yunaremaia, with fixes by @Sameer6305 + - Both methods accessed `store.vectors`/`store.metadata` directly, which are only initialized for the `inmemory` backend — any persistent backend (FAISS, Qdrant, Pinecone, Milvus, SQLite, PgVector, Weaviate) crashed immediately. Same root cause as the #839/#843/#845/#848 cluster, but `VectorManager` operates on a `VectorStore` instance from the outside, so the fix needed a public accessor rather than another internal guard + - Added a backend-agnostic `VectorStore.count()`: the `inmemory` backend counts its local dict; persistent backends delegate to a `count()` on the wrapped backend store when one exists, or raise `NotImplementedError` — following the `get_vector()`/`get_metadata()` precedent from #843, a missing/uninitialized backend store is never silently reported as an empty, healthy store + - `maintain_store()` and `collect_statistics()` now go through `store.count()` instead of touching `.vectors`/`.metadata` + - **Fixed during review** (@Sameer6305): the initial version had `count()` implemented at the dispatch level only, with no shipped backend actually providing one, and `maintain_store()` manufactured a vacuous `metadata_count == vector_count` tautology for persistent backends (always reporting `healthy: True` without checking anything). Added real `count()` implementations to `FAISSStore` (`len(index.vector_ids)` — FAISS has no delete path, so this list is always consistent with the index), `SQLiteVecStore`, and `PgVectorStore` (both via `SELECT COUNT(*)`); `Qdrant`/`Pinecone`/`Milvus`/`Weaviate` continue to raise `NotImplementedError` since none of them guarantee a cheap, reliable synchronous count. `maintain_store()` now reports `metadata_count: None` for persistent backends instead of the fabricated equality, with `healthy` meaning "store is reachable," not "metadata verified" + - Two earlier Qodo findings (a count() path that silently returned 0 for a missing backend store, and an unvalidated `hasattr` check that could raise `TypeError` on a mis-shaped adapter) were fixed before this review — replaced with `NotImplementedError` and a `getattr`+`callable()` capability check, respectively + - New `tests/vector_store/test_vector_manager_persistent.py`: dispatch-level tests for `count()` (inmemory, delegation, missing backend, non-callable `count`, mis-shaped adapter), full `VectorManager` inmemory semantics including divergence detection, persistent-backend dispatch tests, and backend-specific tests against real/mocked FAISS, SQLite (`sqlite-vec`, skipped if unavailable), and PgVector stores + - Core `vector_store` suite: 40 passed + - **`ContextGraph.get_node_property`/`get_node_attributes` "not found" contract clarified; `add_node_attribute` mutation-callback exception safety fixed** (#882, closes #877) by @ZohaibHassan16 - `get_node_property` returned `None` for both "node missing" and "property missing" with no way to distinguish them, and `get_node_attributes` returned `{}` for a missing node while its siblings disagreed on the not-found signal (`get_node_property`/`find_node` → `None`, `get_edge_data` → `{}`). Both now accept a `default=` parameter matching `dict.get()`'s convention, defaulting to their historical return values (`None` and `{}` respectively) for backward compatibility. Callers that need to disambiguate "node missing" from "value legitimately absent" can pass a private sentinel as `default` - Added Google-style docstrings to `get_node_property`, `get_node_attributes`, `get_edge_data`, and `find_node` documenting each method's not-found contract, addressing #877's "sibling not-found contract undocumented" gap diff --git a/semantica/vector_store/faiss_store.py b/semantica/vector_store/faiss_store.py index 54df70a4..7f53a061 100644 --- a/semantica/vector_store/faiss_store.py +++ b/semantica/vector_store/faiss_store.py @@ -537,3 +537,15 @@ class FAISSStore: "vector_count": len(self.index.vector_ids), "faiss_available": FAISS_AVAILABLE, } + + def count(self) -> int: + """Return the number of vectors currently tracked in this store. + + Returns the length of the ``vector_ids`` list maintained by + ``FAISSIndex``. FAISSStore does not implement vector deletion, so + this list is strictly append-only and is always consistent with the + underlying FAISS index (``index.ntotal``). + """ + if self.index is None: + return 0 + return len(self.index.vector_ids) diff --git a/semantica/vector_store/pgvector_store.py b/semantica/vector_store/pgvector_store.py index e1ce8ed0..204b8090 100644 --- a/semantica/vector_store/pgvector_store.py +++ b/semantica/vector_store/pgvector_store.py @@ -950,6 +950,15 @@ class PgVectorStore: except Exception as e: raise ProcessingError("Failed to get stats") from e + def count(self) -> int: + """Return the exact number of vectors stored in this PostgreSQL table. + + Executes ``SELECT COUNT(*) FROM `` — always reflects the + committed state of the table, including any deletes or updates. + """ + stats = self.get_stats() + return int(stats["vector_count"]) + def close(self): """Close the connection pool.""" if self._pool: diff --git a/semantica/vector_store/sqlite_vec_store.py b/semantica/vector_store/sqlite_vec_store.py index 9fbdaed5..a0e3242b 100644 --- a/semantica/vector_store/sqlite_vec_store.py +++ b/semantica/vector_store/sqlite_vec_store.py @@ -740,6 +740,15 @@ class SQLiteVecStore: except Exception as e: raise ProcessingError("Failed to get store statistics") from e + def count(self) -> int: + """Return the exact number of vectors stored in this SQLite table. + + Executes ``SELECT COUNT(*) FROM
`` under the store's lock to + guarantee a consistent, transaction-aware result. + """ + stats = self.get_stats() + return int(stats["vector_count"]) + def close(self): """Close the database connection.""" if hasattr(self, "_lock") and self._lock: diff --git a/semantica/vector_store/vector_store.py b/semantica/vector_store/vector_store.py index d4999554..8fa9ae07 100644 --- a/semantica/vector_store/vector_store.py +++ b/semantica/vector_store/vector_store.py @@ -65,7 +65,7 @@ Author: Semantica Contributors License: MIT """ -from typing import Any, Dict, List, Optional, Tuple, TypedDict, Union +from typing import Any, Dict, List, Optional, Tuple, TypedDict, Union, cast import concurrent.futures import inspect @@ -824,6 +824,35 @@ class VectorStore: else: raise NotImplementedError(f"Backend store {type(self._backend_store).__name__} does not implement get_metadata") + def count(self) -> int: + """Return the number of vectors in the store, backend-agnostic. + + The inmemory backend counts its local dict; persistent backends + delegate to a ``count()`` on the wrapped store when available. + Following the get_vector()/get_metadata() precedent (#843) and the + NotImplementedError-on-unsupported-capability precedent of + _filter_by_metadata() (#848), a persistent backend that cannot + report a count raises NotImplementedError so callers can tell + "no vectors" apart from "counting not supported" — including when + the wrapped backend store is missing entirely (never silently + report an uninitialized store as empty). + """ + if self.backend == "inmemory": + return len(self.vectors) + elif self._backend_store is not None: + count_attr = getattr(self._backend_store, "count", None) + if callable(count_attr): + return cast(int, count_attr()) + raise NotImplementedError( + f"Backend store {type(self._backend_store).__name__} does not " + "implement a count() method. Add a count() method to the " + "backend store adapter to enable vector counting for this backend." + ) + raise NotImplementedError( + f"Backend store is not initialized; cannot count vectors for " + f"backend {self.backend!r}." + ) + def initialize_decision_pipeline( self, graph_store: Optional[Any] = None, @@ -1452,21 +1481,47 @@ class VectorManager: def maintain_store( self, store: VectorStore, **options: Dict[str, Any] ) -> Dict[str, Any]: - """Maintain vector store health.""" - # Check integrity - vector_count = len(store.vectors) - metadata_count = len(store.metadata) + """Maintain vector store health. + For the inmemory backend, both the vector count and the metadata + count are independently tracked in separate dicts and are compared + as an integrity check. + + For persistent backends that implement ``VectorStore.count()``, + only the vector count is available. Metadata is co-located with + each vector in the underlying store (added/deleted atomically), + so a separate metadata count cannot be meaningfully distinguished + from the vector count. The response omits ``metadata_count`` for + such backends and reports ``healthy: True`` to indicate that the + store is reachable and operational. + + If the backend does not implement ``count()``, the ``NotImplementedError`` + propagates to the caller — it is not silenced. + """ + if store.backend == "inmemory": + # Inmemory keeps vectors and metadata in separate dicts; compare + # them to detect accidental divergence (#855). + vector_count = len(store.vectors) + metadata_count = len(store.metadata) + return { + "healthy": vector_count == metadata_count, + "vector_count": vector_count, + "metadata_count": metadata_count, + } + + # Persistent backend: delegate to count(). Metadata and vectors are + # stored together, so only one count is available. + vector_count = store.count() return { - "healthy": vector_count == metadata_count, + "healthy": True, "vector_count": vector_count, - "metadata_count": metadata_count, + "metadata_count": None, } def collect_statistics(self, store: VectorStore) -> Dict[str, Any]: """Collect vector store statistics.""" return { - "total_vectors": len(store.vectors), + "total_vectors": store.count(), "dimension": store.dimension, "backend": store.backend, } diff --git a/tests/vector_store/test_vector_manager_persistent.py b/tests/vector_store/test_vector_manager_persistent.py new file mode 100644 index 00000000..7379fa32 --- /dev/null +++ b/tests/vector_store/test_vector_manager_persistent.py @@ -0,0 +1,457 @@ +"""Regression tests for #855 / #914: VectorManager persistent-backend crash. + +VectorManager.maintain_store() and collect_statistics() used to reach +into VectorStore internals (``.vectors`` / ``.metadata``), which only +exist for the inmemory backend — any persistent backend (FAISS, Qdrant, +Pinecone, Milvus, SQLite, PgVector, Weaviate) crashed with AttributeError. +Both methods now go through the public backend-agnostic +``VectorStore.count()`` accessor. + +Phase 1 (PR #855): dispatch fix + NotImplementedError instead of +AttributeError for backends that don't implement count(). + +Phase 2 (PR #914): count() added to FAISSStore, SQLiteVecStore, and +PgVectorStore — the three backends whose storage contracts guarantee a +reliable, synchronous count. maintain_store() revised so the +persistent-backend path no longer manufactures a vacuous +``metadata_count == vector_count`` tautology; instead it returns +``metadata_count=None`` and delegates healthiness to whether the store is +reachable. +""" + +import tempfile +import unittest +from pathlib import Path +from unittest.mock import MagicMock, patch + +import numpy as np + +from semantica.vector_store.vector_store import VectorStore, VectorManager + + +# --------------------------------------------------------------------------- +# Minimal fake backend stores for dispatch-level unit tests +# --------------------------------------------------------------------------- + +class _CountingBackendStore: + """Fake persistent backend store that supports count().""" + + def __init__(self, n: int): + self._n = n + + def count(self) -> int: + return self._n + + +class _NonCountingBackendStore: + """Fake persistent backend store without any count capability.""" + + +class _MisShapedBackendStore: + """Backend store whose ``count`` attribute is not callable.""" + + count = 42 # plain attribute, not a method + + +# --------------------------------------------------------------------------- +# VectorStore.count() dispatch tests +# --------------------------------------------------------------------------- + +class VectorStoreCountTests(unittest.TestCase): + """VectorStore.count() backend-agnostic accessor — dispatch logic.""" + + def setUp(self): + self.vectors = [np.array([1.0, 0.0]), np.array([0.0, 1.0])] + self.metadata = [{"type": "a"}, {"type": "b"}] + + def test_count_inmemory(self): + store = VectorStore(backend="inmemory", dimension=2) + store.store_vectors(self.vectors, self.metadata) + self.assertEqual(store.count(), 2) + + def test_count_empty_inmemory(self): + store = VectorStore(backend="inmemory", dimension=2) + self.assertEqual(store.count(), 0) + + def test_count_delegates_to_backend_store(self): + store = VectorStore(backend="inmemory", dimension=2) + store.backend = "faiss" + store._backend_store = _CountingBackendStore(7) + self.assertEqual(store.count(), 7) + + def test_count_raises_not_implemented_without_backend_support(self): + store = VectorStore(backend="inmemory", dimension=2) + store.backend = "faiss" + store._backend_store = _NonCountingBackendStore() + with self.assertRaises(NotImplementedError): + store.count() + + def test_count_raises_when_persistent_backend_not_initialized(self): + # A persistent backend with no wrapped store must not silently + # report 0 — that masks a missing initialization as an empty, + # healthy store. Follow the get_vector()/get_metadata() precedent. + store = VectorStore(backend="inmemory", dimension=2) + store.backend = "faiss" + store._backend_store = None + with self.assertRaises(NotImplementedError): + store.count() + + def test_count_raises_when_backend_count_not_callable(self): + # A mis-shaped adapter exposing a non-callable ``count`` attribute + # must surface a clean NotImplementedError, not a TypeError. + store = VectorStore(backend="inmemory", dimension=2) + store.backend = "faiss" + store._backend_store = _MisShapedBackendStore() + with self.assertRaises(NotImplementedError): + store.count() + + def test_count_not_implemented_message_describes_requirement(self): + """Error message should explain *how* to fix it, not claim only + inmemory works (the old misleading message).""" + store = VectorStore(backend="inmemory", dimension=2) + store.backend = "qdrant" + store._backend_store = _NonCountingBackendStore() + with self.assertRaises(NotImplementedError) as ctx: + store.count() + msg = str(ctx.exception) + # Must not claim inmemory is the only backend that works + self.assertNotIn("only supported for the inmemory", msg) + # Must point at what to implement + self.assertIn("count()", msg) + + +# --------------------------------------------------------------------------- +# VectorManager tests — inmemory backend +# --------------------------------------------------------------------------- + +class VectorManagerInmemoryTests(unittest.TestCase): + """VectorManager with the inmemory backend — full integrity semantics.""" + + def setUp(self): + self.vectors = [np.array([1.0, 0.0]), np.array([0.0, 1.0])] + self.metadata = [{"type": "a"}, {"type": "b"}] + self.manager = VectorManager() + + def _store(self): + store = VectorStore(backend="inmemory", dimension=2) + store.store_vectors(self.vectors, self.metadata) + return store + + def test_collect_statistics_inmemory(self): + stats = self.manager.collect_statistics(self._store()) + self.assertEqual(stats["total_vectors"], 2) + self.assertEqual(stats["dimension"], 2) + self.assertEqual(stats["backend"], "inmemory") + + def test_collect_statistics_empty_inmemory(self): + store = VectorStore(backend="inmemory", dimension=2) + stats = self.manager.collect_statistics(store) + self.assertEqual(stats["total_vectors"], 0) + + def test_maintain_store_inmemory_healthy(self): + health = self.manager.maintain_store(self._store()) + self.assertTrue(health["healthy"]) + self.assertEqual(health["vector_count"], 2) + self.assertEqual(health["metadata_count"], 2) + + def test_maintain_store_inmemory_empty(self): + store = VectorStore(backend="inmemory", dimension=2) + health = self.manager.maintain_store(store) + self.assertTrue(health["healthy"]) + self.assertEqual(health["vector_count"], 0) + self.assertEqual(health["metadata_count"], 0) + + def test_maintain_store_inmemory_detects_divergence(self): + """Artificially diverge vectors and metadata — must report unhealthy.""" + store = VectorStore(backend="inmemory", dimension=2) + store.store_vectors(self.vectors, self.metadata) + # Inject an extra metadata entry with no matching vector + store.metadata["orphan"] = {"type": "orphan"} + health = self.manager.maintain_store(store) + self.assertFalse(health["healthy"]) + self.assertEqual(health["vector_count"], 2) + self.assertEqual(health["metadata_count"], 3) + + +# --------------------------------------------------------------------------- +# VectorManager tests — persistent backends (dispatch level) +# --------------------------------------------------------------------------- + +class VectorManagerPersistentDispatchTests(unittest.TestCase): + """VectorManager with fake persistent backends — dispatch/contract tests.""" + + def setUp(self): + self.vectors = [np.array([1.0, 0.0]), np.array([0.0, 1.0])] + self.metadata = [{"type": "a"}, {"type": "b"}] + self.manager = VectorManager() + + def _persistent_store(self, backend_store, backend_name="faiss"): + """Create a VectorStore instance whose backend is swapped to a fake.""" + store = VectorStore(backend="inmemory", dimension=2) + store.backend = backend_name + store._backend_store = backend_store + return store + + # -- collect_statistics -------------------------------------------------- + + def test_collect_statistics_persistent_with_count(self): + store = self._persistent_store(_CountingBackendStore(5)) + stats = self.manager.collect_statistics(store) + self.assertEqual(stats["total_vectors"], 5) + self.assertEqual(stats["dimension"], 2) + self.assertEqual(stats["backend"], "faiss") + + def test_collect_statistics_persistent_without_count_raises(self): + """Must raise NotImplementedError, not AttributeError (#855).""" + store = self._persistent_store(_NonCountingBackendStore()) + with self.assertRaises(NotImplementedError): + self.manager.collect_statistics(store) + + # -- maintain_store ------------------------------------------------------ + + def test_maintain_store_persistent_with_count(self): + store = self._persistent_store(_CountingBackendStore(5)) + health = self.manager.maintain_store(store) + self.assertTrue(health["healthy"]) + self.assertEqual(health["vector_count"], 5) + # Persistent backends cannot independently verify metadata count. + self.assertIsNone(health["metadata_count"]) + + def test_maintain_store_persistent_metadata_count_is_none_not_vacuous(self): + """Regression for Qodo review #914: maintain_store must not + manufacture metadata_count = vector_count to force healthy=True. + The only way to confirm metadata integrity for a persistent backend + is through the backend itself, so metadata_count must be None. + """ + store = self._persistent_store(_CountingBackendStore(3)) + health = self.manager.maintain_store(store) + # metadata_count must be None — never equal to vector_count because + # we didn't actually verify it; we simply don't have the information. + self.assertIsNone(health["metadata_count"]) + # vector_count comes from the real count() call, not fabricated. + self.assertEqual(health["vector_count"], 3) + + def test_maintain_store_persistent_without_count_raises(self): + """Must raise NotImplementedError, not AttributeError (#855).""" + store = self._persistent_store(_NonCountingBackendStore()) + with self.assertRaises(NotImplementedError): + self.manager.maintain_store(store) + + def test_maintain_store_persistent_not_initialized_raises(self): + store = VectorStore(backend="inmemory", dimension=2) + store.backend = "qdrant" + store._backend_store = None + with self.assertRaises(NotImplementedError): + self.manager.maintain_store(store) + + def test_maintain_store_zero_count_not_confused_with_unhealthy(self): + """An empty but reachable persistent store is healthy (count=0).""" + store = self._persistent_store(_CountingBackendStore(0)) + health = self.manager.maintain_store(store) + self.assertTrue(health["healthy"]) + self.assertEqual(health["vector_count"], 0) + self.assertIsNone(health["metadata_count"]) + + +# --------------------------------------------------------------------------- +# FAISSStore.count() — unit tests with mocked faiss +# --------------------------------------------------------------------------- + +class FAISSStoreCountTests(unittest.TestCase): + """FAISSStore.count() returns len(index.vector_ids).""" + + @patch("semantica.vector_store.faiss_store.faiss") + @patch("semantica.vector_store.faiss_store.FAISS_AVAILABLE", True) + def test_count_after_add(self, mock_faiss): + from semantica.vector_store.faiss_store import FAISSStore + + mock_index = MagicMock() + mock_faiss.IndexFlatL2.return_value = mock_index + + store = FAISSStore(dimension=2) + store.create_index() + + vecs = [np.array([1.0, 0.0]), np.array([0.0, 1.0])] + store.add_vectors(vecs) + self.assertEqual(store.count(), 2) + + @patch("semantica.vector_store.faiss_store.faiss") + @patch("semantica.vector_store.faiss_store.FAISS_AVAILABLE", True) + def test_count_empty_no_index(self, mock_faiss): + from semantica.vector_store.faiss_store import FAISSStore + + store = FAISSStore(dimension=2) + # No index created yet — count() must return 0, not raise. + self.assertEqual(store.count(), 0) + + @patch("semantica.vector_store.faiss_store.faiss") + @patch("semantica.vector_store.faiss_store.FAISS_AVAILABLE", True) + def test_count_via_vectorstore_faiss_backend(self, mock_faiss): + """VectorStore.count() delegates to FAISSStore.count().""" + from semantica.vector_store.faiss_store import FAISSStore + + mock_index = MagicMock() + mock_faiss.IndexFlatL2.return_value = mock_index + + faiss_store = FAISSStore(dimension=2) + faiss_store.create_index() + faiss_store.add_vectors([np.array([1.0, 0.0])]) + + vs = VectorStore(backend="inmemory", dimension=2) + vs.backend = "faiss" + vs._backend_store = faiss_store + + self.assertEqual(vs.count(), 1) + + +# --------------------------------------------------------------------------- +# SQLiteVecStore.count() — unit tests with a real in-memory SQLite DB +# --------------------------------------------------------------------------- + +try: + from semantica.vector_store.sqlite_vec_store import SQLITE_VEC_AVAILABLE +except ImportError: + SQLITE_VEC_AVAILABLE = False + + +@unittest.skipUnless(SQLITE_VEC_AVAILABLE, "sqlite-vec not installed") +class SQLiteVecStoreCountTests(unittest.TestCase): + """SQLiteVecStore.count() executes SELECT COUNT(*) against the db.""" + + def _make_store(self, dimension: int = 2): + """Return a SQLiteVecStore backed by an in-memory SQLite database.""" + from semantica.vector_store.sqlite_vec_store import SQLiteVecStore + + # Use ":memory:" for isolation; each test gets a fresh store. + store = SQLiteVecStore( + db_path=":memory:", + table_name="vecs", + dimension=dimension, + distance_metric="cosine", + ) + return store + + def test_count_empty_store(self): + store = self._make_store() + self.assertEqual(store.count(), 0) + + def test_count_after_add(self): + store = self._make_store() + vecs = [np.array([1.0, 0.0], dtype=np.float32), + np.array([0.0, 1.0], dtype=np.float32)] + meta = [{"k": "a"}, {"k": "b"}] + store.add(vecs, meta) + self.assertEqual(store.count(), 2) + + def test_count_after_delete(self): + store = self._make_store() + vecs = [np.array([1.0, 0.0], dtype=np.float32), + np.array([0.0, 1.0], dtype=np.float32)] + meta = [{"k": "a"}, {"k": "b"}] + ids = store.add(vecs, meta) + store.delete([ids[0]]) + self.assertEqual(store.count(), 1) + + def test_count_matches_get_stats(self): + store = self._make_store() + vecs = [np.array([1.0, 0.0], dtype=np.float32)] + store.add(vecs, [{"k": "x"}]) + stats = store.get_stats() + self.assertEqual(store.count(), stats["vector_count"]) + + def test_vectorstore_count_with_sqlite_backend(self): + """VectorStore.count() delegates to SQLiteVecStore.count().""" + store = self._make_store() + vecs = [np.array([1.0, 0.0], dtype=np.float32), + np.array([0.0, 1.0], dtype=np.float32)] + store.add(vecs, [{}, {}]) + + vs = VectorStore(backend="inmemory", dimension=2) + vs.backend = "sqlite" + vs._backend_store = store + self.assertEqual(vs.count(), 2) + + def test_maintain_store_sqlite_via_vectorstore(self): + """maintain_store() works end-to-end with a real SQLiteVecStore.""" + store = self._make_store() + vecs = [np.array([1.0, 0.0], dtype=np.float32)] + store.add(vecs, [{}]) + + vs = VectorStore(backend="inmemory", dimension=2) + vs.backend = "sqlite" + vs._backend_store = store + + manager = VectorManager() + health = manager.maintain_store(vs) + self.assertTrue(health["healthy"]) + self.assertEqual(health["vector_count"], 1) + self.assertIsNone(health["metadata_count"]) + + +# --------------------------------------------------------------------------- +# PgVectorStore.count() — unit tests with mocked psycopg connection +# --------------------------------------------------------------------------- + +class PgVectorStoreCountTests(unittest.TestCase): + """PgVectorStore.count() runs SELECT COUNT(*) via get_stats().""" + + def _make_mock_store(self, row_count: int): + """Return a PgVectorStore with its connection pool mocked out.""" + try: + from semantica.vector_store.pgvector_store import PgVectorStore + except ImportError: + self.skipTest("psycopg not installed") + + store = PgVectorStore.__new__(PgVectorStore) + store.logger = MagicMock() + store.table_name = "vectors" + store.dimension = 2 + store.distance_metric = "cosine" + store._pool = None + + # Build a mock connection context that returns row_count for COUNT(*) + mock_conn = MagicMock() + mock_cur = MagicMock() + mock_cur.fetchone.return_value = (row_count,) + mock_cur.fetchall.return_value = [] + mock_conn.cursor.return_value = mock_cur + mock_conn.__enter__ = MagicMock(return_value=mock_conn) + mock_conn.__exit__ = MagicMock(return_value=False) + store._get_connection = MagicMock(return_value=mock_conn) + + # Stub out psycopg_sql.SQL so the parameterised query builds without + # a real psycopg installation. + from semantica.vector_store import pgvector_store as pgmod + if not hasattr(pgmod, "psycopg_sql") or pgmod.psycopg_sql is None: + self.skipTest("psycopg_sql not available in pgvector_store module") + + return store + + def test_count_returns_db_value(self): + try: + store = self._make_mock_store(9) + except Exception: + self.skipTest("Could not construct mocked PgVectorStore") + self.assertEqual(store.count(), 9) + + def test_count_zero(self): + try: + store = self._make_mock_store(0) + except Exception: + self.skipTest("Could not construct mocked PgVectorStore") + self.assertEqual(store.count(), 0) + + def test_vectorstore_count_delegates_to_pgvector(self): + try: + store = self._make_mock_store(4) + except Exception: + self.skipTest("Could not construct mocked PgVectorStore") + + vs = VectorStore(backend="inmemory", dimension=2) + vs.backend = "pgvector" + vs._backend_store = store + self.assertEqual(vs.count(), 4) + + +if __name__ == "__main__": + unittest.main() From 611874e63ee65fb5fce24ba639a8603d8b31c4c6 Mon Sep 17 00:00:00 2001 From: Zohaib Hassnain <109234410+ZohaibHassan16@users.noreply.github.com> Date: Thu, 13 Aug 2026 22:37:35 +0500 Subject: [PATCH 029/105] security: apply SSRF guard to feed ingestion requests (#928) * security: apply SSRF guard to feed ingestion requests FeedIngestor and FeedMonitor fetched feed and website URLs with plain requests.get/head calls, bypassing the SSRF validation already used by web_ingestor.py and api_ingestor.py. This allowed feed URLs pointing at loopback, link-local, or other private network addresses to be fetched directly. Route all outbound requests in feed_ingestor.py through request_with_ssrf_guard, gated by the same allow_private_ips config option the other ingestors expose. * test: mock the correct request boundary in test_discover_feeds_empty The test still patched requests.get after discover_feeds() moved to request_with_ssrf_guard(), which calls requests.request and performs real DNS resolution. That left the test hitting live network/DNS. * docs(changelog): document FeedIngestor SSRF guard fix (#928, closes #927) Records the SSRF guard applied to all 5 feed-ingestion request sites, the Qodo-flagged test-mock fix, independent PoC verification, and the carried-over exception-swallowing behavior in discover_feeds(). --------- Co-authored-by: Sameer Kadam Co-authored-by: Mohd Kaif <98801504+KaifAhmad1@users.noreply.github.com> Co-authored-by: KaifAhmad1 --- CHANGELOG.md | 7 +++++ semantica/ingest/feed_ingestor.py | 42 ++++++++++++++++++++++++---- tests/ingest/test_feed_ingestor.py | 45 +++++++++++++++++++++--------- 3 files changed, 76 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 717cc1d9..023332ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -97,6 +97,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Security +- **`FeedIngestor`/`FeedMonitor` (RSS/Atom feed ingestion) had no SSRF protection, allowing requests to internal/private network targets** (#928, closes #927) by @ZohaibHassan16 + - `FeedIngestor.ingest_feed()`, `discover_feeds()` (link-tag fetch, common-path HEAD probe, and feed-validation GET), and `FeedMonitor.check_updates()` all called `requests.get()`/`requests.head()` directly with default redirect-following and no scheme allowlist or private/loopback/link-local IP validation — despite `semantica/ingest/ssrf.py`'s `request_with_ssrf_guard()` already existing and being used by `web_ingestor.py`/`api_ingestor.py`. `ingest_feed()`'s own URL check only verified `urlparse(url).scheme`/`.netloc` were non-empty, never that the scheme was http/https or that the resolved target IP was safe. Reachable via the public `ingest_feed()`/`ingest()` entry points with any caller-supplied feed URL + - All 5 call sites now route through `request_with_ssrf_guard()`, which validates scheme (http/https only) and resolved IP before the request, and re-validates every redirect `Location` before following it — closing both the direct-IP and redirect-chain SSRF paths. Added an `allow_private_ips` config option to both `FeedIngestor` and `FeedMonitor`, consistent with the other ingestors + - **Fixed during review** (Qodo): `test_discover_feeds_empty` mocked `requests.get`, which no longer executes now that the code path goes through `request_with_ssrf_guard()` (backed by `requests.request`) — the test was passing without exercising the real code. Corrected to mock `requests.request` and `socket.getaddrinfo` + - `pytest tests/ingest/test_feed_ingestor.py`: 12/12 passed. Independently reproduced the issue's own PoC (`FeedIngestor().ingest_feed("http://127.0.0.1:8765/feed.xml")` against a live local server) and confirmed it now raises `ValidationError` instead of succeeding + - **Known limitation carried over from `discover_feeds()`'s pre-existing design**: its common-path and feed-validation loops use a blanket `except Exception: continue`, which now also silently absorbs `ValidationError` from a blocked candidate URL the same way it already absorbed network failures — the request is still correctly blocked before reaching the network, so this is not an SSRF bypass, just a missed opportunity to log "blocked as SSRF target" distinctly from "unreachable" + - **`RepoIngestor` clone surface hardened against GitPython URL/option injection** (#905, closes #868) by @pravit-amp - `RepoIngestor.ingest_repository()` passed the caller-supplied repository URL and arbitrary `**options` straight through to `git.Repo.clone_from()` on a `GitPython>=3.1.50` floor predating hardening for `ext::`-style transport helpers and `$VAR`/`${VAR}` environment-variable expansion in clone URLs — unvalidated clone options (`upload_pack`, `multi_options`, `template`, `config`, `env`, ...) could be abused for command execution, and unvalidated hostnames allowed SSRF against internal services (e.g. cloud metadata endpoints) - `GitPython` floor raised to `>=3.1.58` diff --git a/semantica/ingest/feed_ingestor.py b/semantica/ingest/feed_ingestor.py index 0dc40559..7a1e50f7 100644 --- a/semantica/ingest/feed_ingestor.py +++ b/semantica/ingest/feed_ingestor.py @@ -42,6 +42,7 @@ from bs4 import BeautifulSoup from ..utils.exceptions import ProcessingError, ValidationError from ..utils.logging import get_logger from ..utils.progress_tracker import get_progress_tracker +from .ssrf import parse_bool, request_with_ssrf_guard @dataclass @@ -425,6 +426,9 @@ class FeedMonitor: self.thread: Optional[threading.Thread] = None self.update_callback: Optional[callable] = None self.check_interval = config.get("check_interval", 3600) # Default 1 hour + self.allow_private_ips = parse_bool( + config.get("allow_private_ips"), default=False + ) def add_feed(self, feed_url: str, **options): """ @@ -485,7 +489,12 @@ class FeedMonitor: try: # Fetch feed - response = requests.get(feed_url, timeout=30) + response = request_with_ssrf_guard( + "GET", + feed_url, + allow_private_ips=self.allow_private_ips, + timeout=30, + ) response.raise_for_status() # Parse feed @@ -575,6 +584,9 @@ class FeedIngestor: self.logger = get_logger("feed_ingestor") self.config = config or {} self.config.update(kwargs) + self.allow_private_ips = parse_bool( + self.config.get("allow_private_ips"), default=False + ) # Initialize feed parser self.parser = FeedParser(**self.config) @@ -638,7 +650,12 @@ class FeedIngestor: request_timeout = timeout or options.get( "timeout", self.config.get("timeout", 30) ) - response = requests.get(feed_url, timeout=request_timeout) + response = request_with_ssrf_guard( + "GET", + feed_url, + allow_private_ips=self.allow_private_ips, + timeout=request_timeout, + ) response.raise_for_status() self.logger.debug( f"Fetched feed from {feed_url}: {len(response.text)} bytes" @@ -693,7 +710,12 @@ class FeedIngestor: try: # Fetch website content - response = requests.get(website_url, timeout=30) + response = request_with_ssrf_guard( + "GET", + website_url, + allow_private_ips=self.allow_private_ips, + timeout=30, + ) response.raise_for_status() # Parse HTML @@ -723,7 +745,12 @@ class FeedIngestor: for path in common_paths: try: feed_url = urljoin(website_url, path) - test_response = requests.head(feed_url, timeout=10) + test_response = request_with_ssrf_guard( + "HEAD", + feed_url, + allow_private_ips=self.allow_private_ips, + timeout=10, + ) if test_response.status_code == 200: content_type = test_response.headers.get("Content-Type", "") if ( @@ -741,7 +768,12 @@ class FeedIngestor: for feed_url in feed_urls: try: # Quick validation by fetching feed - test_response = requests.get(feed_url, timeout=10) + test_response = request_with_ssrf_guard( + "GET", + feed_url, + allow_private_ips=self.allow_private_ips, + timeout=10, + ) if test_response.status_code == 200: validated_feeds.append(feed_url) except Exception: diff --git a/tests/ingest/test_feed_ingestor.py b/tests/ingest/test_feed_ingestor.py index 244cc358..957dedbf 100644 --- a/tests/ingest/test_feed_ingestor.py +++ b/tests/ingest/test_feed_ingestor.py @@ -85,11 +85,15 @@ def test_ingest_feed_errors() -> None: ingestor.ingest_feed("not_a_url") with patch( - "requests.get", - side_effect=requests.exceptions.RequestException("Fail"), + "semantica.ingest.ssrf.socket.getaddrinfo", + return_value=[(2, 1, 6, "", ("93.184.216.34", 0))], ): - with pytest.raises(ProcessingError): - ingestor.ingest_feed("http://valid.com") + with patch( + "requests.request", + side_effect=requests.exceptions.RequestException("Fail"), + ): + with pytest.raises(ProcessingError): + ingestor.ingest_feed("http://valid.com") def test_monitor_loop_lifecycle() -> None: @@ -204,8 +208,22 @@ def test_discover_feeds_empty() -> None: ingestor = FeedIngestor() html = "No feeds here" - with patch("requests.get", return_value=MagicMock(text=html)): - feeds = ingestor.discover_feeds("http://site.com") + mock_response = MagicMock() + mock_response.text = html + mock_response.status_code = 200 + mock_response.headers = {} + + def fake_request(method, url, **kwargs): + if url == "http://site.com": + return mock_response + raise requests.exceptions.RequestException("not found") + + with patch( + "semantica.ingest.ssrf.socket.getaddrinfo", + return_value=[(2, 1, 6, "", ("93.184.216.34", 0))], + ): + with patch("requests.request", side_effect=fake_request): + feeds = ingestor.discover_feeds("http://site.com") assert len(feeds) == 0 @@ -227,15 +245,16 @@ def test_discover_feeds_found() -> None: mock_response = MagicMock() mock_response.text = html mock_response.status_code = 200 + mock_response.headers = {"Content-Type": "application/rss+xml"} - with patch("requests.get", return_value=mock_response): - with patch("requests.head") as mock_head: - # Mock HEAD request headers for the verification step - mock_head.return_value.headers = { - "Content-Type": "application/rss+xml", - } - mock_head.return_value.status_code = 200 + def fake_request(method, url, **kwargs): + return mock_response + with patch( + "semantica.ingest.ssrf.socket.getaddrinfo", + return_value=[(2, 1, 6, "", ("93.184.216.34", 0))], + ): + with patch("requests.request", side_effect=fake_request): feeds = ingestor.discover_feeds("http://site.com") assert "http://site.com/rss.xml" in feeds From c5d13a45db48dc05a5ff1189ee77fb4136f54f6f Mon Sep 17 00:00:00 2001 From: Yunare Maia Date: Fri, 14 Aug 2026 02:07:52 -0300 Subject: [PATCH 030/105] feat(seed): allow_private_ips opt-in for trusted internal API sources (#959) * feat(seed): add allow_private_ips opt-in for trusted internal API sources (Closes #943) SeedDataManager.load_from_api now delegates to the shared SSRF guard (semantica/ingest/ssrf.py, added in #906) instead of raw requests.get, gaining redirect validation and bounded DNS resolution for free. New config option allow_private_ips (parsed via the shared parse_bool helper) lets trusted internal deployments load from private APIs while the secure default (block private/loopback/link-local) is unchanged. Tests updated to mock request_with_ssrf_guard; new tests cover the block-by-default behavior and the opt-in flag reaching the guard. 19/19 green in test_seed_manager.py, 25/25 across both seed suites. Signed-off-by: Yunare Maia * fix(ssrf): strip sensitive headers on cross-host redirects (Qodo finding) request_with_ssrf_guard reused the caller's headers on every redirect hop, so an Authorization bearer token from load_from_api could leak to a different redirect target host. Now strips Authorization and Proxy-Authorization when the redirect origin (netloc) changes, while keeping them for same-host hops (matching requests semantics). 2 new tests: cross-host redirect drops the credential; same-host keeps it. 37/37 green in test_ssrf_protection.py. load_from_api docstring now also documents cloud-metadata blocking and per-hop redirect validation. Signed-off-by: Yunare Maia * fix(ssrf): strip credentials on https->http downgrade redirects (review feedback) _should_strip_auth now mirrors requests' should_strip_auth semantics: strip on hostname change, port change, or scheme downgrade; keep the credential only for the safe http->https upgrade on default ports. Previously only netloc was compared, so an https->http redirect on the same host replayed the Authorization header in cleartext. --------- Signed-off-by: Yunare Maia --- semantica/ingest/ssrf.py | 50 +++++++++++ semantica/seed/seed_manager.py | 24 +++++- tests/ingest/test_ssrf_protection.py | 122 +++++++++++++++++++++++++++ tests/test_seed_manager.py | 32 ++++++- 4 files changed, 222 insertions(+), 6 deletions(-) diff --git a/semantica/ingest/ssrf.py b/semantica/ingest/ssrf.py index ade93a46..083fbcca 100644 --- a/semantica/ingest/ssrf.py +++ b/semantica/ingest/ssrf.py @@ -33,6 +33,46 @@ _DEFAULT_MAX_REDIRECTS = 10 _REDIRECT_STATUS_CODES = frozenset({301, 302, 303, 307, 308}) _STRIP_BODY_ON_REDIRECT = frozenset({301, 302, 303}) +# Standard port per scheme (mirrors requests' DEFAULT_PORTS). +_DEFAULT_PORTS = {"http": 80, "https": 443} + + +def _should_strip_auth(old_url: str, new_url: str) -> bool: + """Decide whether credentials must not follow a redirect. + + Mirrors ``requests.utils.should_strip_auth``: credentials are stripped + when the hostname changes, when the port changes (outside default + ports), or on an https -> http downgrade on the same host. The single + exception is an http -> https upgrade on default ports, which requests + treats as safe to keep the credential for. + """ + old_parsed = urlparse(old_url) + new_parsed = urlparse(new_url) + + if old_parsed.hostname != new_parsed.hostname: + return True + + # Special case: allow http -> https redirect on standard ports. + if ( + old_parsed.scheme == "http" + and old_parsed.port in (80, None) + and new_parsed.scheme == "https" + and new_parsed.port in (443, None) + ): + return False + + changed_port = old_parsed.port != new_parsed.port + changed_scheme = old_parsed.scheme != new_parsed.scheme + default_port = (_DEFAULT_PORTS.get(old_parsed.scheme), None) + if ( + not changed_scheme + and old_parsed.port in default_port + and new_parsed.port in default_port + ): + return False + + return changed_port or changed_scheme + _dns_executor: Optional[concurrent.futures.ThreadPoolExecutor] = None _dns_executor_lock = threading.Lock() @@ -276,6 +316,16 @@ def request_with_ssrf_guard( next_url = urljoin(current_url, str(location).strip()) validate_url_for_request(next_url, allow_private_ips=allow_private_ips) + # Do not leak sensitive headers to a different origin on redirects: + # reuse the caller's headers only while host, port, and scheme keep + # the credential safe, mirroring requests' should_strip_auth. + if _should_strip_auth(current_url, next_url): + kwargs = dict(kwargs) + headers = dict(kwargs.get("headers") or {}) + for sensitive in ("Authorization", "Proxy-Authorization"): + headers.pop(sensitive, None) + kwargs["headers"] = headers + # Match requests' historical method rewriting for 301/302/303. if ( response.status_code in _STRIP_BODY_ON_REDIRECT diff --git a/semantica/seed/seed_manager.py b/semantica/seed/seed_manager.py index 0d3bb046..5051697d 100644 --- a/semantica/seed/seed_manager.py +++ b/semantica/seed/seed_manager.py @@ -43,6 +43,7 @@ from ..utils.helpers import read_json_file, write_json_file from ..utils.logging import get_logger from ..utils.progress_tracker import get_progress_tracker from ..utils.types import EntityDict, RelationshipDict +from ..ingest.ssrf import parse_bool, request_with_ssrf_guard @dataclass @@ -453,6 +454,13 @@ class SeedDataManager: 'entities', 'data', 'results', 'items' keys). Automatically adds entity_type, relationship_type, and source metadata if provided. + SSRF protection is enabled by default: URLs resolving to private, + loopback, link-local (including cloud metadata endpoints such as + 169.254.169.254), or other blocked addresses are rejected, and every + redirect hop is re-validated before being followed. For trusted + internal deployments, pass ``allow_private_ips=True`` in the manager + config to opt in (documented for internal use only). + Args: api_url: Base API URL endpoint: Optional API endpoint path (appended to api_url) @@ -491,8 +499,20 @@ class SeedDataManager: if api_key: request_headers["Authorization"] = f"Bearer {api_key}" - # Make API request - response = requests.get(full_url, headers=request_headers, timeout=30) + # SSRF guard: reject private/loopback/link-local targets by default. + # Trusted internal deployments can opt in via config + # (allow_private_ips=True) — see issue #943. + allow_private = parse_bool(self.config.get("allow_private_ips", False)) + + # Make API request (request_with_ssrf_guard validates the URL and + # every redirect before each hop) + response = request_with_ssrf_guard( + "GET", + full_url, + headers=request_headers, + timeout=30, + allow_private_ips=allow_private, + ) response.raise_for_status() # Parse response diff --git a/tests/ingest/test_ssrf_protection.py b/tests/ingest/test_ssrf_protection.py index 2da84530..d7d3ab9b 100644 --- a/tests/ingest/test_ssrf_protection.py +++ b/tests/ingest/test_ssrf_protection.py @@ -182,6 +182,128 @@ class TestRequestWithSsrfGuardRedirects: session=session, ) + def test_strips_authorization_on_cross_host_redirect(self): + """Sensitive headers must not leak to a different redirect host.""" + redirect = MagicMock() + redirect.status_code = 302 + redirect.headers = {"Location": "https://other-host.example/final"} + redirect.close = MagicMock() + + final = MagicMock() + final.status_code = 200 + final.headers = {} + + session = MagicMock() + session.request.side_effect = [redirect, final] + + with patch( + "semantica.ingest.ssrf.socket.getaddrinfo", + return_value=[(None, None, None, None, ("93.184.216.34", 0))], + ): + request_with_ssrf_guard( + "GET", + "https://example.com/start", + session=session, + headers={"Authorization": "Bearer secret-token"}, + ) + + assert session.request.call_count == 2 + second_call_headers = session.request.call_args_list[1].kwargs.get("headers", {}) + assert "Authorization" not in second_call_headers + # The first hop still had the credential + first_call_headers = session.request.call_args_list[0].kwargs.get("headers", {}) + assert first_call_headers.get("Authorization") == "Bearer secret-token" + + def test_keeps_authorization_on_same_host_redirect(self): + """Same-host redirects keep the credential (requests semantics).""" + redirect = MagicMock() + redirect.status_code = 302 + redirect.headers = {"Location": "https://example.com/final"} + redirect.close = MagicMock() + + final = MagicMock() + final.status_code = 200 + final.headers = {} + + session = MagicMock() + session.request.side_effect = [redirect, final] + + with patch( + "semantica.ingest.ssrf.socket.getaddrinfo", + return_value=[(None, None, None, None, ("93.184.216.34", 0))], + ): + request_with_ssrf_guard( + "GET", + "https://example.com/start", + session=session, + headers={"Authorization": "Bearer secret-token"}, + ) + + assert session.request.call_count == 2 + second_call_headers = session.request.call_args_list[1].kwargs.get("headers", {}) + assert second_call_headers.get("Authorization") == "Bearer secret-token" + + def test_strips_authorization_on_scheme_downgrade(self): + """Credentials must not follow an https -> http downgrade on the same host.""" + redirect = MagicMock() + redirect.status_code = 302 + redirect.headers = {"Location": "http://example.com/final"} + redirect.close = MagicMock() + + final = MagicMock() + final.status_code = 200 + final.headers = {} + + session = MagicMock() + session.request.side_effect = [redirect, final] + + with patch( + "semantica.ingest.ssrf.socket.getaddrinfo", + return_value=[(None, None, None, None, ("93.184.216.34", 0))], + ): + request_with_ssrf_guard( + "GET", + "https://example.com/start", + session=session, + headers={"Authorization": "Bearer secret-token"}, + ) + + assert session.request.call_count == 2 + second_call_headers = session.request.call_args_list[1].kwargs.get("headers", {}) + assert "Authorization" not in second_call_headers + # The first hop still had the credential + first_call_headers = session.request.call_args_list[0].kwargs.get("headers", {}) + assert first_call_headers.get("Authorization") == "Bearer secret-token" + + def test_keeps_authorization_on_scheme_upgrade(self): + """Credentials survive an http -> https upgrade on default ports (requests semantics).""" + redirect = MagicMock() + redirect.status_code = 302 + redirect.headers = {"Location": "https://example.com/final"} + redirect.close = MagicMock() + + final = MagicMock() + final.status_code = 200 + final.headers = {} + + session = MagicMock() + session.request.side_effect = [redirect, final] + + with patch( + "semantica.ingest.ssrf.socket.getaddrinfo", + return_value=[(None, None, None, None, ("93.184.216.34", 0))], + ): + request_with_ssrf_guard( + "GET", + "http://example.com/start", + session=session, + headers={"Authorization": "Bearer secret-token"}, + ) + + assert session.request.call_count == 2 + second_call_headers = session.request.call_args_list[1].kwargs.get("headers", {}) + assert second_call_headers.get("Authorization") == "Bearer secret-token" + def test_follows_safe_redirect(self): redirect = MagicMock() redirect.status_code = 302 diff --git a/tests/test_seed_manager.py b/tests/test_seed_manager.py index 0f73b40d..31f966f1 100644 --- a/tests/test_seed_manager.py +++ b/tests/test_seed_manager.py @@ -147,11 +147,11 @@ def test_load_from_database_import_error(seed_manager): seed_manager.load_from_database("sqlite:///:memory:", query="SELECT 1") assert "Database ingestion module not available" in str(excinfo.value) -@patch("requests.get") -def test_load_from_api(mock_get, seed_manager): +@patch("semantica.seed.seed_manager.request_with_ssrf_guard") +def test_load_from_api(mock_guard, seed_manager): mock_response = MagicMock() mock_response.json.return_value = {"results": [{"id": 1, "name": "Alice"}]} - mock_get.return_value = mock_response + mock_guard.return_value = mock_response records = seed_manager.load_from_api( api_url="http://api.example.com", @@ -162,7 +162,31 @@ def test_load_from_api(mock_get, seed_manager): assert len(records) == 1 assert records[0]["id"] == 1 assert records[0]["entity_type"] == "User" - mock_get.assert_called_once() + mock_guard.assert_called_once() + +def test_load_from_api_blocks_private_by_default(seed_manager): + with pytest.raises(ProcessingError) as excinfo: + seed_manager.load_from_api(api_url="http://127.0.0.1:8000/secret") + assert "blocked" in str(excinfo.value).lower() or "not allowed" in str(excinfo.value).lower() + +@patch("semantica.seed.seed_manager.request_with_ssrf_guard") +def test_load_from_api_allows_private_when_configured(mock_guard, seed_manager): + mock_response = MagicMock() + mock_response.json.return_value = {"results": [{"id": 1, "name": "Alice"}]} + mock_guard.return_value = mock_response + + manager = SeedDataManager(config={"allow_private_ips": True}) + records = manager.load_from_api( + api_url="http://127.0.0.1:8000", + endpoint="users", + entity_type="User" + ) + + assert len(records) == 1 + mock_guard.assert_called_once() + # The opt-in flag must reach the guard + call_kwargs = mock_guard.call_args[1] + assert call_kwargs["allow_private_ips"] is True def test_load_source(seed_manager, temp_data_dir): json_file = temp_data_dir / "source.json" From 09c4b1b570778260d02b67d62f59d3b80e5a65d1 Mon Sep 17 00:00:00 2001 From: sushuaiyu <60034375+ssynb@users.noreply.github.com> Date: Fri, 14 Aug 2026 13:15:12 +0800 Subject: [PATCH 031/105] test(context): skip symlink test without Windows privilege (#908) * test(context): skip symlink test without Windows privilege * test(context): name Windows privilege error code --------- --- tests/context/test_agent_memory_markdown.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/tests/context/test_agent_memory_markdown.py b/tests/context/test_agent_memory_markdown.py index fb9158ed..5a7ac043 100644 --- a/tests/context/test_agent_memory_markdown.py +++ b/tests/context/test_agent_memory_markdown.py @@ -1,4 +1,5 @@ import errno +import sys from copy import deepcopy from datetime import datetime, timedelta, timezone from unittest.mock import MagicMock, patch @@ -8,6 +9,8 @@ import yaml from semantica.context.agent_memory import AgentMemory +_ERROR_PRIVILEGE_NOT_HELD = 1314 + class TrackingVectorStore: def __init__(self): @@ -643,7 +646,13 @@ def test_markdown_export_rejects_symlink_without_touching_target(tmp_path): outside = tmp_path / "outside.md" outside.write_text("do not overwrite", encoding="utf-8") output_path = destination / memory._memory_markdown_filename("mem_symlink") - output_path.symlink_to(outside) + try: + output_path.symlink_to(outside) + except OSError as error: + winerror = getattr(error, "winerror", None) + if sys.platform == "win32" and winerror == _ERROR_PRIVILEGE_NOT_HELD: + pytest.skip("Windows symlink creation requires an unavailable privilege") + raise with pytest.raises(ValueError, match="symbolic link"): memory.export(format="markdown", destination=destination) From c0a051903f5eb58b9fab6da0983fe3ffe909034f Mon Sep 17 00:00:00 2001 From: Ikko Eltociear Ashimine Date: Fri, 14 Aug 2026 15:21:16 +0900 Subject: [PATCH 032/105] docs: update CONTRIBUTING.md (#976) fix GiHub link. --- CONTRIBUTING.md | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0de8809f..b05b1c4b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,20 +2,20 @@ Thank you for your interest in contributing! Every contribution, no matter how small, is valuable. 🎉 -⭐ **Give us a Star** • 🍴 **[Fork Semantica](https://github.com/Hawksight-AI/semantica/fork)** • 💬 **Join our [Discord](https://discord.gg/sV34vps5hH)** +⭐ **Give us a Star** • 🍴 **[Fork Semantica](https://github.com/semantica-agi/semantica/fork)** • 💬 **Join our [Discord](https://discord.gg/sV34vps5hH)** -> **New to contributing?** Start with a [`good first issue`](https://github.com/Hawksight-AI/semantica/labels/good%20first%20issue) or join our [Discord](https://discord.gg/sV34vps5hH) community. +> **New to contributing?** Start with a [`good first issue`](https://github.com/semantica-agi/semantica/labels/good%20first%20issue) or join our [Discord](https://discord.gg/sV34vps5hH) community. --- ## 🚀 Quick Start -1. Find a [`good first issue`](https://github.com/Hawksight-AI/semantica/labels/good%20first%20issue) -2. [Fork Semantica](https://github.com/Hawksight-AI/semantica/fork) & clone the repository +1. Find a [`good first issue`](https://github.com/semantica-agi/semantica/labels/good%20first%20issue) +2. [Fork Semantica](https://github.com/semantica-agi/semantica/fork) & clone the repository 3. Make your changes 4. Submit a pull request! -**Need help?** Join [Discord](https://discord.gg/sV34vps5hH) or [GitHub Discussions](https://github.com/Hawksight-AI/semantica/discussions) +**Need help?** Join [Discord](https://discord.gg/sV34vps5hH) or [GitHub Discussions](https://github.com/semantica-agi/semantica/discussions) --- @@ -39,7 +39,7 @@ If you want to work on an open GitHub issue, please follow these steps to keep t > **Why this matters:** Commenting before opening a PR helps maintainers track who is working on what, assign issues correctly, and prevent two contributors from solving the same problem independently. It also gives you a chance to align on the expected approach before writing code. -Not sure where to start? Try a [`good first issue`](https://github.com/Hawksight-AI/semantica/labels/good%20first%20issue) or ask in [Discord](https://discord.gg/sV34vps5hH). +Not sure where to start? Try a [`good first issue`](https://github.com/semantica-agi/semantica/labels/good%20first%20issue) or ask in [Discord](https://discord.gg/sV34vps5hH). --- @@ -102,7 +102,7 @@ Not sure where to start? Try a [`good first issue`](https://github.com/Hawksight **What:** Report bugs you find -**How:** Use the [bug report template](https://github.com/Hawksight-AI/semantica/issues/new?template=bug_report.md) +**How:** Use the [bug report template](https://github.com/semantica-agi/semantica/issues/new?template=bug_report.md) **Include:** Description, steps to reproduce, expected vs actual behavior, environment details @@ -112,7 +112,7 @@ Not sure where to start? Try a [`good first issue`](https://github.com/Hawksight **What:** Suggest new features or improvements -**How:** Use the [feature request template](https://github.com/Hawksight-AI/semantica/issues/new?template=feature_request.md) +**How:** Use the [feature request template](https://github.com/semantica-agi/semantica/issues/new?template=feature_request.md) **Include:** Problem statement, proposed solution, use cases @@ -132,7 +132,7 @@ Not sure where to start? Try a [`good first issue`](https://github.com/Hawksight **What:** Help others in the community -**Where:** [Discord](https://discord.gg/sV34vps5hH), [GitHub Discussions](https://github.com/Hawksight-AI/semantica/discussions) +**Where:** [Discord](https://discord.gg/sV34vps5hH), [GitHub Discussions](https://github.com/semantica-agi/semantica/discussions) **Examples:** Answer questions, review PRs, share your projects @@ -159,12 +159,12 @@ Not sure where to start? Try a [`good first issue`](https://github.com/Hawksight ### 1. Fork & Clone -First, [fork Semantica](https://github.com/Hawksight-AI/semantica/fork) on GitHub, then: +First, [fork Semantica](https://github.com/semantica-agi/semantica/fork) on GitHub, then: ```bash git clone https://github.com/your-username/semantica.git cd semantica -git remote add upstream https://github.com/Hawksight-AI/semantica.git +git remote add upstream https://github.com/semantica-agi/semantica.git ``` ### 2. Set Up Environment @@ -351,8 +351,8 @@ result = instance.method() ## 🆘 Getting Help - 💬 [Discord](https://discord.gg/sV34vps5hH) - Real-time chat -- 💭 [GitHub Discussions](https://github.com/Hawksight-AI/semantica/discussions) - Q&A -- 🐛 [GitHub Issues](https://github.com/Hawksight-AI/semantica/issues) - Bug reports +- 💭 [GitHub Discussions](https://github.com/semantica-agi/semantica/discussions) - Q&A +- 🐛 [GitHub Issues](https://github.com/semantica-agi/semantica/issues) - Bug reports **Before asking:** Check existing documentation, search issues/discussions, review cookbook examples @@ -387,4 +387,4 @@ This project follows a [Code of Conduct](CODE_OF_CONDUCT.md). Be respectful and Every contribution matters - whether it's a single line of code, a typo fix, a helpful answer, or a bug report. We appreciate you! 🙏 -⭐ **Give us a Star** • 🍴 **[Fork Semantica](https://github.com/Hawksight-AI/semantica/fork)** • 💬 **Join our [Discord](https://discord.gg/sV34vps5hH)** +⭐ **Give us a Star** • 🍴 **[Fork Semantica](https://github.com/semantica-agi/semantica/fork)** • 💬 **Join our [Discord](https://discord.gg/sV34vps5hH)** From 94d0c3dc07109fb4e6df3027dbd571eeefc45d52 Mon Sep 17 00:00:00 2001 From: "Guofang.Tang" <136770748@qq.com> Date: Fri, 14 Aug 2026 18:37:56 +0800 Subject: [PATCH 033/105] fix(kg): remap relationship endpoints after entity resolution (#978) * fix(kg): remap relationship endpoints after entity resolution * fix(kg): harden relationship endpoint remapping --- semantica/kg/graph_builder.py | 81 ++++++++++++++++++++++ tests/kg/test_graph_builder_external.py | 90 +++++++++++++++++++++++++ 2 files changed, 171 insertions(+) diff --git a/semantica/kg/graph_builder.py b/semantica/kg/graph_builder.py index 314b923d..78039a60 100644 --- a/semantica/kg/graph_builder.py +++ b/semantica/kg/graph_builder.py @@ -262,6 +262,77 @@ class GraphBuilder: self._extractor_cache[key] = extractor_cls(method=method, **self.config) return self._extractor_cache[key] + def _remap_relationship_endpoints( + self, + entities: List[Dict[str, Any]], + relationships: List[Dict[str, Any]], + ) -> int: + """Rewrite relationship endpoints after entity resolution. + + Entity merging keeps the canonical entity ID and records the IDs of all + merged inputs in ``merged_from``. Relationships are collected before + resolution, so without this remapping they can continue to reference an + entity that is no longer present in the graph. + + Returns: + The number of relationship endpoints that were remapped. + """ + endpoint_map: Dict[Any, Any] = {} + + for entity in entities: + if not isinstance(entity, dict): + continue + + canonical_id = entity.get("id") + if canonical_id is None: + canonical_id = entity.get("entity_id") + if canonical_id is None: + continue + + # Keep canonical IDs stable and map every source ID retained by the + # merge operation to the surviving entity. + try: + endpoint_map[canonical_id] = canonical_id + except TypeError: + # Invalid/unhashable IDs are left for graph validation to report + # rather than making graph construction fail here. + continue + + merged_from = entity.get("merged_from") or [] + if isinstance(merged_from, (list, tuple, set)): + for source_id in merged_from: + if source_id is not None: + try: + endpoint_map[source_id] = canonical_id + except TypeError: + # Skip invalid aliases while preserving valid ones. + continue + + remapped_count = 0 + for relationship in relationships: + if not isinstance(relationship, dict): + continue + + for endpoint in ("source", "target"): + endpoint_id = relationship.get(endpoint) + try: + canonical_id = endpoint_map.get(endpoint_id) + except TypeError: + # Invalid/unhashable endpoints are left for graph validation + # to report rather than making graph construction fail here. + continue + + if canonical_id is not None and canonical_id != endpoint_id: + relationship[endpoint] = canonical_id + remapped_count += 1 + + if remapped_count: + self.logger.info( + "Remapped %d relationship endpoint(s) after entity resolution", + remapped_count, + ) + return remapped_count + def _extract_from_text(self, text: str, all_entities: List[Any], all_relationships: List[Any], **options): """Helper to extract knowledge from text using configured methods.""" if not options.get("extract", True): @@ -685,6 +756,16 @@ class GraphBuilder: f"Entity resolution complete: {len(all_entities)} -> {len(resolved_entities)} unique entities" ) + # Relationships were collected before entity resolution. Rewrite + # endpoints only when resolution produced merged entity IDs. + if resolver_to_use: + has_merged_entities = any( + isinstance(entity, dict) and entity.get("merged_from") + for entity in resolved_entities + ) + if has_merged_entities: + self._remap_relationship_endpoints(resolved_entities, all_relationships) + if input_relationships_count > 0 and len(all_relationships) == 0: warning_msg = ( f"All relationships were dropped during graph building: " diff --git a/tests/kg/test_graph_builder_external.py b/tests/kg/test_graph_builder_external.py index 72a918f6..a878c99b 100644 --- a/tests/kg/test_graph_builder_external.py +++ b/tests/kg/test_graph_builder_external.py @@ -123,6 +123,96 @@ class TestGraphBuilderExternal(unittest.TestCase): self.assertIn(("2", "3"), ids) self.assertIn(("3", "4"), ids) + def test_relationship_endpoints_are_remapped_after_entity_resolution(self): + builder = GraphBuilder(merge_entities=False, resolve_conflicts=False) + resolver = MagicMock() + resolver.resolve_entities.return_value = [ + { + "id": "alice:1", + "name": "Alice Chen", + "type": "Person", + "merged_from": ["alice:1", "alice:2"], + }, + {"id": "org:1", "name": "Zyx Qqqq", "type": "Organization"}, + ] + + graph = builder.build( + { + "entities": [ + {"id": "alice:1", "name": "Alice Chen", "type": "Person"}, + {"id": "alice:2", "name": "Alice Chen", "type": "Person"}, + {"id": "org:1", "name": "Zyx Qqqq", "type": "Organization"}, + ], + "relationships": [ + { + "source": "alice:2", + "target": "org:1", + "type": "WORKS_FOR", + } + ], + }, + entity_resolver=resolver, + ) + + self.assertEqual( + graph["relationships"], + [{"source": "alice:1", "target": "org:1", "type": "WORKS_FOR"}], + ) + entity_ids = {entity["id"] for entity in graph["entities"]} + for relationship in graph["relationships"]: + self.assertIn(relationship["source"], entity_ids) + self.assertIn(relationship["target"], entity_ids) + + def test_unhashable_entity_ids_do_not_crash_remapping(self): + builder = GraphBuilder(merge_entities=False, resolve_conflicts=False) + resolver = MagicMock() + resolver.resolve_entities.return_value = [ + { + "id": ["invalid-canonical-id"], + "name": "Invalid ID", + "type": "Person", + "merged_from": ["invalid-canonical-id"], + }, + { + "id": "alice:1", + "name": "Alice Chen", + "type": "Person", + "merged_from": [["invalid-source-id"]], + }, + ] + + graph = builder.build( + { + "entities": [{"id": "alice:1", "name": "Alice Chen", "type": "Person"}], + "relationships": [], + }, + entity_resolver=resolver, + ) + + self.assertEqual(len(graph["entities"]), 2) + + def test_relationship_remapping_skips_unmerged_entities(self): + builder = GraphBuilder(merge_entities=False, resolve_conflicts=False) + resolver = MagicMock() + resolver.resolve_entities.return_value = [ + {"id": "alice:1", "name": "Alice Chen", "type": "Person"}, + {"id": "org:1", "name": "Zyx Qqqq", "type": "Organization"}, + ] + + with patch.object(builder, "_remap_relationship_endpoints") as remap: + builder.build( + { + "entities": [ + {"id": "alice:1", "name": "Alice Chen", "type": "Person"}, + {"id": "org:1", "name": "Zyx Qqqq", "type": "Organization"}, + ], + "relationships": [], + }, + entity_resolver=resolver, + ) + + remap.assert_not_called() + def test_warning_when_all_relationships_dropped(self): builder = GraphBuilder(merge_entities=False, resolve_conflicts=False) From 1c0cebb1c3c009c22b43889d243d5e320c976901 Mon Sep 17 00:00:00 2001 From: LAKSHAN MURUGANANDAM Date: Fri, 14 Aug 2026 15:10:51 +0400 Subject: [PATCH 034/105] security(context): harden Markdown import against TOCTOU symlink races (#932) * security(context): harden Markdown import against TOCTOU symlink races Closes #856 * fix(context): harden markdown import security tests * docs(changelog): add entry for Markdown import TOCTOU symlink hardening Documents the (#932, closes #856) fix in the Unreleased/Fixed section. --------- Co-authored-by: Sameer Kadam Co-authored-by: Mohd Kaif <98801504+KaifAhmad1@users.noreply.github.com> Co-authored-by: KaifAhmad1 --- CHANGELOG.md | 8 ++ semantica/context/agent_memory.py | 47 ++++++++- tests/context/test_agent_memory_markdown.py | 106 ++++++++++++++++++++ 3 files changed, 160 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 023332ee..d4ee0138 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,6 +52,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **Markdown import hardened against TOCTOU symlink races during file reads** (#932, closes #856) by @lakshanmuruganandam, with fixes by @Sameer6305 + - `AgentMemory._read_markdown_path` read files via `Path.read_text()` after a `Path.is_symlink()` pre-check, leaving a time-of-check/time-of-use window: a path validated as a regular file could be swapped for a symlink before the actual read, causing the importer to follow the link and read an unintended target + - Reads now go through a new `_read_markdown_file_content()` helper: the path is opened via low-level `os.open()` with `os.O_NOFOLLOW` on platforms that support it (POSIX), so a symlink substituted after validation fails atomically with `ELOOP` instead of being followed; the resulting file descriptor is then verified with `os.fstat()`/`stat.S_ISREG()` to reject non-regular files (FIFOs, devices) even after a successful open + - Directory imports now also exclude symlinked entries from the file listing (`not file_path.is_symlink()`), consistent with the single-file path already rejecting them + - **Known limitation**: Windows has no `os.O_NOFOLLOW`, so on that platform the only defense is the earlier `is_symlink()` pre-check, leaving a narrow TOCTOU window; documented inline rather than implying a stronger cross-platform guarantee than the implementation provides + - New `tests/context/test_agent_memory_markdown.py` coverage: rejecting a symlinked path at both the private helper and the public `import_data()` API, silently excluding symlinked entries during directory import, and the `fstat()`/`S_ISREG` guard against non-regular files (mocked FIFO) + - `pytest tests/context/test_agent_memory_markdown.py`: 46 passed, 4 skipped (symlink-creation tests skip on Windows without `SeCreateSymbolicLinkPrivilege`) + - **`VectorManager.maintain_store()`/`collect_statistics()` crashed with `AttributeError` on persistent `VectorStore` backends** (#914, closes #855) by @yunaremaia, with fixes by @Sameer6305 - Both methods accessed `store.vectors`/`store.metadata` directly, which are only initialized for the `inmemory` backend — any persistent backend (FAISS, Qdrant, Pinecone, Milvus, SQLite, PgVector, Weaviate) crashed immediately. Same root cause as the #839/#843/#845/#848 cluster, but `VectorManager` operates on a `VectorStore` instance from the outside, so the fix needed a public accessor rather than another internal guard - Added a backend-agnostic `VectorStore.count()`: the `inmemory` backend counts its local dict; persistent backends delegate to a `count()` on the wrapped backend store when one exists, or raise `NotImplementedError` — following the `get_vector()`/`get_metadata()` precedent from #843, a missing/uninitialized backend store is never silently reported as an empty, healthy store diff --git a/semantica/context/agent_memory.py b/semantica/context/agent_memory.py index 2f2b8995..38dd122c 100644 --- a/semantica/context/agent_memory.py +++ b/semantica/context/agent_memory.py @@ -59,9 +59,11 @@ License: MIT """ import copy +import errno import hashlib import os import re +import stat import tempfile from collections import deque from dataclasses import dataclass, field @@ -1906,7 +1908,49 @@ class AgentMemory: return memories + def _read_markdown_file_content(self, file_path: Path) -> str: + if file_path.is_symlink(): + raise ValueError(f"Symlink Markdown import paths are rejected: {file_path}") + + flags = os.O_RDONLY + if hasattr(os, "O_NOFOLLOW"): + # On POSIX, O_NOFOLLOW makes os.open() fail with ELOOP if the + # final path component is a symlink, atomically closing the TOCTOU + # window between the is_symlink() check above and the open call. + # On Windows, O_NOFOLLOW is not available; the is_symlink() pre-check + # above is the only symlink defense and remains vulnerable to a narrow + # race. The fstat()/S_ISREG guard below still rejects special files + # (FIFOs, devices) on both platforms. + flags |= os.O_NOFOLLOW + + try: + fd = os.open(str(file_path), flags) + except OSError as exc: + if exc.errno == getattr(errno, "ELOOP", None): + raise ValueError( + f"Symlink Markdown import paths are rejected: {file_path}" + ) from exc + raise + + try: + stat_res = os.fstat(fd) + if not stat.S_ISREG(stat_res.st_mode): + raise ValueError( + f"Markdown import path is not a regular file: {file_path}" + ) + with open(fd, "r", encoding="utf-8", closefd=True) as f: + return f.read() + except Exception: + try: + os.close(fd) + except OSError: + pass + raise + def _read_markdown_path(self, path: Path) -> List[Tuple[str, str]]: + if path.is_symlink(): + raise ValueError(f"Symlink Markdown import paths are rejected: {path}") + if not path.exists(): raise FileNotFoundError(f"Markdown import path does not exist: {path}") @@ -1916,6 +1960,7 @@ class AgentMemory: file_path for file_path in path.iterdir() if file_path.is_file() + and not file_path.is_symlink() and file_path.suffix.lower() in self._MARKDOWN_EXTENSIONS ), key=lambda file_path: (file_path.name.casefold(), file_path.name), @@ -1926,7 +1971,7 @@ class AgentMemory: raise ValueError(f"Markdown import path is not a file or directory: {path}") return [ - (str(file_path), file_path.read_text(encoding="utf-8")) + (str(file_path), self._read_markdown_file_content(file_path)) for file_path in file_paths ] diff --git a/tests/context/test_agent_memory_markdown.py b/tests/context/test_agent_memory_markdown.py index 5a7ac043..bf1ca128 100644 --- a/tests/context/test_agent_memory_markdown.py +++ b/tests/context/test_agent_memory_markdown.py @@ -733,3 +733,109 @@ def test_markdown_export_destination_must_be_a_directory(tmp_path): with pytest.raises(ValueError, match="not a directory"): AgentMemory().export(format="markdown", destination=destination) + + +def test_markdown_import_file_open_security_rejects_symlink(tmp_path): + memory = AgentMemory() + target = tmp_path / "secret.txt" + target.write_text("secret content", encoding="utf-8") + symlink_file = tmp_path / "memory.md" + try: + symlink_file.symlink_to(target) + except OSError as error: + winerror = getattr(error, "winerror", None) + if sys.platform == "win32" and winerror == _ERROR_PRIVILEGE_NOT_HELD: + pytest.skip("Windows symlink creation requires an unavailable privilege") + raise + + with pytest.raises(ValueError, match="Symlink Markdown import paths are rejected"): + memory._read_markdown_file_content(symlink_file) + +def test_markdown_import_public_api_rejects_symlink(tmp_path): + """ + import_data(..., format="markdown") must propagate the symlink rejection + through the full call chain: import_data → _import_markdown_payload → + _read_markdown_path → _read_markdown_file_content. + + This complements test_markdown_import_file_open_security_rejects_symlink, + which only tests the private helper. A future refactor that bypasses + _read_markdown_file_content would silently stop being protected; this test + catches that. + """ + target = tmp_path / "secret.txt" + target.write_text("secret content", encoding="utf-8") + symlink_file = tmp_path / "memory.md" + try: + symlink_file.symlink_to(target) + except OSError as error: + winerror = getattr(error, "winerror", None) + if sys.platform == "win32" and winerror == _ERROR_PRIVILEGE_NOT_HELD: + pytest.skip("Windows symlink creation requires an unavailable privilege") + raise + + memory = AgentMemory() + with pytest.raises(ValueError, match="Symlink Markdown import paths are rejected"): + memory.import_data(symlink_file, format="markdown") + + +def test_markdown_import_directory_silently_skips_symlinked_entries(tmp_path): + """ + When importing a directory, symlink entries must be silently excluded. + Only real regular files must be read. + + This tests the filter in _read_markdown_path: + not file_path.is_symlink() + which was added by PR #932. + """ + # Write a real Markdown file in the directory + real_md = tmp_path / "real.md" + real_md.write_text( + markdown_document(required_frontmatter(memory_id="dir-real"), "Real content"), + encoding="utf-8", + ) + # Write the symlink target outside the directory + target = tmp_path.parent / "outside.txt" + target.write_text("must not be read", encoding="utf-8") + link_md = tmp_path / "evil.md" + try: + link_md.symlink_to(target) + except OSError as error: + winerror = getattr(error, "winerror", None) + if sys.platform == "win32" and winerror == _ERROR_PRIVILEGE_NOT_HELD: + pytest.skip("Windows symlink creation requires an unavailable privilege") + raise + + memory = AgentMemory() + # Must succeed, returning only the real file + results = memory._read_markdown_path(tmp_path) + assert len(results) == 1, ( + f"Expected 1 result (real.md only), got {len(results)}: " + f"{[r[0] for r in results]}" + ) + assert "Real content" in results[0][1] + + +def test_markdown_import_rejects_non_regular_file(tmp_path): + """ + _read_markdown_file_content must raise ValueError when the opened file + descriptor does not refer to a regular file (S_ISREG fails). + + This tests the fstat()/S_ISREG guard, which is the defense-in-depth layer + that catches special files (FIFOs, character devices) even when the + is_symlink() pre-check passes. The test works on both POSIX and Windows + because it mocks os.fstat rather than relying on platform-specific + filesystem objects. + """ + import stat as stat_module + + real_file = tmp_path / "not_really_regular.md" + real_file.write_text("some data", encoding="utf-8") + + # Build a mock stat result whose st_mode describes a FIFO (S_IFIFO). + fake_stat = MagicMock() + fake_stat.st_mode = stat_module.S_IFIFO | 0o600 # FIFO with rw permissions + + memory = AgentMemory() + with patch("semantica.context.agent_memory.os.fstat", return_value=fake_stat): + with pytest.raises(ValueError, match="not a regular file"): + memory._read_markdown_file_content(real_file) From 80b1cca07b0ebeb3757af58f37ef90efb88fb21e Mon Sep 17 00:00:00 2001 From: Shubham Srivastava Date: Fri, 14 Aug 2026 12:26:04 +0100 Subject: [PATCH 035/105] test(semantic_extract): guard openai-dependent tests and assert on the logger, not stdout (#935) * test(semantic_extract): skip openai-dependent tests when the SDK is absent, assert logs not stdout * test(semantic_extract): pass logger name to assertLogs to match suite convention All 11 existing assertLogs call sites in the suite pass a logger name string rather than a Logger instance; tests/reasoning/test_reasoner.py uses this exact .logger.name form. Behaviour is unchanged. --------- Co-authored-by: Mohd Kaif <98801504+KaifAhmad1@users.noreply.github.com> --- .../test_pr482_deepseek_openai.py | 64 ++++++++++--------- 1 file changed, 33 insertions(+), 31 deletions(-) diff --git a/tests/semantic_extract/test_pr482_deepseek_openai.py b/tests/semantic_extract/test_pr482_deepseek_openai.py index a0b12e74..30846196 100644 --- a/tests/semantic_extract/test_pr482_deepseek_openai.py +++ b/tests/semantic_extract/test_pr482_deepseek_openai.py @@ -8,6 +8,16 @@ from pydantic import BaseModel sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../"))) +# The openai SDK is an optional extra (`pip install semantica[llm-openai]`), not a +# dev dependency. Only the tests that spec a mock against the real OpenAI class +# need it — the rest of this module must still run without it. +try: + from openai import OpenAI +except ImportError: # pragma: no cover - depends on the installed extras + OpenAI = None + +requires_openai = unittest.skipIf(OpenAI is None, "openai SDK not installed") + class TestDeepSeekProviderInit(unittest.TestCase): """Tests for DeepSeekProvider.__init__ and _init_client after PR #482.""" @@ -171,20 +181,9 @@ class TestDeepSeekProviderGenerate(unittest.TestCase): class TestDeepSeekInstructorPath(unittest.TestCase): """Tests for generate_typed instructor path with DeepSeekProvider (OpenAI client).""" - def _make_provider(self, api_key="sk-test"): - from semantica.semantic_extract.providers import DeepSeekProvider - from unittest.mock import MagicMock - from openai import OpenAI - with patch.object(DeepSeekProvider, "_init_client", return_value=None): - provider = DeepSeekProvider(api_key=api_key) - # After PR #482, client is an OpenAI instance - mock_client = MagicMock(spec=OpenAI) - provider.client = mock_client - return provider - + @requires_openai def test_generate_typed_instructor_openai_isinstance_check(self): """After PR #482, client is OpenAI, so instructor path must use from_openai.""" - from openai import OpenAI from semantica.semantic_extract.providers import DeepSeekProvider with patch.object(DeepSeekProvider, "_init_client", return_value=None): provider = DeepSeekProvider(api_key="sk-test") @@ -228,8 +227,8 @@ class TestVerboseModeAssignment(unittest.TestCase): except Exception: pass # other errors are OK — we only care NameError is gone - def test_generate_typed_verbose_true_prints(self): - """When verbose=True, generate_typed must print the confirmation line.""" + def test_generate_typed_verbose_true_logs(self): + """When verbose=True, generate_typed must log the confirmation line.""" provider = self._make_openai_provider() class Schema(BaseModel): @@ -243,21 +242,21 @@ class TestVerboseModeAssignment(unittest.TestCase): mock_instructor.from_provider.side_effect = Exception("skip") mock_instructor.Mode.TOOLS = "tools" - import io - captured = io.StringIO() with patch("semantica.semantic_extract.providers.instructor", mock_instructor): - with patch("sys.stdout", captured): + with self.assertLogs(provider.logger.name, level="DEBUG") as captured: try: provider.generate_typed("prompt", Schema, verbose=True) except Exception: pass - output = captured.getvalue() - # verbose_mode=True should trigger the print statement - self.assertIn("generate_typed", output) + # verbose_mode=True should trigger the debug log line + self.assertTrue( + any("generate_typed" in line for line in captured.output), + f"expected a generate_typed debug record, got {captured.output}", + ) - def test_generate_typed_verbose_false_no_print(self): - """When verbose=False (default), generate_typed must not print anything.""" + def test_generate_typed_verbose_false_no_log(self): + """When verbose=False (default), generate_typed must not log the line.""" provider = self._make_openai_provider() class Schema(BaseModel): @@ -271,16 +270,18 @@ class TestVerboseModeAssignment(unittest.TestCase): mock_instructor.from_provider.side_effect = Exception("skip") mock_instructor.Mode.TOOLS = "tools" - import io - captured = io.StringIO() with patch("semantica.semantic_extract.providers.instructor", mock_instructor): - with patch("sys.stdout", captured): + with patch.object(provider, "logger") as mock_logger: try: provider.generate_typed("prompt", Schema) except Exception: pass - self.assertEqual(captured.getvalue(), "") + debug_calls = [str(c) for c in mock_logger.debug.call_args_list] + self.assertFalse( + any("generate_typed" in c for c in debug_calls), + f"expected no generate_typed debug record, got {debug_calls}", + ) def test_generate_typed_verbose_from_config(self): """verbose_mode must also respect config-level verbose setting.""" @@ -298,25 +299,26 @@ class TestVerboseModeAssignment(unittest.TestCase): mock_instructor.from_provider.side_effect = Exception("skip") mock_instructor.Mode.TOOLS = "tools" - import io - captured = io.StringIO() with patch("semantica.semantic_extract.providers.instructor", mock_instructor): - with patch("sys.stdout", captured): + with self.assertLogs(provider.logger.name, level="DEBUG") as captured: try: provider.generate_typed("prompt", Schema) except Exception: pass - self.assertIn("generate_typed", captured.getvalue()) + self.assertTrue( + any("generate_typed" in line for line in captured.output), + f"expected a generate_typed debug record, got {captured.output}", + ) class TestDeepSeekGenerateTypedInstructorIntegration(unittest.TestCase): """Integration-style tests: DeepSeekProvider.generate_typed with instructor.""" + @requires_openai def test_generate_typed_deepseek_uses_openai_client_for_instructor(self): """generate_typed instructor path for DeepSeek must reuse the OpenAI client.""" from semantica.semantic_extract.providers import DeepSeekProvider - from openai import OpenAI with patch.object(DeepSeekProvider, "_init_client", return_value=None): provider = DeepSeekProvider(api_key="sk-test") From 75f88b1c4072befd9c75c78019492c1f1ab3cea1 Mon Sep 17 00:00:00 2001 From: Zohaib Hassnain <109234410+ZohaibHassan16@users.noreply.github.com> Date: Fri, 14 Aug 2026 17:06:42 +0500 Subject: [PATCH 036/105] Fix/explorer backend failure states (#980) * fix(explorer): show a retryable error when the graph fails to load The dependency-pre-bundle overlay had no failure path: on a fetch error it kept rendering the last progress frame forever with no retry. Route isError/error out of the load query, surface a real error card with the underlying message, and let retry re-fetch without a full page reload. * fix(explorer): reflect real backend connectivity on the landing page The status dot and 'System Online' text were static, so a dead backend still looked healthy. Track checking/online/offline explicitly and drive both off the same state so they can't disagree. * feat(explorer): let search results be dismissed, round relevance scores The results strip had no close affordance and stayed pinned until the next search. Add a header row with a dismiss button, and round scores to whole numbers instead of showing three decimals of a raw relevance value nobody can act on. * feat(explorer): add typeahead suggestions to graph search Typing in the search box now debounces a query against the existing search endpoint and shows a combobox dropdown, with arrow-key navigation, Enter/click to jump straight to a node, and Escape to dismiss. Previously nothing happened until the full form was submitted. * fix(explorer): abort stale typeahead requests and clear suggestions on error Clearing the search box while a suggestion fetch was in flight never aborted it, so a late response could reopen the dropdown with results for a query that was no longer typed. A non-OK response also left whatever suggestions were already on screen untouched instead of clearing them. Abort on every effect cleanup (not just unmount) and clear suggestions on any non-abort failure. * docs(changelog): add entry for Explorer backend failure states fix Documents the (#980, closes #977) fix in the Unreleased/Fixed section. --------- Co-authored-by: Sameer Kadam Co-authored-by: KaifAhmad1 --- CHANGELOG.md | 9 + explorer/src/App.tsx | 50 +++- .../GraphWorkspace/GraphLoadingOverlay.tsx | 95 +++++++ .../GraphWorkspace/GraphWorkspace.tsx | 256 ++++++++++++++++-- 4 files changed, 379 insertions(+), 31 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d4ee0138..11f89028 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,6 +52,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **Explorer UI hid backend failures: graph load hung forever, landing page always showed "System Online"** (#980, closes #977) by @ZohaibHassan16, reviewed by @Sameer6305 + - `GraphWorkspace.tsx` only destructured `{ data, isLoading, isFetching }` from `useLoadGraph()`, ignoring the `isError`/`error`/`refetch` that `useQuery` (`retry: 0`) already returned. Combined with `GraphLoadingOverlay` having no error prop and `showLoadingOverlay` staying true whenever `loadingProgress` held a stale frame, a backend-down or failed fetch left the graph workspace stuck on the last progress frame indefinitely, with no error message and no way to recover short of a full page reload + - `GraphLoadingOverlay` now accepts `error`/`onRetry` and renders an error card with the real fetch error message and a Retry button (`refetch()`) instead of the stuck progress UI + - The landing page's `WelcomeScreen` replaced its hardcoded `ready: boolean` (and hardcoded "System Online" text) with a real `checking` / `online` / `offline` status derived from the same connectivity probe already driving the 4th metric card, so the status dot, text, and metric can no longer drift apart or lie about connectivity + - **Smaller fixes bundled in the same PR**: search results are now dismissible (previously stayed open indefinitely, pushing the graph down); relevance scores display as rounded whole numbers instead of `96.900`/`138.000`; added a debounced (250ms) typeahead combobox to graph search with arrow-key navigation, `aria-activedescendant`, and Escape-to-close, using the existing `/api/graph/search` endpoint + - **Fixed during review** (Qodo): the typeahead's debounced fetch had no `AbortController`, so a fast-typing user could have a stale suggestion response resolve after a newer one, replacing correct suggestions with outdated ones. In-flight requests are now aborted on every re-debounce and when the query is cleared after a selection + - **Noted during review** (@Sameer6305): `GraphWorkspaceShell.tsx` contains a third, unused implementation of the same graph-loading/error-handling logic this PR fixes — the issue itself named "two copies that drifted apart" as the root cause the original bug slipped through. Deliberately left out of this PR's scope and tracked separately in #981 rather than blocking this fix + - `npx tsc -b`: clean; `test:graph-store`/`test:graph-workspace`/`test:plugin-registry`: 42 passed; `npm run build`: succeeds + - **Markdown import hardened against TOCTOU symlink races during file reads** (#932, closes #856) by @lakshanmuruganandam, with fixes by @Sameer6305 - `AgentMemory._read_markdown_path` read files via `Path.read_text()` after a `Path.is_symlink()` pre-check, leaving a time-of-check/time-of-use window: a path validated as a regular file could be swapped for a symlink before the actual read, causing the importer to follow the link and read an unintended target - Reads now go through a new `_read_markdown_file_content()` helper: the path is opened via low-level `os.open()` with `os.O_NOFOLLOW` on platforms that support it (POSIX), so a symlink substituted after validation fails atomically with `ELOOP` instead of being followed; the resulting file descriptor is then verified with `os.fstat()`/`stat.S_ISREG()` to reject non-regular files (FIFOs, devices) even after a successful open diff --git a/explorer/src/App.tsx b/explorer/src/App.tsx index f3f97520..8f94d477 100644 --- a/explorer/src/App.tsx +++ b/explorer/src/App.tsx @@ -67,6 +67,14 @@ type GraphStatsPayload = { edges?: number; }; +type ConnectionStatus = 'checking' | 'online' | 'offline'; + +const CONNECTION_STATUS_LABEL: Record = { + checking: 'Connecting…', + online: 'System Online', + offline: 'Backend Unreachable', +}; + const queryClient = new QueryClient(); const PREVIEW_DOTS = Array.from({ length: 42 }, (_, i) => ({ @@ -719,19 +727,34 @@ const shellStyles = ` align-items: center; gap: 10px; margin-bottom: 24px; + --status-color: #4cc38a; + --status-shadow-a: 0 0 0 3px rgba(76, 195, 138, 0.22), 0 0 12px rgba(76, 195, 138, 0.5); + --status-shadow-b: 0 0 0 5px rgba(76, 195, 138, 0.1), 0 0 20px rgba(76, 195, 138, 0.35); + } + + .landing-status-bar[data-status='checking'] { + --status-color: #f2b66d; + --status-shadow-a: 0 0 0 3px rgba(242, 182, 109, 0.22), 0 0 12px rgba(242, 182, 109, 0.5); + --status-shadow-b: 0 0 0 5px rgba(242, 182, 109, 0.1), 0 0 20px rgba(242, 182, 109, 0.35); + } + + .landing-status-bar[data-status='offline'] { + --status-color: #ff7b72; + --status-shadow-a: 0 0 0 3px rgba(255, 123, 114, 0.22), 0 0 12px rgba(255, 123, 114, 0.5); + --status-shadow-b: 0 0 0 5px rgba(255, 123, 114, 0.1), 0 0 20px rgba(255, 123, 114, 0.35); } .landing-status-dot { width: 8px; height: 8px; border-radius: 999px; - background: #4cc38a; - box-shadow: 0 0 0 3px rgba(76, 195, 138, 0.22), 0 0 12px rgba(76, 195, 138, 0.5); + background: var(--status-color); + box-shadow: var(--status-shadow-a); animation: landing-pulse 2.4s ease-in-out infinite; } .landing-status-text { - color: #4cc38a; + color: var(--status-color); font: 700 11px/1 "JetBrains Mono", monospace; letter-spacing: 0.1em; text-transform: uppercase; @@ -1323,8 +1346,8 @@ const shellStyles = ` } @keyframes landing-pulse { - 0%, 100% { box-shadow: 0 0 0 3px rgba(76, 195, 138, 0.22), 0 0 12px rgba(76, 195, 138, 0.5); } - 50% { box-shadow: 0 0 0 5px rgba(76, 195, 138, 0.1), 0 0 20px rgba(76, 195, 138, 0.35); } + 0%, 100% { box-shadow: var(--status-shadow-a); } + 50% { box-shadow: var(--status-shadow-b); } } .workspace-loading { @@ -1494,10 +1517,10 @@ function WelcomeScreen({ onOpenDecisions: () => void; onOpenManage: () => void; }) { - const [stats, setStats] = useState<{ nodes: number | null; edges: number | null; ready: boolean }>({ + const [stats, setStats] = useState<{ nodes: number | null; edges: number | null; status: ConnectionStatus }>({ nodes: null, edges: null, - ready: false, + status: 'checking', }); useEffect(() => { @@ -1507,31 +1530,32 @@ function WelcomeScreen({ .then((response) => (response.ok ? response.json() as Promise : null)) .then((payload) => { if (!payload) { - setStats((current) => ({ ...current, ready: false })); + setStats((current) => ({ ...current, status: 'offline' })); return; } setStats({ nodes: getNumberStat(payload, ['node_count', 'nodeCount', 'nodes']), edges: getNumberStat(payload, ['edge_count', 'edgeCount', 'edges']), - ready: true, + status: 'online', }); }) .catch((error: unknown) => { if (error instanceof DOMException && error.name === 'AbortError') { return; } - setStats((current) => ({ ...current, ready: false })); + setStats((current) => ({ ...current, status: 'offline' })); }); return () => controller.abort(); }, []); + const isOnline = stats.status === 'online'; const metrics: LandingMetric[] = [ { label: 'Knowledge nodes', value: formatMetric(stats.nodes, 'Live'), tone: 'cyan' }, { label: 'Relationships mapped', value: formatMetric(stats.edges, 'Ready'), tone: 'mint' }, { label: 'Graph modes', value: '3', tone: 'amber' }, - { label: stats.ready ? 'Dataset online' : 'Ready to explore', value: stats.ready ? 'Active' : 'Standby', tone: 'rose' }, + { label: isOnline ? 'Dataset online' : 'Ready to explore', value: isOnline ? 'Active' : 'Standby', tone: 'rose' }, ]; const secondaryLaunchers: LandingAction[] = [ @@ -1574,9 +1598,9 @@ function WelcomeScreen({ {/* ── Hero ── */}
-
+
- System Online + {CONNECTION_STATUS_LABEL[stats.status]}
Semantica v2 · Semantic Intelligence
diff --git a/explorer/src/workspaces/GraphWorkspace/GraphLoadingOverlay.tsx b/explorer/src/workspaces/GraphWorkspace/GraphLoadingOverlay.tsx index 77a94130..a1ca4ea8 100644 --- a/explorer/src/workspaces/GraphWorkspace/GraphLoadingOverlay.tsx +++ b/explorer/src/workspaces/GraphWorkspace/GraphLoadingOverlay.tsx @@ -1,4 +1,5 @@ import { useEffect, useRef, useState, type CSSProperties } from "react"; +import { AlertTriangle, RefreshCw } from "lucide-react"; import { GRAPH_THEME, withAlpha } from "./graphTheme"; import { GRAPH_LOAD_STAGE_SEQUENCE, createGraphLoadProgress, getGraphLoadStageLabel } from "./graphLoading"; @@ -121,6 +122,58 @@ const LOADING_OVERLAY_CSS = ` 0% { transform: translateX(-120%); } 100% { transform: translateX(360%); } } + .graph-stage-loader-card[data-error="true"] { + pointer-events: auto; + border-color: rgba(255, 123, 114, 0.32); + background: + radial-gradient(circle at top left, rgba(255, 123, 114, 0.12), transparent 32%), + linear-gradient(145deg, rgba(7, 17, 31, 0.96), rgba(24, 14, 18, 0.86)); + } + .graph-stage-loader-error-mark { + width: 38px; + height: 38px; + flex: 0 0 auto; + border-radius: 12px; + display: grid; + place-items: center; + color: #ff9e97; + background: rgba(255, 123, 114, 0.12); + border: 1px solid rgba(255, 123, 114, 0.28); + } + .graph-stage-loader-error-detail { + padding: 10px 12px; + border-radius: 10px; + background: rgba(0, 0, 0, 0.32); + border: 1px solid rgba(255, 123, 114, 0.18); + color: #ffb4ae; + font-family: "JetBrains Mono", "Fira Code", Consolas, monospace; + font-size: 12px; + line-height: 1.55; + word-break: break-word; + } + .graph-stage-loader-retry { + display: inline-flex; + align-items: center; + gap: 7px; + padding: 9px 16px; + border-radius: 8px; + font-size: 13px; + font-weight: 700; + cursor: pointer; + border: 1px solid rgba(127, 208, 255, 0.4); + background: linear-gradient(135deg, rgba(74, 163, 255, 0.28), rgba(56, 210, 160, 0.16)); + color: #e8f6ff; + transition: 160ms ease; + } + .graph-stage-loader-retry:hover { + border-color: rgba(127, 208, 255, 0.62); + background: linear-gradient(135deg, rgba(74, 163, 255, 0.4), rgba(56, 210, 160, 0.24)); + transform: translateY(-1px); + } + .graph-stage-loader-retry:focus-visible { + outline: 2px solid #7fd0ff; + outline-offset: 2px; + } `; function formatLayoutSource(source: GraphLoadProgress["layoutSource"]) { @@ -170,10 +223,14 @@ export function GraphLoadingOverlay({ progress, visible, showGraphBehind, + error = null, + onRetry, }: { progress: GraphLoadProgress | null; visible: boolean; showGraphBehind: boolean; + error?: string | null; + onRetry?: () => void; }) { const [renderVisible, setRenderVisible] = useState(visible); const [exiting, setExiting] = useState(false); @@ -226,6 +283,44 @@ export function GraphLoadingOverlay({ return null; } + if (error) { + return ( +
+ +
+
+ +
+
+ Could not load the graph +
+
+ The Explorer API did not return graph data. Check that the backend is running and reachable, then try again. +
+
+
+ +
{error}
+ + {onRetry ? ( +
+ +
+ ) : null} +
+
+ ); + } + const activeProgress = progress ?? displayProgress; const isLiveStage = activeProgress.phase === "stabilizing_layout" || activeProgress.showGraphBehind || showGraphBehind; const overlayBackground = isLiveStage diff --git a/explorer/src/workspaces/GraphWorkspace/GraphWorkspace.tsx b/explorer/src/workspaces/GraphWorkspace/GraphWorkspace.tsx index df2cd6ec..d38d21ad 100644 --- a/explorer/src/workspaces/GraphWorkspace/GraphWorkspace.tsx +++ b/explorer/src/workspaces/GraphWorkspace/GraphWorkspace.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef, useState, type ComponentType, type ReactNode } from "react"; +import { useCallback, useEffect, useId, useMemo, useRef, useState, type ComponentType, type ReactNode } from "react"; import { Activity, Clock3, @@ -12,6 +12,7 @@ import { RefreshCw, Search, Users, + X, ZoomIn, ZoomOut, } from "lucide-react"; @@ -282,37 +283,168 @@ function SegmentedModeControl({ items }: { items: GraphToolbarItem[] }) { ); } +const SUGGESTION_DEBOUNCE_MS = 250; +const SUGGESTION_LIMIT = 6; + function SearchCommandBar({ value, disabled, onChange, onSubmit, + onSelectSuggestion, }: { value: string; disabled: boolean; onChange: (value: string) => void; onSubmit: () => void; + onSelectSuggestion: (result: SearchResult) => void; }) { + const [suggestions, setSuggestions] = useState([]); + const [suggestionsOpen, setSuggestionsOpen] = useState(false); + const [highlightedIndex, setHighlightedIndex] = useState(-1); + const abortRef = useRef(null); + const debounceRef = useRef(null); + const listboxId = useId(); + + useEffect(() => { + if (debounceRef.current !== null) { + window.clearTimeout(debounceRef.current); + } + + const query = value.trim(); + if (disabled || !query) { + abortRef.current?.abort(); + setSuggestions([]); + setSuggestionsOpen(false); + setHighlightedIndex(-1); + return; + } + + debounceRef.current = window.setTimeout(() => { + abortRef.current?.abort(); + const controller = new AbortController(); + abortRef.current = controller; + + fetch("/api/graph/search", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ query, limit: SUGGESTION_LIMIT }), + signal: controller.signal, + }) + .then((response) => { + if (!response.ok) { + throw new Error(`Search failed with status ${response.status}`); + } + return response.json(); + }) + .then((data: { results?: SearchResult[] }) => { + setSuggestions(data.results ?? []); + setSuggestionsOpen(true); + setHighlightedIndex(-1); + }) + .catch((suggestionError: unknown) => { + if (suggestionError instanceof DOMException && suggestionError.name === "AbortError") { + return; + } + setSuggestions([]); + setSuggestionsOpen(false); + setHighlightedIndex(-1); + }); + }, SUGGESTION_DEBOUNCE_MS); + + return () => { + if (debounceRef.current !== null) { + window.clearTimeout(debounceRef.current); + } + abortRef.current?.abort(); + }; + }, [value, disabled]); + + const closeSuggestions = () => { + setSuggestionsOpen(false); + setHighlightedIndex(-1); + }; + + const selectSuggestion = (result: SearchResult) => { + setSuggestions([]); + closeSuggestions(); + onSelectSuggestion(result); + }; + return (
0} + aria-haspopup="listbox" + aria-owns={listboxId} onSubmit={(event) => { event.preventDefault(); - if (!disabled) { - onSubmit(); + if (disabled) return; + if (suggestionsOpen && highlightedIndex >= 0 && suggestions[highlightedIndex]) { + selectSuggestion(suggestions[highlightedIndex]); + return; } + closeSuggestions(); + onSubmit(); }} > onChange(event.target.value)} + onFocus={() => { + if (suggestions.length > 0) { + setSuggestionsOpen(true); + } + }} + onBlur={() => { + window.setTimeout(closeSuggestions, 120); + }} + onKeyDown={(event) => { + if (!suggestionsOpen || suggestions.length === 0) return; + if (event.key === "ArrowDown") { + event.preventDefault(); + setHighlightedIndex((current) => (current + 1) % suggestions.length); + } else if (event.key === "ArrowUp") { + event.preventDefault(); + setHighlightedIndex((current) => (current <= 0 ? suggestions.length - 1 : current - 1)); + } else if (event.key === "Escape") { + event.preventDefault(); + closeSuggestions(); + } + }} placeholder="Search command, node, or concept" aria-label="Search graph nodes" + aria-autocomplete="list" + aria-controls={listboxId} + aria-activedescendant={highlightedIndex >= 0 ? `${listboxId}-${highlightedIndex}` : undefined} /> + + {suggestionsOpen && suggestions.length > 0 ? ( +
    + {suggestions.map((result, index) => ( +
  • { + event.preventDefault(); + selectSuggestion(result); + }} + onMouseEnter={() => setHighlightedIndex(index)} + > + {result.node.content || result.node.id} + {result.node.type} +
  • + ))} +
+ ) : null} ); } @@ -577,6 +709,7 @@ const HUD_CSS = ` gap: 10px; } .explore-search-command { + position: relative; min-width: 0; height: 43px; display: grid; @@ -592,6 +725,50 @@ const HUD_CSS = ` color: ${GRAPH_THEME.ui.text.muted}; box-shadow: inset 0 1px 0 rgba(255,255,255,0.045), 0 14px 30px rgba(0,0,0,0.16); } + .explore-search-suggestions { + position: absolute; + top: calc(100% + 6px); + left: 0; + right: 0; + z-index: 30; + margin: 0; + padding: 6px; + list-style: none; + max-height: 288px; + overflow-y: auto; + border-radius: 14px; + border: 1px solid ${GRAPH_THEME.ui.control.inputBorder}; + background: ${GRAPH_THEME.ui.surface.cardStrong}; + box-shadow: 0 18px 40px rgba(0,0,0,0.32), inset 0 1px 0 rgba(255,255,255,0.04); + } + .explore-search-suggestions li { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 10px; + padding: 8px 10px; + border-radius: 10px; + cursor: pointer; + color: ${GRAPH_THEME.ui.text.body}; + } + .explore-search-suggestions li[data-highlighted="true"] { + background: ${GRAPH_THEME.ui.control.hoverBg}; + } + .explore-search-suggestion-label { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 13px; + font-weight: 600; + } + .explore-search-suggestion-type { + flex-shrink: 0; + font-size: 11px; + color: ${GRAPH_THEME.ui.text.subtle}; + text-transform: uppercase; + letter-spacing: 0.04em; + } .explore-search-command:focus-within { border-color: ${GRAPH_THEME.ui.control.activeBorder}; box-shadow: inset 0 1px 0 rgba(255,255,255,0.06), 0 0 0 1px ${GRAPH_THEME.ui.control.focusRing}, 0 16px 32px rgba(0,0,0,0.18); @@ -1225,12 +1402,28 @@ export function GraphWorkspace({ externalFocusNodeId, externalFocusToken }: Grap })); }, []); - const { data: summary, isLoading, isFetching } = useLoadGraph({ + const { + data: summary, + isLoading, + isFetching, + isError: isGraphLoadError, + error: graphLoadError, + refetch: refetchGraph, + } = useLoadGraph({ enabled: true, onGraphReady: applyGraphReadySummary, onProgress: handleLoadProgress, }); + const graphLoadErrorMessage = isGraphLoadError + ? (graphLoadError instanceof Error ? graphLoadError.message : "Unknown error while loading the graph.") + : null; + + const handleRetryGraphLoad = useCallback(() => { + setLoadingProgress(null); + void refetchGraph(); + }, [refetchGraph]); + useEffect(() => { if (isLayoutRunning) { return; @@ -1523,6 +1716,11 @@ export function GraphWorkspace({ externalFocusNodeId, externalFocusToken }: Grap } }, [searchQuery]); + const handleClearSearchResults = useCallback(() => { + setSearchResults([]); + setSearchError(""); + }, []); + const handleRunPredictions = useCallback(async () => { if (!inspectableNodeId) return; setIsRunningPredictions(true); @@ -1901,7 +2099,7 @@ export function GraphWorkspace({ externalFocusNodeId, externalFocusToken }: Grap viewMode, ]); - const showLoadingOverlay = !graphReady && (isLoading || isFetching || Boolean(loadingProgress)); + const showLoadingOverlay = !graphReady && (isLoading || isFetching || Boolean(loadingProgress) || isGraphLoadError); const showSettlingStatus = graphReady && loadingProgress?.phase === "stabilizing_layout"; const hasGraphContent = Boolean(summary?.nodeCount); const activePath = pathResult?.path ?? EMPTY_PATH; @@ -2764,6 +2962,10 @@ export function GraphWorkspace({ externalFocusNodeId, externalFocusToken }: Grap disabled={searchDisabled} onChange={setSearchQuery} onSubmit={() => void handleSearch()} + onSelectSuggestion={(result) => { + setSearchQuery(""); + focusNode(result.node.id); + }} />
@@ -2839,20 +3041,36 @@ export function GraphWorkspace({ externalFocusNodeId, externalFocusToken }: Grap {searchError ?
{searchError}
: null} {searchResults.length ? ( -
- {searchResults.map((result) => ( - - ))} +
+
+ {searchResults.map((result) => ( + + ))} +
) : null} @@ -2946,6 +3164,8 @@ export function GraphWorkspace({ externalFocusNodeId, externalFocusToken }: Grap progress={loadingProgress} visible={showLoadingOverlay} showGraphBehind={hasGraphContent || Boolean(loadingProgress?.showGraphBehind)} + error={graphLoadErrorMessage} + onRetry={handleRetryGraphLoad} />
From 5bc09a5f5abc347e08e0b6e3de872bb3b2aa8c16 Mon Sep 17 00:00:00 2001 From: Lakshay Saini <76612216+lakshayxi@users.noreply.github.com> Date: Fri, 14 Aug 2026 20:16:41 +0530 Subject: [PATCH 037/105] refactor(explorer): remove dead graph workspace shell (#984) * refactor(explorer): remove dead graph workspace shell * refactor(explorer): remove unused graph runtime stage --- .../GraphWorkspace/GraphRuntimeStage.tsx | 479 ---------- .../GraphWorkspace/GraphWorkspaceShell.tsx | 862 ------------------ .../workspaces/GraphWorkspace/useGraphData.ts | 223 ----- 3 files changed, 1564 deletions(-) delete mode 100644 explorer/src/workspaces/GraphWorkspace/GraphRuntimeStage.tsx delete mode 100644 explorer/src/workspaces/GraphWorkspace/GraphWorkspaceShell.tsx delete mode 100644 explorer/src/workspaces/GraphWorkspace/useGraphData.ts diff --git a/explorer/src/workspaces/GraphWorkspace/GraphRuntimeStage.tsx b/explorer/src/workspaces/GraphWorkspace/GraphRuntimeStage.tsx deleted file mode 100644 index 009b22bb..00000000 --- a/explorer/src/workspaces/GraphWorkspace/GraphRuntimeStage.tsx +++ /dev/null @@ -1,479 +0,0 @@ -import { forwardRef, useEffect, useImperativeHandle, useMemo, useRef, useState } from "react"; - -import { batchMergeEdges, batchMergeNodes, clearGraph, graph, type EdgeAttributes, type NodeAttributes } from "../../store/graphStore"; -import { SigmaSceneAdapter } from "./SigmaSceneAdapter"; -import { createGraphLoadProgress } from "./graphLoading"; -import { resolveDisplayGraph } from "./graphSceneState"; -import { - chooseColorAccessor, - colorForNodeKey, - computeDegreeMap, - computeEdgeSize, - computeNodeSize, - computePageRank, - deterministicPosition, -} from "./graphAnalytics"; -import { GRAPH_THEME } from "./graphConfig"; -import type { GraphSceneHandle } from "./scene"; -import type { - GraphDataSnapshot, - GraphEffectsState, - GraphLayoutSource, - GraphLayoutStatus, - GraphLoadProgress, - GraphPath, - GraphSelectedNodeState, - GraphStageHandle, - GraphViewMode, -} from "./types"; - -const STAGE_EFFECTS_STATE: GraphEffectsState = { - pathPulseEnabled: false, - pathFlowEnabled: false, - lensEnabled: false, - temporalEmphasisEnabled: false, - semanticRegionsEnabled: false, - contoursEnabled: false, - pathfindingEnabled: false, - communitiesEnabled: false, - centralityEnabled: false, - legendEnabled: false, - diagnosticsEnabled: false, - lensMode: "neighborhood", - effectQuality: "bounded", -}; -const EMPTY_PATH: string[] = []; - -const socketProtocol = () => (window.location.protocol === "https:" ? "wss:" : "ws:"); - -function yieldToMain(): Promise { - if ("scheduler" in window && typeof (window as Window & { scheduler?: { yield?: () => Promise } }).scheduler?.yield === "function") { - return (window as Window & { scheduler: { yield: () => Promise } }).scheduler.yield(); - } - return new Promise((resolve) => setTimeout(resolve, 0)); -} - -function buildSelectedNodeState(nodeId: string): GraphSelectedNodeState | null { - if (!nodeId || !graph.hasNode(nodeId)) { - return null; - } - - const attributes = graph.getNodeAttributes(nodeId) as NodeAttributes; - return { - id: nodeId, - label: String(attributes.label || nodeId), - content: String(attributes.content || attributes.label || nodeId), - nodeType: attributes.nodeType || "entity", - color: attributes.color, - valid_from: attributes.valid_from ?? null, - valid_until: attributes.valid_until ?? null, - properties: attributes.properties ?? {}, - neighborCount: graph.neighbors(nodeId).length, - visibleNeighborCount: graph.neighbors(nodeId).length, - collapsedNeighborCount: 0, - isNeighborhoodCollapsed: false, - canCollapseNeighborhood: graph.neighbors(nodeId).length > 8, - }; -} - -function hasUsableCoordinate(value: unknown): value is number { - return typeof value === "number" && Number.isFinite(value); -} - -interface GraphRuntimeStageProps { - snapshot: GraphDataSnapshot | null | undefined; - selectedNodeId: string; - activePath: GraphPath; - onNodeSelect: (nodeId: string) => void; - onSelectedNodeStateChange: (state: GraphSelectedNodeState | null) => void; - isLayoutRunning: boolean; - onLayoutRunningChange: (running: boolean) => void; - viewMode: GraphViewMode; - temporalTime: Date | null; - onActiveNodeCountChange: (count: number | null) => void; - onProgressChange: (progress: GraphLoadProgress | null) => void; - onLayoutStatusChange: (status: GraphLayoutStatus) => void; - onRuntimeReady: () => void; -} - -export const GraphRuntimeStage = forwardRef( - function GraphRuntimeStage( - { - snapshot, - selectedNodeId, - activePath, - onNodeSelect, - onSelectedNodeStateChange, - isLayoutRunning, - onLayoutRunningChange, - viewMode, - temporalTime, - onActiveNodeCountChange, - onProgressChange, - onLayoutStatusChange, - onRuntimeReady, - }, - ref, - ) { - const sceneRef = useRef(null); - const prevActiveIdsRef = useRef>(new Set()); - const [graphVersion, setGraphVersion] = useState(0); - const [runtimeLayoutSource, setRuntimeLayoutSource] = useState(snapshot?.summary.layoutSource ?? "runtime"); - const displayResult = useMemo( - () => resolveDisplayGraph(selectedNodeId, activePath, EMPTY_PATH, viewMode, { aggregationEnabled: true }), - [activePath, graphVersion, selectedNodeId, viewMode], - ); - - const stageSignature = useMemo(() => (snapshot ? `${snapshot.fetchedAt}:${snapshot.summary.nodeCount}:${snapshot.summary.edgeCount}` : null), [snapshot]); - - useImperativeHandle(ref, () => ({ - fitView: () => sceneRef.current?.fitView(), - focusNode: (nodeId: string) => sceneRef.current?.focusNode(nodeId), - }), []); - - useEffect(() => { - let cancelled = false; - - async function hydrateSnapshot() { - if (!snapshot) { - return; - } - - onProgressChange(createGraphLoadProgress({ - phase: "computing_styling", - progressKind: "indeterminate", - nodesLoaded: snapshot.summary.nodeCount, - nodesTotal: snapshot.summary.nodeCount, - edgesLoaded: snapshot.summary.edgeCount, - edgesTotal: snapshot.summary.edgeCount, - message: "Computing runtime graph styling", - showGraphBehind: false, - })); - - const degreeByNode = computeDegreeMap(snapshot.nodes, snapshot.edges); - const pageRankByNode = computePageRank(snapshot.nodes, snapshot.edges); - const nodeIndexById = new Map(snapshot.nodes.map((node, index) => [node.id, index])); - const previousPositions = new Map(); - - graph.forEachNode((nodeId, attributes) => { - const raw = attributes as Partial; - const x = Number(raw.x); - const y = Number(raw.y); - if (Number.isFinite(x) && Number.isFinite(y)) { - previousPositions.set(nodeId, { x, y }); - } - }); - - let explicitCoordinateCount = 0; - let carriedCoordinateCount = 0; - const draftAttributes = snapshot.nodes.map((node) => { - const previousPosition = previousPositions.get(node.id); - const position = hasUsableCoordinate(node.x) && hasUsableCoordinate(node.y) - ? { x: node.x, y: node.y } - : previousPosition - ? previousPosition - : deterministicPosition(node.id, nodeIndexById.get(node.id) ?? 0, snapshot.nodes.length); - - if (hasUsableCoordinate(node.x) && hasUsableCoordinate(node.y)) { - explicitCoordinateCount += 1; - } else if (previousPosition) { - carriedCoordinateCount += 1; - } - - return { - id: node.id, - attributes: { - label: node.content || node.id, - x: position.x, - y: position.y, - nodeType: node.type, - content: node.content, - valid_from: node.valid_from, - valid_until: node.valid_until, - properties: node.properties, - } as NodeAttributes, - }; - }); - - const layoutSource: GraphLayoutSource = explicitCoordinateCount > 0 - ? "provided" - : carriedCoordinateCount > 0 - ? "carried" - : "runtime"; - const hasCoordinates = explicitCoordinateCount > 0 || carriedCoordinateCount > 0; - setRuntimeLayoutSource(layoutSource); - - const colorAccessor = chooseColorAccessor(draftAttributes); - await yieldToMain(); - if (cancelled) { - return; - } - - onProgressChange(createGraphLoadProgress({ - phase: "hydrating_scene", - progressKind: "indeterminate", - nodesLoaded: snapshot.summary.nodeCount, - nodesTotal: snapshot.summary.nodeCount, - edgesLoaded: snapshot.summary.edgeCount, - edgesTotal: snapshot.summary.edgeCount, - message: "Hydrating graph scene and renderer", - showGraphBehind: false, - })); - - const nodesToMerge = draftAttributes.map(({ id, attributes }) => { - const colorKey = colorAccessor(id, attributes); - const baseColor = colorForNodeKey(colorKey); - const dynamicSize = computeNodeSize(id, degreeByNode, pageRankByNode); - return { - id, - attributes: { - ...attributes, - color: baseColor, - baseColor, - size: dynamicSize, - baseSize: dynamicSize, - degree: degreeByNode.get(id) ?? 0, - pageRank: pageRankByNode.get(id) ?? 0, - glowColor: baseColor, - borderColor: GRAPH_THEME.nodes.border, - borderSize: 1, - } as NodeAttributes, - }; - }); - - const edgesToMerge = snapshot.edges.map((edge) => ({ - id: edge.id, - familyId: edge.familyId, - source: edge.source, - target: edge.target, - attributes: { - edgeId: edge.id, - familyId: edge.familyId, - sourceId: edge.source, - targetId: edge.target, - weight: edge.weight, - edgeType: edge.type, - properties: edge.properties, - size: computeEdgeSize(edge.weight), - baseSize: computeEdgeSize(edge.weight), - color: GRAPH_THEME.edges.baseColor, - baseColor: GRAPH_THEME.edges.baseColor, - } as EdgeAttributes, - })); - - clearGraph(); - batchMergeNodes(nodesToMerge); - batchMergeEdges(edgesToMerge); - prevActiveIdsRef.current = new Set(snapshot.nodes.map((node) => node.id)); - - await yieldToMain(); - if (cancelled) { - return; - } - - onLayoutStatusChange({ - state: layoutSource === "runtime" ? "bootstrapping" : "interactive", - source: layoutSource, - hasCoordinates, - layoutReady: layoutSource !== "runtime", - displacement: null, - elapsedMs: 0, - stableSamples: 0, - }); - - onLayoutRunningChange(layoutSource === "runtime"); - if (selectedNodeId) { - sceneRef.current?.focusNode(selectedNodeId); - } else { - sceneRef.current?.getRuntime()?.requestRender(); - } - setGraphVersion((current) => current + 1); - if (layoutSource !== "runtime") { - onProgressChange(null); - } else { - onProgressChange(createGraphLoadProgress({ - phase: "stabilizing_layout", - progressKind: "indeterminate", - nodesLoaded: snapshot.summary.nodeCount, - nodesTotal: snapshot.summary.nodeCount, - edgesLoaded: snapshot.summary.edgeCount, - edgesTotal: snapshot.summary.edgeCount, - message: "Settling runtime layout", - showGraphBehind: true, - layoutSource, - layoutState: "bootstrapping", - })); - } - onRuntimeReady(); - } - - void hydrateSnapshot(); - return () => { - cancelled = true; - }; - }, [onLayoutRunningChange, onLayoutStatusChange, onProgressChange, onRuntimeReady, selectedNodeId, snapshot, stageSignature]); - - useEffect(() => { - if (!selectedNodeId) { - onSelectedNodeStateChange(null); - return; - } - - onSelectedNodeStateChange(buildSelectedNodeState(selectedNodeId)); - }, [graphVersion, onSelectedNodeStateChange, selectedNodeId, viewMode]); - - useEffect(() => { - if (!snapshot || !temporalTime) { - return; - } - - let cancelled = false; - - const applySnapshot = async () => { - try { - const response = await fetch(`/api/temporal/snapshot?at=${encodeURIComponent(temporalTime.toISOString())}`); - if (!response.ok || cancelled) { - return; - } - - const data: { active_node_ids: string[]; active_node_count: number } = await response.json(); - if (cancelled) { - return; - } - - const nextActiveIds = new Set(data.active_node_ids); - requestAnimationFrame(() => { - if (cancelled) { - return; - } - - const previous = prevActiveIdsRef.current; - previous.forEach((id) => { - if (!nextActiveIds.has(id) && graph.hasNode(id)) { - graph.setNodeAttribute(id, "hidden", true); - } - }); - nextActiveIds.forEach((id) => { - if (graph.hasNode(id)) { - graph.setNodeAttribute(id, "hidden", false); - } - }); - - prevActiveIdsRef.current = nextActiveIds; - onActiveNodeCountChange(data.active_node_count); - sceneRef.current?.getRuntime()?.requestRender(); - }); - } catch (error) { - if (!cancelled) { - console.error("[GraphRuntimeStage] temporal snapshot failed", error); - } - } - }; - - void applySnapshot(); - return () => { - cancelled = true; - }; - }, [onActiveNodeCountChange, snapshot, temporalTime]); - - useEffect(() => { - const socket = new WebSocket(`${socketProtocol()}//${window.location.host}/ws/graph-updates`); - - socket.onmessage = (event) => { - try { - const message = JSON.parse(event.data); - if (message.event === "connection_ack" || message.event !== "graph_mutation") { - return; - } - - const eventType = message.data?.event_type; - const payload = message.data?.payload; - if (eventType === "ADD_NODE" && payload?.id) { - batchMergeNodes([ - { - id: payload.id, - attributes: { - label: payload.properties?.content || payload.id, - x: Number.isFinite(Number(payload.x ?? payload.properties?.x)) - ? Number(payload.x ?? payload.properties?.x) - : deterministicPosition(payload.id, graph.order + 1, Math.max(graph.order + 1, 1)).x, - y: Number.isFinite(Number(payload.y ?? payload.properties?.y)) - ? Number(payload.y ?? payload.properties?.y) - : deterministicPosition(payload.id, graph.order + 1, Math.max(graph.order + 1, 1)).y, - nodeType: payload.type, - content: payload.properties?.content || payload.id, - valid_from: payload.properties?.valid_from ?? null, - valid_until: payload.properties?.valid_until ?? null, - properties: payload.properties || {}, - size: 8, - color: colorForNodeKey(`${payload.type || "entity"}:${payload.id}`), - baseColor: colorForNodeKey(`${payload.type || "entity"}:${payload.id}`), - baseSize: 8, - glowColor: colorForNodeKey(`${payload.type || "entity"}:${payload.id}`), - borderColor: GRAPH_THEME.nodes.border, - borderSize: 1, - }, - }, - ]); - } - - if (eventType === "ADD_EDGE" && payload?.source_id && payload?.target_id) { - batchMergeEdges([ - { - id: String(payload.id), - familyId: payload.familyId ? String(payload.familyId) : String(payload.id), - source: payload.source_id, - target: payload.target_id, - attributes: { - edgeId: String(payload.id), - familyId: payload.familyId ? String(payload.familyId) : String(payload.id), - sourceId: payload.source_id, - targetId: payload.target_id, - weight: Number(payload.weight ?? 1), - edgeType: payload.type, - properties: payload.properties || {}, - size: computeEdgeSize(Number(payload.weight ?? 1)), - baseSize: computeEdgeSize(Number(payload.weight ?? 1)), - color: payload.properties?.inferred ? GRAPH_THEME.edges.pathColor : GRAPH_THEME.edges.baseColor, - baseColor: GRAPH_THEME.edges.baseColor, - }, - }, - ]); - } - - sceneRef.current?.getRuntime()?.requestRender(); - setGraphVersion((current) => current + 1); - } catch (error) { - console.error("[GraphRuntimeStage] websocket update failed", error); - } - }; - - return () => { - socket.close(); - }; - }, []); - - return ( - - ); - }, -); diff --git a/explorer/src/workspaces/GraphWorkspace/GraphWorkspaceShell.tsx b/explorer/src/workspaces/GraphWorkspace/GraphWorkspaceShell.tsx deleted file mode 100644 index 7a085544..00000000 --- a/explorer/src/workspaces/GraphWorkspace/GraphWorkspaceShell.tsx +++ /dev/null @@ -1,862 +0,0 @@ -import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from "react"; - -import { GraphLoadingOverlay } from "./GraphLoadingOverlay"; -import { getGraphLoadTitle } from "./graphLoading"; -import { useGraphData, useReloadGraphData } from "./useGraphData"; -import type { - ApiNode, - GraphLayoutStatus, - GraphLoadProgress, - GraphPath, - GraphSelectedNodeState, - GraphStageHandle, - GraphViewMode, -} from "./types"; - -type SearchResult = { - node: { - id: string; - type: string; - content: string; - properties: Record; - }; - score: number; -}; - -type LinkPrediction = { - target: string; - type: string; - label?: string; - score: number; -}; - -type PathResponse = { - path: GraphPath; - total_weight: number; - hop_count: number; - distance_band: "direct" | "near" | "mid-range" | "distant"; -}; - -type TemporalBounds = { - min?: string | null; - max?: string | null; -}; - -const GraphRuntimeStage = lazy(() => - import("./GraphRuntimeStage").then((module) => ({ default: module.GraphRuntimeStage })), -); -const TimelinePanel = lazy(() => - import("./TimelinePanel").then((module) => ({ default: module.TimelinePanel })), -); - -const HUD_CSS = ` - .palantir-bg { - background: - radial-gradient(circle at top, rgba(103, 182, 255, 0.1), transparent 24%), - linear-gradient(180deg, #07111d 0%, #02060e 100%); - } - .palantir-grid { - position: absolute; - inset: 0; - background-image: - linear-gradient(rgba(88, 166, 255, 0.04) 1px, transparent 1px), - linear-gradient(90deg, rgba(88, 166, 255, 0.04) 1px, transparent 1px); - background-size: 44px 44px; - pointer-events: none; - z-index: 1; - opacity: 0.78; - } - .palantir-vignette { - position: absolute; - inset: 0; - background: radial-gradient(ellipse at center, transparent 34%, rgba(1, 4, 9, 0.88) 100%); - pointer-events: none; - z-index: 2; - } - .hud-scrollbar::-webkit-scrollbar { width: 6px; } - .hud-scrollbar::-webkit-scrollbar-track { background: transparent; } - .hud-scrollbar::-webkit-scrollbar-thumb { background: rgba(88, 166, 255, 0.25); border-radius: 6px; } - .graph-shell-top { position: absolute; top: 18px; left: 18px; right: 18px; z-index: 10; display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; pointer-events: none; } - .graph-status-card, .graph-command-card { - pointer-events: auto; - border: 1px solid rgba(132, 197, 255, 0.12); - background: linear-gradient(180deg, rgba(7, 16, 29, 0.86), rgba(10, 22, 39, 0.72)), radial-gradient(circle at top, rgba(103, 182, 255, 0.08), transparent 50%); - box-shadow: 0 18px 42px rgba(0, 0, 0, 0.28), inset 0 1px 0 rgba(255,255,255,0.04); - backdrop-filter: blur(18px); - } - .graph-status-card { width: min(420px, 38vw); border-radius: 24px; padding: 16px 18px; } - .graph-command-card { width: min(620px, 55vw); border-radius: 24px; padding: 14px; display: flex; flex-direction: column; gap: 12px; } - .graph-status-label { display: inline-flex; align-items: center; gap: 8px; color: rgba(160, 191, 223, 0.88); font-size: 11px; font-weight: 800; text-transform: uppercase; letter-spacing: 0.08em; margin-bottom: 10px; } - .graph-status-label::before { content: ""; width: 7px; height: 7px; border-radius: 999px; background: linear-gradient(135deg, #8ed3ff, #ffb36a); box-shadow: 0 0 12px rgba(142, 211, 255, 0.5); } - .graph-status-title { color: #eef5ff; font-size: 20px; font-weight: 800; letter-spacing: -0.04em; margin-bottom: 6px; } - .graph-status-copy { color: #8fa8c6; font-size: 12px; line-height: 1.55; margin-bottom: 14px; max-width: 40ch; } - .graph-status-metrics, .graph-command-row, .graph-toggle-cluster, .graph-action-cluster { display: flex; gap: 8px; flex-wrap: wrap; } - .graph-command-row { justify-content: space-between; align-items: center; gap: 10px; } - .graph-search-shell { flex: 1; min-width: 260px; display: flex; align-items: center; gap: 10px; padding: 8px 10px 8px 14px; border-radius: 18px; border: 1px solid rgba(132, 197, 255, 0.12); background: rgba(0, 0, 0, 0.18); box-shadow: inset 0 1px 0 rgba(255,255,255,0.03); } - .graph-search-shell input { flex: 1; min-width: 0; border: none !important; background: transparent !important; padding: 0 !important; margin: 0 !important; } - .graph-search-shell input:focus { outline: none; } - .graph-search-results { position: absolute; top: 120px; right: 18px; width: min(420px, calc(100vw - 132px)); max-height: 320px; overflow-y: auto; padding: 12px; border-radius: 20px; border: 1px solid rgba(132, 197, 255, 0.14); background: linear-gradient(180deg, rgba(8, 18, 33, 0.94), rgba(10, 21, 38, 0.86)); box-shadow: 0 18px 50px rgba(0,0,0,0.34); backdrop-filter: blur(18px); pointer-events: auto; z-index: 11; } - .graph-search-results-label { color: #6f89ab; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; margin-bottom: 10px; } - .graph-search-result-card { width: 100%; text-align: left; padding: 12px 14px; border-radius: 16px; border: 1px solid rgba(132, 197, 255, 0.08); background: rgba(255, 255, 255, 0.025); cursor: pointer; transition: transform 160ms ease, border-color 160ms ease, background 160ms ease; } - .graph-search-result-card:hover { transform: translateY(-1px); border-color: rgba(132, 197, 255, 0.18); background: rgba(103, 182, 255, 0.08); } - .graph-inspector { pointer-events: auto; position: absolute; right: 18px; top: 154px; bottom: 108px; width: 380px; overflow-y: auto; transition: transform 0.34s cubic-bezier(0.16,1,0.3,1), opacity 0.22s ease; border-radius: 28px; border: 1px solid rgba(132, 197, 255, 0.14); background: linear-gradient(180deg, rgba(8, 18, 33, 0.9), rgba(6, 12, 22, 0.88)), radial-gradient(circle at top, rgba(103, 182, 255, 0.08), transparent 40%); box-shadow: -18px 0 48px rgba(0, 0, 0, 0.32), inset 0 1px 0 rgba(255,255,255,0.04); backdrop-filter: blur(20px); } - .graph-inspector[data-open='false'] { transform: translateX(calc(100% + 24px)); opacity: 0; } - @keyframes sem-loader-pulse { - 0%, 100% { transform: translateY(0) scale(0.92); opacity: 0.55; } - 50% { transform: translateY(-4px) scale(1.08); opacity: 1; } - } - @media (max-width: 1220px) { - .graph-shell-top { flex-direction: column; align-items: stretch; } - .graph-status-card, .graph-command-card { width: auto; } - .graph-search-results { top: 202px; right: 18px; left: 18px; width: auto; } - } -`; - -function useDebounce(value: T, delay: number): T { - const [debouncedValue, setDebouncedValue] = useState(value); - useEffect(() => { - const timeout = setTimeout(() => setDebouncedValue(value), delay); - return () => clearTimeout(timeout); - }, [delay, value]); - return debouncedValue; -} - -function sourceAttribution(properties: Record) { - const keys = ["source", "source_url", "pmid", "pmids", "evidence", "provenance", "confidence"]; - return keys - .filter((key) => key in properties) - .map((key) => ({ key, value: properties[key] })); -} - -function toSelectedNodeState(node: ApiNode, neighborCount: number, fallbackColor = "#58a6ff"): GraphSelectedNodeState { - return { - id: node.id, - label: node.content || node.id, - content: node.content || node.id, - nodeType: node.type, - color: fallbackColor, - valid_from: node.valid_from ?? null, - valid_until: node.valid_until ?? null, - properties: node.properties ?? {}, - neighborCount, - visibleNeighborCount: neighborCount, - collapsedNeighborCount: 0, - isNeighborhoodCollapsed: false, - canCollapseNeighborhood: neighborCount > 8, - }; -} - -function TimelineFallback({ min, max }: TemporalBounds) { - return ( -
- Temporal scrubber - {min || max ? "Preparing timeline runtime..." : "Temporal bounds loading..."} -
- ); -} - -function NodePanel({ - node, - predictions, - predictionType, - onPredictionTypeChange, - onRunPredictions, - pathTargetId, - onPathTargetChange, - onTracePath, - pathResult, - onDownloadProvenance, -}: { - node: GraphSelectedNodeState | null; - predictions: LinkPrediction[]; - predictionType: string; - onPredictionTypeChange: (value: string) => void; - onRunPredictions: () => void; - pathTargetId: string; - onPathTargetChange: (value: string) => void; - onTracePath: () => void; - pathResult: PathResponse | null; - onDownloadProvenance: (format: "json" | "markdown") => void; -}) { - if (!node) { - return ( -
-

- Search for a node or click one in the canvas to inspect its properties. -

-
- ); - } - - const properties = node.properties ?? {}; - const attribution = sourceAttribution(properties); - const accentColor = node.color || "#58a6ff"; - const propertyEntries = Object.entries(properties).filter(([key]) => !["x", "y", "valid_from", "valid_until", "content", "source", "source_url", "pmid", "pmids", "evidence", "provenance", "confidence"].includes(key)); - - return ( - - ); -} - -export function GraphWorkspaceShell() { - const [selectedNodeId, setSelectedNodeId] = useState(""); - const [selectedNodeState, setSelectedNodeState] = useState(null); - const [isLayoutRunning, setIsLayoutRunning] = useState(false); - const [viewMode, setViewMode] = useState("full"); - const [searchQuery, setSearchQuery] = useState(""); - const [searchResults, setSearchResults] = useState([]); - const [searchError, setSearchError] = useState(""); - const [predictionType, setPredictionType] = useState(""); - const [predictions, setPredictions] = useState([]); - const [pathTargetId, setPathTargetId] = useState(""); - const [pathResult, setPathResult] = useState(null); - const [activeNodeCount, setActiveNodeCount] = useState(null); - const [temporalBounds, setTemporalBounds] = useState(null); - const [scrubberTime, setScrubberTime] = useState(null); - // Deduplicates setScrubberTime calls by millisecond value — same fix as - // GraphWorkspace.tsx (issue #830). - const lastScrubberMsRef = useRef(null); - const onTimeChange = useCallback((time: Date) => { - const ms = time.getTime(); - if (ms === lastScrubberMsRef.current) { - return; - } - lastScrubberMsRef.current = ms; - setScrubberTime(time); - }, []); - const [loadingProgress, setLoadingProgress] = useState(null); - const [isGraphStageReady, setIsGraphStageReady] = useState(false); - const [layoutStatus, setLayoutStatus] = useState({ - state: "idle", - source: "runtime", - hasCoordinates: false, - layoutReady: false, - displacement: null, - elapsedMs: 0, - stableSamples: 0, - }); - - const debouncedTime = useDebounce(scrubberTime, 150); - const stageRef = useRef(null); - const reload = useReloadGraphData(); - const { data: snapshot, isLoading, isFetching, isError, error } = useGraphData({ enabled: true, onProgress: setLoadingProgress }); - - const handleSelectedNodeStateChange = useCallback((state: GraphSelectedNodeState | null) => { - setSelectedNodeState(state); - }, []); - - const handleLayoutRunningChange = useCallback((running: boolean) => { - setIsLayoutRunning(running); - }, []); - - const handleActiveNodeCountChange = useCallback((count: number | null) => { - setActiveNodeCount(count); - }, []); - - const handleProgressChange = useCallback((progress: GraphLoadProgress | null) => { - setLoadingProgress(progress); - }, []); - - const handleRuntimeReady = useCallback(() => { - setIsGraphStageReady(true); - }, []); - - const handleLayoutStatusChange = useCallback((status: GraphLayoutStatus) => { - setLayoutStatus(status); - if (status.layoutReady) { - setLoadingProgress(null); - } - }, []); - - const [prevFetchedAt, setPrevFetchedAt] = useState(snapshot?.fetchedAt); - if (snapshot?.fetchedAt !== prevFetchedAt) { - setPrevFetchedAt(snapshot?.fetchedAt); - if (snapshot) { - setIsGraphStageReady(false); - setActiveNodeCount(null); - setLayoutStatus({ - state: snapshot.summary.layoutReady ? "interactive" : "idle", - source: snapshot.summary.layoutSource ?? "runtime", - hasCoordinates: snapshot.summary.hasCoordinates ?? false, - layoutReady: snapshot.summary.layoutReady ?? false, - displacement: null, - elapsedMs: 0, - stableSamples: 0, - }); - } - } - - useEffect(() => { - let cancelled = false; - const loadBounds = async () => { - try { - const response = await fetch("/api/temporal/bounds"); - if (!response.ok || cancelled) return; - const data: TemporalBounds = await response.json(); - if (!cancelled) setTemporalBounds(data); - } catch { - if (!cancelled) setTemporalBounds(null); - } - }; - void loadBounds(); - return () => { - cancelled = true; - }; - }, [snapshot?.summary.nodeCount, snapshot?.summary.edgeCount]); - - const neighborCountMap = useMemo(() => { - const map = new Map(); - if (!snapshot) return map; - for (const node of snapshot.nodes) map.set(node.id, 0); - for (const edge of snapshot.edges) { - map.set(edge.source, (map.get(edge.source) ?? 0) + 1); - map.set(edge.target, (map.get(edge.target) ?? 0) + 1); - } - return map; - }, [snapshot]); - - const visibleSelectedNode = useMemo(() => { - if (!selectedNodeId) return null; - if (selectedNodeState?.id === selectedNodeId) return selectedNodeState; - const snapshotNode = snapshot?.nodes.find((candidate) => candidate.id === selectedNodeId); - if (snapshotNode) return toSelectedNodeState(snapshotNode, neighborCountMap.get(snapshotNode.id) ?? 0); - const searchNode = searchResults.find((candidate) => candidate.node.id === selectedNodeId)?.node; - return searchNode - ? { - id: searchNode.id, - label: searchNode.content || searchNode.id, - content: searchNode.content || searchNode.id, - nodeType: searchNode.type, - color: "#58a6ff", - valid_from: null, - valid_until: null, - properties: searchNode.properties ?? {}, - neighborCount: 0, - visibleNeighborCount: 0, - collapsedNeighborCount: 0, - isNeighborhoodCollapsed: false, - canCollapseNeighborhood: false, - } - : null; - }, [neighborCountMap, searchResults, selectedNodeId, selectedNodeState, snapshot]); - - const focusNode = useCallback((nodeId: string) => { - setSelectedNodeId(nodeId); - setPathResult(null); - - if (!nodeId) { - setSelectedNodeState(null); - setPredictions([]); - return; - } - - setSearchResults([]); - setIsLayoutRunning(false); - }, []); - - const handleSearch = useCallback(async () => { - if (!searchQuery.trim()) { - setSearchResults([]); - return; - } - - setSearchError(""); - try { - const response = await fetch("/api/graph/search", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ query: searchQuery, limit: 8 }), - }); - if (!response.ok) { - throw new Error(`Search failed with status ${response.status}`); - } - - const data = await response.json(); - setSearchResults(data.results || []); - if (data.results?.length) { - focusNode(data.results[0].node.id); - } - } catch (searchFetchError) { - setSearchError(searchFetchError instanceof Error ? searchFetchError.message : "Search failed"); - } - }, [focusNode, searchQuery]); - - const handleRunPredictions = useCallback(async () => { - if (!selectedNodeId) return; - - try { - const response = await fetch("/api/enrich/links", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - node_id: selectedNodeId, - top_n: 6, - candidate_type: predictionType || undefined, - min_score: 0, - }), - }); - if (!response.ok) { - throw new Error(`Link prediction failed with status ${response.status}`); - } - - const data = await response.json(); - setPredictions(data.predictions || []); - } catch (predictionError) { - console.error("[GraphWorkspaceShell] prediction failed", predictionError); - setPredictions([]); - } - }, [predictionType, selectedNodeId]); - - const handleTracePath = useCallback(async () => { - if (!selectedNodeId || !pathTargetId.trim()) return; - - try { - const pathParams = new URLSearchParams({ - source: selectedNodeId, - target: pathTargetId.trim(), - algorithm: "dijkstra", - }); - const response = await fetch( - `/api/graph/path?${pathParams.toString()}`, - ); - if (!response.ok) { - throw new Error(`Path lookup failed with status ${response.status}`); - } - - const data: PathResponse = await response.json(); - setPathResult(data); - if (data.path?.length) { - const lastStep = data.path[data.path.length - 1]; - stageRef.current?.focusNode(lastStep); - } - } catch (pathError) { - console.error("[GraphWorkspaceShell] path trace failed", pathError); - setPathResult(null); - } - }, [pathTargetId, selectedNodeId]); - - const handleDownloadProvenance = useCallback(async (format: "json" | "markdown") => { - if (!selectedNodeId) return; - - const suffix = format === "markdown" ? "markdown" : "json"; - const response = await fetch(`/api/provenance/report?node_id=${encodeURIComponent(selectedNodeId)}&format=${suffix}`); - if (!response.ok) { - throw new Error(`Provenance report failed with status ${response.status}`); - } - - const blob = await response.blob(); - const url = window.URL.createObjectURL(blob); - const anchor = document.createElement("a"); - anchor.href = url; - anchor.download = `${selectedNodeId}_provenance.${format === "markdown" ? "md" : "json"}`; - document.body.appendChild(anchor); - anchor.click(); - window.URL.revokeObjectURL(url); - document.body.removeChild(anchor); - }, [selectedNodeId]); - - const searchSummary = useMemo(() => { - if (!searchResults.length) return null; - return `${searchResults.length} search result${searchResults.length === 1 ? "" : "s"}`; - }, [searchResults.length]); - - const focusedSummary = useMemo(() => { - if (!visibleSelectedNode) return null; - if (viewMode === "focused") { - const visibleNeighbors = Math.min(visibleSelectedNode.neighborCount, 16); - return `${visibleNeighbors + 1} nodes in focused view`; - } - return `${visibleSelectedNode.neighborCount} direct neighbors highlighted`; - }, [viewMode, visibleSelectedNode]); - - const requestViewMode = useCallback((nextViewMode: GraphViewMode) => { - if (nextViewMode === "focused") { - if (!selectedNodeId) { - return; - } - setViewMode("focused"); - setIsLayoutRunning(false); - return; - } - - setViewMode("full"); - }, [selectedNodeId]); - - const showLoadingOverlay = - isLoading - || isFetching - || !isGraphStageReady - || (layoutStatus.source === "runtime" && !layoutStatus.layoutReady && !selectedNodeId && viewMode === "full"); - - const layoutStatusLabel = useMemo(() => { - if (layoutStatus.source === "provided" && layoutStatus.layoutReady) return "Persisted layout"; - if (layoutStatus.source === "carried" && layoutStatus.layoutReady) return "Preserved layout"; - if (layoutStatus.state === "bootstrapping") return "Bootstrapping layout"; - if (layoutStatus.state === "running") return "Stabilizing layout"; - if (layoutStatus.state === "failed") return "Layout timeout fallback"; - return null; - }, [layoutStatus]); - - return ( -
- -
-
- -
- - - - -
- - }> - - - -
-
-
-
Graph Studio
-
{visibleSelectedNode ? visibleSelectedNode.label : "Knowledge Explorer"}
-
- {showLoadingOverlay && loadingProgress ? {getGraphLoadTitle(loadingProgress.phase)} : null} - {layoutStatusLabel ? {layoutStatusLabel} : null} - {snapshot ? {snapshot.summary.nodeCount.toLocaleString()} nodes · {snapshot.summary.edgeCount.toLocaleString()} edges : null} - {activeNodeCount !== null ? {activeNodeCount.toLocaleString()} active : null} - {searchSummary ? {searchSummary} : null} - {focusedSummary ? {focusedSummary} : null} - {isError ? {(error as Error).message} : null} -
-
- -
-
-
- {selectedNodeId ? ( - <> - - - - ) : ( - Select a node to switch graph views - )} -
- -
- - -
-
- -
-
- setSearchQuery(event.target.value)} - onKeyDown={(event) => { - if (event.key === "Enter") { - void handleSearch(); - } - }} - placeholder="Search a node, e.g. Metformin" - style={{ ...inputStyle, minWidth: 260 }} - disabled={showLoadingOverlay && !selectedNodeId} - /> - -
-
-
-
- - {searchError ?
{searchError}
: null} - {searchResults.length ? ( -
-
Search Results
-
- {searchResults.map((result) => ( - - ))} -
-
- ) : null} - -
- void handleRunPredictions()} - pathTargetId={pathTargetId} - onPathTargetChange={setPathTargetId} - onTracePath={() => void handleTracePath()} - pathResult={pathResult} - onDownloadProvenance={(format) => void handleDownloadProvenance(format)} - /> -
-
-
- ); -} - -const metricPillStyle: CSSProperties = { - background: "rgba(88, 166, 255, 0.08)", - color: "#8ed3ff", - padding: "6px 11px", - borderRadius: 999, - fontSize: 12, - fontWeight: 700, - border: "1px solid rgba(88, 166, 255, 0.14)", -}; - -const sectionStyle: CSSProperties = { - display: "flex", - flexDirection: "column", - gap: 10, - padding: 14, - background: "linear-gradient(180deg, rgba(255,255,255,0.025), rgba(255,255,255,0.01))", - border: "1px solid rgba(255, 255, 255, 0.06)", - borderRadius: 16, -}; - -const sectionTitleStyle: CSSProperties = { - color: "#8fa8c6", - fontSize: 11, - fontWeight: 800, - textTransform: "uppercase", - letterSpacing: "0.08em", -}; - -const inputStyle: CSSProperties = { - width: "100%", - background: "rgba(0, 0, 0, 0.24)", - border: "1px solid rgba(88, 166, 255, 0.14)", - color: "#fff", - borderRadius: 12, - padding: "10px 12px", - fontSize: 13, -}; - -const actionButtonStyle: CSSProperties = { - background: "linear-gradient(180deg, rgba(53, 130, 245, 0.28), rgba(25, 88, 185, 0.18))", - color: "#fff", - border: "1px solid rgba(88, 166, 255, 0.2)", - borderRadius: 12, - padding: "10px 13px", - cursor: "pointer", - fontWeight: 700, - fontSize: 12, - display: "inline-flex", - alignItems: "center", - justifyContent: "center", - boxShadow: "inset 0 1px 0 rgba(255,255,255,0.05)", -}; - -const secondaryActionButtonStyle: CSSProperties = { - ...actionButtonStyle, - background: "rgba(255, 255, 255, 0.035)", - border: "1px solid rgba(255, 255, 255, 0.06)", - color: "#d6e5f8", - fontWeight: 500, -}; - -const predictionCardStyle: CSSProperties = { - textAlign: "left", - padding: 12, - background: "rgba(88, 166, 255, 0.06)", - border: "1px solid rgba(88, 166, 255, 0.1)", - borderRadius: 14, - cursor: "pointer", -}; - -const pathStepStyle: CSSProperties = { - color: "#e6edf3", - fontSize: 13, - padding: "8px 10px", - background: "rgba(255, 255, 255, 0.03)", - borderRadius: 8, -}; - -const propertyCardStyle: CSSProperties = { - background: "rgba(0, 0, 0, 0.18)", - padding: "10px 12px", - borderRadius: 12, - border: "1px solid rgba(255, 255, 255, 0.05)", -}; - -const emptyTextStyle: CSSProperties = { - color: "#8b949e", - fontSize: 12, - lineHeight: 1.5, -}; - -const subtleChipStyle: CSSProperties = { - background: "rgba(255, 255, 255, 0.035)", - color: "#9fb6d2", - padding: "5px 9px", - borderRadius: 999, - fontSize: 11, - border: "1px solid rgba(255, 255, 255, 0.06)", -}; - -const collapseStyle: CSSProperties = { - border: "1px solid rgba(255, 255, 255, 0.05)", - borderRadius: 14, - background: "rgba(0, 0, 0, 0.14)", - overflow: "hidden", -}; - -const summaryStyle: CSSProperties = { - cursor: "pointer", - listStyle: "none", - padding: "12px 14px", - color: "#c6d4e3", - fontSize: 12, - fontWeight: 700, - letterSpacing: "0.04em", - textTransform: "uppercase", -}; diff --git a/explorer/src/workspaces/GraphWorkspace/useGraphData.ts b/explorer/src/workspaces/GraphWorkspace/useGraphData.ts deleted file mode 100644 index b6380335..00000000 --- a/explorer/src/workspaces/GraphWorkspace/useGraphData.ts +++ /dev/null @@ -1,223 +0,0 @@ -import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { createGraphLoadProgress } from "./graphLoading"; -import type { ApiEdge, ApiNode, GraphDataSnapshot, GraphLoadProgress, GraphLayoutSource } from "./types"; - -interface NodeListResponse { - nodes: ApiNode[]; - total: number; - skip: number; - limit: number; - next_cursor?: string | null; -} - -interface EdgeListResponse { - edges: ApiEdge[]; - total: number; - skip: number; - limit: number; - next_cursor?: string | null; -} - -const PAGE_LIMIT = 1000; - -async function fetchAllNodes( - signal: AbortSignal, - onProgress?: (progress: GraphLoadProgress) => void, -): Promise { - let cursor: string | null = null; - const collected: ApiNode[] = []; - let total: number | null = null; - - while (true) { - const url = new URL("/api/graph/nodes", window.location.origin); - url.searchParams.set("limit", String(PAGE_LIMIT)); - if (cursor) { - url.searchParams.set("cursor", cursor); - } - - const response = await fetch(url.toString(), { signal }); - if (!response.ok) { - throw new Error(`Fetch failed: ${response.status}`); - } - - const data: NodeListResponse = await response.json(); - if (!data.nodes?.length) { - break; - } - - total = data.total ?? total; - collected.push(...data.nodes); - onProgress?.(createGraphLoadProgress({ - phase: "fetching_nodes", - progressKind: total ? "determinate" : "indeterminate", - loaded: collected.length, - total, - nodesLoaded: collected.length, - nodesTotal: total, - edgesLoaded: 0, - edgesTotal: null, - message: total - ? `Loading nodes ${collected.length.toLocaleString()} of ${total.toLocaleString()}` - : `Loading nodes ${collected.length.toLocaleString()}`, - })); - - if (!data.next_cursor) { - break; - } - cursor = data.next_cursor; - await yieldToMain(); - } - - return collected; -} - -async function fetchAllEdges( - signal: AbortSignal, - nodeIds: Set, - nodeProgress: { loaded: number; total: number | null }, - onProgress?: (progress: GraphLoadProgress) => void, -): Promise { - let cursor: string | null = null; - const collected: ApiEdge[] = []; - const seenEdgeIds = new Set(); - let total: number | null = null; - let warnedOverTotal = false; - - while (true) { - const url = new URL("/api/graph/edges", window.location.origin); - url.searchParams.set("limit", String(PAGE_LIMIT)); - if (cursor) { - url.searchParams.set("cursor", cursor); - } - - const response = await fetch(url.toString(), { signal }); - if (!response.ok) { - throw new Error(`Fetch failed: ${response.status}`); - } - - const data: EdgeListResponse = await response.json(); - if (!data.edges?.length) { - break; - } - - total = data.total ?? total; - const validEdges = data.edges.filter((edge) => { - if (!nodeIds.has(edge.source) || !nodeIds.has(edge.target)) { - return false; - } - if (seenEdgeIds.has(edge.id)) { - return false; - } - seenEdgeIds.add(edge.id); - return true; - }); - collected.push(...validEdges); - const safeLoaded = total ? Math.min(seenEdgeIds.size, total) : seenEdgeIds.size; - if (!warnedOverTotal && total !== null && seenEdgeIds.size > total) { - warnedOverTotal = true; - console.warn("[graph-runtime] edge pagination returned more unique edge ids than total", { - uniqueEdgesLoaded: seenEdgeIds.size, - total, - }); - } - onProgress?.(createGraphLoadProgress({ - phase: "fetching_edges", - progressKind: total ? "determinate" : "indeterminate", - loaded: safeLoaded, - total, - nodesLoaded: nodeProgress.loaded, - nodesTotal: nodeProgress.total, - edgesLoaded: safeLoaded, - edgesTotal: total, - message: total - ? `Loading edges ${safeLoaded.toLocaleString()} of ${total.toLocaleString()}` - : `Loading edges ${safeLoaded.toLocaleString()}`, - })); - - if (!data.next_cursor) { - break; - } - cursor = data.next_cursor; - await yieldToMain(); - } - - return collected; -} - -function yieldToMain(): Promise { - if ("scheduler" in window && typeof (window as Window & { scheduler?: { yield?: () => Promise } }).scheduler?.yield === "function") { - return (window as Window & { scheduler: { yield: () => Promise } }).scheduler.yield(); - } - return new Promise((resolve) => setTimeout(resolve, 0)); -} - -function hasUsableCoordinate(value: number | null | undefined): value is number { - return typeof value === "number" && Number.isFinite(value); -} - -interface UseGraphDataOptions { - enabled?: boolean; - onProgress?: (progress: GraphLoadProgress) => void; -} - -export function useGraphData(options: UseGraphDataOptions = {}) { - const { enabled = true, onProgress } = options; - - return useQuery({ - queryKey: ["graph", "runtime-snapshot"], - enabled, - staleTime: Infinity, - queryFn: async ({ signal }): Promise => { - const startedAt = performance.now(); - onProgress?.(createGraphLoadProgress({ - phase: "bootstrapping", - progressKind: "indeterminate", - nodesLoaded: 0, - nodesTotal: null, - edgesLoaded: 0, - edgesTotal: null, - message: "Preparing graph session", - })); - - const nodes = await fetchAllNodes(signal, onProgress); - const nodeIds = new Set(nodes.map((node) => node.id)); - const edges = await fetchAllEdges( - signal, - nodeIds, - { loaded: nodes.length, total: nodes.length }, - onProgress, - ); - - onProgress?.(createGraphLoadProgress({ - phase: "hydrating_scene", - progressKind: "indeterminate", - nodesLoaded: nodes.length, - nodesTotal: nodes.length, - edgesLoaded: edges.length, - edgesTotal: edges.length, - message: "Preparing graph runtime snapshot", - })); - - return { - nodes, - edges, - summary: { - nodeCount: nodes.length, - edgeCount: edges.length, - loadTimeMs: Math.round(performance.now() - startedAt), - hasCoordinates: nodes.some((node) => hasUsableCoordinate(node.x) && hasUsableCoordinate(node.y)), - layoutSource: (nodes.some((node) => hasUsableCoordinate(node.x) && hasUsableCoordinate(node.y)) - ? "provided" - : "runtime") as GraphLayoutSource, - layoutReady: nodes.some((node) => hasUsableCoordinate(node.x) && hasUsableCoordinate(node.y)), - }, - fetchedAt: Date.now(), - }; - }, - }); -} - -export function useReloadGraphData() { - const queryClient = useQueryClient(); - return () => queryClient.invalidateQueries({ queryKey: ["graph", "runtime-snapshot"] }); -} From 80b9bea0d58d373ea47ec4d75fed556f7aaad2d0 Mon Sep 17 00:00:00 2001 From: manjunath bhaskar <64712453+manjunathbhaskar@users.noreply.github.com> Date: Fri, 14 Aug 2026 17:14:13 +0200 Subject: [PATCH 038/105] fix(ingest): lock the repo host DNS resolve cache against concurrent mutation (#979) * fix(ingest): lock the repo host DNS resolve cache against concurrent mutation _REPO_HOST_RESOLVE_CACHE is a module level OrderedDict shared by every RepoIngestor instance and thread. _resolve_repo_host_ips and _prune_repo_host_resolve_cache read, wrote, and iterated it with no lock, so concurrent ingest_repository() calls (e.g. from a thread pool) could mutate the dict while another thread was iterating it during pruning. This reliably raised RuntimeError: OrderedDict mutated during iteration under ordinary concurrent usage, not just adversarial input. Reproduced with 32 threads hammering _resolve_repo_host_ips with a low TTL and small cache cap so pruning and eviction happen on nearly every call; the crash showed up within the first few hundred iterations on every run before the fix and did not reproduce at all after it. Fix adds a threading.Lock guarding every read, write, and prune of the cache. The blocking socket.getaddrinfo call stays outside the lock so a slow DNS lookup for one host cannot stall cache access for other hosts. Added a regression test, TestRepoHostResolveCacheThreadSafety, that drives 32 threads through _resolve_repo_host_ips with a short TTL and small cache cap and asserts no exception is raised. Full test suite: 4088 passed, 332 failed, 140 errors both before and after this change (same counts on main), all from missing optional dependencies in this local environment (snowflake, sqlite-vec, spaCy models, faiss/torch version mismatches), not from this fix. The ingest and SSRF focused test files pass cleanly: 106 passed, 0 failed. * test(ingest): fail fast on the first hung thread in the resolve-cache race test join(timeout=30) alone doesn't fail the test if a worker hangs -- it just returns after the timeout with the thread still running, and the test falls through to the errors check, which trivially passes since a hung thread never got far enough to append one. A future deadlock could slip past this test looking green. Assert immediately after each individual join rather than after the whole loop: checking only once every thread has been joined means a mass hang costs up to 32*30s = 16 minutes before the test even reaches the check. Failing on the first hung thread caps the worst case at ~30s instead. Worker threads are daemon=True so a genuine hang can't also block the test process from exiting. Verified the assertion is load-bearing, not cosmetic: temporarily injected an artificial 9999s sleep into the first worker in a throwaway copy of the test and confirmed the test now fails in ~31s with a clear message, instead of the ~16 minutes a mass hang would otherwise cost. That copy was never committed. Addresses the review comment on #979 from ZohaibHassan16 and Qodo's automated review. * test(ingest): fail fast on the first hung thread, for real this time The previous commit (f94e3b38) claimed to check is_alive() right after each individual join, but a git staging mistake meant it actually committed the old batched version instead (checking all 32 threads only after the whole join loop finished) -- ZohaibHassan16 caught this by timing it directly, 5 hanging threads took ~5x longer than 1 hanging thread, which the per-thread version would not do. This commit was built by resetting to the current branch tip, verifying byte-for-byte against a separately saved copy of the intended fix, and confirming the actual committed git object (not just `git diff`) has the inline check before pushing anything. Assert immediately after each individual join rather than after the whole loop: checking only once every thread has been joined means a mass hang costs up to 32*30s = 16 minutes before the test even reaches the check. Failing on the first hung thread caps the worst case at ~30s regardless of how many threads hang. --------- --- semantica/ingest/repo_ingestor.py | 58 +++++++++++----- tests/ingest/test_repo_ingestor_security.py | 73 +++++++++++++++++++++ 2 files changed, 116 insertions(+), 15 deletions(-) diff --git a/semantica/ingest/repo_ingestor.py b/semantica/ingest/repo_ingestor.py index 4b4f88c1..ad0a9b88 100644 --- a/semantica/ingest/repo_ingestor.py +++ b/semantica/ingest/repo_ingestor.py @@ -35,6 +35,7 @@ import re import shutil import socket import tempfile +import threading import time from collections import OrderedDict from dataclasses import dataclass, field @@ -67,6 +68,14 @@ _REPO_HOST_RESOLVE_CACHE: "OrderedDict[str, Tuple[float, Tuple[str, ...]]]" = ( ) _REPO_HOST_RESOLVE_CACHE_TTL_SECONDS = 60.0 _REPO_HOST_RESOLVE_CACHE_MAX_ENTRIES = 1024 +# Guards all reads/writes/prunes of _REPO_HOST_RESOLVE_CACHE. The cache is a +# module-level OrderedDict shared by every RepoIngestor instance and every +# thread; without a lock, concurrent ingest_repository() calls can mutate the +# dict while another thread is iterating it (e.g. during pruning), raising +# "RuntimeError: OrderedDict mutated during iteration". The blocking +# socket.getaddrinfo() call is intentionally kept outside this lock so a slow +# DNS lookup for one host cannot stall cache access for other hosts. +_REPO_HOST_RESOLVE_CACHE_LOCK = threading.Lock() @dataclass @@ -597,15 +606,19 @@ class RepoIngestor: """ cache_key = host.lower().rstrip(".") now = time.monotonic() - RepoIngestor._prune_repo_host_resolve_cache(now) - cached = _REPO_HOST_RESOLVE_CACHE.get(cache_key) - if cached is not None: - expires_at, ips = cached - if now < expires_at: - _REPO_HOST_RESOLVE_CACHE.move_to_end(cache_key) - return ips - _REPO_HOST_RESOLVE_CACHE.pop(cache_key, None) + with _REPO_HOST_RESOLVE_CACHE_LOCK: + RepoIngestor._prune_repo_host_resolve_cache_locked(now) + cached = _REPO_HOST_RESOLVE_CACHE.get(cache_key) + if cached is not None: + expires_at, ips = cached + if now < expires_at: + _REPO_HOST_RESOLVE_CACHE.move_to_end(cache_key) + return ips + _REPO_HOST_RESOLVE_CACHE.pop(cache_key, None) + # DNS resolution is blocking I/O; keep it outside the lock so a slow + # or hanging lookup for one host cannot stall cache access for + # concurrent lookups of other hosts. try: addrinfos = socket.getaddrinfo( host, None, type=socket.SOCK_STREAM @@ -629,17 +642,32 @@ class RepoIngestor: ) result = tuple(ips) - _REPO_HOST_RESOLVE_CACHE[cache_key] = ( - now + _REPO_HOST_RESOLVE_CACHE_TTL_SECONDS, - result, - ) - _REPO_HOST_RESOLVE_CACHE.move_to_end(cache_key) - RepoIngestor._prune_repo_host_resolve_cache(now) + with _REPO_HOST_RESOLVE_CACHE_LOCK: + now = time.monotonic() + _REPO_HOST_RESOLVE_CACHE[cache_key] = ( + now + _REPO_HOST_RESOLVE_CACHE_TTL_SECONDS, + result, + ) + _REPO_HOST_RESOLVE_CACHE.move_to_end(cache_key) + RepoIngestor._prune_repo_host_resolve_cache_locked(now) return result @staticmethod def _prune_repo_host_resolve_cache(now: Optional[float] = None) -> None: - """Remove expired host entries and enforce a hard cache size cap.""" + """Remove expired host entries and enforce a hard cache size cap. + + Acquires ``_REPO_HOST_RESOLVE_CACHE_LOCK``. Callers that already hold + the lock must use ``_prune_repo_host_resolve_cache_locked`` instead to + avoid deadlocking on the (non-reentrant) lock. + """ + if now is None: + now = time.monotonic() + with _REPO_HOST_RESOLVE_CACHE_LOCK: + RepoIngestor._prune_repo_host_resolve_cache_locked(now) + + @staticmethod + def _prune_repo_host_resolve_cache_locked(now: Optional[float] = None) -> None: + """Prune implementation; caller must already hold the cache lock.""" if now is None: now = time.monotonic() diff --git a/tests/ingest/test_repo_ingestor_security.py b/tests/ingest/test_repo_ingestor_security.py index 738c0f76..67494abc 100644 --- a/tests/ingest/test_repo_ingestor_security.py +++ b/tests/ingest/test_repo_ingestor_security.py @@ -1,6 +1,7 @@ """Security-focused tests for RepoIngestor (issue #868).""" import socket +import threading from unittest.mock import MagicMock, patch import pytest @@ -480,3 +481,75 @@ class TestLocalPathSupport: assert not isinstance(exc, ValidationError), ( f"Local path must not raise ValidationError; got: {exc}" ) + + +class TestRepoHostResolveCacheThreadSafety: + """Regression test: _REPO_HOST_RESOLVE_CACHE must survive concurrent use. + + _REPO_HOST_RESOLVE_CACHE is a module-level OrderedDict shared across every + RepoIngestor instance and thread. Before the fix, _resolve_repo_host_ips + and _prune_repo_host_resolve_cache read, wrote, and iterated the dict with + no lock. Under concurrent host validation (e.g. multiple + ingest_repository() calls running in a thread pool), one thread's + insert/evict during another thread's iteration reliably raised + RuntimeError: OrderedDict mutated during iteration. + """ + + def test_concurrent_resolve_repo_host_ips_does_not_raise(self): + def fake_getaddrinfo(host, *args, **kwargs): + return [(socket.AF_INET, socket.SOCK_STREAM, 0, "", ("93.184.216.34", 0))] + + orig_ttl = repo_ingestor_mod._REPO_HOST_RESOLVE_CACHE_TTL_SECONDS + orig_max = repo_ingestor_mod._REPO_HOST_RESOLVE_CACHE_MAX_ENTRIES + # Small TTL/cap so eviction and pruning happen on nearly every call, + # keeping the dict under constant mutation without needing an + # unreasonably large iteration count. + repo_ingestor_mod._REPO_HOST_RESOLVE_CACHE_TTL_SECONDS = 0.001 + repo_ingestor_mod._REPO_HOST_RESOLVE_CACHE_MAX_ENTRIES = 8 + + errors = [] + errors_lock = threading.Lock() + + def worker(worker_id): + for i in range(500): + host = f"race-host-{worker_id}-{i}.example.com" + try: + repo_ingestor_mod.RepoIngestor._resolve_repo_host_ips(host) + except Exception as exc: # pragma: no cover - failure path + with errors_lock: + errors.append(exc) + + try: + with patch( + "semantica.ingest.repo_ingestor.socket.getaddrinfo", + side_effect=fake_getaddrinfo, + ): + # daemon=True so a hung worker cannot also block the test + # process from exiting once it's reported below. + threads = [ + threading.Thread(target=worker, args=(n,), daemon=True) + for n in range(32) + ] + for t in threads: + t.start() + # Assert right after each join, not after the whole loop: + # join(timeout=30) alone does not fail the test if a thread + # hangs, and checking only once every thread has been + # joined means a mass hang costs up to 32*30s = 16 minutes + # before the test even reaches the check -- the exact + # CI-reliability problem this guards against. Failing on + # the first hung thread caps the worst case at ~30s. + for t in threads: + t.join(timeout=30) + assert not t.is_alive(), ( + f"worker thread {t.name} did not finish within " + f"the 30s join timeout (still running)" + ) + finally: + repo_ingestor_mod._REPO_HOST_RESOLVE_CACHE_TTL_SECONDS = orig_ttl + repo_ingestor_mod._REPO_HOST_RESOLVE_CACHE_MAX_ENTRIES = orig_max + + assert not errors, ( + f"Concurrent host resolution raised {len(errors)} error(s); " + f"first: {errors[0]!r}" + ) From 8a4ebafb9a999e2b879652b5b698b405617f614b Mon Sep 17 00:00:00 2001 From: hsd2514 <150319109+hsd2514@users.noreply.github.com> Date: Fri, 14 Aug 2026 22:21:04 +0530 Subject: [PATCH 039/105] fix(context): honor explicit causal edges in decision tracing (#983) * fix(context): honor explicit causal edges in decision tracing trace_decision_causality() inferred causes purely from shared NER entities plus timestamp ordering, so relationships recorded through add_causal_relationship() never affected the trace. When entity extraction returned nothing, trace_decision_chain() came back empty even though an explicit CAUSED edge was stored in the graph. Traverse the explicit CAUSED/INFLUENCED/PRECEDENT_FOR edges first, since they are the ground truth the caller recorded, and keep the entity and timestamp inference as an additive fallback for pairs with no explicit link. Edges whose source has no decision record (for example a graph restored via from_dict) are skipped so a stale edge cannot abort the trace. analyze_decision_influence() now reports explicitly linked decisions as direct influence rather than surfacing them only as indirect, and no longer lists the same decision under both direct and indirect. Closes #975 * fix(context): address review feedback on causal edge tracing Follow-up to the explicit causal edge fix, covering the issues raised in review. A stored edge weight of 0.0 was coerced to the 1.0 default by a truthiness check, inflating confidence_decay in the causal chain report. add_edge() is public and can create causal edges with any weight, so use an explicit None check instead. Explicit causes were collected into a dict keyed by source_id, so multiple causal edges between the same pair of decisions overwrote each other and only the last was traced. Collect every edge instead, keeping a separate set of source ids for the entity fallback exclusion. Cycle detection used a single traversal-wide visited set, so a decision reached through one branch became unreachable through another and branching graphs silently lost valid chains. Detect cycles per path instead; max_depth still bounds the traversal. Build a reverse index of causal edges once per call rather than scanning the edge list at every visited node, and use edge_type_index in the influence analysis. The three causal edge types are now a shared constant. Adds regression tests for zero weights, parallel edges, branching graphs and cycle termination. * fix(context): bound causal trace and report truncation Per-path cycle detection keeps branching graphs correct but makes the traversal combinatorial in max_depth: on a densely connected graph the number of distinct causal paths grows by roughly the branching factor per level, so a raised max_depth could return hundreds of thousands of chain reports and take seconds of CPU. Add a max_chains bound, defaulting to 10000. Rather than dropping chains silently, which is the exact failure this fix set out to eliminate, the traversal stops at the bound and appends a {"truncated": True, ...} marker so callers can always tell the trace is incomplete. A warning is logged with the same detail. Pass max_chains=None for the previous unbounded behaviour. Graphs that fit within the bound are unaffected. --------- Co-authored-by: Zohaib Hassnain <109234410+ZohaibHassan16@users.noreply.github.com> --- semantica/context/context_graph.py | 151 ++++++-- .../test_decision_causal_edge_regression.py | 325 ++++++++++++++++++ 2 files changed, 453 insertions(+), 23 deletions(-) create mode 100644 tests/context/test_decision_causal_edge_regression.py diff --git a/semantica/context/context_graph.py b/semantica/context/context_graph.py index da252b84..df7c71be 100644 --- a/semantica/context/context_graph.py +++ b/semantica/context/context_graph.py @@ -412,6 +412,12 @@ class ContextEdge: _ATTRS_MISSING = object() +#: Edge types that represent an explicitly recorded causal relationship between +#: two decisions. These are authoritative: they are what the caller asserted via +#: add_causal_relationship(), as opposed to relationships inferred from shared +#: entities and timestamps. +_CAUSAL_EDGE_TYPES = ("CAUSED", "INFLUENCED", "PRECEDENT_FOR") + class ContextGraph: """ @@ -2780,11 +2786,20 @@ class ContextGraph: direct_influence.discard(decision_id) direct_influence.update(self._decision_index.get(decision["category"], set())) direct_influence.discard(decision_id) + + # Explicit causal relationships recorded via add_causal_relationship() are + # ground truth and always count as direct influence, in either direction. + for edge_type in _CAUSAL_EDGE_TYPES: + for edge in self.edge_type_index.get(edge_type, []): + if edge.source_id == decision_id and edge.target_id in self._decisions: + direct_influence.add(edge.target_id) + elif edge.target_id == decision_id and edge.source_id in self._decisions: + direct_influence.add(edge.source_id) # Indirect influence (through graph relationships) indirect_influence = set() if include_indirect and self.config.get("advanced_analytics"): - indirect_influence = self._find_indirect_decision_influence(decision_id, max_depth) + indirect_influence = self._find_indirect_decision_influence(decision_id, max_depth) - direct_influence # Calculate influence scores influence_scores = {} @@ -2888,42 +2903,106 @@ class ContextGraph: def trace_decision_causality( self, decision_id: str, - max_depth: int = 5 + max_depth: int = 5, + max_chains: Optional[int] = 10000 ) -> List[Dict[str, Any]]: """ Trace causal chain for a decision. - + Args: decision_id: Decision to trace max_depth: Maximum depth for causal analysis - + max_chains: Maximum number of chains to return. Densely connected + graphs can contain a combinatorial number of distinct causal + paths, so the traversal stops once this many chains have been + collected and appends a ``{"truncated": True, ...}`` marker so + callers can tell the trace is incomplete. Pass None for no limit. + Returns: Causal chain as list of decision relationships """ if not hasattr(self, '_decisions') or decision_id not in self._decisions: raise ValueError(f"Decision {decision_id} not found") - + try: # Use graph traversal to find causal relationships causal_chain = [] - visited = set() - - def trace_recursive(current_id, depth, path): - if depth >= max_depth or current_id in visited: + chain_limit = float("inf") if max_chains is None else max_chains + truncated = False + + # Reverse index of explicit causal edges, built once per call so the + # traversal does not rescan the edge list at every visited node. + # Edges may reference decision nodes that were never recorded through + # record_decision() (e.g. a graph restored via from_dict), so only + # causes with a known decision record are kept. + incoming_causal_edges = defaultdict(list) + for edge_type in _CAUSAL_EDGE_TYPES: + for edge in self.edge_type_index.get(edge_type, []): + if edge.source_id in self._decisions: + incoming_causal_edges[edge.target_id].append(edge) + + def record_chain(cause_path): + """Record one chain. Returns False once the cap is reached.""" + nonlocal truncated + if len(causal_chain) >= chain_limit: + truncated = True + return False + causal_chain.append( + self._build_causal_chain_report(list(reversed(cause_path))) + ) + return True + + def trace_recursive(current_id, depth, path, path_ids): + # Cycle detection is per-path rather than global: a decision reached + # through one branch must stay traversable through another, otherwise + # branching graphs silently lose valid chains. max_depth bounds the + # traversal. + if truncated or depth >= max_depth or current_id in path_ids: return - - visited.add(current_id) + + path_ids = path_ids | {current_id} current_decision = self._decisions[current_id] - - # Find potential causes (decisions that influenced this one) + + # Explicit causal relationships recorded via add_causal_relationship() + # take precedence - they are the ground truth the caller recorded. + # Every edge is traced, so parallel relationships between the same + # pair of decisions are all reported rather than overwriting. + explicit_causes = incoming_causal_edges.get(current_id, []) + explicit_cause_ids = {edge.source_id for edge in explicit_causes} + + for edge in explicit_causes: + cause_id = edge.source_id + cause_dec = self._decisions[cause_id] + weight = getattr(edge, "weight", None) + # A stored weight of 0.0 is meaningful and must not be coerced + # to the 1.0 default. + edge_weight = 1.0 if weight is None else float(weight) + hop = { + "from": cause_id, + "from_scenario": cause_dec.get("scenario", ""), + "to": current_id, + "to_scenario": current_decision.get("scenario", ""), + "type": edge.edge_type, + "edge_weight": edge_weight, + } + cause_path = path + [hop] + if not record_chain(cause_path): + return + trace_recursive(cause_id, depth + 1, cause_path, path_ids) + if truncated: + return + + # Find potential causes (decisions that influenced this one) via + # shared entities/timestamps - additive heuristic, skipping anything + # already covered by an explicit relationship above. potential_causes = [] for entity in current_decision["entities"]: for other_decision_id in self._entity_index.get(entity, set()): - if other_decision_id != current_id: + if other_decision_id != current_id and other_decision_id not in explicit_cause_ids: other_decision = self._decisions[other_decision_id] if other_decision["timestamp"] < current_decision["timestamp"]: potential_causes.append(other_decision_id) - + for cause_id in potential_causes: cause_dec = self._decisions.get(cause_id, {}) edge_weight = float(cause_dec.get("confidence", 1.0)) @@ -2936,10 +3015,32 @@ class ContextGraph: "edge_weight": edge_weight, } cause_path = path + [hop] - causal_chain.append(self._build_causal_chain_report(list(reversed(cause_path)))) - trace_recursive(cause_id, depth + 1, cause_path) - - trace_recursive(decision_id, 0, []) + if not record_chain(cause_path): + return + trace_recursive(cause_id, depth + 1, cause_path, path_ids) + if truncated: + return + + trace_recursive(decision_id, 0, [], frozenset()) + + if truncated: + # Never drop chains silently: the caller is told the trace is partial. + self.logger.warning( + "Causal trace for %s truncated at %s chains; " + "raise max_chains or lower max_depth for a complete trace.", + decision_id, + max_chains, + ) + causal_chain.append({ + "truncated": True, + "max_chains": max_chains, + "message": ( + f"Causal trace truncated at {max_chains} chains. " + "The result is incomplete; raise max_chains or lower " + "max_depth for a complete trace." + ), + }) + return causal_chain except Exception as e: @@ -3408,21 +3509,25 @@ class ContextGraph: def trace_decision_chain( self, decision_id: str, - max_steps: int = 5 + max_steps: int = 5, + max_chains: Optional[int] = 10000 ) -> List[Dict[str, Any]]: """ Easy way to trace how decisions are connected. - + Args: decision_id: Starting decision max_steps: Maximum steps to trace - + max_chains: Maximum number of chains to return; see + trace_decision_causality(). Pass None for no limit. + Returns: Decision chain connections """ return self.trace_decision_causality( decision_id=decision_id, - max_depth=max_steps + max_depth=max_steps, + max_chains=max_chains ) def check_decision_rules( diff --git a/tests/context/test_decision_causal_edge_regression.py b/tests/context/test_decision_causal_edge_regression.py new file mode 100644 index 00000000..91bade6d --- /dev/null +++ b/tests/context/test_decision_causal_edge_regression.py @@ -0,0 +1,325 @@ +"""Regression tests for explicit causal edges in decision tracing (issue #975). + +``trace_decision_causality()`` used to infer causes purely from shared NER +entities plus timestamps, so relationships recorded through +``add_causal_relationship()`` had no effect on the trace. When entity +extraction found nothing, the chain came back empty even though an explicit +``CAUSED`` edge was stored in the graph. +""" + +from semantica.context import ContextGraph +from semantica.context.context_graph import ContextEdge + + +CAUSAL_EDGE_TYPES = ("CAUSED", "INFLUENCED", "PRECEDENT_FOR") + + +def _graph_with_linked_decisions(category_a="hardware", category_b="failover"): + """Two decisions joined by an explicit CAUSED edge.""" + graph = ContextGraph(advanced_analytics=True) + cause = graph.record_decision( + category=category_a, + scenario="Server Alpha fails", + reasoning="PSU defect on server Alpha", + outcome="flagged", + confidence=0.9, + ) + effect = graph.record_decision( + category=category_b, + scenario="Failover to server Beta", + reasoning="Failover triggered because of server Alpha outage", + outcome="approved", + confidence=0.9, + ) + graph.add_causal_relationship(cause, effect, relationship_type="CAUSED") + return graph, cause, effect + + +def test_trace_uses_explicit_edge_when_no_entities_extracted(): + """The issue's reproduction: explicit edge must drive the trace on its own.""" + graph, cause, effect = _graph_with_linked_decisions() + + # Precondition: the bug is only visible when NER finds nothing to overlap on. + assert graph._decisions[cause]["entities"] == [] + assert graph._decisions[effect]["entities"] == [] + + chains = graph.trace_decision_chain(effect) + + assert chains, "explicit CAUSED edge must produce a causal chain" + hops = [hop for chain in chains for hop in chain["hops"]] + assert any( + hop["from"] == cause and hop["to"] == effect and hop["type"] == "CAUSED" + for hop in hops + ) + + +def test_trace_reports_relationship_type_of_each_explicit_edge(): + for relationship_type in CAUSAL_EDGE_TYPES: + graph = ContextGraph(advanced_analytics=True) + cause = graph.record_decision( + category="a", scenario="upstream", reasoning="r", + outcome="approved", confidence=0.9, + ) + effect = graph.record_decision( + category="b", scenario="downstream", reasoning="r", + outcome="approved", confidence=0.9, + ) + graph.add_causal_relationship(cause, effect, relationship_type=relationship_type) + + hops = [hop for chain in graph.trace_decision_chain(effect) for hop in chain["hops"]] + assert [hop["type"] for hop in hops] == [relationship_type] + + +def test_trace_follows_multi_hop_explicit_chain(): + graph = ContextGraph(advanced_analytics=True) + first = graph.record_decision( + category="a", scenario="root cause", reasoning="r", + outcome="flagged", confidence=0.9, + ) + second = graph.record_decision( + category="b", scenario="mitigation", reasoning="r", + outcome="approved", confidence=0.9, + ) + third = graph.record_decision( + category="c", scenario="follow-up", reasoning="r", + outcome="approved", confidence=0.9, + ) + graph.add_causal_relationship(first, second, relationship_type="CAUSED") + graph.add_causal_relationship(second, third, relationship_type="CAUSED") + + chains = graph.trace_decision_chain(third) + traced = {(hop["from"], hop["to"]) for chain in chains for hop in chain["hops"]} + + assert (second, third) in traced + assert (first, second) in traced + + +def test_trace_survives_edge_referencing_unrecorded_decision(): + """Edges can outlive ``_decisions`` (e.g. a graph restored via from_dict). + + Such an edge must be skipped rather than aborting the whole trace. + """ + graph, cause, effect = _graph_with_linked_decisions() + + graph.add_node("ghost", "decision", content="never recorded via record_decision") + graph._add_internal_edge( + ContextEdge( + source_id="ghost", + target_id=effect, + edge_type="CAUSED", + weight=1.0, + metadata={}, + ) + ) + + chains = graph.trace_decision_chain(effect) + + assert not any("error" in chain for chain in chains) + hops = [hop for chain in chains for hop in chain["hops"]] + assert any(hop["from"] == cause for hop in hops), "valid chain must survive" + assert not any(hop["from"] == "ghost" for hop in hops) + + +def test_explicitly_linked_decision_counts_as_direct_influence(): + """Differing categories, so the category-match shortcut cannot mask the bug.""" + graph, cause, effect = _graph_with_linked_decisions( + category_a="hardware", category_b="failover" + ) + + impact = graph.analyze_decision_impact(cause) + direct_ids = {entry["decision_id"] for entry in impact["direct_influence"]} + indirect_ids = {entry["decision_id"] for entry in impact["indirect_influence"]} + + assert effect in direct_ids + assert effect not in indirect_ids + + +def test_influence_is_not_double_counted_as_direct_and_indirect(): + graph, cause, effect = _graph_with_linked_decisions( + category_a="shared", category_b="shared" + ) + + impact = graph.analyze_decision_impact(cause) + direct_ids = {entry["decision_id"] for entry in impact["direct_influence"]} + indirect_ids = {entry["decision_id"] for entry in impact["indirect_influence"]} + + assert not direct_ids & indirect_ids + + +def test_explicit_edge_weight_of_zero_is_preserved(): + """``add_edge()`` is public and can create causal edges with any weight. + + A stored 0.0 must not be coerced to the 1.0 default, which would inflate + ``confidence_decay`` in the causal-chain report. + """ + graph = ContextGraph(advanced_analytics=True) + cause = graph.record_decision( + category="a", scenario="upstream", reasoning="r", + outcome="approved", confidence=0.9, + ) + effect = graph.record_decision( + category="b", scenario="downstream", reasoning="r", + outcome="approved", confidence=0.9, + ) + graph.add_edge(cause, effect, "CAUSED", weight=0.0) + + chains = graph.trace_decision_chain(effect) + + assert [hop["edge_weight"] for chain in chains for hop in chain["hops"]] == [0.0] + assert [chain["confidence_decay"] for chain in chains] == [0.0] + + +def test_parallel_causal_edges_are_all_traced(): + """Multiple causal edges between the same pair must not overwrite each other.""" + graph = ContextGraph(advanced_analytics=True) + cause = graph.record_decision( + category="a", scenario="upstream", reasoning="r", + outcome="approved", confidence=0.9, + ) + effect = graph.record_decision( + category="b", scenario="downstream", reasoning="r", + outcome="approved", confidence=0.9, + ) + graph.add_edge(cause, effect, "CAUSED", weight=0.8) + graph.add_edge(cause, effect, "INFLUENCED", weight=0.3) + + hops = [hop for chain in graph.trace_decision_chain(effect) for hop in chain["hops"]] + + assert sorted(hop["type"] for hop in hops) == ["CAUSED", "INFLUENCED"] + assert sorted(hop["edge_weight"] for hop in hops) == [0.3, 0.8] + + +def test_branching_graph_does_not_drop_alternative_chains(): + """Diamond graph: both routes through the shared ancestor must be reported. + + Cycle detection is per-path, so visiting ``S`` via one branch must not + prevent reaching it again through the other. + """ + graph = ContextGraph(advanced_analytics=True) + ids = { + name: graph.record_decision( + category="ops", scenario=name, reasoning="r", + outcome="approved", confidence=0.9, + ) + for name in ("R", "S", "A", "B", "D") + } + names = {decision_id: name for name, decision_id in ids.items()} + for source, target in [("R", "S"), ("S", "A"), ("S", "B"), ("A", "D"), ("B", "D")]: + graph.add_causal_relationship(ids[source], ids[target], relationship_type="CAUSED") + + chains = graph.trace_decision_chain(ids["D"], max_steps=10) + paths = { + " -> ".join( + [names[hop["from"]] for hop in chain["hops"]] + + [names[chain["hops"][-1]["to"]]] + ) + for chain in chains + } + + assert "R -> S -> A -> D" in paths + assert "R -> S -> B -> D" in paths + + +def test_cyclic_causal_edges_terminate(): + """A causal cycle must not recurse forever once cycle detection is per-path.""" + graph = ContextGraph(advanced_analytics=True) + first = graph.record_decision( + category="a", scenario="A", reasoning="r", outcome="approved", confidence=0.9, + ) + second = graph.record_decision( + category="b", scenario="B", reasoning="r", outcome="approved", confidence=0.9, + ) + third = graph.record_decision( + category="c", scenario="C", reasoning="r", outcome="approved", confidence=0.9, + ) + graph.add_causal_relationship(first, second, relationship_type="CAUSED") + graph.add_causal_relationship(second, third, relationship_type="CAUSED") + graph.add_causal_relationship(third, first, relationship_type="CAUSED") + + chains = graph.trace_decision_chain(first, max_steps=5) + + assert chains + assert not any("error" in chain for chain in chains) + + +def _dense_causal_graph(levels, width): + """Layered DAG where every decision in a layer causes every one in the next.""" + graph = ContextGraph(advanced_analytics=True) + layers = [] + for level in range(levels): + layers.append([ + graph.record_decision( + category="ops", scenario=f"L{level}n{index}", reasoning="r", + outcome="approved", confidence=0.9, + ) + for index in range(width) + ]) + for level in range(levels - 1): + for source in layers[level]: + for target in layers[level + 1]: + graph.add_causal_relationship(source, target, relationship_type="CAUSED") + return graph, layers[-1][0] + + +def test_dense_graph_is_bounded_and_reports_truncation(): + """Per-path traversal is combinatorial, so the result must stay bounded. + + Truncation is reported rather than silently dropping chains, which is the + very failure this module exists to prevent. + """ + graph, sink = _dense_causal_graph(levels=9, width=5) + + chains = graph.trace_decision_chain(sink, max_steps=9, max_chains=500) + + markers = [chain for chain in chains if chain.get("truncated")] + assert len(markers) == 1, "truncation must be reported exactly once" + assert markers[0]["max_chains"] == 500 + assert len(chains) == 501, "500 chains plus the marker" + + +def test_small_graph_reports_no_truncation(): + """The cap must not alter results for graphs that fit within it.""" + graph, sink = _dense_causal_graph(levels=5, width=2) + + chains = graph.trace_decision_chain(sink) + + assert chains + assert not any(chain.get("truncated") for chain in chains) + + +def test_max_chains_none_disables_the_cap(): + graph, sink = _dense_causal_graph(levels=5, width=5) + + capped = graph.trace_decision_chain(sink, max_chains=100) + uncapped = graph.trace_decision_chain(sink, max_chains=None) + + assert len(capped) == 101 + assert not any(chain.get("truncated") for chain in uncapped) + assert len(uncapped) > len(capped) + + +def test_entity_based_inference_still_applies_without_explicit_edges(): + """The entity heuristic remains as a fallback; it must not be regressed.""" + graph = ContextGraph(advanced_analytics=True) + earlier = graph.record_decision( + category="ops", scenario="first", reasoning="r", + outcome="approved", confidence=0.9, + ) + later = graph.record_decision( + category="ops", scenario="second", reasoning="r", + outcome="approved", confidence=0.9, + ) + + # Simulate NER having produced a shared entity between the two decisions. + shared_entity = "server_alpha" + for decision_id in (earlier, later): + graph._decisions[decision_id]["entities"] = [shared_entity] + graph._entity_index.setdefault(shared_entity, set()).update({earlier, later}) + graph._decisions[earlier]["timestamp"] = graph._decisions[later]["timestamp"] - 60 + + hops = [hop for chain in graph.trace_decision_chain(later) for hop in chain["hops"]] + + assert any( + hop["from"] == earlier and hop["to"] == later and hop["type"] == "influences" + for hop in hops + ) From 4513b61e400472651265c3099674abd4de9361ff Mon Sep 17 00:00:00 2001 From: Yunare Maia Date: Fri, 14 Aug 2026 14:10:23 -0300 Subject: [PATCH 040/105] ci: pin Python dependencies in requirements-ci.txt for reproducible CI (#945) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ci: pin Python dependencies in requirements-ci.txt for reproducible CI Adds a committed lockfile pinning all transitive dependencies at exact versions (uv pip compile, Python 3.11, all extras — 1581 lines), the Python equivalent of explorer/package-lock.json + npm ci. - CI installs from requirements-ci.txt before building the wheel - CI verifies the lockfile is byte-identical to a fresh compile (fails on staleness after pyproject.toml changes) - CONTRIBUTING documents the regeneration command Closes #938 Signed-off-by: Yunare Maia * ci: address Qodo review — security scans use pinned deps, exclude gpu extras - security-scan.yml installs from requirements-ci.txt instead of "./[llm-litellm]" so Safety scans the exact CI/release dependency tree - security.yml runs pip-audit -r requirements-ci.txt for the same parity - lockfile regenerated with --extra all (the cross-platform set) instead of --all-extras, which pulled faiss-gpu/cupy from the Linux-only gpu extra and co-installed faiss-cpu + faiss-gpu in CI - uv pinned to 0.12.1 (the version that generated the lockfile) in CI and CONTRIBUTING so regeneration is deterministic Signed-off-by: Yunare Maia * ci: make lockfile staleness check immune to upstream releases The previous check re-resolved pyproject.toml without constraints, so any upstream package release (e.g. boto3 1.43.69 -> 1.43.70) failed CI even when nothing in the repo changed — exactly the time-dependent drift Qodo flagged. The check now re-resolves with requirements-ci.txt as a constraint and compares only version lines, so it detects intentional pyproject.toml changes but ignores upstream releases. CONTRIBUTING updated to match. Signed-off-by: Yunare Maia * ci: fix security workflows — install pip-audit; order tooling after pinned deps Security workflow: the pip-audit install step was lost in the rebase conflict merge — pip-audit was invoked but never installed (exit 127). Security-scan workflow: installing safety first let the pinned requirements-ci.txt overwrite its transitive deps (rich), breaking the safety CLI at runtime (RuntimeError: Type not yet supported). Tooling is now installed AFTER the pinned set. Signed-off-by: Yunare Maia * fix(ci): address review — hashes, build isolation, release builds, docs (4/4) ZohaibHassan16's review flagged 4 supply-chain gaps; all addressed: 1. **Release builds now use the lockfile**: release.yml installs requirements-ci.txt and runs `python -m build --no-isolation` so the sdist/wheel is built against the exact tested dependency set. 2. **Build isolation pinned**: [build-system].requires is now setuptools==84.0.0 + wheel==0.48.0 (exact pins, no ranges). 3. **Hashes**: requirements-ci.txt regenerated with --generate-hashes (5,708 sha256 hashes, verified against PyPI). Staleness check updated to strip the `\` line continuations hashes introduce. 4. **CONTRIBUTING.md documents the separate environment**: hashes, never-install-into-dev note, build-system pins, --no-isolation release builds. Validated: stale-check diff clean, hash spot-check matches PyPI. Signed-off-by: Yunare Maia * fix(ci): apply --no-isolation to CI build + align benchmark to Python 3.11 Follow-up to ZohaibHassan16's second review round: 1. ci.yml was still running `python -m build` with build isolation (unpinned setuptools/wheel from PyPI) — now `python -m build --no-isolation` against the pinned deps, matching release.yml. 2. benchmark.yml was on Python 3.12 while the lockfile is compiled for 3.11 — aligned to 3.11 so every workflow runs the same environment. Signed-off-by: Yunare Maia * fix(ci): install pinned wheel before --no-isolation build python -m build --no-isolation failed with 'Missing dependencies: wheel==0.48.0' because wheel is build-time only — uv's lockfile excludes it, so installing requirements-ci.txt alone left the build env without it. Both ci.yml and release.yml now install wheel==0.48.0 (the same pin [build-system] declares) before building. Validated locally: wheel builds clean with --no-isolation. Signed-off-by: Yunare Maia --------- Signed-off-by: Yunare Maia Co-authored-by: Zohaib Hassnain <109234410+ZohaibHassan16@users.noreply.github.com> --- .github/workflows/benchmark.yml | 4 +- .github/workflows/ci.yml | 22 +- .github/workflows/release.yml | 10 +- .github/workflows/security-scan.yml | 11 +- .github/workflows/security.yml | 21 +- CONTRIBUTING.md | 33 + pyproject.toml | 2 +- requirements-ci.txt | 7167 +++++++++++++++++++++++++++ 8 files changed, 7250 insertions(+), 20 deletions(-) create mode 100644 requirements-ci.txt diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml index f75c199a..0157ee24 100644 --- a/.github/workflows/benchmark.yml +++ b/.github/workflows/benchmark.yml @@ -17,10 +17,10 @@ jobs: with: fetch-depth: 0 - - name: Set up Python 3.12 + - name: Set up Python 3.11 uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7 with: - python-version: "3.12" + python-version: "3.11" cache: 'pip' - name: Install Dependencies diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6122e91b..4ea31ff8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,8 +42,28 @@ jobs: - name: Build Explorer frontend working-directory: explorer run: npm run build + - name: Install pinned Python dependencies + run: | + pip install -r requirements-ci.txt + - name: Verify requirements-ci.txt is up to date + run: | + pip install uv==0.12.1 + # Re-resolve with the committed file as a constraint: upstream package + # releases must NOT fail CI (deps only change when pyproject.toml + # changes intentionally). Compare only version lines (pkg==ver), + # ignoring the -c constraint comments and the `\` line continuations + # that --generate-hashes emits. + uv pip compile pyproject.toml --python-version 3.11 --extra all \ + --constraint requirements-ci.txt -o /tmp/requirements-ci-check.txt + diff \ + <(grep -E '^[a-zA-Z0-9._-]+==' requirements-ci.txt | sed 's/ \\$//') \ + <(grep -E '^[a-zA-Z0-9._-]+==' /tmp/requirements-ci-check.txt) - run: pip install build - - run: python -m build + # wheel is build-time only (not in requirements-ci.txt) — install the + # same pinned version [build-system] declares so --no-isolation works. + - run: pip install wheel==0.48.0 + - name: Build package (no isolation — pinned deps) + run: python -m build --no-isolation - name: Verify Explorer frontend is packaged run: | python - <<'PY' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 302ede85..bbc8770c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -36,8 +36,16 @@ jobs: run: | npm ci npm run build + # Install the pinned dependency set (with hashes) so the sdist/wheel + # build runs against the same versions CI tests against. + - name: Install pinned build dependencies + run: pip install -r requirements-ci.txt - run: pip install build - - run: python -m build + # wheel is build-time only (not in requirements-ci.txt) — install the + # same pinned version [build-system] declares so --no-isolation works. + - run: pip install wheel==0.48.0 + - name: Build package (no isolation — pinned deps) + run: python -m build --no-isolation - name: Verify Explorer frontend is packaged run: | python - <<'PY' diff --git a/.github/workflows/security-scan.yml b/.github/workflows/security-scan.yml index ab742e76..5b4461af 100644 --- a/.github/workflows/security-scan.yml +++ b/.github/workflows/security-scan.yml @@ -45,11 +45,14 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip + # Install the pinned dependency set FIRST so Safety scans Semantica's + # exact CI/release dependency tree (requirements-ci.txt is generated + # from pyproject.toml extras, so this covers the project's real deps). + pip install -r requirements-ci.txt + # Tooling AFTER the pinned set: installing safety/bandit/semgrep/jq + # first lets the pinned requirements overwrite their transitive deps + # (e.g. rich), which breaks the safety CLI at runtime. pip install safety bandit semgrep jq - # Install the project itself (core deps + the LiteLLM provider extra) - # so Safety scans Semantica's actual dependency tree, not just the - # scanner tools' own dependencies. - pip install -e ".[llm-litellm]" - name: Run Safety Check (Package Vulnerabilities) run: | diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 5fb86cde..412e7eaa 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -8,6 +8,7 @@ on: branches: [main] paths: - 'pyproject.toml' + - 'requirements-ci.txt' - '.github/workflows/security.yml' permissions: @@ -23,21 +24,19 @@ jobs: python-version: '3.11' # Upgrade first: actions/setup-python's baked-in setuptools has been # behind known-vulnerable floors before (e.g. PYSEC-2026-3447 / - # CVE-2026-59890, fixed in 83.0.0) regardless of what this project's - # own [build-system] requires -- that only governs isolated build - # environments, not the ambient one pip-audit scans here. + # setuptools 75.1.0), so don't trust the preinstalled one. - run: python -m pip install --upgrade pip setuptools - - run: pip install pip-audit - # Install the [all] extra so pip-audit sees every optional dependency - # group (fastapi, python-multipart, etc.), not just pip-audit's own - # deps. PYSEC-2024-38 (#869) shipped in the first place because - # neither this job (bare env, no extras) nor security-scan.yml's - # Safety check (installs only [llm-litellm]) ever had fastapi or + # Audit the pinned dependency set (requirements-ci.txt is compiled from + # pyproject.toml with --extra all — the same coverage as the [all] + # extra, minus the Linux-only gpu set — so this keeps scan parity with + # CI/release builds without a time-dependent resolution). This is the + # fix for PYSEC-2024-38 (#869): the bare-env job never had fastapi or # python-multipart installed to look at. - - run: pip install -e ".[all]" + - run: pip install -r requirements-ci.txt # PR runs gate on findings, since they're scoped to actual # pyproject.toml changes under review. The schedule/workflow_dispatch # runs stay non-blocking until a full pass over pre-existing findings # across the whole [all] tree has been done. - - run: pip-audit + - run: pip install pip-audit + - run: pip-audit -r requirements-ci.txt continue-on-error: ${{ github.event_name != 'pull_request' }} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b05b1c4b..3edcc239 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -181,6 +181,39 @@ pip install -e ".[dev]" pre-commit install ``` +### Pinned CI dependencies + +`requirements-ci.txt` pins every transitive dependency at exact versions so CI, +security scans, and release builds install the same packages every run (the +Python equivalent of `explorer/package-lock.json` + `npm ci`). It is a +**separate build environment**: every package carries a SHA-256 hash +(`--generate-hashes`), so installs are reproducible and supply-chain safe — +never install into your local dev environment from it. + +Regenerate it after changing `pyproject.toml` dependencies: + +```bash +pip install uv==0.12.1 +uv pip compile pyproject.toml --python-version 3.11 --extra all --generate-hashes -o requirements-ci.txt +``` + +The `all` extra is the repo's cross-platform dependency set (GPU extras like +`faiss-gpu`/`cupy` are excluded and installed separately on Linux — see +`pyproject.toml`). Keep the pinned `uv` version in sync with CI so regeneration +is deterministic. + +CI's staleness check re-resolves with the committed lockfile as a constraint +and compares version lines only: upstream package releases never fail CI — +the lockfile changes only when `pyproject.toml` changes intentionally. + +CI fails if `requirements-ci.txt` is stale relative to `pyproject.toml` +(the version-line comparison detects new/removed/changed dependencies). + +Build-system pins: `[build-system].requires` is pinned to exact versions +(`setuptools==84.0.0`, `wheel==0.48.0`) and release builds run +`python -m build --no-isolation` against the lockfile — no unpinned +build-time isolation anywhere. + ### 3. Create Branch ```bash diff --git a/pyproject.toml b/pyproject.toml index 577cb94f..03949d4e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [build-system] -requires = ["setuptools>=83.0.0", "wheel"] +requires = ["setuptools==84.0.0", "wheel==0.48.0"] build-backend = "setuptools.build_meta" [project] diff --git a/requirements-ci.txt b/requirements-ci.txt new file mode 100644 index 00000000..d57fa744 --- /dev/null +++ b/requirements-ci.txt @@ -0,0 +1,7167 @@ +# This file was autogenerated by uv via the following command: +# uv pip compile -p 3.11 --extra all --generate-hashes -o requirements-ci.txt pyproject.toml +accelerate==1.14.0 \ + --hash=sha256:41b9c4377a54e0b460a959b0defa1b736e4ca0a2373252d9a539964c2afe3c8d \ + --hash=sha256:e94390c2863b873be18f623f9df48a0d8fe5eff13ea7f1a00092b0a7904888c6 + # via + # docling-ibm-models + # docling-slim +agno==2.8.7 \ + --hash=sha256:6a2763eb469163f7b79ab1da6ca2f22d8619f6b9d614574f975d9c12bb4323ea \ + --hash=sha256:d49396a2062ee6994ca82695b9bd1e1b95667fec432c544afa38133e564bf090 + # via semantica (pyproject.toml) +agnoctl==0.1.3 \ + --hash=sha256:6fce1d2482b1f2e0a3d14b0a7c12fbd49d8df4f0bf0a4fd9fd91753cbff5efdc \ + --hash=sha256:94e1570cf2673ace2d7fa347b51c5fb2416d6f993a0a24b4fca71b04b15e72dd + # via agno +aiohappyeyeballs==2.7.1 \ + --hash=sha256:065665c041c42a5938ed220bdcd7230f22527fbec085e1853d2402c8a3615d9d \ + --hash=sha256:9243213661e29250eb41368e5daa826fc017156c3b8a11440826b2e3ed376472 + # via aiohttp +aiohttp==3.14.3 \ + --hash=sha256:03cd2bde3d7f085b64e549c985f4bb928cad7e8ecf5323bfca320db548d81b39 \ + --hash=sha256:041badb8f84396357c4d3ad26de6afd7a32b112f43d3c63045c0c8278cfd2043 \ + --hash=sha256:0a5ff2dfbb9ce645fa5b8ef3e02c6c0b9cc3f6030ff863d0c51fffc50cb5541b \ + --hash=sha256:0fdea2281997af69da84c77ffa6f5938a0285f21fb3887c249d67419ca865b3d \ + --hash=sha256:11fb37ef075669eee52ab1928fbf6e1741fada40409fa309ebde9607a962aebf \ + --hash=sha256:134ac5ddcf61c6fad984b9a5727d83492ada43d63471db20fb73042c13fca62f \ + --hash=sha256:152516815ef926786a0b6ae2b8f1fd2e0c71582dee0b435636865316fd4891b7 \ + --hash=sha256:1576145bdceeb92382d899751e12743a3a5b8e460a841e3e50543859e54864dc \ + --hash=sha256:16100ad3ab8d649fdfbee87602d9d2dcdca9df0b9eda8a1b5fdc0d41f96da559 \ + --hash=sha256:16ea7e24c309fb7c0bbd505d149abe4fe4dccfb8db911db7dbec0921bc889a6f \ + --hash=sha256:18c441d0a8fca6de8d1f546849b9f0ab20d435993e2c5b59562b2fae6be2f929 \ + --hash=sha256:18cb43369747b2ae007bd2655fb8e63a099c2ff1d207962943636dac989b3147 \ + --hash=sha256:1b59533861b70a2185c8f4f350f791f39d64358ef6944ce71c5240c9ec0982c9 \ + --hash=sha256:1c5281acc88b92396f88c7e1e2748f8466689df22b80170e4f51efa712fb47a8 \ + --hash=sha256:1c5ec8fb1bcc31a8466f74aaf26c345d5c386fa4bd08a3f0eb9c7a4a3fe8b5bf \ + --hash=sha256:1caa7b0d05f3e3a36f87788c59e970a7ee1cefcfcbb924a9f138c4a6551c9cb7 \ + --hash=sha256:21c016079415ed3fd676963e9793700a566d85dbbd6bfc564b9b2d209147dcc8 \ + --hash=sha256:2498f0fe69ead802f9675beca44a7c21c62fdaa4ec5145ea1c3ad6edbee29f85 \ + --hash=sha256:25bd2708db6bdf6a6630dd37bdcdfcb47c4434d22ac69c64665b802910140b30 \ + --hash=sha256:270d3dace9ca2f10f0da5d8ebe519b7a310fc6112ed916e32df5866df0888553 \ + --hash=sha256:2e1161602f45a54de2ce0905243a95f58cb42dcd378402f3697f5e0b21e9d2e7 \ + --hash=sha256:2e9878ae68e4a5f1c0abe4dd497dbc3d51946f5837b56759e2a02e78fa90ef86 \ + --hash=sha256:30402d03a7c0ff52bce290b57e564e9079fd9d0cb545c8aba73f86a103162d2e \ + --hash=sha256:33a2d7c28d33797a2e99923dffa63f83d908a19b6bf26cfe80fa790aa5e1a75a \ + --hash=sha256:362a3fd481769cac1a824514bcd86fda51c65e8fe6e051099e008fddde6db17c \ + --hash=sha256:38901a84da3ce22249f6e860bf8f90d141bcab7da090cc398f8bb58c0e44b7da \ + --hash=sha256:39aded8c7f3b935b54aab1d8d73c70ec0ee2d3ec3b943e0e86611bc150ba47f5 \ + --hash=sha256:3a26434dafe408229ff3403458ca58de24fb51936504decac49ce6755f77e59d \ + --hash=sha256:3ae5b3a59436d089b5395d910121a390feed4d00578eb95a0fd1a329fe963100 \ + --hash=sha256:3d4f72af88ac2474bb5bca640030320e3d38a0163a1d7533500e87be458eef71 \ + --hash=sha256:3f42e9b78301f11c8f861746175d8b9c1ccef713fcad9eab396e2f6db8ed4a22 \ + --hash=sha256:42a67efc36300d052fb4508a53e8b6901b9284b599ae63945c377569c5fcc1e1 \ + --hash=sha256:48d67b87db6279c044760787eb01f6413032c2e6f3ba1cafaa492b1c8e578479 \ + --hash=sha256:498c6c623134f8e09a3c4e60bcd607a0b4590dd7dbf08dd40851b27cbb520ccb \ + --hash=sha256:49f7325beb0f85ef4aef5f48f490269575f83e6e2acad00a1d80b807eb027062 \ + --hash=sha256:4e3ac92d90e92773b2362d506068e9a948192bd553e743c5b2429e28527c8661 \ + --hash=sha256:530125ee1163c4219af35dc3aa1206e541e7b31b6efc1a3f93b70a136f65d427 \ + --hash=sha256:5373dc80ad1aa2fb9ad95c83f24eef418bbda3a61375f128e5b0192e4f3f9b32 \ + --hash=sha256:53e5179d8abb5710f8e83ba207c41c8d1261fcffd4616500e15ca2b7a33be10a \ + --hash=sha256:53e7b4ce82b54a8bcc71b3b67a5cbd177ca1d7f592cbc92cd38b7349f73482db \ + --hash=sha256:543906c127fb1d929b95076db19b83fa2d46751006ff1e23b093aa5ac4d8db42 \ + --hash=sha256:54cfcdee2770dac994417cbb0ee1f3eb0e7cb6b30c79bf44f2c02ff79ec5124a \ + --hash=sha256:55bdcc472aafe2de4a253045cc128007a64f1e0264fb675791e132ea5edaa3bd \ + --hash=sha256:56f355e79f71aef2a85c80305cc915f894b170dba76de5fe84f6351939b83c06 \ + --hash=sha256:5895ef58c4620afe02fa16044f023dc4dafec08158f9d08874a46a7dbc0341b8 \ + --hash=sha256:5bcb6ff3fdab1258a192679ff1a05d44f59626430aa05cd1a9d2447423599228 \ + --hash=sha256:5f08ec777f35ee70720233b8b9811d3bb5d728137f30ac91b7457709c3261ac0 \ + --hash=sha256:614c61d478b83953e261d02bb2df750f17227cd33ef8002945bf5aebbde21919 \ + --hash=sha256:617105e2c3018ee38d0c8ce5ee3c84f621a6d8b9f723202aacaff28449ca91ee \ + --hash=sha256:6debfa7312ff9d4c124dc71d72e9a0a4b9e0879e48ba6fcb42bef5c3300289e2 \ + --hash=sha256:7041d52c3a7fa20c9e8c182b534704abb19502c8bdcbde7ab23bfda6f642394f \ + --hash=sha256:70c987b27534f9ae1a723f47ae921571d616da21d3208282bf4c52af5164ac43 \ + --hash=sha256:74ab5b6a9fb13e873e5a90946588baecaf488745e1db1a4a5c433f971f035098 \ + --hash=sha256:78253b573e6ffab5028924fc98bc281aae05445969982a10864bc360dea2016c \ + --hash=sha256:7a75aa63cbf9b21cfaf60dc2657e19df2c2867d91707d653fee171ffeedd1371 \ + --hash=sha256:8800c996b01c2772a783e3e46f3e1abd5823029adca0df54231960de9bfefa5b \ + --hash=sha256:89176250f686cb9853c0fb7ead90e639e915b84a6f43eedc2a4e7ec21f1037f0 \ + --hash=sha256:8a5fd34f7f7410d1730d5c2ba873cacb2eed3fede366feb268a70ba22581ed8f \ + --hash=sha256:8b3b60de05f3dcb6f6a00f818bb2ec781cee4de0645f59ccaf99b1d1823b6100 \ + --hash=sha256:8f2f1c4c032c7cedd7d8da6f54c97b70266c6570c3108d3fdffee7188bb70529 \ + --hash=sha256:9491196535a88924a60afd5b5f434b5b203b6cc616250878dbdb223a8f7844bc \ + --hash=sha256:9aa6e61fdf20105c4144e755bd586008ff450791d67b1c8146fdc15959c4d51c \ + --hash=sha256:9d9edccfe496b476db5f398d97b865e9a6752bcf8aec4eef8390ce20fb64bb41 \ + --hash=sha256:9fc7b5bfec6573f3ae844f457fdde5adeb713f8b8e4a81ad64fc207b49383716 \ + --hash=sha256:a0dc483c00da8b673abbb367eb6f8d8f4bcec30eb58529ea13cb42e7fd2dfa33 \ + --hash=sha256:a3a8296e7ab5c295f53f1041487cb088e1480775aafbf7fe545d93b770a0f96f \ + --hash=sha256:a3e22975f905b89a55a488c2a08f2fdb2186175349e917d48985cc468a3d4c6e \ + --hash=sha256:a4af35c443e0b1a1bd6a8af3f3485d7fda15c142751a00f3ff8090f0b93346fa \ + --hash=sha256:a94dbaae5ae27bd849c93570669bff91e0510f33a80805738e3de72a7be0447b \ + --hash=sha256:ac74facc01463f138b0da5580329cfcc82818dea5656e83ddcd11268fc12ff80 \ + --hash=sha256:ad4c8b7488d745d2ca4838ebd8ae5ba9b56341d30b1da43640e4ce87f9f49646 \ + --hash=sha256:b014a6ed7cf912e787149fdc529166d3ceabac23f26efeea3158c9aba2354e7e \ + --hash=sha256:b20032766aedf6261c7a566585a40867d092ac03a0d81592d5370ef9b054f99b \ + --hash=sha256:b2466434105a4e03113c36ec775cc2ebe6676b62eae326fa670bb607ef788c1c \ + --hash=sha256:b304db572b4368edd8dda8a2274f73156fe15558fca4a917cb8a09fc47af5963 \ + --hash=sha256:ba59d59aba08ac02fc03b0c8983ccd5ee39a199d0552ce9e6d2b4845b34d59ae \ + --hash=sha256:bd52f811e65f6fb634b1047159657c98f52b407f8efec907bcfc09da9a4c0a25 \ + --hash=sha256:bdd0e2834dce1a26c1bbe26464861e16bbe217042cbff619247c11594472518c \ + --hash=sha256:c23ec8ee9d5ab2f5421f9c7fffce208435607af27fd46d4a44e031954352838f \ + --hash=sha256:c39846c3aad97a8530c89d7a3869a8f8e9e3762c6ac0504481e5c80948f7e807 \ + --hash=sha256:c3c200cf9757edd785051dc699c7ecbec22110dbfcb3fefc7a9f9695eda8ea7a \ + --hash=sha256:c7d3a97c678d34fc5b59da671ee9cd630096ddc643e7b5a30d54a2a6f3574d3f \ + --hash=sha256:c8653fd547c93a61aadc612007790f5555cdd18946fa48cf45e26d8ea4ea473d \ + --hash=sha256:cc7cb243a68167172f48c1fd43cee91ec4b1d40cefd190edd43369d1a6bc9c82 \ + --hash=sha256:ccd4893707b3e2a13e39c90d43cf80edf2e4d0457935bcc103bf2346214c3f15 \ + --hash=sha256:cd817772b2fcf2b8c0905795318485f9ec16eae60b29feb7f4c77085311637f0 \ + --hash=sha256:cda5fd5c95ad7a125a2e8464acc78b98b94c475a3780d6aa0aa157c93f470f4d \ + --hash=sha256:cef89a58e628c4efcac3275c2d68083f82426dcdc89c1492a6f654f9f7ea6ab9 \ + --hash=sha256:d1558173930a5a8d3069cee5c92fc91c87c4dbcb099debbb3622053717145a19 \ + --hash=sha256:d6088ec9894113802bddb3c09e974929aed2c7b3a8c456219b8aab4481f1a239 \ + --hash=sha256:d6218d92e450824e9b4881f44e8c09f1853b490f9a64130801024a4793b1b3b0 \ + --hash=sha256:d77640cc618c1d99fc4f8589c0f24a730adfa54eb1e57ef7bf0c8dfb78da898c \ + --hash=sha256:d7d2deec16eeedf55f2c7cf75b521ea3856a5177e123844f8fd0f114ce252cb5 \ + --hash=sha256:db332af25642007330fca8be5c4d194caf2bea7a7fc84415aff3497af5dfee6b \ + --hash=sha256:dd54d0e8717de95939766febac482ac0474d8ac3b048115f9f2b1d23a16e7db4 \ + --hash=sha256:ddcac3c6b382e81f1dd0499199d4136b877beb4cb5ef770bbbfba56c4b8f55d2 \ + --hash=sha256:df82f3787c940c94986b34222d59c9e38843fba85139f36e85255a82ad5355a9 \ + --hash=sha256:dfa68deb2a443bdaa3ea5297b0699c1464f08aef3812b486d1348eee61b07dc0 \ + --hash=sha256:dff9461ec275f22135650d5ba4b4931a11f3958df7dfbb8db630000d4dee0883 \ + --hash=sha256:e1e74298bab6ee0d6e749ed4fd1901c7e604bdda32c03d787a2cc71c46d0433d \ + --hash=sha256:e2667f0bbe7eb6c74eae5e9691441ad186e5845ca3cff63230fc09c4e7514f5d \ + --hash=sha256:e3be98a7c30b8c25d573dafba7171d66dfb05ee6a9070fc46535464ff97700a6 \ + --hash=sha256:e568e14940c09955aa51f4e645b6daa18a581c5dcfcd73744dcc86a856e3ced3 \ + --hash=sha256:e72ee89e28d907a18f46959b4eb0bb06701cc7f8cf4366e00029e2ccfaaf5924 \ + --hash=sha256:e92eb8acc45eb6a9f4935071a77edf5b85cc6f8dfad5cd99e97653c26593cdde \ + --hash=sha256:ea05e1f97ceea523942d9b2a7d7c0359d781d683d6b043f5943a602b14da4787 \ + --hash=sha256:eac645b09bcfdf73df7536331f0678c1086ea250981118ddb5199e17ccef72bb \ + --hash=sha256:eb0495d778817619273c108784292be161a924b9f5ae5cbbc70a2caa6838250b \ + --hash=sha256:ebe8e504f058fe91223351cecd2d9d6946c9d241bb0250d898ffbdf584cc72b0 \ + --hash=sha256:ed099d105449c4f9e84f24af203cd131349d4761d8813fa7e02c32e7128cd910 \ + --hash=sha256:f0f177d1b195b9e06376cfd7d308d8a1b920909a609d03ac82a8c73bbb16d3b9 \ + --hash=sha256:f3d2669fe7dec7fc359ecdb5984b29b50d85d5d00f8c1cb61de4f4a24ee42627 \ + --hash=sha256:f4e05329faa0ea1a404b37de4f034fd2c2defcca06a68dc6745e4e56c88e8a48 \ + --hash=sha256:f53bcd52f585e1ac3e590d61434eb61f9a88c38df041b4ea126d97144344a77b \ + --hash=sha256:f55119f7bf25f49ed210f6096090715da24f2943c62102448915fde3c62877ce \ + --hash=sha256:f631fe87a6f30df5fbe6d79640b25e4cffb38c31c7fb6f10871517b84b0f8c1a \ + --hash=sha256:f8fb78a83c9e5f741ca3a68cfb455c1f5bb83b4e7249a3848b3cd78d0a8563b0 \ + --hash=sha256:fa9467a8113aa69d3d7c55a70ef0b7c636010a40993f3df9d9d0d73b3eb7ef24 \ + --hash=sha256:fd51ebf9d3a00c074df4ede271023f4d2dba289bcc740b88191872716014e3c5 + # via + # instructor + # litellm +aiosignal==1.4.0 \ + --hash=sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e \ + --hash=sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7 + # via aiohttp +amqp==5.3.1 \ + --hash=sha256:43b3319e1b4e7d1251833a93d672b4af1e40f3d632d479b98661a95f117880a2 \ + --hash=sha256:cddc00c725449522023bad949f70fff7b48f0b1ade74d170a6f10ab044739432 + # via kombu +annotated-doc==0.0.5 \ + --hash=sha256:117bac03a25ede5df5440e855b32d556049ca169ead221505badf432fed4b101 \ + --hash=sha256:c7e58ce09192557605d8bbd92836d7e1d520ac9580096042c0bfd197efacf1bb + # via + # fastapi + # typer +annotated-types==0.8.0 \ + --hash=sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7 \ + --hash=sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0 + # via pydantic +anthropic==0.121.0 \ + --hash=sha256:6048713fa441e59e1cba8363171cd2a86273b25bd213e9c7ac70a523af88b011 \ + --hash=sha256:e79d6e08ab3376602fc9a70d4d5ea3540817c76cf7e16658bed790834e1833d6 + # via semantica (pyproject.toml) +antlr4-python3-runtime==4.9.3 \ + --hash=sha256:f224469b4168294902bb1efa80a8bf7855f24c99aef99cbefc1bcd3cce77881b + # via omegaconf +anyio==4.14.2 \ + --hash=sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494 \ + --hash=sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f + # via + # anthropic + # google-genai + # groq + # httpx + # jupyter-server + # openai + # starlette + # watchfiles +argon2-cffi==25.1.0 \ + --hash=sha256:694ae5cc8a42f4c4e2bf2ca0e64e51e23a040c6a517a85074683d3959e1346c1 \ + --hash=sha256:fdc8b074db390fccb6eb4a3604ae7231f219aa669a2652e0f20e16ba513d5741 + # via jupyter-server +argon2-cffi-bindings==25.1.0 \ + --hash=sha256:1db89609c06afa1a214a69a462ea741cf735b29a57530478c06eb81dd403de99 \ + --hash=sha256:1e021e87faa76ae0d413b619fe2b65ab9a037f24c60a1e6cc43457ae20de6dc6 \ + --hash=sha256:21378b40e1b8d1655dd5310c84a40fc19a9aa5e6366e835ceb8576bf0fea716d \ + --hash=sha256:2630b6240b495dfab90aebe159ff784d08ea999aa4b0d17efa734055a07d2f44 \ + --hash=sha256:3c6702abc36bf3ccba3f802b799505def420a1b7039862014a65db3205967f5a \ + --hash=sha256:3d3f05610594151994ca9ccb3c771115bdb4daef161976a266f0dd8aa9996b8f \ + --hash=sha256:473bcb5f82924b1becbb637b63303ec8d10e84c8d241119419897a26116515d2 \ + --hash=sha256:5acb4e41090d53f17ca1110c3427f0a130f944b896fc8c83973219c97f57b690 \ + --hash=sha256:5d588dec224e2a83edbdc785a5e6f3c6cd736f46bfd4b441bbb5aa1f5085e584 \ + --hash=sha256:6dca33a9859abf613e22733131fc9194091c1fa7cb3e131c143056b4856aa47e \ + --hash=sha256:7aef0c91e2c0fbca6fc68e7555aa60ef7008a739cbe045541e438373bc54d2b0 \ + --hash=sha256:84a461d4d84ae1295871329b346a97f68eade8c53b6ed9a7ca2d7467f3c8ff6f \ + --hash=sha256:87c33a52407e4c41f3b70a9c2d3f6056d88b10dad7695be708c5021673f55623 \ + --hash=sha256:8b8efee945193e667a396cbc7b4fb7d357297d6234d30a489905d96caabde56b \ + --hash=sha256:a1c70058c6ab1e352304ac7e3b52554daadacd8d453c1752e547c76e9c99ac44 \ + --hash=sha256:a98cd7d17e9f7ce244c0803cad3c23a7d379c301ba618a5fa76a67d116618b98 \ + --hash=sha256:aecba1723ae35330a008418a91ea6cfcedf6d31e5fbaa056a166462ff066d500 \ + --hash=sha256:b0fdbcf513833809c882823f98dc2f931cf659d9a1429616ac3adebb49f5db94 \ + --hash=sha256:b55aec3565b65f56455eebc9b9f34130440404f27fe21c3b375bf1ea4d8fbae6 \ + --hash=sha256:b957f3e6ea4d55d820e40ff76f450952807013d361a65d7f28acc0acbf29229d \ + --hash=sha256:ba92837e4a9aa6a508c8d2d7883ed5a8f6c308c89a4790e1e447a220deb79a85 \ + --hash=sha256:c4f9665de60b1b0e99bcd6be4f17d90339698ce954cfd8d9cf4f91c995165a92 \ + --hash=sha256:c87b72589133f0346a1cb8d5ecca4b933e3c9b64656c9d175270a000e73b288d \ + --hash=sha256:d3e924cfc503018a714f94a49a149fdc0b644eaead5d1f089330399134fa028a \ + --hash=sha256:da0c79c23a63723aa5d782250fbf51b768abca630285262fb5144ba5ae01e520 \ + --hash=sha256:e2fd3bfbff3c5d74fef31a722f729bf93500910db650c925c2d6ef879a7e51cb + # via argon2-cffi +arrow==1.4.0 \ + --hash=sha256:749f0769958ebdc79c173ff0b0670d59051a535fa26e8eba02953dc19eb43205 \ + --hash=sha256:ed0cc050e98001b8779e84d461b0098c4ac597e88704a655582b21d116e526d7 + # via isoduration +ast-serialize==0.8.0 \ + --hash=sha256:0485a25ef519c62e749ee3c1ad8070e591b380d67226349eb5a70b228dc1ac4a \ + --hash=sha256:057769b5921336eb2d9124f2a731b42ed05ffdac559b840dbdf6f3937cf153dc \ + --hash=sha256:1f9caa63fad8241257ae401b5ff0a64026c6adb36b8e86cbe8782d9ea505daf6 \ + --hash=sha256:252f883290d1cdb728eb7fe1d9a7221b88af5a329aae0bc91ddee4dafb820331 \ + --hash=sha256:2880350b13d3eae69a0d70bc1fb6c9bfaca4dbd0e20ba8cd1aa483080b56ff06 \ + --hash=sha256:293cc1c5bfa741f8e3fbe8175b9c07beee487c9a6fdbb25a5acad9f1df2d30a9 \ + --hash=sha256:2d39a56282cfcc0d8eeea37267c754be59c98d48505c23b1dae5c6011f3813dd \ + --hash=sha256:2efa40b068197d5efb62655b43baadb842ed71c4958cccd3e8b86a35726f0119 \ + --hash=sha256:31883542dd6c94d178f5db3d32fbd69c5eb88b3a7c018e7ac8cc0c45195ddbed \ + --hash=sha256:3926fa117b5e65019853a2969966d11c7175af377a3425991f3fe73784412405 \ + --hash=sha256:39e92ff8e8cb45947fe9007174b2950e1fb098e6abd00266a13cd3bcf6675068 \ + --hash=sha256:3a8660fe66667b76a6e9dccd1d33e66b229fde3b308db991c041609226c005b6 \ + --hash=sha256:3ccebbed24f1281062d5852353c72c47502955926cfcb8345ffb3a44d87ff3d3 \ + --hash=sha256:3d822605fa7bb326ef868d25fafced7fc660fa46d9b90c02ea86d5e2f5d325f7 \ + --hash=sha256:40a57b73731be45da4fa41430c4d5dc94a24b3a4faba7b9e069978c0402064ea \ + --hash=sha256:43dd6d596879bb1cb8a12cc9dae7bb10090a39a35883026c24f82488a195619a \ + --hash=sha256:485f1113af805e9e170b95ef993ca3fbd4f89c04bab25c58b4fc632d854801ab \ + --hash=sha256:4c38b915511e32bc718c49dbce98ff9af36bac0ad6a604f58000cd5e3aecdba7 \ + --hash=sha256:4ca7e6fd1ad845d1cc649dc2ecd499db2f8f46af5bf8da7b70dd858774cc038b \ + --hash=sha256:5075b9da3ef807eda752502446dfecea3b381c4900b7e27a5d5f4f899eb39951 \ + --hash=sha256:54f95b486018d262bcb387a9afd96f0da74508b442762b80c769454a6fbb3ee3 \ + --hash=sha256:6102f2f985c2e542be85cd857678ec9356fefa792b93cadfadd31139f5696f27 \ + --hash=sha256:6479d9722a4cd21b578f5478074c41e6169f04811996ec881655560f703a5bba \ + --hash=sha256:6c37c43e4004dfb42d321ddedc569dc17ff4259296f3af577c9ea46a809bc010 \ + --hash=sha256:6f18048fe9f6dd266bd577cdec48bdcecb74faaa01fe941324435483b013ed2a \ + --hash=sha256:77308ae6c5cf5264cc0f01a7c556ec77a9e68eb1f61b093534d698139fdc3b14 \ + --hash=sha256:861794565b06337005c1447ef23103a3d5a627d08bdc827870d00d0b28ef5f51 \ + --hash=sha256:86b8a1e6d90467345356098b040150e82fbc26d24a7a202224b13dc1f6264ca0 \ + --hash=sha256:8c9d537f59e936392cfd3597789d1390304dd659efc3c486ce7f40fb6b8a9f53 \ + --hash=sha256:8d53a23f27e1ed3a36b2d26fd2a1a6228c8e85a1ed62ff7cdb44bd610769f20a \ + --hash=sha256:9118ad3e369727060b2696fc4078f250ecffca4248ba87f537f55cea9f9dce06 \ + --hash=sha256:96abc072ad29db8d02194afd47d68987322622787daceae82398d7b69f3ba2e6 \ + --hash=sha256:9830ff7e764f74d9eefb01170c61a9f0fd2c027dac5fcb72e064decd57d56371 \ + --hash=sha256:9a2ef9cf12f2de4f1028c42c1dd7d775255e0fb3e5bb48896c97e35ef52366fe \ + --hash=sha256:9d187197d234aa45d6cfa2b096be5f666e8cc2e7eb3722d0ab8926293cf5720c \ + --hash=sha256:9da7330f3e235bf7da89b8d39205c6350fc0c08a85379743f2df9fff87d6d980 \ + --hash=sha256:a02cbed7d8bfdcdee88edaac12bd50d53d9953aaa2e1852ef078625be5f1c0b5 \ + --hash=sha256:a63bed264e818cd83eec11feed0f50aa162542b91132ef58afebc857182763a5 \ + --hash=sha256:ab0f9a59f7d63d0d441b56b9a818b273705264352d5115cfee12e940e816d958 \ + --hash=sha256:ac4f0a83c55a9b782f79ad55a5247b7db123c1db405959791c2ef886e9710c9f \ + --hash=sha256:b2a5978662fd4db463dfb4b974d2b10ac6430b98f5333aabc7051909df3561d0 \ + --hash=sha256:bd84d60bca7079e741be4ac5dbe237751a59d7f6f9f0126b11880d63822cbe16 \ + --hash=sha256:c85d8d18db5b2dfcb3b7e38a4d600ca35504c0ed8a6f75cd1c811e4ffe248a15 \ + --hash=sha256:d8b3c8eee4c1baef9d4e84d2a59a805501617127be42615cb48970b15b0892b6 \ + --hash=sha256:db1b957291bca08c7e72f43a12357b2948e20775d970e3fc3dac0aa3160ab725 \ + --hash=sha256:ddd3b61f45c132da66c5476b281891e08c1fd87fbdabe8a6973e1622efc85f06 \ + --hash=sha256:e0910c3442a75216dde0f102d854ba2aaa71d2482e0ee213630b9bf29584fba3 \ + --hash=sha256:e1bd223df0f6c96b396975fa604cb33bce53d9b4a0185490be4c4a289f7c9c87 \ + --hash=sha256:e7266307e5fba39836edb79def8608887af48820508bff3c5f2941e1e04d1534 \ + --hash=sha256:e94f9121d13fa36cbf21314783c77d05ae3a0868decd18cf5233fdcc6de49ac8 \ + --hash=sha256:f0190a33d7f97c65e9069f7a7f40499eea6b5cbe260c558378109caf20ce934b \ + --hash=sha256:f3186969ee66a9863b00acc6523ace44c56974eecb348a7ea4b228d9f0b80e19 \ + --hash=sha256:f359df4bd921918af8bebd142a376c77511d7151cc8ba852760b587b5a4a54f3 \ + --hash=sha256:f7cc5f10386994c0f4844f1e6d6a97127e9b478660eb6dec2b257644f0acab64 \ + --hash=sha256:fa70ed4dea0bb18b30a1789c77baa701d0ef30c474f2ccabdea61e25623a8827 \ + --hash=sha256:fdc0d5b18ff8fb364e87923e47c0a91d0d69dbcaeaa274591f7fd26892cc3a3a \ + --hash=sha256:ffa5e7cb08f96fed9121f77b224151e41caf88feab9d652bb46c78202b6fbeda + # via mypy +asttokens==3.0.2 \ + --hash=sha256:3ecdbd8f2cc195f53ccada3a613538bb5f9ef6f6869129f13e03c30a677b8fe2 \ + --hash=sha256:9da13157f5b28becde0bd374fc677dcd3c290614264eff096f167c469cd9f933 + # via stack-data +async-lru==2.3.0 \ + --hash=sha256:89bdb258a0140d7313cf8f4031d816a042202faa61d0ab310a0a538baa1c24b6 \ + --hash=sha256:eea27b01841909316f2cc739807acea1c623df2be8c5cfad7583286397bb8315 + # via jupyterlab +async-timeout==5.0.1 \ + --hash=sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c \ + --hash=sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3 + # via redis +attrs==26.1.0 \ + --hash=sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309 \ + --hash=sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32 + # via + # aiohttp + # jsonlines + # jsonschema + # referencing +audioread==3.1.0 \ + --hash=sha256:1c4ab2f2972764c896a8ac61ac53e261c8d29f0c6ccd652f84e18f08a4cab190 \ + --hash=sha256:b30d1df6c5d3de5dcef0fb0e256f6ea17bdcf5f979408df0297d8a408e2971b4 + # via librosa +authlib==1.7.2 \ + --hash=sha256:2cea25fefcd4e7173bdf1372c0afc265c8034b23a8cd5dcb6a9164b826c64231 \ + --hash=sha256:3e1faedc9d87e7d56a164eca3ccb6ace0d61b94abe83e92242f8dc8bba9b4a9f + # via weaviate-client +azure-core==1.41.0 \ + --hash=sha256:522b4011e8180b1a3dcd2024396a4e7fe9ac37fb8597db47163d230b5efe892d \ + --hash=sha256:f46ff5dfcd230f25cf1c19e8a34b8dc08a337b2503e268bb600a16c00db8ad5a + # via azure-storage-blob +azure-storage-blob==12.30.0 \ + --hash=sha256:2cd74d4d5731e5eb6b8d5c5056ee115a5e88f8fdf22517b739836fda685018be \ + --hash=sha256:d415ac50b67a8da6b3ae7e9f1014b1b55cd7aafa0b8d4ca9b380568dc7360423 + # via semantica (pyproject.toml) +babel==2.18.0 \ + --hash=sha256:b80b99a14bd085fcacfa15c9165f651fbb3406e66cc603abf11c5750937c992d \ + --hash=sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35 + # via jupyterlab-server +beautifulsoup4==4.15.0 \ + --hash=sha256:288e3ca7d54b06f2ac191970bc275c1939cb46d450b255bf6718b04aa37ab4f7 \ + --hash=sha256:d6f88de62e1d4e38ecb1077eb9724cd0eff29d2a08ca16a401e9b9e93f117cf9 + # via + # semantica (pyproject.toml) + # docling-slim + # nbconvert +bertopic==0.17.4 \ + --hash=sha256:0dbe7ee22c18fb76efe7f4fbf3a14b51c1e67d9fccc569e3f81e75b8f32646d1 \ + --hash=sha256:f92aa560cdf2bcbf9e22c8ee83dd3bfb225b8dc29381dec7327cc0b21bd852ad + # via semantica (pyproject.toml) +billiard==4.2.4 \ + --hash=sha256:525b42bdec68d2b983347ac312f892db930858495db601b5836ac24e6477cde5 \ + --hash=sha256:55f542c371209e03cd5862299b74e52e4fbcba8250ba611ad94276b369b6a85f + # via celery +black==26.5.1 \ + --hash=sha256:0e48b87e03bf109288e55cfceadcfa15ff5470aca2851a851950ed2926f450d7 \ + --hash=sha256:1037d5ac7b7b310b2632ad867ec8d0e4c4819dcdb0b820f63135da746a24e418 \ + --hash=sha256:1ef92b76f7733f282fd096ea406200b5a286c42947412b0eaff3a74e3616cefe \ + --hash=sha256:1f7ea64ebfa01b50f693508fc39f875e264446d3b097088f84f203b9d09618a0 \ + --hash=sha256:22f2cd76d069cc54c71f10360744ba8983fbb616903b4304a85b734915c8e1b4 \ + --hash=sha256:2b36cf2ddf5566e205f6535f782a62194a184d33e175b64ae8c40b1737522be3 \ + --hash=sha256:30d3c14661f2792e9142cce3eeeb1cbc175b3eb5f733be0c8eeb99651e52b0c3 \ + --hash=sha256:32d5ea7f6c8bdfa6e648326ebca1f02b0764e2a029edc6f8dce2627e19d468c3 \ + --hash=sha256:3915f256e75a2d7cf88d8953d37f780455dc586cc72dee059c528fe77f581217 \ + --hash=sha256:4ad6fa01f941920f54f2bbb35f3df7673428a0ef98a0b0840c2eaef3b110efa8 \ + --hash=sha256:4ed7f7da04046d2e488437170797d3b4a4ad83906683bcb7dfc68b673bbce5e2 \ + --hash=sha256:5119fa92ae61f786e8c3662fd60aece1d0a2dd5cca5d0c79417a95e7a4272a59 \ + --hash=sha256:577f21094ea469ef92ec1adaf2c9441a226d2144d01a5be2fa823cecf6543e50 \ + --hash=sha256:58b4bd92cf88aacf83d88479c8f9caee044b1ec55f2451a337354a7ea2590a22 \ + --hash=sha256:5c34b25da232ead53a6f335b76dbea124f4d152ad568b9080d6f944bc2b34b52 \ + --hash=sha256:87ed5c6f450580a2f6790bc7cbfb016dfc73bc750249762268a3695361315eef \ + --hash=sha256:89c93167a74d3a75dfaa38a5c7cca015537d5820dd7f17d63267d674a61cae90 \ + --hash=sha256:96ae2c733b2aabdd9986e2c5df628ff3473676cd1c5faded1ff496cf6d74083c \ + --hash=sha256:9942db8888e06943c5dde66ca0037dcff82a2a4ec1ad0ada9e0d2ee9d9823893 \ + --hash=sha256:9d98d4137277c75dfb898ec8d846c4fd68ba1e9cf77f95e2865c203dc18f4c3d \ + --hash=sha256:a1dca32d9f1784af512a13410ec204c6f7f0aa9797a111c42e1c03449821c264 \ + --hash=sha256:dd321f668053961824bcc1be1cc1df748b2d7e4fa28086b08331e577b0100a73 \ + --hash=sha256:e1a26503279b6b310669fb0b219c39e4820b77e8189fe80f522bb511f247db0a \ + --hash=sha256:e88976690a64b0af98312ca958415849cb42423423c5f2ee74af4b49a97a2168 \ + --hash=sha256:ea8d16dc41655aa113cd64665e7219446cd7e4ff2248d7178eaa905190c86b18 \ + --hash=sha256:ecb3e624844c798144e9bd986954e0adc81d8911a1f30f375e1252fe26e8c294 \ + --hash=sha256:ed1a20af114c301a0269bf01163d51dbef72737fd65f850001e7cbe7f3c7abae + # via semantica (pyproject.toml) +bleach==6.4.0 \ + --hash=sha256:4202482733d85cedd04e59fcb2f89f4e4c7c385a78d3c3c23c30446843a37452 \ + --hash=sha256:4b6b6a54fff2e69a3dde9d21cc6301220bee3c3cb792187d11403fd795031081 + # via nbconvert +blis==1.3.3 \ + --hash=sha256:034d4560ff3cc43e8aa37e188451b0440e3261d989bb8a42ceee865607715ecd \ + --hash=sha256:1e647341f958421a86b028a2efe16ce19c67dba2a05f79e8f7e80b1ff45328aa \ + --hash=sha256:1ef6d6e2b599a3a2788eb6d9b443533961265aa4ec49d574ed4bb846e548dcdb \ + --hash=sha256:27f82b8633030f8d095d2b412dffa7eb6dbc8ee43813139909a20012e54422ea \ + --hash=sha256:2a1c74e100665f8e918ebdbae2794576adf1f691680b5cdb8b29578432f623ef \ + --hash=sha256:30b8a5b90cb6cb81d1ada9ae05aa55fb8e70d9a0ae9db40d2401bb9c1c8f14c4 \ + --hash=sha256:3f6c595185176ce021316263e1a1d636a3425b6c48366c1fd712d08d0b71849a \ + --hash=sha256:3f966ca74f89f8a33e568b9a1d71992fc9a0d29a423e047f0a212643e21b5458 \ + --hash=sha256:45866a9027d43b93e8b59980a23c5d7358b6536fc04606286e39fdcfce1101c2 \ + --hash=sha256:6297e7616c158b305c9a8a4e47ca5fc9b0785194dd96c903b1a1591a7ca21ddf \ + --hash=sha256:62fb8c731347b0f98f5f81d19d339049e61489798738467d156c66cc329b0754 \ + --hash=sha256:631836d4f335e62c30aa50a1aa0170773265c73654d296361f95180006e88c04 \ + --hash=sha256:650f1d2b28e3c875927c63deebda463a6f9d237dff30e445bfe2127718c1a344 \ + --hash=sha256:66e6249564f1db22e8af1e0513ff64134041fa7e03c8dd73df74db3f4d8415a7 \ + --hash=sha256:6f165930e8d3a85c606d2003211497e28d528c7416fbfeafb6b15600963f7c9b \ + --hash=sha256:7260da065958b4e5475f62f44895ef9d673b0f47dcf61b672b22b7dae1a18505 \ + --hash=sha256:7a0fc4b237a3a453bdc3c7ab48d91439fcd2d013b665c46948d9eaf9c3e45a97 \ + --hash=sha256:7e88181e9dd8430029ebaf22d41bf79e756e8c95363e9471717102c66beb4a6d \ + --hash=sha256:8177879fd3590b5eecdd377f9deafb5dc8af6d684f065bd01553302fb3fcf9a7 \ + --hash=sha256:878d4d96d8f2c7a2459024f013f2e4e5f46d708b23437dae970d998e7bff14a0 \ + --hash=sha256:8c888438ae99c500422d50698e3028b65caa8ebb44e24204d87fda2df64058f7 \ + --hash=sha256:9b0d42420ddd543eec51ccb99d38364a0c0833b6895eced37127822de6ecacff \ + --hash=sha256:9de26fbd72bac900c273b76d46f0b45b77a28eace2e01f6ac6c2239531a413bb \ + --hash=sha256:9e5fdf4211b1972400f8ff6dafe87cb689c5d84f046b4a76b207c0bd2270faaf \ + --hash=sha256:c3e33cfbf22a418373766816343fcfcd0556012aa3ffdf562c29cddec448a415 \ + --hash=sha256:c4ae70629cf302035d268858a10ca4eb6242a01b2dc8d64422f8e6dcb8a8ee74 \ + --hash=sha256:d0114cf2d8f19e0ed210f9ae92594cd0a12efa1bbbce444028b0fc365bbbb8af \ + --hash=sha256:d563160f874abb78a57e346f07312c5323f7ad67b6370052b6b17087ef234a8e \ + --hash=sha256:d734b19fba0be7944f272dfa7b443b37c61f9476d9ab054a9ac53555ceadd2e0 \ + --hash=sha256:e10c8d3e892b1dbdff365b9d00e08291876fc336915bf1a5e9f188ed087e1a91 \ + --hash=sha256:e5a662c48cd4aad5dae1a950345df23957524f071315837a4c6feb7d3b288990 \ + --hash=sha256:e9327a6ca67de8ae76fe071e8584cc7f3b2e8bfadece4961d40f2826e1cda2df \ + --hash=sha256:e9f5c53b277f6ac5b3ca30bc12ebab7ea16c8f8c36b14428abb56924213dc127 \ + --hash=sha256:f0628a030d44aa71cac5973e40c9e95ec767abaaf2fd366a094b9398885f82f2 \ + --hash=sha256:f20f7ad69aaffd1ce14fe77de557b6df9b61e0c9e582f75a843715d836b5c8af \ + --hash=sha256:f36c0ca84a05ee5d3dbaa38056c4423c1fc29948b17a7923dd2fed8967375d74 + # via thinc +boto3==1.43.69 \ + --hash=sha256:4eb494d05b2bd08a7eee61b8ac4c34745c99e9bbce435c91f8d15d372dd8c2db \ + --hash=sha256:76297a0b415849c63575ae08a4f1661b2dc8ee0100f104b86f98aa69b47fa2c7 + # via semantica (pyproject.toml) +botocore==1.43.69 \ + --hash=sha256:5caa46b740d9a886137146ffbb69edb691f702bfe74c64e85621947ae00181fd \ + --hash=sha256:b1f0e01c53d6b84ee9c184ebf3636c3b3aef85e0ae8498c74afb8734ff224f87 + # via + # boto3 + # s3transfer +cachetools==7.1.7 \ + --hash=sha256:a3e2a00b14d8f8a6b70c1dae7b4685e7ad3bc965c5b42124a2d6ce895da6cf50 \ + --hash=sha256:ef98ef375ad188819ef2f9b3645e3987f4b8c5b7550e436ad998c2de78296df0 + # via pymilvus +catalogue==2.0.10 \ + --hash=sha256:4f56daa940913d3f09d589c191c74e5a6d51762b3a9e37dd53b7437afd6cda15 \ + --hash=sha256:58c2de0020aa90f4a2da7dfad161bf7b3b054c86a5f09fcedc0b2b740c109a9f + # via + # spacy + # srsly + # thinc +celery==5.6.3 \ + --hash=sha256:0808f42f80909c4d5833202360ffafb2a4f83f4d8e23e1285d926610e9a7afa6 \ + --hash=sha256:177006bd2054b882e9f01be59abd8529e88879ef50d7918a7050c5a9f4e12912 + # via semantica (pyproject.toml) +certifi==2026.7.22 \ + --hash=sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775 \ + --hash=sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55 + # via + # docling-slim + # httpcore + # httpx + # pinecone-client + # pulsar-client + # requests +cffi==2.1.1 \ + --hash=sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e \ + --hash=sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66 \ + --hash=sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2 \ + --hash=sha256:154852545011f779917b11c78db2358d095da62a9a172b78ad0a583ee5adc0d0 \ + --hash=sha256:194cffa889098ced9976c3fc6340305e43f6303657d298da55366907c05c22d6 \ + --hash=sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971 \ + --hash=sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c \ + --hash=sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d \ + --hash=sha256:1dea0e4d7d4f11f619fe8c1d76caf49e24405b4b5743c0e3be16a500ecd930c9 \ + --hash=sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517 \ + --hash=sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735 \ + --hash=sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80 \ + --hash=sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f \ + --hash=sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1 \ + --hash=sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29 \ + --hash=sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8 \ + --hash=sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c \ + --hash=sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e \ + --hash=sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48 \ + --hash=sha256:3311ed60d36f83378794e1009ac6258bafbf81f7888b4caa7b35a521e3f95813 \ + --hash=sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac \ + --hash=sha256:34e261f78cb6ceaaa36f42f2613f4380d94d9c759a9c73c769ee6e0247364632 \ + --hash=sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6 \ + --hash=sha256:398aff33cee2767e3e781d2554c54bd0dff386bb437581e0d8011fde1a942ec1 \ + --hash=sha256:3d22a20b1fb1632cc72c22f95f7b0d2961c3e1c235f245ba4c606c4771035659 \ + --hash=sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688 \ + --hash=sha256:42e2f76b9455f5a9a844f770bf3e200ed3da0e15f5df3db9c31fe80b04b3d004 \ + --hash=sha256:42f6930c31dc7f50732c9ae793c2786c7b6b044195967bbdde40bb9be81c4cc0 \ + --hash=sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062 \ + --hash=sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779 \ + --hash=sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94 \ + --hash=sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50 \ + --hash=sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab \ + --hash=sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac \ + --hash=sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6 \ + --hash=sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676 \ + --hash=sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1 \ + --hash=sha256:5a59cc1c4442bc3d5c703bf720b51138d0bfc173618807c9ee2490a7541dd3d9 \ + --hash=sha256:5bb4e7ea95dcd6a014a6fef62e62467d67d8e582326443f3d68e71d6320a9fcf \ + --hash=sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13 \ + --hash=sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e \ + --hash=sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e \ + --hash=sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973 \ + --hash=sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527 \ + --hash=sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72 \ + --hash=sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890 \ + --hash=sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c \ + --hash=sha256:6e192623c49c94421616a5778fba35cf0d5a8d000650c1967ef4448ee5cdd990 \ + --hash=sha256:7225e4514edb64eb6740324353e0da0711954fd8d7da4576755b1c6e09b697cd \ + --hash=sha256:75f80557d1389eddbd0de2681f6a390a0c5338c31ddaa821381c203fc3fd50d9 \ + --hash=sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94 \ + --hash=sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3 \ + --hash=sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80 \ + --hash=sha256:7ce713ace7c0e4520535b42b77eaa742c16dab813978064913e5a3cf82973b41 \ + --hash=sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5 \ + --hash=sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c \ + --hash=sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a \ + --hash=sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4 \ + --hash=sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e \ + --hash=sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6 \ + --hash=sha256:9f8d177621de5cb38ee3e731eda45d421db093ec0739f46a5594babda7987a98 \ + --hash=sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b \ + --hash=sha256:a48d62ab9d6f4f98c983223a547af44be6ca3691074c31cecced6facd3ba2dc1 \ + --hash=sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03 \ + --hash=sha256:a6e721d4b0e45d5b65e87534470e67b18dcd092c83f68fba09f152b9cbc061af \ + --hash=sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231 \ + --hash=sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2 \ + --hash=sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3 \ + --hash=sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836 \ + --hash=sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5 \ + --hash=sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399 \ + --hash=sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96 \ + --hash=sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e \ + --hash=sha256:baed1e86cc735622097354b9d1281406caf42ff42a886d29faa8e8d1630333be \ + --hash=sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf \ + --hash=sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc \ + --hash=sha256:c7659f22557c5a0bc4855cd635f55edec690cc008a40768527762cb9fb263455 \ + --hash=sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0 \ + --hash=sha256:c8d2c9fd1f2d16f780d15127abb050d13d1a76c03a4bd87d7e4980e45e511e12 \ + --hash=sha256:ca82be1a1d406ecfe1d25dc16cb33488e5a16bf4438c9fb590484ea29d92478b \ + --hash=sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7 \ + --hash=sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692 \ + --hash=sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54 \ + --hash=sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3 \ + --hash=sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b \ + --hash=sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be \ + --hash=sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d \ + --hash=sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358 \ + --hash=sha256:df913725b79db7bcf03448f36b7bf8815363417d5b58deecf9305e3e30f0f21a \ + --hash=sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7 \ + --hash=sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc \ + --hash=sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960 \ + --hash=sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125 \ + --hash=sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb \ + --hash=sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a \ + --hash=sha256:f5cfbc5fe74540d335175b656c725d74d90e3730c626d92575eea35029d9afaa \ + --hash=sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf \ + --hash=sha256:f8ec5e643a9a937f64e1999eb9f75d072263751912dc5cd06d3c85f8f44be7c3 \ + --hash=sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4 \ + --hash=sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264 + # via + # argon2-cffi-bindings + # cryptography + # soundfile +cfgv==3.5.0 \ + --hash=sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0 \ + --hash=sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132 + # via pre-commit +chardet==7.5.1 \ + --hash=sha256:0df08f2b2f6ac04b3e7f9e8ad1b1559c2e8497338ff9dfa1e0922335ff9dfe8d \ + --hash=sha256:126b2a65141ed8a460c721d19f487c7b6fd12542aa761ce449f543296d0dd71e \ + --hash=sha256:1cd58589a52211901c5ac57016feffbe8a7e7e6328f5bf0b03ce44043e221e99 \ + --hash=sha256:26160da949c66f0cca280101d85e6e4fddca53bfe465a1b69ceb3c9998295cc0 \ + --hash=sha256:2c99dea9eea1bdc6cc20dcb3555581234899d58a4147163c7d03d8fca11402b0 \ + --hash=sha256:36843a0e9e3196142e317806d5ca29ab0bb714de2315897124a018438cc535a3 \ + --hash=sha256:3eb37b2c0aa67bfb1112aa90bfdd95cd3b4006fe051a2ea4872c3c6ba9cf855b \ + --hash=sha256:44214df32ff7c87fe82d7f1f21c7fe95c04769869e523f944413e668e001f52c \ + --hash=sha256:469f164a608ccee4a8a2c0c2b4328470df9b07443e8269714f8e8a51f6fdf4c4 \ + --hash=sha256:46d10bbb7ba7ba345694fe0276a61290d4cc25d3624c03282311dbc58c1d49b4 \ + --hash=sha256:4d30a84ec52c37532ad7978329a41224c454959b22503b8f8ed4df763e6c3ed2 \ + --hash=sha256:54bae16fc5b7ea39956ee737dd09b5f5438deafa9f565ac27c882992c3965b88 \ + --hash=sha256:5953d8236049aed0411908cfeaeeec03984ab2f109f980f0ec70fbe6938c8f9e \ + --hash=sha256:59598a8e15769ebe62fd0c153a5e4347a7a126cc7b376a724ff63ad80b890506 \ + --hash=sha256:61312fd3ff363c3ec549548250630a02fd3123c360dd492bf1fce0d28e915b87 \ + --hash=sha256:6df2e255413c5f277067d9af7444ab1e9719126335efbefae24d8370aedf35c5 \ + --hash=sha256:6eefafa763b7099c3c0a86c343097d69b766b3fe5705edba9400bae26450af1f \ + --hash=sha256:71f152d66e7bd1faad615897d34765243cb567ad6aef07bf0d7c0cdd69bf6cce \ + --hash=sha256:8a001a8f030625b705d9a4e68116e573462bd38192cc6c1bfa318b45606747ac \ + --hash=sha256:951ccab3a037a563079f4d448e82bbfee5f2715239440f732ffb0ac9f251dedb \ + --hash=sha256:9c378ccd8c0fab30171ed7c54d501f72c4294d9b98c71ea1ff7852aa9ccac399 \ + --hash=sha256:a198d47eaa28e1ba458f11ab636f0677f34c5d1ce7e909bec6ca2f346c21e78c \ + --hash=sha256:a599a836fbd41ff5a2a0c20e211da13ba8cc14dcba1e4bcfad8a7bcad64a2ff6 \ + --hash=sha256:a6b20b42a9e6048d557aec9903df33239c299b4643c6553203127c8f92f47e78 \ + --hash=sha256:a77d6d2d61f39b40423bd0abaee32175739cec9f11edf9e7236a5341d0e05c99 \ + --hash=sha256:b1c049906b95db7b12fd674f661f75285baf925eae98aa52a4a09305fc786855 \ + --hash=sha256:b72b9b95c636d170d9a6284d99be9fd93ca08bb2221385ff1a5b69da98ec4f76 \ + --hash=sha256:ba7e9b6c15b4fcdf07ae675e5116dee610425f9ad6955c9bdb6bf99aed2e555d \ + --hash=sha256:bbf6948b7a5b85af5e435993c4e5fdb092d0d8f38c00d68c96e2640bafce5ec2 \ + --hash=sha256:c461fc9746912d19ab77efc1912b7f9a364b7fca1d787e1215207d5f6f68685d \ + --hash=sha256:d06a8bacc8b6a26e900c3bd825601f9a8c02e063e687e960cf77363a9a397a0b \ + --hash=sha256:e6faa6b18c7af2fca8d4cbb51fa035fa47f8f4b68547ae927a1c0be34dfc96fa \ + --hash=sha256:e72489029c1f6e4be6138dd045a4e52bffaba5d5da0398df585bfcf8b239e324 \ + --hash=sha256:ecbe0e0a9fff7825fc48650ef297ede49c71a7abc411a0638416207a70bf78c0 \ + --hash=sha256:f22396ad419f1e78594057e200aa7253be56840f5b04d07b86ccecd99e09c068 \ + --hash=sha256:fad6fbc154113e3b17bb757c34b21477e4b6d69fdd4ce51ff2b3f29a42f08b5b + # via semantica (pyproject.toml) +charset-normalizer==3.5.0 \ + --hash=sha256:054420b5db984971d886e5e4e2c37c760ae6682aedbd066687ff0949d9ed5f08 \ + --hash=sha256:06f4fb62a9139bef056b8b2da6773c94c2f259f90e4b8e53b166f3d0372d7cf6 \ + --hash=sha256:076cf9d3f3c7e410295c09d96355cf3b1bcae74990034d80e4371e20fe1ba4c6 \ + --hash=sha256:07f6f42b5a6325df35b458004fb5f9f29bf502d89287a33c7cdef3590e31de0f \ + --hash=sha256:0b2e44e6d42d1a4ff78ccc219a93c5449105d10b16198d1aea581080df8073f9 \ + --hash=sha256:0b373bab0b867b68b8eb249da9478cab9181a42993437cd2f5dba5fb0b4fbd1b \ + --hash=sha256:0c8953d9d1617794cfc40d81179571c9ba3805dd029623a15c93f1fb70e60a74 \ + --hash=sha256:0cce46dd29d73e135e8087b96eb62a4aca6d69391b7f97808c6588ebed3178f3 \ + --hash=sha256:0dfe83c1b4d00abbf433998117a14f56a5c2bc68226c0d331709eed0d1ce539b \ + --hash=sha256:0f211c21aa316cb6e2662e54a1194633a79d98a50a876addacfce7ba5b34b09f \ + --hash=sha256:0f76dc0a47f94cb9b69d86f01e477f4b0371ca70208b9ccea7e063c41eed9046 \ + --hash=sha256:125ee619611019471b177c70bc3e9d4cda9fad7e01d93523501d3b188df0193a \ + --hash=sha256:1328cc57dd4372be1265f68232cee890e087416e3e6e93e6ffb32c2bad4d36a4 \ + --hash=sha256:143792a43e06dc3b27fc891948406e251502dc19ff9216cd80182b79131be5c5 \ + --hash=sha256:14f6904a3cf870abf044df3a8c4924ac6c8ef77e9896586fd37e73ae96cff2af \ + --hash=sha256:168a0cb536b5123a77bc42ecf5e0bf6f923d0d9ae43c42a14eb0677c19ac6c19 \ + --hash=sha256:17a0fd0e23961c2c017372e37aabc7ca8fceb9e10ad898977dfb40ad3927baae \ + --hash=sha256:17db18db9a1374d5b9d9a3252f980b4243b0b4efd1df03fac78bb587f6ce98cd \ + --hash=sha256:196e270c4e80827b5072eed7d6aa661d133afada94fe366669f9609e718d305e \ + --hash=sha256:19e52bda45086df8a4be4bb5910af6f5d9d3b538c78712c8ae09ef10b85bf458 \ + --hash=sha256:1a573e1e428f93908e79e04b349717f400e720f2f82285f0aaaf3ee0ff7f4c79 \ + --hash=sha256:1c010dd86d3f4c4433c9634d33ce8147393b270dfa54f217f965540b8ae8e075 \ + --hash=sha256:1cdfed4d7a59333c8220c67dd3be4e7a6c887b67453a64394022dcc919570add \ + --hash=sha256:1d366548d2ee28a8cfdcc4296363978cc644a728333be9824d2de4652e83df0a \ + --hash=sha256:1f56ce84b317ef2a59d7d3461891c7597c79247d2192bb8114c68a1a1debfcc0 \ + --hash=sha256:1f99a8c3a1da5d955edbad18208b3d627bdd54c48a6e739fa877bdca98c686d6 \ + --hash=sha256:2080aa129a28267984cdc902898993d788c995c384e285d0d19199f56760d52e \ + --hash=sha256:22a1889f1c9b752c63c36758a0c2145458e3cadb20fced7a0790002e9dd12b26 \ + --hash=sha256:2401f7671242e921e604f609d429f6b282ea4ca787a6ffd22ed7372011ddb9d1 \ + --hash=sha256:2403b489c103e9a18c835863fc6dd54361355c8291d4cafdb37492b683440b9b \ + --hash=sha256:2df26d4134948616be0ece05d0b24d621d3990f37147b5883c52052b613ef1f5 \ + --hash=sha256:301bfc4877c4f4f62b344235ecc58d06c901683801636eef819f88769c315ba2 \ + --hash=sha256:30ae26a1adcd943690dcbbc47f28be762bae9e08ad7442b78c86b1c0dd5a626c \ + --hash=sha256:3288a560dc3114d5d2ebe309b1ef43f8af355eafe25856832415c2a8196c9db3 \ + --hash=sha256:32e6d56dd825205f81e5c45bcebb4df6a11fb2bbf4969a01ef156d6ced90c224 \ + --hash=sha256:3418edd0ecb72a0a3861cf72f31be0ad9b7fe338ce2b58fb5cc80b9aeb792700 \ + --hash=sha256:3587d94b5c9f05c2dc4c3f3d47aba6375ff141a21adae3051d8d4d53e8a937c0 \ + --hash=sha256:3684ebbdffd51329ac44245d1d227d90b965797aa1a8abd026568a1f6ae88811 \ + --hash=sha256:368eb2fc9482158b3a3386e8f01fa61f479c968e9a19ceab8f0188b86b312991 \ + --hash=sha256:38a395079f229a631dece74e24c69c1f612536dd51f345a7d6a98abe2d3e047a \ + --hash=sha256:3b08ebf9488c7ff5eff038e48e6ea938178dfd9dcc8598b5ca941e4ae27b20be \ + --hash=sha256:3bfbe543d957213fc9a3db4979a8e171b7aa7504c1d737029defdb03a6095a38 \ + --hash=sha256:3cfdab178a4add5483e26a9bb1c16d8018ccf39b4be7a3aea6c3979e6828f2ee \ + --hash=sha256:3d00e18e7bbf47e332ab63903d18bae31efc701b1d8cca0382b97784a621fc44 \ + --hash=sha256:401ea6e7af9e7852ed818f64714b579c1935482049670847ca3bd7ba45dc63fb \ + --hash=sha256:420b19411959eec115063229536788e6b32d0a7fa907d6b940317919120d702d \ + --hash=sha256:4253da1b4456b633651a8d59eb1dc7a8a8fa38241014dd7c217b353e547ae394 \ + --hash=sha256:4346a693c08b1d0cfc0e3325bfb0ecd4322fb1a6904d68cf416f8da5e981b234 \ + --hash=sha256:478650a70a750d75d5add401606c77f77069c32e4ba2c9131dc6cee566962ca0 \ + --hash=sha256:48920bf6fe83eb2226756ac623fa54940487154eb18f80889d5735cf234965c0 \ + --hash=sha256:49bd5feb59b0bf3cbf6ebcf4352e371c95b9da9bacd4449f8b64d0ad2c10a26e \ + --hash=sha256:4c440122e1ea68b1f8b44a631ebf49c39180f6869b1da22d76e8a724208ec6e9 \ + --hash=sha256:4ebebb410bc517e1d284c52a123e82704b21e4e7e26a21ebecf7439d0647b8a3 \ + --hash=sha256:527e28a5e751d9e11369b9c5f9ab35c748eb9c109101920c7deb40d6eadf8d03 \ + --hash=sha256:54c963ce6404e52255b737e8a06d356fc762d59096ae566203a67cf2b7d050f2 \ + --hash=sha256:562d24ca7797c1af8852994950c2e623a907b201fc4b0ed29e92af173d3828ca \ + --hash=sha256:5780a29823e1d2bec69b7a104ead4195a43f3e97782efaedbf1f79a0157af715 \ + --hash=sha256:58ca5dc0a0ef99f2801ec0574214c978e9574055bc783830bbb6e7433218609f \ + --hash=sha256:5a4ee37248dfac25107c758bda99d545ce73e60b44d2dd39e4a2bb9f2831e9f5 \ + --hash=sha256:5a54587f93f2e289f8faf25b35c997d4cc75cf677485ac6f50c985715989f99c \ + --hash=sha256:5b81980668800dd1c69faad8aea6e85a8cee0e13bcd3bba7671695ff16260293 \ + --hash=sha256:5c23fa4f6eccdd601949cb00f3988c01d64e671d8faba356397971077022e144 \ + --hash=sha256:5e68229977b2dea28e7061c0c0630a23f2f9f6e9c6fb38d77d3d6dbfe3768b74 \ + --hash=sha256:5f51a19dc52197a20218b05ec5336d0c6b3b09935f838724722032c8d45dc91a \ + --hash=sha256:606a86c1c3196f3738de39a67a7490bbd61cb31c0e0436070bd0c6a48170b38e \ + --hash=sha256:6083d10a846218502d664375b9448508d9fa580bd834567423156c6abfbe899d \ + --hash=sha256:608553f476fca509537e804c4a71f5eb166ce63b75141f89c2c686ce1aa36956 \ + --hash=sha256:63ea0cc840c66670183578c2630d138c0e944aeadfc33f25173ee240f5db780d \ + --hash=sha256:6753de11eef42f1c321b26d682957d92c7f7bbce6530f34bbe0f9291dd37cc6f \ + --hash=sha256:68b7e84ae8239a94f8d2c8f3f3a3a81bcde54805ec8f42a34de927d155688ec6 \ + --hash=sha256:69d647cf158eb6bc9c99503292abed1f2079a2de5859f06a403f8aee6417475d \ + --hash=sha256:6abb1f356fb865baeb6ebc3fadd843e9a96fbf49b9adcca55037f3cceccb7438 \ + --hash=sha256:6c06875a1d4a7537bef70f659b55c6b55b9a47ec3ba8f2db610350c2d9915e6e \ + --hash=sha256:6c57af4084c10cb3286688d65e4c654190ff5edcbc2411d08cdca0a8a44c59a1 \ + --hash=sha256:6c95450fce59f00c6d08eff6572ec2e736e5054c9450253afd5748f8416f2eb9 \ + --hash=sha256:6da562a20a49673fe365b05750e98d03bb2c5f8b8d03562b014c1abb3df739f1 \ + --hash=sha256:6e44bc2780516b3df986d6fe33103c7080cd9dcd5576fe3cb4b0f64309c8f22b \ + --hash=sha256:70ff1c16eb0eb5ee6bb12739292347f981a5ba764cc4df1bc2e69b0405d4ac3b \ + --hash=sha256:72982d9958a42f8132bf2d6b90214ed66477295ef1188731f98ae3511c6eeb5a \ + --hash=sha256:74892fe9f33d204860e782e0a2030bb39f9f0af1e7a24f7d5a5b632df311f655 \ + --hash=sha256:75e243abbb528c1a774390ed71e3f868a9f37b1373442e4bbadd401cfc505ff4 \ + --hash=sha256:7cdded069549b5eae3d5d9bb6c2e5bb4fe83f9b81863e2a193cd747bf197aebb \ + --hash=sha256:7faa47b56070b3dd6f4898ed28528843ab130d53266cb9948d9b1f3bb1a5c5e8 \ + --hash=sha256:7ffc43fe52618fcd7abc6ee0b46aea527db10da73305fcc6aaf9710ac7a33ec7 \ + --hash=sha256:815f143a91983ba3041bba066e492ae3c42de523fb1c699685a1abf3313b7d1b \ + --hash=sha256:826a295a039178479a325be1ae60eded1f0b10f7dda749df59e2440de8f61d64 \ + --hash=sha256:82cc5835997ec78afe293a192e385099355770a7db94b2fb1239d36b32796f1c \ + --hash=sha256:830c04a49998b5ed58c8b642c65b7b26419397f52392a64121ba9fd0e95e7f9f \ + --hash=sha256:83b62410bd36bb1178a7d563e2ee0cf21eb1c980c912ab99c2c78f06227f1731 \ + --hash=sha256:84b736e3b391601bc47b86da381c749c0f894e9191aaca9f31f30c2632206df3 \ + --hash=sha256:85f9e0e2724bbddf05de65e5fb03b73eb23e985b7df4259c1d19feb302eb8dc2 \ + --hash=sha256:8b3e9e29b8b07cc461b9ce7768db7693a93979d0dadf22046f6f3555ded2f516 \ + --hash=sha256:8b8788f114845c01f2b520e0b91ea58d143276cfc0483aa943e815f7b9555c15 \ + --hash=sha256:8cb9b6892b53bd6d11fa4cde3dbee020b1f0b6656be1fbaa1ec0d4324a7839db \ + --hash=sha256:8efc3f1563ed431882dd0dc0411b5f8ace1b1b89074981deaf6bd8af77dbe1bc \ + --hash=sha256:8f006866047c6ec4b627ec144b1e0bbc7427cb31fd7c08d19897d0ac9032af3d \ + --hash=sha256:91f9f7c151e772acebe489eaec96e96a2877202d7dd144e3f96b8676881715a0 \ + --hash=sha256:9491f594859b68052edebd69e05fb045055a713b57a67974e6c1553b4e503c39 \ + --hash=sha256:96720f2aeed3434bc48f4d52fbad64ecc820cfed88915d664780ed9ba09ede78 \ + --hash=sha256:96ae7ab5d8155fde927aa0864fbc8ba3cc4fde6d41ab0c7cea9d6012b4978603 \ + --hash=sha256:98820e1ceb25c6df7a80c4fd8efa59cb121f99bc7c4c1693ad94a2caff5b311d \ + --hash=sha256:993dfcbe75a85a3784abb5084f2c41b915767c90546fcc92803cffa28611baea \ + --hash=sha256:9a1d9b13e5e394e13e3c316f0d910d100b17681ff59797f30da1dba032061296 \ + --hash=sha256:9bb3e0d1345b9c0fe73673ea656375f38a78ec679c2edeae0c24800f04798a85 \ + --hash=sha256:9ce0f885239357379d92fd9a5fddbe20f0e30e0527c29ba69f8e99eeb1304a76 \ + --hash=sha256:9e0213f3f8a2674a6778be299aea1d6dc6dda015aab86f683bca6d78f81f27bb \ + --hash=sha256:9e726478d7a213847860219d74665a6892a643ac93b8f76580f6cf9ed39996b7 \ + --hash=sha256:a17864853f7c518ae7d4b368af98f427f9396805476af40af8698560f09d7d97 \ + --hash=sha256:a284c36b9c6616bf0a8aa4aabba668a0c75ba65ccf40a79868aeaa69ad996897 \ + --hash=sha256:a3ad0e3da22852533858663848608f3f24c0d35e5cde415a4903476f2b4c88ec \ + --hash=sha256:a5613a3a82c974227bde18f03409e30c467f8065cb56d822e3eb83708a5f223d \ + --hash=sha256:a565303d118ea3b94a4b6c076bf568069726be414e43b06d58f7070b076ce11d \ + --hash=sha256:a60773eb5fda796e6e6f76b9c152d270fe59f9788a51a6ff8ba44082d8548ae4 \ + --hash=sha256:a7cb4cd266bd85613367fb85a30cfbf6fe6349919e87e18ca8dba584951bfb8a \ + --hash=sha256:a864bdcacd8bff58bb4845304e031f821a3ec64b2b7259f2d409cd49c9e59ca3 \ + --hash=sha256:ac5a9cc079c67d75f4ddf343276031879eadbb333d1bb231cce297b8d7b9aae8 \ + --hash=sha256:ac68ebfa549cc623e0e9add2937526340c629ccf667b4da85b7ef5f99e70bbd9 \ + --hash=sha256:aff38231e3171c578b2c449a01afa44e9ff40844597a32873da102394f63d28e \ + --hash=sha256:b476cdb63df22da2b91837593380be3ddbe406f36c506c1c91d80e7196b66288 \ + --hash=sha256:b787efadba00f5da6fe89513bfbe3852d52ca3a448fdec165765cb3b44a80248 \ + --hash=sha256:b7eb3eab5c646d3de7dcb14a7c9caebace5249c5767da39e1761cb1576e521a3 \ + --hash=sha256:b8ea208b304587d47931b36481342d20336e0d338ab052f8b4305926482598d6 \ + --hash=sha256:bf1e75dc07a3850b53d1e5f75e04d3ae12afe56284be7821771eaa2466350c73 \ + --hash=sha256:bf91921009025e96ce57a03ced6d14604fc3baf0530351638e9504a55da6fa3b \ + --hash=sha256:c387c6bf91b4774e359a48a179e2872b8e8bf741e4fde06ba8d1665eb9a4760a \ + --hash=sha256:c38d1e9bc2073b0984d2099ea647fd7f6c0d8f83a1e14e0cd32926f16e4c44ce \ + --hash=sha256:c41b067eddcfa5ee6b1169c287605be7fb6b0ea22bba6474c5bb978a668def4f \ + --hash=sha256:c455829625df983f716cbaecbba77f2d1dc2e0e0ed1638c059cece15a279344b \ + --hash=sha256:c54036a518748b6c02e666f6d46c3817561998fb904c3be25b56fb4fe3dc5706 \ + --hash=sha256:c5c6d47a865147e0ae3322ce92e7fb52ba3169d94b447deda56897ea2aa6fac9 \ + --hash=sha256:c5e981a5ac8641381efe6f0029467500661616a530d27bc6eedfe45f840599f8 \ + --hash=sha256:c75191e3c8052045179646cb40e280800a4e0bdfda34d9c949c2f268d44e80e4 \ + --hash=sha256:c825661dfcf843119ab57cdcac0df7a48e168764c66917bc74f9a42ecb096da9 \ + --hash=sha256:c9bde7a960720c8b8e1b5ef7afaa0c9a2f3b55c44abd635b2b29dd066b298e3a \ + --hash=sha256:c9f45186390aee4d1f26f723c615b67df346766c3b16df000d84d6e374f06757 \ + --hash=sha256:d016dc857136c726958102c3b8a3986acdc65ace6fbf12cfdc09cc4bfa2935b2 \ + --hash=sha256:d08952c0f14eb56d9dad72a2e17773b5f709c55b28635822d18c4adf38680833 \ + --hash=sha256:d22a083497d2f7d06a57172c5b60ee66cedcf304fde5226d4dfdc94f6180f5b1 \ + --hash=sha256:d2478bd3b2ead3962a484fb802891be40d10049fb74f83e09cb4463fad023fea \ + --hash=sha256:d54625cbf4e6b60bf0639728cb8b4cb541e340f6d7cafae5806051a40ddf4c45 \ + --hash=sha256:d6100f877d2ed95f0856a3fde25334153add94bf2224c43f45f88e7039262aaa \ + --hash=sha256:d672f329ae504ee240eb39b6effb3318aa8e7e8924c0ce8eee5760b3fad98539 \ + --hash=sha256:d7229a99120c6c2792d96f4857c2648ce5530e93667a2c2388c5ef69a6b84775 \ + --hash=sha256:d74bcf1cdd8ac8267fb216473ce6b112efa07b163536288094541415084d131c \ + --hash=sha256:d788e2ded0c4c47efa4d73cfe59eaf975ee32f425219873d2cb3e3fbaa00f636 \ + --hash=sha256:d867cefea33acad8e33a3eb408cca7889a9cf999bd5433d962089d5a13b6e75f \ + --hash=sha256:d8a9316f4da85e937242642b537c6d55d7e9287dd38e5634732f8233932aff45 \ + --hash=sha256:d90254c8f609338c53ec180fcd4c4f9c16502e238e3fc88ca7fd4c2f38d445b8 \ + --hash=sha256:d9419f44e568f7fafcdc0b3b5c766a2364e705a9b34fb8a56b431e0d1f3f4258 \ + --hash=sha256:d95244906ed69d0f79f190893c65e336c15959003e21449256dc05c001b52ea2 \ + --hash=sha256:dc28949de1bb5f7f30a46f15d74ce7ac5aaa63e03c5de04d68f571c7423af834 \ + --hash=sha256:dc7f6aca0bdac5e6520c8b6769bda69315fe7cb57f69885f115bc8ca02d1d022 \ + --hash=sha256:deb99535e9bf0bea8e274c6413eb939a21be35a3f492678dba4d5b1f4d70f142 \ + --hash=sha256:e31786a947b136329bfdc458c82c06d4ec539b4a4436b7da4df4aafc9902ee80 \ + --hash=sha256:e3b9eaa99a6d8c9ace4cd303915947ef55088d4cd87c6676874f98c5c03aa040 \ + --hash=sha256:e46a37ea7fcf9ae01d71b2e5ece19f1565987f3e308394b829197cbefc061f92 \ + --hash=sha256:e4e8fa586df2208ef040684751345f10f503834a757c9a74ecd19c1a2f9b1ccd \ + --hash=sha256:e54dd1a66fa4bce0ccaf0db9dde336e49b3eec646dc4c1c0991279369d373a14 \ + --hash=sha256:e5f834965c2fe589837bac1002e07e25734ff70381903ccd95b3d649e22bfa40 \ + --hash=sha256:ec6c464cf45867f66a2273e2214d9199a8fbad5cb95ca0fd45f6a2fe1d9d2cf4 \ + --hash=sha256:f044cb1cf44012184715f46584658993b5fee9344d71c4b0c455a17a299730c0 \ + --hash=sha256:f0fde5e5100c735b2274ab898f0742a5dcde492796296cfbe7e0ad6a4cd1a396 \ + --hash=sha256:f1619a3cc174a7e3963dd34348e6fceb6e50db0ddeb0031bd7c73a58286454fa \ + --hash=sha256:f278e131afa96a3622cef9211c406ea2ad1b68eb06f8837cd443684a40e0ae50 \ + --hash=sha256:f2ce3d39fb4a9d674e6639dd5d3146b2e273475d2260f10163228d66fc04433d \ + --hash=sha256:f7496aed56b06325a1ad419c5bf23c6dd042558e874f71dd1b958f3e255f3053 \ + --hash=sha256:f8cd1283a9fe6c2065c807e9d5da81afe5e1e004caef39adc0d8ae86dd883698 \ + --hash=sha256:f9f91d3e8382900f3a68fa0ce94294479de9cd2de6bc0c70acd0f0dfd511836b \ + --hash=sha256:fd68c825548a611158230e2f9222e210ceb2e3391995c0aa5865cbdf3ab4bd49 \ + --hash=sha256:fded2e82ff082e5d8e017e2ddcc1411bd8cb83b8585097fc401ef574f756b888 \ + --hash=sha256:fec352b793cdc183cc9e7e0b6c10fd7bff38ec54ba44cc43599b9b56f7f3db2e \ + --hash=sha256:ffdd7ac514301d0a67f7c23b9f2b431ef909a3c3dd6c3766668d0a6f5900c94e + # via requests +click==8.4.2 \ + --hash=sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6 \ + --hash=sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76 + # via + # semantica (pyproject.toml) + # black + # celery + # click-didyoumean + # click-plugins + # click-repl + # huggingface-hub + # litellm + # python-oxmsg + # spacy + # uvicorn +click-didyoumean==0.3.1 \ + --hash=sha256:4f82fdff0dbe64ef8ab2279bd6aa3f6a99c3b28c05aa09cbfc07c9d7fbb5a463 \ + --hash=sha256:5c4bb6007cfea5f2fd6583a2fb6701a22a41eb98957e63d0fac41c10e7c3117c + # via celery +click-plugins==1.1.1.2 \ + --hash=sha256:008d65743833ffc1f5417bf0e78e8d2c23aab04d9745ba817bd3e71b0feb6aa6 \ + --hash=sha256:d7af3984a99d243c131aa1a828331e7630f4a88a9741fd05c927b204bcf92261 + # via celery +click-repl==0.3.0 \ + --hash=sha256:17849c23dba3d667247dc4defe1757fff98694e90fe37474f3feebb69ced26a9 \ + --hash=sha256:fb7e06deb8da8de86180a33a9da97ac316751c094c6899382da7feeeeb51b812 + # via celery +cloudpathlib==0.24.0 \ + --hash=sha256:b1c51e2d2ec7dc4fed6538991f4aea849d6cf11a7e6b9069f86e461aa1f9b5b4 \ + --hash=sha256:c521a984e77b47e656fe78e20a7e3e260e0ab45fc69e33ac01094227c979e34a + # via weasel +colorlog==6.12.0 \ + --hash=sha256:2a7924c1dadf18b22a0eb8b06d1c7b01d5341707ec1641eb6fcc4fde0c3e8e5f \ + --hash=sha256:30d392604e9110045a2c2aeefc27d7a017abbab63f3a8aee594eac0801df784e + # via rapidocr +colourmap==1.2.1 \ + --hash=sha256:4dac30026b072fc210fa23ddaf17b48fbf5c75b947425e9ab10b514afdcef39f \ + --hash=sha256:86b614f458b301145e52f5aaf6f270911021eeb6170a42ce3354b2c5b70e3cf1 + # via + # d3blocks + # d3graph + # distfit + # scatterd +comm==0.2.3 \ + --hash=sha256:2dc8048c10962d55d7ad693be1e7045d891b7ce8d999c97963a5e3e99c055971 \ + --hash=sha256:c615d91d75f7f04f095b30d1c1711babd43bdc6419c1be9886a85f2f4e489417 + # via + # ipykernel + # ipywidgets +confection==1.3.3 \ + --hash=sha256:b9fef9ee84b237ef4611ec3eb5797b70e13063e6310ad9f15536373f5e313c82 \ + --hash=sha256:f0f6810d567ff73993fe74d218ca5e1ffb6a44fb03f391257fc5d033546cbfaa + # via + # spacy + # thinc + # weasel +contourpy==1.3.3 \ + --hash=sha256:023b44101dfe49d7d53932be418477dba359649246075c996866106da069af69 \ + --hash=sha256:07ce5ed73ecdc4a03ffe3e1b3e3c1166db35ae7584be76f65dbbe28a7791b0cc \ + --hash=sha256:083e12155b210502d0bca491432bb04d56dc3432f95a979b429f2848c3dbe880 \ + --hash=sha256:0bf67e0e3f482cb69779dd3061b534eb35ac9b17f163d851e2a547d56dba0a3a \ + --hash=sha256:0c1fc238306b35f246d61a1d416a627348b5cf0648648a031e14bb8705fcdfe8 \ + --hash=sha256:13b68d6a62db8eafaebb8039218921399baf6e47bf85006fd8529f2a08ef33fc \ + --hash=sha256:15ff10bfada4bf92ec8b31c62bf7c1834c244019b4a33095a68000d7075df470 \ + --hash=sha256:177fb367556747a686509d6fef71d221a4b198a3905fe824430e5ea0fda54eb5 \ + --hash=sha256:1cadd8b8969f060ba45ed7c1b714fe69185812ab43bd6b86a9123fe8f99c3263 \ + --hash=sha256:1fd43c3be4c8e5fd6e4f2baeae35ae18176cf2e5cced681cca908addf1cdd53b \ + --hash=sha256:22e9b1bd7a9b1d652cd77388465dc358dafcd2e217d35552424aa4f996f524f5 \ + --hash=sha256:23416f38bfd74d5d28ab8429cc4d63fa67d5068bd711a85edb1c3fb0c3e2f381 \ + --hash=sha256:283edd842a01e3dcd435b1c5116798d661378d83d36d337b8dde1d16a5fc9ba3 \ + --hash=sha256:2a2a8b627d5cc6b7c41a4beff6c5ad5eb848c88255fda4a8745f7e901b32d8e4 \ + --hash=sha256:2b7e9480ffe2b0cd2e787e4df64270e3a0440d9db8dc823312e2c940c167df7e \ + --hash=sha256:322ab1c99b008dad206d406bb61d014cf0174df491ae9d9d0fac6a6fda4f977f \ + --hash=sha256:33c82d0138c0a062380332c861387650c82e4cf1747aaa6938b9b6516762e772 \ + --hash=sha256:348ac1f5d4f1d66d3322420f01d42e43122f43616e0f194fc1c9f5d830c5b286 \ + --hash=sha256:3519428f6be58431c56581f1694ba8e50626f2dd550af225f82fb5f5814d2a42 \ + --hash=sha256:3c30273eb2a55024ff31ba7d052dde990d7d8e5450f4bbb6e913558b3d6c2301 \ + --hash=sha256:3d1a3799d62d45c18bafd41c5fa05120b96a28079f2393af559b843d1a966a77 \ + --hash=sha256:451e71b5a7d597379ef572de31eeb909a87246974d960049a9848c3bc6c41bf7 \ + --hash=sha256:459c1f020cd59fcfe6650180678a9993932d80d44ccde1fa1868977438f0b411 \ + --hash=sha256:4d00e655fcef08aba35ec9610536bfe90267d7ab5ba944f7032549c55a146da1 \ + --hash=sha256:4debd64f124ca62069f313a9cb86656ff087786016d76927ae2cf37846b006c9 \ + --hash=sha256:4feffb6537d64b84877da813a5c30f1422ea5739566abf0bd18065ac040e120a \ + --hash=sha256:50ed930df7289ff2a8d7afeb9603f8289e5704755c7e5c3bbd929c90c817164b \ + --hash=sha256:51e79c1f7470158e838808d4a996fa9bac72c498e93d8ebe5119bc1e6becb0db \ + --hash=sha256:556dba8fb6f5d8742f2923fe9457dbdd51e1049c4a43fd3986a0b14a1d815fc6 \ + --hash=sha256:598c3aaece21c503615fd59c92a3598b428b2f01bfb4b8ca9c4edeecc2438620 \ + --hash=sha256:5ed3657edf08512fc3fe81b510e35c2012fbd3081d2e26160f27ca28affec989 \ + --hash=sha256:626d60935cf668e70a5ce6ff184fd713e9683fb458898e4249b63be9e28286ea \ + --hash=sha256:644a6853d15b2512d67881586bd03f462c7ab755db95f16f14d7e238f2852c67 \ + --hash=sha256:655456777ff65c2c548b7c454af9c6f33f16c8884f11083244b5819cc214f1b5 \ + --hash=sha256:66c8a43a4f7b8df8b71ee1840e4211a3c8d93b214b213f590e18a1beca458f7d \ + --hash=sha256:6afc576f7b33cf00996e5c1102dc2a8f7cc89e39c0b55df93a0b78c1bd992b36 \ + --hash=sha256:6c3d53c796f8647d6deb1abe867daeb66dcc8a97e8455efa729516b997b8ed99 \ + --hash=sha256:709a48ef9a690e1343202916450bc48b9e51c049b089c7f79a267b46cffcdaa1 \ + --hash=sha256:70f9aad7de812d6541d29d2bbf8feb22ff7e1c299523db288004e3157ff4674e \ + --hash=sha256:8153b8bfc11e1e4d75bcb0bff1db232f9e10b274e0929de9d608027e0d34ff8b \ + --hash=sha256:87acf5963fc2b34825e5b6b048f40e3635dd547f590b04d2ab317c2619ef7ae8 \ + --hash=sha256:88df9880d507169449d434c293467418b9f6cbe82edd19284aa0409e7fdb933d \ + --hash=sha256:929ddf8c4c7f348e4c0a5a3a714b5c8542ffaa8c22954862a46ca1813b667ee7 \ + --hash=sha256:92d9abc807cf7d0e047b95ca5d957cf4792fcd04e920ca70d48add15c1a90ea7 \ + --hash=sha256:95b181891b4c71de4bb404c6621e7e2390745f887f2a026b2d99e92c17892339 \ + --hash=sha256:9e999574eddae35f1312c2b4b717b7885d4edd6cb46700e04f7f02db454e67c1 \ + --hash=sha256:a15459b0f4615b00bbd1e91f1b9e19b7e63aea7483d03d804186f278c0af2659 \ + --hash=sha256:a22738912262aa3e254e4f3cb079a95a67132fc5a063890e224393596902f5a4 \ + --hash=sha256:ab2fd90904c503739a75b7c8c5c01160130ba67944a7b77bbf36ef8054576e7f \ + --hash=sha256:ab3074b48c4e2cf1a960e6bbeb7f04566bf36b1861d5c9d4d8ac04b82e38ba20 \ + --hash=sha256:afe5a512f31ee6bd7d0dda52ec9864c984ca3d66664444f2d72e0dc4eb832e36 \ + --hash=sha256:b08a32ea2f8e42cf1d4be3169a98dd4be32bafe4f22b6c4cb4ba810fa9e5d2cb \ + --hash=sha256:b20c7c9a3bf701366556e1b1984ed2d0cedf999903c51311417cf5f591d8c78d \ + --hash=sha256:b2e8faa0ed68cb29af51edd8e24798bb661eac3bd9f65420c1887b6ca89987c8 \ + --hash=sha256:b7301b89040075c30e5768810bc96a8e8d78085b47d8be6e4c3f5a0b4ed478a0 \ + --hash=sha256:b7448cb5a725bb1e35ce88771b86fba35ef418952474492cf7c764059933ff8b \ + --hash=sha256:ca0fdcd73925568ca027e0b17ab07aad764be4706d0a925b89227e447d9737b7 \ + --hash=sha256:ca658cd1a680a5c9ea96dc61cdbae1e85c8f25849843aa799dfd3cb370ad4fbe \ + --hash=sha256:cbedb772ed74ff5be440fa8eee9bd49f64f6e3fc09436d9c7d8f1c287b121d77 \ + --hash=sha256:cd5dfcaeb10f7b7f9dc8941717c6c2ade08f587be2226222c12b25f0483ed497 \ + --hash=sha256:cf9022ef053f2694e31d630feaacb21ea24224be1c3ad0520b13d844274614fd \ + --hash=sha256:d002b6f00d73d69333dac9d0b8d5e84d9724ff9ef044fd63c5986e62b7c9e1b1 \ + --hash=sha256:d06bb1f751ba5d417047db62bca3c8fde202b8c11fb50742ab3ab962c81e8216 \ + --hash=sha256:d304906ecc71672e9c89e87c4675dc5c2645e1f4269a5063b99b0bb29f232d13 \ + --hash=sha256:e4e6b05a45525357e382909a4c1600444e2a45b4795163d3b22669285591c1ae \ + --hash=sha256:e74a9a0f5e3fff48fb5a7f2fd2b9b70a3fe014a67522f79b7cca4c0c7e43c9ae \ + --hash=sha256:ea37e7b45949df430fe649e5de8351c423430046a2af20b1c1961cae3afcda77 \ + --hash=sha256:f64836de09927cba6f79dcd00fdd7d5329f3fccc633468507079c829ca4db4e3 \ + --hash=sha256:fd6ec6be509c787f1caf6b247f0b1ca598bef13f4ddeaa126b7658215529ba0f \ + --hash=sha256:fd907ae12cd483cd83e414b12941c632a969171bf90fc937d0c9f268a31cafff \ + --hash=sha256:fd914713266421b7536de2bfa8181aa8c699432b6763a0ea64195ebe28bff6a9 \ + --hash=sha256:fde6c716d51c04b1c25d0b90364d0be954624a0ee9d60e23e850e8d48353d07a + # via matplotlib +coverage==7.15.4 \ + --hash=sha256:002a438859f7b430bc99afeaf01a6d187dad1d0dc907b64cdeffc632a5db8fd8 \ + --hash=sha256:050467a7983b8e2fe7dd41a78bb30c3e7f8c0b8cafda14b1c46f8b5e3cf2dd3c \ + --hash=sha256:050f66a08805acb5b8a23c6d4a517b1ecf82c08e81ed0e4bd727df065e5c6624 \ + --hash=sha256:0548198fff07ccf4faf469520bce1c2eceb1ce3e62891921138dec10907f9d00 \ + --hash=sha256:05e491d4f3165d62d4f5c8fd48dfeabf2ae8f42cbbd484319af33ea851b78982 \ + --hash=sha256:07545711d4f0f32852a18f18ad11f76f0109909d09e78b9008b4cfc67e829429 \ + --hash=sha256:12b59c90084e3234fb11184886bf4a40f4f16a8c8f867be2e087b81f8e8868d4 \ + --hash=sha256:141fae2cabf5569b782c10afc4c850ce10f618c13f8db54765cba99cc839da1f \ + --hash=sha256:150089274bdc9f940628552cb92844e0223c987f1902ab8efe9f45a2ec758d88 \ + --hash=sha256:1587fb771d1ccceef708fdde1e5af8c7ed24b486b61d13a321acb7d8145390aa \ + --hash=sha256:1c9bf40ebef178a45192c75c4964760bb261b0e6ad725da5fc4c93f674f19753 \ + --hash=sha256:1cd685005cd2c4200adfc14cf39a603b9320efab3f18a8f7f156d20c9cc3345f \ + --hash=sha256:1d10025d96ea89fc2f73714dbc4cbd433fe012c1ac9e23f895d7728b238b6e52 \ + --hash=sha256:1f4f826d70f772ab8b0c052329580d7fe8b8abd191e4ce0c8f81aec6614665d3 \ + --hash=sha256:21b803935e2efc3acebe9697197a294fccf5dc4e5382bd6369542ff7a7d2a1d7 \ + --hash=sha256:226c66e80ec0598d3b9b4874123df167ccca342aca8714f77cac6829688ee09c \ + --hash=sha256:2413074a5ecbb61a01a7888fc72db0ca324d13588c5b38bc0dd8564cdcdfea26 \ + --hash=sha256:288bde2a2d7ab6b6c2d7252fcde8b524387f2d970bdba9658fc6f8bbcaef0f9b \ + --hash=sha256:2c9872e4d9dc5d3cf616bf4b382f5a00359305a5be666a3dd0b5cdb4e49597f9 \ + --hash=sha256:2e179f19bfe1d31f8eeeaa12990194d761c4f62f0759661000bca6cd8729f40b \ + --hash=sha256:2ff8f5e9b8f7a94f0c11c45631eee103dbcb7d63274edd12c56efe1be690b3b4 \ + --hash=sha256:317db01a2cb02552fd67e2b1cca77a4b528a2a277176c5e0bf2cecbb639d3f54 \ + --hash=sha256:337399ad2c93b3acd2a937627dae8b3e86b66707cd3d3e856347999aadf1ef8d \ + --hash=sha256:349062d66f00b40fa2c1c222438bad25fabf755631b5d82937fe985c8008615c \ + --hash=sha256:357a173465c7ce028d07a95cc2b63b5bf59f50ecdd5ad75c5cbb78ada984048e \ + --hash=sha256:37e2f0cdf58e2e1fed4e4d5a8f8786ae2f7eb80b478016876667dc4a01d60a97 \ + --hash=sha256:39ece820e29e0a2ba34b3ecb3be83c27e997eed8926f2ba6fe7ce7a0bda5843b \ + --hash=sha256:3a54f5a0d85050c73a38f6793090ee83974531e67fe5e57a1da9bee11398aa5e \ + --hash=sha256:3d6f4955b73b5445271379a59e3792b0d978f42d4a01e0cf7a67d9c33a3bb0a5 \ + --hash=sha256:3da9e92d1c551fd7563833e9ade686efb0c4b7363ab7681a94283958c950bf5e \ + --hash=sha256:3fc2130bf37df31852a8384f12601563a45a0024bccc6624f38355cba7a8d360 \ + --hash=sha256:3fc9e047706fb4a9abb54f719d3aa643e80e5bb3818182c40aee01ac0f0247ba \ + --hash=sha256:3ff205ab5e3ecc670f6a4dd19d9cbf12ede53dd41cfc1e15716ec961ea6d314e \ + --hash=sha256:4256ced708e598e05209bc1a8ab4074e04a51dba4c62fb45926a229af675ace7 \ + --hash=sha256:425920379052ff1fe465268f3361d35804a241bbdd5a1b592c8cb60df4c52325 \ + --hash=sha256:43619d04c3671792d2c4706ae8bf45e265dc87bbd4078189ef8b847ea1e74be2 \ + --hash=sha256:460115e32ee40566476db5048f9bec1e842c127ad8e6f8be745aad3ac9cbc839 \ + --hash=sha256:46822e9b6ff1c6a72b518c162c44a8f45a61a1d609c51084bf5b16c023c5037b \ + --hash=sha256:474223409d88eb20d2d6a0d37ea60e8647a65a90cc008dc1f0410af5f64f1e0d \ + --hash=sha256:4a4bf917c9953f57c957be31c1cd504e3bd2f34d4a352b9d391a3025336f6768 \ + --hash=sha256:4dff9daa47d83120c3ec38ce921214242944a832aa04e903e50b5b7ebac8972d \ + --hash=sha256:4e6f6f632b7b2f714bf7a1346e8f97b650ee71f3c298aaad42a2ab60f0f07645 \ + --hash=sha256:5172326e861a38b48b48befca15e0f477a26b283337a33a739c8fed229934e36 \ + --hash=sha256:5669c8378ebde86f5def7a25d29586631b58acc27ffde04399f678f3dfc6e082 \ + --hash=sha256:5a325e815318638aed1655d9c06e6d7c2d3d46c09231ce988070428a8762d734 \ + --hash=sha256:5c3431b2161279b7db5c2a1aa58ae02e5cb8c3c42d93a5094be3f5537bd5b11b \ + --hash=sha256:63fd6fcd1dd6e158f7eb78606e72933b3f6d01e7b747f99c6c12d764307a0fdc \ + --hash=sha256:67bc345491ab55b837277d76f5775d057e8c7f1ac44d890d8c2c82adde258c6f \ + --hash=sha256:6879ded16a27f3eeca19b900c147e81616e7054db451471a611b2755ee5249f7 \ + --hash=sha256:68be5e1de60ff13c9095bbec0e5a7fa45b33b101752215b91345ea1f61c4a278 \ + --hash=sha256:69484d1aca26e322e1c3ce03f09341e84524ababad2d7202161738d83cc9f82e \ + --hash=sha256:69bb2400abef928e365ea7d4d9925169ada78ed2295546780002d4b65de3df88 \ + --hash=sha256:6befeab5fb2b51c958ca4ac6c5d141a1e8240f4f76e46350f1911963deda49cd \ + --hash=sha256:6e0a8a5083b096487d6cfced94cdd514d8f5db6f113610fb36c0620edb1028cf \ + --hash=sha256:770e9325ab5ea6d56f77e59b29ecfe0ac20b57a82a601876f90494a4dda0386f \ + --hash=sha256:7a2b580774a4786c1053157c0165e04476e03ff293993d7c148eee784a94bae6 \ + --hash=sha256:7bbd7d6418e0dab31a206af5203bd43ae36edb8e7fba1940b055d3e9249290d7 \ + --hash=sha256:7c922735321eef3f87c280a3d39afff6b646723a2880b862cda4ac7a093b8aa8 \ + --hash=sha256:7d1abebdb047729e852b9c77a00497dfbeb11eb3a117e037d7dbc3ac8e5f5c54 \ + --hash=sha256:7f2f62ae3cd189dd2e13aece758c57b3eecbd27be070dbd4cbd10936049e5dbf \ + --hash=sha256:81294c7e6ab30c5f74c0353b11b2fd6320e72d9bee6ac73b357caa8b916323a5 \ + --hash=sha256:81661f82d302484e3119e7c80c519c02fa9bcc2a6b339baf67d67bc89c580f04 \ + --hash=sha256:83cf06cdd687677742caff1a9134833b7a8b75f111519d2cb0e0ba1b9a851e15 \ + --hash=sha256:899b9da30f3c6c336566e3707495bb23e8302d39d862f01fa78c48b99b9437e2 \ + --hash=sha256:8af623e5cd92080acddd02b38f2f406a2c3a0893c38950b211890361448fbf26 \ + --hash=sha256:8b4f1c3a69ca580f3fbd6b2046915f536d7f586874f25c1bb23add2a3c88d50f \ + --hash=sha256:8bc16bb47b7679670eceff71d78bfb7d6e5b143f6c2cd117487ec7c75e0d4b78 \ + --hash=sha256:8df457da2249d3c75ca2e5e835d59c725abfe92d27fdff6cd99eed85b51d5e9a \ + --hash=sha256:8ee3838dcb656602c3b51e16aed9bfb0822f8d8d6d1c5966d32ec8c104be8e20 \ + --hash=sha256:8fa4de68e2a752468ff14b4e15db7def689a71be759e826a31ccecbef69c5fd0 \ + --hash=sha256:93a3a0b662abcc10c73a47cbc72cd60f63618d6989fb2d1286e50eacd974f303 \ + --hash=sha256:964730a1e9de9c0cf11be6a1a3c79ce419c34882842abd256086ba4698705e84 \ + --hash=sha256:96e257121228ec5cd2bb919276e94ac11074471bc37d68dbae0e8308cce15fff \ + --hash=sha256:986be58c3ab54aae8d3496a6225eea74f760fdbe739b38bd442c7e8d133aa53b \ + --hash=sha256:9e71e7bc71c686a123347ae47a0de33a175e797a85bb57b791492adf4eec8ed8 \ + --hash=sha256:a0865421cfdc53654b342d515e5a233187590882d20b95752150e53f65460017 \ + --hash=sha256:a093fd37229918976f602aa07aa59e0973cde82186f220c8e197f721f5be0ce4 \ + --hash=sha256:a58a94fed5da6997d258e8f7668c1e195fbd04a691d781b7558f1e468f9e68bc \ + --hash=sha256:a67a9f78b2942d87ba8ce3059c642164d2aedd65337377fb52fe9803656bc5c7 \ + --hash=sha256:a9447978a92f405d301123cfd39ff49895490efb769a758fe2734c7f631bf8ce \ + --hash=sha256:a9464451c4efffe8d47ace5a540b10b0dc10e879066290f8600872b7f54a419d \ + --hash=sha256:ac41cc14bebda0dbfb0628036b7f75706935c95bcc07fefe9a0f93614aa60a57 \ + --hash=sha256:af6c538498ce66c10d3fd541c2a8d5b03da5850355add34e6cba564210cb9e72 \ + --hash=sha256:b10075e5421d04265766a6d1dac809bbeb8a946fbb23c8f82c227409b2190719 \ + --hash=sha256:b24e078eabcd6a9caa8b0713f9bc1eeb310bcc960a29d45a3b4fcd4b16d5b11d \ + --hash=sha256:bbac5abad70df71019988f83f26ac7092ff2642975def4429e98dc7585ef3490 \ + --hash=sha256:be619439dbcd31a2eab10b32de9fff62c26ed4bab69dc32b8363fdaaa0882809 \ + --hash=sha256:c2de40895718f91951b86712b4c5b694acaf9a0a49be13874896f599a1eed3f4 \ + --hash=sha256:c38efe30fd74e5c19e9433f11fb1f5dc9c6522770971b7c6145bbaa413dc8800 \ + --hash=sha256:c6103639613fe6c1e989082948419bc77a2d26b6c825c99d7fad25f7d3d87afc \ + --hash=sha256:c65a9e0dfc6143491879da4e13b5e30f8be192055de508d737fb14601edbd22c \ + --hash=sha256:c705b28feb2775dc82a25f1d473a370bc37ff93f5177f4e29ce2425f560f6921 \ + --hash=sha256:c7dbc748ac8a1e3e59a2b28bea47675e6e778081dbbf081bde0d75def2fcbe1d \ + --hash=sha256:cb476b2e828ecb71cb6b6a928d23fd20a7ddb501188022dae1c37499149cc338 \ + --hash=sha256:cbde877ef9dd7baf272b9bfef2b8a25edd45d9170fc326951dd20eb480335e85 \ + --hash=sha256:cfe20cc8cf8821d4fe54f89106cbf06aa27f37b5bbe3535568065a81539b4150 \ + --hash=sha256:d003b7a5708ddad5c206c79607a6b92abb6fc13c57d99d8a4468cc03a2941ced \ + --hash=sha256:d0be6daac4cce6b8c8dc65886bae1b082ddbca4da8e5cbb5e15166acf253e264 \ + --hash=sha256:d12b33a3a50a1676b7784dc8d00a0c6d66a9f2add4b85a041c19b6a7e53ef23c \ + --hash=sha256:d15715e8c46552827e5e4f30a35575a2dbcad14454cf3284c54483946bd16931 \ + --hash=sha256:d28a4a899354d0ea6214cc59b4fa19eefbce1b9ff1688ab579acf49e894bd3fb \ + --hash=sha256:d3af93dddb5659276c63bc16ac6466ac2033a70ca816097bbc06345b8ccdf571 \ + --hash=sha256:d4fedd1f7f428f9fe83b1ead5e7cc87a43427be31aadafbac3ac0636dc7abb22 \ + --hash=sha256:d802e1947603162ded419bff83ac7489820355d2b856dfb09206574e3a37ac0c \ + --hash=sha256:d80f974b20782d9612c8b4c9beeca867074c7cf4079d1419843fa25a26428b25 \ + --hash=sha256:d9df165544774574ee004b953023d1bebada1894a80b1052a43d798b0f676e67 \ + --hash=sha256:de602f34123c2f4af1c1869c6dbbbd60da6d5983bf01937367295d135cccbfce \ + --hash=sha256:def597967dafc2e8d97c9097ea453c464e0bb8ed38f193a43070f10dc623bb6d \ + --hash=sha256:e101dbb4b9b72f0cddd8cdc8c9c5b47f456766f5e0ac82dbfb75e5c55409b78a \ + --hash=sha256:e4193a04b518f7968f3099755f5509ee7cccc6dc2b92a6b14841934d22e222c9 \ + --hash=sha256:e98dcc55d572b38e69d117da7e8e8efb8500f1f5eaf81ecd460a63220790b839 \ + --hash=sha256:ea82116c9893fa89e929b7f197ee5a1950a76e91cc5c85ba503fc02379d04890 \ + --hash=sha256:ebd5a6d8466ff30836572f3ba2cae8a5e8f85029b1c6d5e2ed338dc472a5166a \ + --hash=sha256:f0204ed122758782970526057093f448051a39db9d810d4e344bb87a3546f425 \ + --hash=sha256:f21b56dcace11dfe013014201f577dcd592b2a9b72182d930361b47cf6f73f25 \ + --hash=sha256:f41c17c4668a655ce96d090d8d5ffdc24ef64b5a02f9753884d08483e8a4a41a \ + --hash=sha256:f9de0a24a4079b53e523b5c5e2c5945ec251ab486652659955187cf255a259bc \ + --hash=sha256:fb55d0e70bb15f2e81477613627286581414693d74ac7963c93a790dd453ca9d \ + --hash=sha256:ff97a14362eef486483ed44042ca2027ea257df6ff768e62358ee0c9776925ac \ + --hash=sha256:ffb3c2aacea411cc7e1d27712490c11108e2de1d39019ae32915493a59a8b9ed \ + --hash=sha256:ffb58d7eff5b7f6ecc6fa21d6288ab7f968a212cb67d682c269c09b9eba3b66f + # via pytest-cov +cryptography==50.0.0 \ + --hash=sha256:031e2d5dd4bb9caa3ca9c82e5a197fd8ae680232cee62603d1a813f3f07e3d03 \ + --hash=sha256:06a32a980526a6ab9a4b9bf8f7385800791e2bb960903cb6b530e4817509a3b7 \ + --hash=sha256:07479a1cb08219ab719147e742e76090c9c773321959bb94946fffdd397a6437 \ + --hash=sha256:07949c449a1abcf60d1ee6e88956d89404c7df3c8258f46589e912988e551987 \ + --hash=sha256:105110f43a471dbd0060b9c9516cb8a6a79233631a04cc2ba16f28323ac6e025 \ + --hash=sha256:11b74db56cdbe3cdee6e3f6982ecb70334fa10dce99ed58bf7894aaaa3b2a037 \ + --hash=sha256:12b9c6996425c76ea6c457ace4f3073e715b8c545add07cd1a8f3a4f90691269 \ + --hash=sha256:1489e263a8048bb8b6a8bac662eb2d402ea5d2b7b4699b72f385f1e2772db105 \ + --hash=sha256:19736989797678c6af1e55cd49055cdbcb55d8f6b5583ac5335f933aba9101dc \ + --hash=sha256:1b4a266766514614f8aa60416e71f2fc6e575d36e7bdc90f644fadb2f4b75b95 \ + --hash=sha256:2a8183b489dc1f7f80f135780fadc1108f14b31b8a40411c7a5b17425f65f28b \ + --hash=sha256:37fdb0d0111f1e2ff07139dfb79f1b49531f8e213c46f1163dd7642979b58c47 \ + --hash=sha256:3f5735ffe4996d28b809371756219f5354864902a3b9e7c0b9ee87041209fc9c \ + --hash=sha256:49e7d93abdbd2990caced757e5fade25302f719c3c8fb6e6fff2dde98999fc41 \ + --hash=sha256:5e34edd123674534acd70147f0ca331eaa2c74e6325fb2028c886aa26ba0b68c \ + --hash=sha256:62598a8a57f815db4c6259a4e97d857dab56697e7de8e8ab02352ab74da1995d \ + --hash=sha256:65c2c3add92b45fd0709db8594536aea39c2a67af0e27ffcf049c498501140b7 \ + --hash=sha256:6ba6a53445bd3cfa809ef3ef5f1589aa6ba08784a1d962bf47d0940e871dab1c \ + --hash=sha256:6e7d61120573a7f2cd94cc095f9e81f6967c61ccdf194285aa143ecec8e0b708 \ + --hash=sha256:7cec5b856506da6defb290f30c9ee687d5f5e8cb0bd3f6459dde43b0b4fa40ef \ + --hash=sha256:80b63928fa35083b33966ce1efb70e5b9607181e49dcd1c22c8c005e319f667f \ + --hash=sha256:82148ec5bddac30b51a5b3c1945075f896fa022cb93f8e4a01e9f6ee95292c5f \ + --hash=sha256:828743d939e9629bc267b8e2d08d8bb67cd4319c771a33d4b18b22dd8fb7440a \ + --hash=sha256:8d89f3976b10b4ce31118de72329025f70d2c6ead14a8217c5514dd2c6d5a78f \ + --hash=sha256:8eb5e1172eb569ea8a872796576e6a67c276351728b6455d5beb01242b027c6a \ + --hash=sha256:900131fafd8aead39ac7dd3a7e833be754c17a95cfd91221636949fe4eb0aa8a \ + --hash=sha256:910d11e1a385c654bf738bf3e6b8e6ed5de0f5610fcae2be9e5b398d8081d20e \ + --hash=sha256:910e1d2668e7de9648f2bcee30e180db2a6b15c30f887d7c4c93ddf96e3992e3 \ + --hash=sha256:9aa87839c383bdbab6ef865787a1fb877af8dd03464c4400322726feaaadfc6d \ + --hash=sha256:a1b30560f2acc95aa8b2e06e716a13dbfc97314747b80d9707e307f77b40d6b3 \ + --hash=sha256:a91296cb61e8df6f86d0c19cc4068228da256bf59bf86049fbd821084565327f \ + --hash=sha256:b42a28c1844fd9de8f3f7d540e36b66f3a9c83fceac7170ebc7a6a19edd9dcae \ + --hash=sha256:bd1c592e4d5974f0d08d4888e432157adba757c66da0246918e43677fafa2d30 \ + --hash=sha256:c87f62a3d3b9888ed0fdde100ec06aa61ca9cd44bad9057d1dff9a516b5f5bb9 \ + --hash=sha256:c99c003e088647b8a5b7c145d6f78c335f6348332b62e142d411c4b63d1460b9 \ + --hash=sha256:ccdc4a71a4dabae05de219404f9f4abc38e3b58422177ff93d0da05967dafa07 \ + --hash=sha256:d24fead1d4d076e1bfb006dcec392074a3cd8d7b4fc8a595aa64073b2b7a96ba \ + --hash=sha256:d58c3db7cd6eed54e6c06744db55456b65ebd7492ddeae9c1e93cfca7aa857d3 \ + --hash=sha256:d764dcf130c428ef66786f866dd750f53182bc608813489915e9fc106bb0c82f \ + --hash=sha256:df2a58a472f332225671c35b0a830208b86d004f82baa8530fa3782c85646533 \ + --hash=sha256:e722f16708d854fe924790e051061f6704a472c3bac347b6fd88033ea8dd0dc5 \ + --hash=sha256:ecfed7367f965a0328cfbdd70da860f15441f002f613185668c6e6ebf5a0ac11 \ + --hash=sha256:eeac2acb5a20ed25e0ad6d1df9891a520b78b404266b6d11778f25d5d691a6c9 \ + --hash=sha256:f59e38625469987d7ef6d495323c55e7db6c212eaf6112267e0d3b565a2e9c9f \ + --hash=sha256:f89831ef99dd7dd169ab06d63a831adb9e20a87aac6d380266bbda5823349169 \ + --hash=sha256:fd9192b7b70c573d7f214eb1ae35e00d359f6f5e4b27c7e21e30de1fc6204645 + # via + # authlib + # azure-storage-blob + # google-auth + # joserfc +cuda-bindings==13.3.1 \ + --hash=sha256:04436a9364059c84b8f9636f359eccda1cf814341f5b670c71d80d2f79dbc708 \ + --hash=sha256:120fcc53d57903df529c3486962c56528cba5b7d6c57c99537320ed9922c8b86 \ + --hash=sha256:18c8c167c8907b8f02531ca810534315c458dabef31f7965095619bf647b9202 \ + --hash=sha256:1ab2f74ed65bfef4163ba07a8db16f1085e0729291db12a2423aff84ee8278b8 \ + --hash=sha256:2732904099e0a4d4db774a5fc6d91ee95fae065b4d2ecabb4968c5fe2406c9d7 \ + --hash=sha256:36febb7c1079d68a981dbbd8d5a67235b399802b82075c9388624719607e52b9 \ + --hash=sha256:507b0e19e7f934c5e30f30f0244ad70a75812619a7d3a0d742543caae1bd50f1 \ + --hash=sha256:61120b5e4f4a63f67efd7e7396914cb9ef871bb1f0021e990fb70277be240a4d \ + --hash=sha256:8de12ef60bf40756852cb62bbb40460609269f6ece522903d1cc93d73a3ececb \ + --hash=sha256:9851b0caa8bfd3bc6fa054eaf57bea7c8e9c3a62db2d2621224677f49f3c53d0 \ + --hash=sha256:9efb21c1ee64981e184b9e0ba5eb3179e5ba3d4b51665a6cb52b8ef3d01a7cbf \ + --hash=sha256:b134dd8c5c66ae4c4ad814f7aee88fd215353c077010cbc47e3b55ed35ec9eff \ + --hash=sha256:c0c4b1a995098c46695c24257a342dc97d6e6d3f3050b944c9f43bd26d734051 \ + --hash=sha256:c3c772dfff49681541d59630c90f858e173ac926b9c593a2b7123f2a1043cc76 \ + --hash=sha256:c5879712accf6e14bb01aa5e67440eb84998b8d104b509cc7a6dc0b8f656a474 \ + --hash=sha256:c7855c4868aabc0cfae28abbe83d56734bdfbd08f08fc234ac1912a12858bf49 \ + --hash=sha256:e32d08f71ebcdf00f0f41eab2eb37e8da94c8ed411cc9f7f7a019ce6b34abe3a \ + --hash=sha256:efd4c814d311ec08c981f6dded1dbe7d4b371067ee4f6c14cccec4bde9590f80 + # via torch +cuda-pathfinder==1.6.0 \ + --hash=sha256:1503af579d8379c24bdd65528379bc57039b0455be9f5f9686cf8e473a1fce51 + # via cuda-bindings +cuda-toolkit==13.0.3.0 \ + --hash=sha256:d693caaa261214ddd7dbb60d68e71cbed884e68c2be7509778f3051da0b91c3f + # via torch +cycler==0.12.1 \ + --hash=sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30 \ + --hash=sha256:88bb128f02ba341da8ef447245a9e138fae777f6a23943da4540077d3601eb1c + # via matplotlib +cymem==2.0.13 \ + --hash=sha256:03cb7bdb55718d5eb6ef0340b1d2430ba1386db30d33e9134d01ba9d6d34d705 \ + --hash=sha256:042e8611ef862c34a97b13241f5d0da86d58aca3cecc45c533496678e75c5a1f \ + --hash=sha256:0d78a27c88b26c89bd1ece247d1d5939dba05a1dae6305aad8fd8056b17ddb51 \ + --hash=sha256:0dca715e708e545fd1d97693542378a00394b20a37779c1ae2c8bdbb43acef79 \ + --hash=sha256:1366c7437a209230f4b797fae10227a8206d4021d37c9f9c0d31fd97ea4feb35 \ + --hash=sha256:1710390e7fb2510a8091a1991024d8ae838fd06b02cdfdcd35f006192e3c6b0e \ + --hash=sha256:1775d3fd34cf099929b79c3e48469283642463f977af6801231f3c0e5d9c9369 \ + --hash=sha256:18ad5b116a82fa3674bc8838bd3792891b428971e2123ae8c0fd3ca472157c5e \ + --hash=sha256:1c91a92ae8c7104275ac26bd4d29b08ccd3e7faff5893d3858cb6fadf1bc1588 \ + --hash=sha256:2fae4a29a36267772010a05ca85220e53f4fb3e1083d4f686342d8a3cc7620db \ + --hash=sha256:2ff1c41fd59b789579fdace78aa587c5fc091991fa59458c382b116fc36e30dc \ + --hash=sha256:30c4e75a3a1d809e89106b0b21803eb78e839881aa1f5b9bd27b454bc73afde3 \ + --hash=sha256:38aefeb269597c1a0c2ddf1567dd8605489b661fa0369c6406c1acd433b4c7ba \ + --hash=sha256:45dcaba0f48bef9cc3d8b0b92058640244a95a9f12542210b51318da97c2cf28 \ + --hash=sha256:52b76216c59e0078cebd988754426c41ee60e7c972f4c5185ac32fbd53b6d035 \ + --hash=sha256:666ce6146bc61b9318aa70d91ce33f126b6344a25cf0b925621baed0c161e9cc \ + --hash=sha256:673183466b0ff2e060d97ec5116711d44200b8f7be524323e080d215ee2d44a5 \ + --hash=sha256:68489bf0035c4c280614067ab6a82815b01dc9fcd486742a5306fe9f68deb7ef \ + --hash=sha256:6bbd701338df7bf408648191dff52472a9b334f71bcd31a21a41d83821050f67 \ + --hash=sha256:6d36710760f817194dacb09d9fc45cb6a5062ed75e85f0ef7ad7aeeb13d80cc3 \ + --hash=sha256:717270dcfd8c8096b479c42708b151002ff98e434a7b6f1f916387a6c791e2ad \ + --hash=sha256:742fc19764467a49ed22e56a4d2134c262d73a6c635409584ae3bf9afa092c33 \ + --hash=sha256:7700b116524b087e0169f10f267539223b48240ef2734c3a727a9e6b4db9a671 \ + --hash=sha256:7a8d404a48af953084fcec4239487ab05e14b829da251628827d51c87880aeb8 \ + --hash=sha256:7e1a863a7f144ffb345397813701509cfc74fc9ed360a4d92799805b4b865dd1 \ + --hash=sha256:8431347901026e9b554daeb982f5f1ecf3780ce46435c7a8e0cb82490e58f13c \ + --hash=sha256:84c1168c563d9d1e04546cb65e3e54fde2bf814f7c7faf11fc06436598e386d1 \ + --hash=sha256:84e2976e38cd663f758e40b5497fa5cd183d7c5fb0d04ce81a4b42a1ba124ff0 \ + --hash=sha256:891fd9030293a8b652dc7fb9fdc79a910a6c76fc679cd775e6741b819ffea476 \ + --hash=sha256:89c4889bd16513ce1644ccfe1e7c473ba7ca150f0621e66feac3a571bde09e7e \ + --hash=sha256:8efc4f308169237aade0e82877a65a563833dec32eb7ab2326120253e0e9e918 \ + --hash=sha256:90c2d0c04bcda12cd5cebe9be93ce3af6742ad8da96e1b1907e3f8e00291def1 \ + --hash=sha256:92a2ce50afa5625fb5ce7c9302cee61e23a57ccac52cd0410b4858e572f8614b \ + --hash=sha256:9d441d0e45798ec1fd330373bf7ffa6b795f229275f64016b6a193e6e2a51522 \ + --hash=sha256:a84ba3178d9128b9ffb52ce81ebab456e9fe959125b51109f5b73ebdfc6b60d6 \ + --hash=sha256:a868c1e3fe4f6f51232883b7bee7319a35e36644604577f4c6032cf957b9056b \ + --hash=sha256:a9150be2896fbe0096911bc9138a54e6fcc3814f197bc0c3d64d7c005a5e579d \ + --hash=sha256:ac699c8ec72a3a9de8109bd78821ab22f60b14cf2abccd970b5ff310e14158ed \ + --hash=sha256:bc116a70cc3a5dc3d1684db5268eff9399a0be8603980005e5b889564f1ea42f \ + --hash=sha256:bee2791b3f6fc034ce41268851462bf662ff87e8947e35fb6dd0115b4644a61f \ + --hash=sha256:c0046a619ecc845ccb4528b37b63426a0cbcb4f14d7940add3391f59f13701e6 \ + --hash=sha256:c16cb80efc017b054f78998c6b4b013cef509c7b3d802707ce1f85a1d68361bf \ + --hash=sha256:c3ff57aec4f264451739b18b7eab9d161d51af42b570ca0392fc051dcca28cb1 \ + --hash=sha256:c8dbfddfe5c604974e17c6f373cedd4d25cd67f84812ede7dea12128fa0c2015 \ + --hash=sha256:c8f30971cadd5dcf73bcfbbc5849b1f1e1f40db8cd846c4aa7d3b5e035c7b583 \ + --hash=sha256:c90a6ecba994a15b17a3f45d7ec74d34081df2f73bd1b090e2adc0317e4e01b6 \ + --hash=sha256:c9251d889348fe79a75e9b3e4d1b5fa651fca8a64500820685d73a3acc21b6a8 \ + --hash=sha256:ce821e6ba59148ed17c4567113b8683a6a0be9c9ac86f14e969919121efb61a5 \ + --hash=sha256:d1c950eebb9f0f15e3ef3591313482a5a611d16fc12d545e2018cd607f40f472 \ + --hash=sha256:d2a4bf67db76c7b6afc33de44fb1c318207c3224a30da02c70901936b5aafdf1 \ + --hash=sha256:d670329ee8dbbbf241b7c08069fe3f1d3a1a3e2d69c7d05ea008a7010d826298 \ + --hash=sha256:d8d06ea59006b1251ad5794bcc00121e148434826090ead0073c7b7fedebe431 \ + --hash=sha256:e02d3e2c3bfeb21185d5a4a70790d9df40629a87d8d7617dc22b4e864f665fa3 \ + --hash=sha256:e03bb575a96c59bc210d7d59862747f0012696b0dac3427ce8af33c7afb3d4a2 \ + --hash=sha256:e8afbc5162a0fe14b6463e1c4e45248a1b2fe2cbcecc8a5b9e511117080da0eb \ + --hash=sha256:e9027764dc5f1999fb4b4cabee1d0322c59e330c0a6485b436a68275f614277f \ + --hash=sha256:e96848faaafccc0abd631f1c5fb194eac0caee4f5a8777fdbb3e349d3a21741c \ + --hash=sha256:ec99efa03cf8ec11c8906aa4d4cc0c47df393bc9095c9dd64b89b9b43e220b04 \ + --hash=sha256:ed9de1b9b042f76fe5c312e4359eab58bf52ac7dfdf6887368a760410d809440 \ + --hash=sha256:f190a92fe46197ee64d32560eb121c2809bb843341733227f51538ce77b3410d \ + --hash=sha256:f3aee3adf16272bca81c5826eed55ba3c938add6d8c9e273f01c6b829ecfde22 \ + --hash=sha256:fb8291691ba7ff4e6e000224cc97a744a8d9588418535c9454fd8436911df612 \ + --hash=sha256:fe5424b38f61709b046df2ba7a6d7b54272ee1e2a2c56772d9cd309e4c1ea5ef \ + --hash=sha256:fece5229fd5ecdcd7a0738affb8c59890e13073ae5626544e13825f26c019d3c \ + --hash=sha256:ff036bbc1464993552fd1251b0a83fe102af334b301e3896d7aa05a4999ad042 + # via + # preshed + # spacy + # thinc +d3blocks==1.8.1 \ + --hash=sha256:2c4834bc8496547b1a7144ac3e6f89281d5450bd286ccb05fa635f036f1f9ead \ + --hash=sha256:510d2a443f6c9bf47e75739357a69d0eb9be2436f2f68e3801dd9d85a801cadb + # via semantica (pyproject.toml) +d3graph==3.0.1 \ + --hash=sha256:345254d65e50d0de3a1a099c6bc99139a9fba59179b8c6b2212b80af6b8d2775 \ + --hash=sha256:91598fa901c6a1b01b634ae4974b2a220731441f669b6d66a2ca066c1fe05ee6 + # via d3blocks +datazets==1.1.4 \ + --hash=sha256:8c11d3a8d2ee2d49ef6d1f1512bdd33f7c7c592aa48267fe6e44ed7726e16b72 \ + --hash=sha256:ecf8701f893a12d55fc0d2ff2100744c79a96fd28dc1f5f4a3981ae3776ca5bd + # via + # d3blocks + # d3graph +debugpy==1.8.21 \ + --hash=sha256:0042da0ecd0a8b50dc4a54395ecd870d258d73fa18776f50c91fdcabdcad2675 \ + --hash=sha256:0fddfdc130ac6d8bfc0415b0409822fa901c8f310e5c945ac5653a0352532344 \ + --hash=sha256:13678151fc401e2d68c9880b91e28714f797d40422994572b24560ef80910a88 \ + --hash=sha256:15d4963bd5ffa48f0da0947fd06757fa7621945048a14ad7705431566d3c0e7c \ + --hash=sha256:2c2ae706dec41d99a9ca1f7ebc987a83e65578363be6f6b3ac9067504917fae1 \ + --hash=sha256:3d6922439bf33fd38a3e2c447869ebc7b97da5cd3d329ff1ef9bc06c4903437e \ + --hash=sha256:4743373c1cac7f9e74a1b9915bf1dbe0e900eca657ffb170ae07ac8363205ae9 \ + --hash=sha256:4e70cc8b5079f885cb43910924ee0aab73b8b6b2a14eff23afdd9895d86e79eb \ + --hash=sha256:4e7c2d784d78ad4b71a5f8cd7b59c167719ec8a7a0211dbb3eb1bfeda78bc4e2 \ + --hash=sha256:72b5d676c4cbfac3bac5bb01c138a4656e843f93f03ce2a5f4e394ad49fbee73 \ + --hash=sha256:84c564d8cc701d41843b29a92814c1f1bef6798724ca9d675c284ad9f6a547d7 \ + --hash=sha256:8eeab7b5462f683452c57c0126aaa5ec4e974ddb705f39ba87dff8818c8e08f9 \ + --hash=sha256:9bb2a685287a2ac9b181cde89edcec64845cb51de7faaa75badb9a698bc24782 \ + --hash=sha256:9f5171176a0084b95d2ebe55a4d1f7b2a75b74c5dbec577ebd3a85c740551c36 \ + --hash=sha256:9f96713896f39c3dff0ee841f47320c3f2983d33c341e009361bb0ebc79adc4e \ + --hash=sha256:a3c53278e84c94e11bd87c53970ec391d1a67396c8b22609fcac576520e611a6 \ + --hash=sha256:a7fe47fd23da57b9e0bec3f4a8ee65a2dc55782455ed7f2141d75ab5d2eaeef5 \ + --hash=sha256:aa648733047443eb1d07682c4ef287d36a54507b643ffdf38b09a3ef002c72a0 \ + --hash=sha256:aa9d941d6dfe3d0407e4b3ca0b9ec466030e260fbf1174094f68785680f66db6 \ + --hash=sha256:b1e37d333663c8851516a47364ef473da127f9caebe4417e6df6f5825a7e9a92 \ + --hash=sha256:bd7ba9dd3daa7c2f942c6ca8d4695a16bf9ac16b63615261c7982bc74f7ed20c \ + --hash=sha256:c193d474f0a211191f2b4449d2d06157c689013035bd952f3b617e0ef422b176 \ + --hash=sha256:da456226c7b4c69e35dbe35dcee6623d912000a77816db7856a41af1c72a0264 \ + --hash=sha256:e935f9dc0501be523c8a8e1853c39432e1354e9ece717ae5998fd2371c4542c3 \ + --hash=sha256:ecbd158386c31ffe71d46f72d44d56e66331ab9b16cad649156d514368f23ab2 \ + --hash=sha256:f15c10084f9861b5e8414a48f18f8e4aadf51a98a59e72c16aa28281ca994672 \ + --hash=sha256:f68b891688e61bdc08b8d364d919ff0051e0b94657b39dcd027bc3173edb7cdc \ + --hash=sha256:f843a8b08c2edeaf9b1582eed4f25441af21a297c22ff16bf76a662557aa9c9e \ + --hash=sha256:fe0744a12353406de0ae8ccff0d0a4a666f00801a3db8fd04e7a5f761cd520e8 \ + --hash=sha256:ffd932c6796afadab6993ec96745918a8cb2444dbd392074f769db5ea40ab440 + # via ipykernel +decorator==5.3.1 \ + --hash=sha256:4cbcdd55a6efadb9dbea26b858f4fb3264567b52d69ca0d25b721b553f60ea82 \ + --hash=sha256:f47fe6fdbd2edd623ecfe36875d37aba411624e2670dd395dddae1358689bb3c + # via librosa +defusedxml==0.7.1 \ + --hash=sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69 \ + --hash=sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61 + # via + # semantica (pyproject.toml) + # docling-core + # docling-slim + # nbconvert +deprecation==2.1.0 \ + --hash=sha256:72b3bde64e5d778694b0cf68178aed03d15e15477116add3fb773e581f9518ff \ + --hash=sha256:a10811591210e1fb0e768a8c25517cabeabcba6f0bf96564f8ff45189f90b14a + # via weaviate-client +dill==0.4.1 \ + --hash=sha256:1e1ce33e978ae97fcfcff5638477032b801c46c7c65cf717f95fbc2248f79a9d \ + --hash=sha256:423092df4182177d4d8ba8290c8a5b640c66ab35ec7da59ccfa00f6fa3eea5fa + # via multiprocess +distfit==2.0.2 \ + --hash=sha256:26123275de1da9df8d967a9c9e7224fa1212fb29ccfabe2a6b67ade9195ede0d \ + --hash=sha256:2a180ead9d03f17616bd205032a0953af32c480be5b137170fd6b6efff25f892 + # via d3graph +distlib==0.4.3 \ + --hash=sha256:4b0ce306c966eb73bc3a7b6abad017c556dadd92c44701562cd528ac7fde4d5b \ + --hash=sha256:f152097224a0ae24be5a0f6bae1b9359af82133bce63f98a95f86cae1aede9ed + # via virtualenv +distro==1.9.0 \ + --hash=sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed \ + --hash=sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2 + # via + # anthropic + # google-genai + # groq + # openai +doclang==0.7.3 \ + --hash=sha256:9440c4ca9f7e061a7b8d33bdf15b1029be69a4c13cd8952dd6ce541884e4c685 \ + --hash=sha256:ca50615357e46ebf9597bb9065b9112367103ec24bd539f8ae12649224cf50b0 + # via docling-core +docling==2.119.0 \ + --hash=sha256:04b9aef29b9b94fc6e56fcd440824337d132a84074c7ba20b9cf055f220e72fb \ + --hash=sha256:3ff6f1dbe9f53ac034aca03c39fae374b381dc1eda4ad85d27a458fc13fbc917 + # via semantica (pyproject.toml) +docling-core==2.91.0 \ + --hash=sha256:4949a5dd77ae1daf4153c095897d3bdde1c870f2bbe401bf94d8834bef867998 \ + --hash=sha256:dc40fe76524a2700f869265015a9ef86027888e73b5652f324b3b5c52a2df240 + # via + # docling-ibm-models + # docling-parse + # docling-slim +docling-ibm-models==3.14.0 \ + --hash=sha256:795d39cd0f7b1e14a702e681b0ef0f9bd31deaedddb4e2686ad577296ecb8fc9 \ + --hash=sha256:def964e3d524f66c7321ef9d48d4021278f14319f01d3f78058cd2324f641e22 + # via docling-slim +docling-parse==7.12.1 \ + --hash=sha256:0fcc7234daf1db53e1c3d8063083d850620c3e44d2975be1c01fe0bbcd86b3d1 \ + --hash=sha256:187cb186bed683e6cc7f00ffc010f2bf4f541cb87964194a8bf101b558b8455e \ + --hash=sha256:24b29a5ca40071b3d5ea7494c22a5552f4d9d29bf93bdfc1925986d922feff5f \ + --hash=sha256:25c8759b19292ad8cf31e1b133980c30f0fd8800b510c02c56a3af5dbb989232 \ + --hash=sha256:32e4c806e63f5e6dcf63643d3472b241e7786653bad82321c95b937b69942a5e \ + --hash=sha256:33fc4afd8b46a2c760a8ca4558b4d744d6c9d207318581f3234fec3df91ca85f \ + --hash=sha256:344c7f1da55be7fc992045fb5ff07b411e589b1da62a8f943842ac7ac986245a \ + --hash=sha256:447ced8771f7053f9f834efec363d578d92598119b95e83bed2551c185a7355f \ + --hash=sha256:4f73b41fda9c4fa6beb6c3d685071dd28a2c1f00e69f0133fbc15bda8c0f7ab7 \ + --hash=sha256:55d3189fd36a5afcab884680e51c9131fd28359f2af32e718dd34a7432d62af3 \ + --hash=sha256:5e38953b7e023b6456c8e989d2e92a212489d7b412616c1b6d7c9b62953ee564 \ + --hash=sha256:61a2f203a8e4ce5cd27fba2b6de4474eb11b5e6e1e17116e0dd349376d234d6d \ + --hash=sha256:6488966275412d599aa2fcd0920cea38bb20ac937aeb5bd135ea5354c6dcb5b6 \ + --hash=sha256:8a3debe619e2303442139472ec39a21cdb4714b27d026cf0c34233411779c8c4 \ + --hash=sha256:8e5ee53b6dfe6806b7c3a0d84d564530c5be9dfe0cb3823014f4654d92f345ee \ + --hash=sha256:a3c91f46776ba2aa3c31667747f4f39e91f8c1b58e391663c5e44d7bb416a3f6 \ + --hash=sha256:b3a740f373f00c87555cdbbf996b726df1e99a9de5d95a24484f71d1f7394077 \ + --hash=sha256:c73d965106e07f4af76f137ff1ad4ed67c7fc89cc745f96c214377bfde2e9baf \ + --hash=sha256:cc40315ddda283efc8abe5e62472464c1ff9f55f38ed65fc6f7e42c7ff425062 \ + --hash=sha256:cfddd1f3a3631e87d4c965b4ffd0dc95e249b046af0a6b8916e129285fb0e80c \ + --hash=sha256:d5c015e19e9be88ce001115a1913bf6addf4c2931c3556e9cf2a10b596bbe46d \ + --hash=sha256:dd75ec4d62f42db4a56b1cce6c0f6d3a8605feb364027f95b22dae7465f23ab0 \ + --hash=sha256:e1fef05edfc00ef3133fa38b8b169e76d1897ac71f550577971751aa2cb8fe5d \ + --hash=sha256:fb9426f173fadece791c0007b98b94ba5ee8c71721748005fa992b5d2a8cd0ad \ + --hash=sha256:ff5ac71bc2a702cbc7bd4fd7c2fcbf6af483c982af4bae36fb727a9db169a849 \ + --hash=sha256:fff030c85eb85fc6589bdabc64bdc58a0c82cd6e0870ff9cf55114be525f46f5 + # via docling-slim +docling-slim==2.119.0 \ + --hash=sha256:7b4ee3891e536403f07b6ab702bc757d6c3e4fe146475656d97cf911ac96db8f \ + --hash=sha256:ca385bc6b0ca99a4f0bba07feaa4c0fc4d5fa685a22184baa79de4bd87598323 + # via docling +docstring-parser==0.18.0 \ + --hash=sha256:292510982205c12b1248696f44959db3cdd1740237a968ea1e2e7a900eeb2015 \ + --hash=sha256:b3fcbed555c47d8479be0796ef7e19c2670d428d72e96da63f3a40122860374b + # via + # agno + # anthropic + # instructor +et-xmlfile==2.0.0 \ + --hash=sha256:7a91720bc756843502c3b7504c77b8fe44217c85c537d85037f0f536151b2caa \ + --hash=sha256:dab3f4764309081ce75662649be815c4c9081e88f0837825f90fd28317d4da54 + # via openpyxl +executing==2.2.1 \ + --hash=sha256:3632cc370565f6648cc328b32435bd120a1e4ebb20c77e3fdde9a13cd1e533c4 \ + --hash=sha256:760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017 + # via stack-data +faiss-cpu==1.15.0 \ + --hash=sha256:22dddb013e764aad66dac6cd15b49c7598d60339e0591b73b5e081629419c21b \ + --hash=sha256:30da3029952f0de69f16ce31946fd63fc3e292c867749bbcd2c0a0f09fd06f65 \ + --hash=sha256:37170d5e9ead4b6bfd9c314afc39e17e92064068a0c5a4063dd3f39568c2667e \ + --hash=sha256:50ea471ef1f4f3580eda8ab0ec9727d4bf65fd71c444bf306ce7cdbba8a42b21 \ + --hash=sha256:5b940897b317febaa761088513a3db164fad3ac71a5e1ed7be9a052c9bf1a447 \ + --hash=sha256:5d0a2d5d33fe023e263d0d355a837f20db67578e3be27fc5f4012a273274abf6 \ + --hash=sha256:88fbe1acac6978869063cb2f9477f85718da596a6e0a17751618f9c756bce255 \ + --hash=sha256:90169515a95ea58a9a95d419e518907927a8ef54c46788396365ec5902c9c8df \ + --hash=sha256:dd383bb1ce06fabcff5785f998f253aa88f88dcbe1fe36c922417cd6666dd896 \ + --hash=sha256:e0fe7278f3784b7d205ae715a115801cafb75f6e55db6b0fbe83c4ff379f003f \ + --hash=sha256:ec9b29aae29e428c085c2d49dbb02e4673cdea75db418d420f9e60e0b4184498 + # via semantica (pyproject.toml) +faker==40.36.0 \ + --hash=sha256:754048c76c03afa7de83eee8f4bcee3cf668cbb7d995f54a4e9678db7f110308 \ + --hash=sha256:82b9497d9cfe017048075bcf969298a74b1b6e39f5e4dad1211085d1133f7b62 + # via polyfactory +falkordb==1.6.2 \ + --hash=sha256:73dbbd9df61c56f45cf2fe8b9028888ad4892712790ce4e40d7ceabe118609ae \ + --hash=sha256:b17571ebf4d65dbd3588e8c470b16f0ddfea820e0a15ed73af0dd1a4728b480d + # via semantica (pyproject.toml) +fastapi==0.141.1 \ + --hash=sha256:bfb91aa2d334c61cb35ba9a116fc123b3d3df31640b801cf57a7a78ec3f603b3 \ + --hash=sha256:e8822fc40db1e1858054d7a949a888695bc9bdce70139178e33bd2871a453ca1 + # via semantica (pyproject.toml) +fastembed==0.8.0 \ + --hash=sha256:40bee672657574a1009e35ec50030a55f2b426842cb011845379817641bbbbd0 \ + --hash=sha256:75966edfa8b006ee78514c726bd7f6a50721dadc89305279052be9db72fd53e8 + # via semantica (pyproject.toml) +fastjsonschema==2.22.1 \ + --hash=sha256:0b83d1ce8d7845b959dcb20e1a5c3c8883b6541d9c52ab02cce5166b75ec805f \ + --hash=sha256:cf377ff5c9a6f4f3125fb35f75a2c5767bd824ffbcf62c209a93cd48d1453999 + # via nbformat +fastuuid==0.14.0 \ + --hash=sha256:05a8dde1f395e0c9b4be515b7a521403d1e8349443e7641761af07c7ad1624b1 \ + --hash=sha256:0737606764b29785566f968bd8005eace73d3666bd0862f33a760796e26d1ede \ + --hash=sha256:089c18018fdbdda88a6dafd7d139f8703a1e7c799618e33ea25eb52503d28a11 \ + --hash=sha256:09098762aad4f8da3a888eb9ae01c84430c907a297b97166b8abc07b640f2995 \ + --hash=sha256:09378a05020e3e4883dfdab438926f31fea15fd17604908f3d39cbeb22a0b4dc \ + --hash=sha256:0c9ec605ace243b6dbe3bd27ebdd5d33b00d8d1d3f580b39fdd15cd96fd71796 \ + --hash=sha256:0df14e92e7ad3276327631c9e7cec09e32572ce82089c55cb1bb8df71cf394ed \ + --hash=sha256:12ac85024637586a5b69645e7ed986f7535106ed3013640a393a03e461740cb7 \ + --hash=sha256:1383fff584fa249b16329a059c68ad45d030d5a4b70fb7c73a08d98fd53bcdab \ + --hash=sha256:139d7ff12bb400b4a0c76be64c28cbe2e2edf60b09826cbfd85f33ed3d0bbe8b \ + --hash=sha256:13ec4f2c3b04271f62be2e1ce7e95ad2dd1cf97e94503a3760db739afbd48f00 \ + --hash=sha256:178947fc2f995b38497a74172adee64fdeb8b7ec18f2a5934d037641ba265d26 \ + --hash=sha256:193ca10ff553cf3cc461572da83b5780fc0e3eea28659c16f89ae5202f3958d4 \ + --hash=sha256:1a771f135ab4523eb786e95493803942a5d1fc1610915f131b363f55af53b219 \ + --hash=sha256:1bf539a7a95f35b419f9ad105d5a8a35036df35fdafae48fb2fd2e5f318f0d75 \ + --hash=sha256:1ca61b592120cf314cfd66e662a5b54a578c5a15b26305e1b8b618a6f22df714 \ + --hash=sha256:1e3cc56742f76cd25ecb98e4b82a25f978ccffba02e4bdce8aba857b6d85d87b \ + --hash=sha256:1e690d48f923c253f28151b3a6b4e335f2b06bf669c68a02665bc150b7839e94 \ + --hash=sha256:2b29e23c97e77c3a9514d70ce343571e469098ac7f5a269320a0f0b3e193ab36 \ + --hash=sha256:2dce5d0756f046fa792a40763f36accd7e466525c5710d2195a038f93ff96346 \ + --hash=sha256:2ec3d94e13712a133137b2805073b65ecef4a47217d5bac15d8ac62376cefdb4 \ + --hash=sha256:2fb3c0d7fef6674bbeacdd6dbd386924a7b60b26de849266d1ff6602937675c8 \ + --hash=sha256:2fc37479517d4d70c08696960fad85494a8a7a0af4e93e9a00af04d74c59f9e3 \ + --hash=sha256:33e678459cf4addaedd9936bbb038e35b3f6b2061330fd8f2f6a1d80414c0f87 \ + --hash=sha256:3964bab460c528692c70ab6b2e469dd7a7b152fbe8c18616c58d34c93a6cf8d4 \ + --hash=sha256:3acdf655684cc09e60fb7e4cf524e8f42ea760031945aa8086c7eae2eeeabeb8 \ + --hash=sha256:448aa6833f7a84bfe37dd47e33df83250f404d591eb83527fa2cac8d1e57d7f3 \ + --hash=sha256:47c821f2dfe95909ead0085d4cb18d5149bca704a2b03e03fb3f81a5202d8cea \ + --hash=sha256:4edc56b877d960b4eda2c4232f953a61490c3134da94f3c28af129fb9c62a4f6 \ + --hash=sha256:5816d41f81782b209843e52fdef757a361b448d782452d96abedc53d545da722 \ + --hash=sha256:6e6243d40f6c793c3e2ee14c13769e341b90be5ef0c23c82fa6515a96145181a \ + --hash=sha256:6fbc49a86173e7f074b1a9ec8cf12ca0d54d8070a85a06ebf0e76c309b84f0d0 \ + --hash=sha256:73657c9f778aba530bc96a943d30e1a7c80edb8278df77894fe9457540df4f85 \ + --hash=sha256:73946cb950c8caf65127d4e9a325e2b6be0442a224fd51ba3b6ac44e1912ce34 \ + --hash=sha256:77a09cb7427e7af74c594e409f7731a0cf887221de2f698e1ca0ebf0f3139021 \ + --hash=sha256:77e94728324b63660ebf8adb27055e92d2e4611645bf12ed9d88d30486471d0a \ + --hash=sha256:7a3c0bca61eacc1843ea97b288d6789fbad7400d16db24e36a66c28c268cfe3d \ + --hash=sha256:7f2f3efade4937fae4e77efae1af571902263de7b78a0aee1a1653795a093b2a \ + --hash=sha256:808527f2407f58a76c916d6aa15d58692a4a019fdf8d4c32ac7ff303b7d7af09 \ + --hash=sha256:83cffc144dc93eb604b87b179837f2ce2af44871a7b323f2bfed40e8acb40ba8 \ + --hash=sha256:84b0779c5abbdec2a9511d5ffbfcd2e53079bf889824b32be170c0d8ef5fc74c \ + --hash=sha256:9579618be6280700ae36ac42c3efd157049fe4dd40ca49b021280481c78c3176 \ + --hash=sha256:9a133bf9cc78fdbd1179cb58a59ad0100aa32d8675508150f3658814aeefeaa4 \ + --hash=sha256:9bd57289daf7b153bfa3e8013446aa144ce5e8c825e9e366d455155ede5ea2dc \ + --hash=sha256:a0809f8cc5731c066c909047f9a314d5f536c871a7a22e815cc4967c110ac9ad \ + --hash=sha256:a6f46790d59ab38c6aa0e35c681c0484b50dc0acf9e2679c005d61e019313c24 \ + --hash=sha256:a8a0dfea3972200f72d4c7df02c8ac70bad1bb4c58d7e0ec1e6f341679073a7f \ + --hash=sha256:aa75b6657ec129d0abded3bec745e6f7ab642e6dba3a5272a68247e85f5f316f \ + --hash=sha256:ab32f74bd56565b186f036e33129da77db8be09178cd2f5206a5d4035fb2a23f \ + --hash=sha256:ab3f5d36e4393e628a4df337c2c039069344db5f4b9d2a3c9cea48284f1dd741 \ + --hash=sha256:ac60fc860cdf3c3f327374db87ab8e064c86566ca8c49d2e30df15eda1b0c2d5 \ + --hash=sha256:ae64ba730d179f439b0736208b4c279b8bc9c089b102aec23f86512ea458c8a4 \ + --hash=sha256:af5967c666b7d6a377098849b07f83462c4fedbafcf8eb8bc8ff05dcbe8aa209 \ + --hash=sha256:b2fdd48b5e4236df145a149d7125badb28e0a383372add3fbaac9a6b7a394470 \ + --hash=sha256:b852a870a61cfc26c884af205d502881a2e59cc07076b60ab4a951cc0c94d1ad \ + --hash=sha256:b9a0ca4f03b7e0b01425281ffd44e99d360e15c895f1907ca105854ed85e2057 \ + --hash=sha256:bbb0c4b15d66b435d2538f3827f05e44e2baafcc003dd7d8472dc67807ab8fd8 \ + --hash=sha256:bcc96ee819c282e7c09b2eed2b9bd13084e3b749fdb2faf58c318d498df2efbe \ + --hash=sha256:c0a94245afae4d7af8c43b3159d5e3934c53f47140be0be624b96acd672ceb73 \ + --hash=sha256:c0eb25f0fd935e376ac4334927a59e7c823b36062080e2e13acbaf2af15db836 \ + --hash=sha256:c3091e63acf42f56a6f74dc65cfdb6f99bfc79b5913c8a9ac498eb7ca09770a8 \ + --hash=sha256:c501561e025b7aea3508719c5801c360c711d5218fc4ad5d77bf1c37c1a75779 \ + --hash=sha256:c7502d6f54cd08024c3ea9b3514e2d6f190feb2f46e6dbcd3747882264bb5f7b \ + --hash=sha256:caa1f14d2102cb8d353096bc6ef6c13b2c81f347e6ab9d6fbd48b9dea41c153d \ + --hash=sha256:cb9a030f609194b679e1660f7e32733b7a0f332d519c5d5a6a0a580991290022 \ + --hash=sha256:cd5a7f648d4365b41dbf0e38fe8da4884e57bed4e77c83598e076ac0c93995e7 \ + --hash=sha256:d23ef06f9e67163be38cece704170486715b177f6baae338110983f99a72c070 \ + --hash=sha256:d31f8c257046b5617fc6af9c69be066d2412bdef1edaa4bdf6a214cf57806105 \ + --hash=sha256:d55b7e96531216fc4f071909e33e35e5bfa47962ae67d9e84b00a04d6e8b7173 \ + --hash=sha256:d9e4332dc4ba054434a9594cbfaf7823b57993d7d8e7267831c3e059857cf397 \ + --hash=sha256:de01280eabcd82f7542828ecd67ebf1551d37203ecdfd7ab1f2e534edb78d505 \ + --hash=sha256:df61342889d0f5e7a32f7284e55ef95103f2110fee433c2ae7c2c0956d76ac8a \ + --hash=sha256:e0976c0dff7e222513d206e06341503f07423aceb1db0b83ff6851c008ceee06 \ + --hash=sha256:e150eab56c95dc9e3fefc234a0eedb342fac433dacc273cd4d150a5b0871e1fa \ + --hash=sha256:e23fc6a83f112de4be0cc1990e5b127c27663ae43f866353166f87df58e73d06 \ + --hash=sha256:ec27778c6ca3393ef662e2762dba8af13f4ec1aaa32d08d77f71f2a70ae9feb8 \ + --hash=sha256:f54d5b36c56a2d5e1a31e73b950b28a0d83eb0c37b91d10408875a5a29494bad \ + --hash=sha256:f74631b8322d2780ebcf2d2d75d58045c3e9378625ec51865fe0b5620800c39d + # via litellm +filelock==3.32.2 \ + --hash=sha256:87dd94cf281e586d135fa51132b8e3d9a598b316e90377a288663c9321036c82 \ + --hash=sha256:c33351e1f49cae33414acbc6d56784e6ecee82514ec90795da1161fc4836b5b8 + # via + # huggingface-hub + # python-discovery + # torch + # virtualenv +filetype==1.2.0 \ + --hash=sha256:66b56cd6474bf41d8c54660347d37afcc3f7d1970648de365c102ef77548aadb \ + --hash=sha256:7ce71b6880181241cf7ac8697a2f1eb6a8bd9b429f7ad6d27b8db9ba5f1c2d25 + # via docling-slim +flake8==7.3.0 \ + --hash=sha256:b9696257b9ce8beb888cdbe31cf885c90d31928fe202be0889a7cdafad32f01e \ + --hash=sha256:fe044858146b9fc69b551a4b490d69cf960fcb78ad1edcb84e7fbb1b4a8e3872 + # via semantica (pyproject.toml) +flatbuffers==25.12.19 \ + --hash=sha256:7634f50c427838bb021c2d66a3d1168e9d199b0607e6329399f04846d42e20b4 + # via onnxruntime +fonttools==4.63.0 \ + --hash=sha256:032038247a96c1690f9f31e377c389383c902531b085aa4e4dabd6f57f870e69 \ + --hash=sha256:063e08bd17bd5a90127a14123de0d6a952dbc847695fd98b63c043d58057f90c \ + --hash=sha256:0c18358a155d75034911c5ee397a5b44cd19dd325dbb8b35fb60bf421d6a72ac \ + --hash=sha256:0eac00b9118c3c2f87d272e45341871c5b3066baa3c86897fa634a7c3fb59096 \ + --hash=sha256:1e874792a8212b44583ea02189d9e693906b2f78b261f372f95d6c563210ac1d \ + --hash=sha256:22135da48a348785c5e2d5d2d9d6bec5ed44adacbaeb9db12d9493bf6c6bfa68 \ + --hash=sha256:22693918177bd9ceabec4736d338045f357769416fc6b0b2508eefef75b08616 \ + --hash=sha256:27fdc65af8da6f88b9c6121c47a464cbe359fcfff7ff6fc2d37a1f395d755b78 \ + --hash=sha256:2b8ae05d9eacf6081414d759c0a352769ac28ce31280d6bb8e77b03f9e3c449f \ + --hash=sha256:2c14b4fd138c4bafcca294765c547914e1aa431ae1ca94ab99d8db08c958bd3b \ + --hash=sha256:308f957cdeaf8abe4e5f2f124902ef405448af92c90f80e302a3b771c2e6116b \ + --hash=sha256:37dd23e621e3b0aef1baa70a303b80aaf38449632cfc8fd2a55fb285bbccfc02 \ + --hash=sha256:445af2eab030a16b9171ea8bdda7ebf7d96bda2df88ee182a464252f6e05e20d \ + --hash=sha256:51394295f1a51de8b5f30bdb1e1b9a4231536c7064ef5c6e211eec19fa36036f \ + --hash=sha256:58dc6bb86a78d782f00f9190ca02c119cf5bbe2807536e361e18d42019f877d8 \ + --hash=sha256:59ac449f8cca9b4ffa08d2e7bbadad87ce710d69d1eda5c3c1ce579baa987272 \ + --hash=sha256:6b2248c5decb223562f7902ff6325077a073f608ee8e33e88ad88db734eb9f49 \ + --hash=sha256:6d4741eb179121cab9eea4cb2393d24492373a260d7945006358c08cfbf45419 \ + --hash=sha256:6db5140a60a5d731d21ec076745b40a310607731b0a565b50776393188649001 \ + --hash=sha256:6e528da43bc3791085f8cb6141b1d13e459226790240340fcbb4625649238b03 \ + --hash=sha256:796f27556dbe094c4824f75ca85267e4df776c79036c8441469a4df37038c196 \ + --hash=sha256:79cdc9f567aec74a72918fd060283911406750cbc9fd28c1316023deb6ce31a9 \ + --hash=sha256:7d76edbff9014094dbf03bd2d074709dfa6ec7aba13d838c937a2b33d2d6a86e \ + --hash=sha256:7d782fac32985914c351556f68ac0855391572bcd87de50e05970d3cd4c96fc5 \ + --hash=sha256:7dd683fef0663e9f0f45cf541d788d24caa3ec9db50796b588e1757d8b3bc007 \ + --hash=sha256:85be818f5506e8a7753153def2c9550178f0ecae6a47b5e0e8dbb23f7cc90380 \ + --hash=sha256:948428a275741f0b64b113c955425a953314f4b9ab9997f73a72c83e68e569c8 \ + --hash=sha256:9ced0bd02ac751dd6319b0da88aaef24414e3b0dbc32bb4f24944821a3741a27 \ + --hash=sha256:9e12f105d2b6342c559c298afb674006bb2893afc7102dcf8a1b55b0486b4e40 \ + --hash=sha256:a8b33a82979e0a6a34ff435cc81317be1f95ec1ebb7a3a2d1c8a6a54f02ae44e \ + --hash=sha256:a9faff9e0c1f76f9fd55899d2ce785832efebab37eb8ae13995853aef178bef0 \ + --hash=sha256:af2fd1664d00a397d75f806985ddb36282091c2131a73a6485c23b4a34722263 \ + --hash=sha256:afefc1ed0a59785a7fb06ea7e1678e849c193e1e387db783579bc7b3056fcfcb \ + --hash=sha256:b1cd75a03ad8cb5bc40c90bfde68c0c47de423aa19e5c0f362b43520645eea94 \ + --hash=sha256:ba04cb5891d4c0c21b6da95eda8d7b090021508a294fff33464fc7d241e0856b \ + --hash=sha256:bf00f21eb5fb721dbaf73d1e9da6d02a1af7768f2ebcf9798be98beab8ba90f6 \ + --hash=sha256:c0425b277a59cff3d80ca42162a8de360f318438a2ac83570842a678d826d579 \ + --hash=sha256:c1aaa4b9c75798400ac043ce04d74e7830376c85095a5a6ed7cba2f17a266bf4 \ + --hash=sha256:c2a2a42198b696a6f48fad91709afb55176e66a5e566131219dba372fb7f8c59 \ + --hash=sha256:caeb583deeb5168e694b65cda8b4ee62abedfa66cf88488734466f2366b9c4e0 \ + --hash=sha256:cb014d58140a38135f16064c74c652ed57aa0b75cbf8bb59cac821f7edb5334e \ + --hash=sha256:ccf41f2efdf56994d22d73bef4ced1052161958169428d06ba9724ea9e9a64be \ + --hash=sha256:cd7e9857e5e63738b9d9fd707bc1f59c8b09e5177726d23664db393c59bb08bd \ + --hash=sha256:d76ac49f929aecaf82d83250b8347e099d7aecba0f4726c1d9b6df3b8bb5fe18 \ + --hash=sha256:d7e5c9973aa04c95650c96e5f5ad865fbf42d62079163ecfab1e01cbc2504c22 \ + --hash=sha256:dcf076a4474fe0d7367e5bbf5b052c7284fa1feca729c04176ce513521afd8a0 \ + --hash=sha256:e3297a6a4059b4acc3a1e9a8b04741f240a80044eef08ebd32e8b5bcdddce75b \ + --hash=sha256:ee08ebfa58f6e1aeff5697ab9582105bb620008c1caafb681e4c557e7483027b \ + --hash=sha256:ef3048ef05dbb552b89817713d9cac912e00d0fde4a3105c00d29e52e10c89af \ + --hash=sha256:fd1e3094f42d806d3d7c79162fc59e5910fcbe3a7360c385b8da969bc4493745 + # via matplotlib +fqdn==1.5.1 \ + --hash=sha256:105ed3677e767fb5ca086a0c1f4bb66ebc3c100be518f0e0d755d9eae164d89f \ + --hash=sha256:3a179af3761e4df6eb2e026ff9e1a3033d3587bf980a0b1b2e1e5d08d7358014 + # via jsonschema +frozenlist==1.8.0 \ + --hash=sha256:0325024fe97f94c41c08872db482cf8ac4800d80e79222c6b0b7b162d5b13686 \ + --hash=sha256:032efa2674356903cd0261c4317a561a6850f3ac864a63fc1583147fb05a79b0 \ + --hash=sha256:03ae967b4e297f58f8c774c7eabcce57fe3c2434817d4385c50661845a058121 \ + --hash=sha256:06be8f67f39c8b1dc671f5d83aaefd3358ae5cdcf8314552c57e7ed3e6475bdd \ + --hash=sha256:073f8bf8becba60aa931eb3bc420b217bb7d5b8f4750e6f8b3be7f3da85d38b7 \ + --hash=sha256:07cdca25a91a4386d2e76ad992916a85038a9b97561bf7a3fd12d5d9ce31870c \ + --hash=sha256:09474e9831bc2b2199fad6da3c14c7b0fbdd377cce9d3d77131be28906cb7d84 \ + --hash=sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d \ + --hash=sha256:0f96534f8bfebc1a394209427d0f8a63d343c9779cda6fc25e8e121b5fd8555b \ + --hash=sha256:102e6314ca4da683dca92e3b1355490fed5f313b768500084fbe6371fddfdb79 \ + --hash=sha256:11847b53d722050808926e785df837353bd4d75f1d494377e59b23594d834967 \ + --hash=sha256:119fb2a1bd47307e899c2fac7f28e85b9a543864df47aa7ec9d3c1b4545f096f \ + --hash=sha256:13d23a45c4cebade99340c4165bd90eeb4a56c6d8a9d8aa49568cac19a6d0dc4 \ + --hash=sha256:154e55ec0655291b5dd1b8731c637ecdb50975a2ae70c606d100750a540082f7 \ + --hash=sha256:168c0969a329b416119507ba30b9ea13688fafffac1b7822802537569a1cb0ef \ + --hash=sha256:17c883ab0ab67200b5f964d2b9ed6b00971917d5d8a92df149dc2c9779208ee9 \ + --hash=sha256:1a7607e17ad33361677adcd1443edf6f5da0ce5e5377b798fba20fae194825f3 \ + --hash=sha256:1a7fa382a4a223773ed64242dbe1c9c326ec09457e6b8428efb4118c685c3dfd \ + --hash=sha256:1aa77cb5697069af47472e39612976ed05343ff2e84a3dcf15437b232cbfd087 \ + --hash=sha256:1b9290cf81e95e93fdf90548ce9d3c1211cf574b8e3f4b3b7cb0537cf2227068 \ + --hash=sha256:20e63c9493d33ee48536600d1a5c95eefc870cd71e7ab037763d1fbb89cc51e7 \ + --hash=sha256:21900c48ae04d13d416f0e1e0c4d81f7931f73a9dfa0b7a8746fb2fe7dd970ed \ + --hash=sha256:229bf37d2e4acdaf808fd3f06e854a4a7a3661e871b10dc1f8f1896a3b05f18b \ + --hash=sha256:2552f44204b744fba866e573be4c1f9048d6a324dfe14475103fd51613eb1d1f \ + --hash=sha256:27c6e8077956cf73eadd514be8fb04d77fc946a7fe9f7fe167648b0b9085cc25 \ + --hash=sha256:28bd570e8e189d7f7b001966435f9dac6718324b5be2990ac496cf1ea9ddb7fe \ + --hash=sha256:294e487f9ec720bd8ffcebc99d575f7eff3568a08a253d1ee1a0378754b74143 \ + --hash=sha256:29548f9b5b5e3460ce7378144c3010363d8035cea44bc0bf02d57f5a685e084e \ + --hash=sha256:2c5dcbbc55383e5883246d11fd179782a9d07a986c40f49abe89ddf865913930 \ + --hash=sha256:2dc43a022e555de94c3b68a4ef0b11c4f747d12c024a520c7101709a2144fb37 \ + --hash=sha256:2f05983daecab868a31e1da44462873306d3cbfd76d1f0b5b69c473d21dbb128 \ + --hash=sha256:33139dc858c580ea50e7e60a1b0ea003efa1fd42e6ec7fdbad78fff65fad2fd2 \ + --hash=sha256:332db6b2563333c5671fecacd085141b5800cb866be16d5e3eb15a2086476675 \ + --hash=sha256:33f48f51a446114bc5d251fb2954ab0164d5be02ad3382abcbfe07e2531d650f \ + --hash=sha256:34187385b08f866104f0c0617404c8eb08165ab1272e884abc89c112e9c00746 \ + --hash=sha256:342c97bf697ac5480c0a7ec73cd700ecfa5a8a40ac923bd035484616efecc2df \ + --hash=sha256:3462dd9475af2025c31cc61be6652dfa25cbfb56cbbf52f4ccfe029f38decaf8 \ + --hash=sha256:39ecbc32f1390387d2aa4f5a995e465e9e2f79ba3adcac92d68e3e0afae6657c \ + --hash=sha256:3e0761f4d1a44f1d1a47996511752cf3dcec5bbdd9cc2b4fe595caf97754b7a0 \ + --hash=sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad \ + --hash=sha256:3ef2d026f16a2b1866e1d86fc4e1291e1ed8a387b2c333809419a2f8b3a77b82 \ + --hash=sha256:405e8fe955c2280ce66428b3ca55e12b3c4e9c336fb2103a4937e891c69a4a29 \ + --hash=sha256:42145cd2748ca39f32801dad54aeea10039da6f86e303659db90db1c4b614c8c \ + --hash=sha256:4314debad13beb564b708b4a496020e5306c7333fa9a3ab90374169a20ffab30 \ + --hash=sha256:433403ae80709741ce34038da08511d4a77062aa924baf411ef73d1146e74faf \ + --hash=sha256:44389d135b3ff43ba8cc89ff7f51f5a0bb6b63d829c8300f79a2fe4fe61bcc62 \ + --hash=sha256:48e6d3f4ec5c7273dfe83ff27c91083c6c9065af655dc2684d2c200c94308bb5 \ + --hash=sha256:494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383 \ + --hash=sha256:4970ece02dbc8c3a92fcc5228e36a3e933a01a999f7094ff7c23fbd2beeaa67c \ + --hash=sha256:4e0c11f2cc6717e0a741f84a527c52616140741cd812a50422f83dc31749fb52 \ + --hash=sha256:50066c3997d0091c411a66e710f4e11752251e6d2d73d70d8d5d4c76442a199d \ + --hash=sha256:517279f58009d0b1f2e7c1b130b377a349405da3f7621ed6bfae50b10adf20c1 \ + --hash=sha256:54b2077180eb7f83dd52c40b2750d0a9f175e06a42e3213ce047219de902717a \ + --hash=sha256:5500ef82073f599ac84d888e3a8c1f77ac831183244bfd7f11eaa0289fb30714 \ + --hash=sha256:581ef5194c48035a7de2aefc72ac6539823bb71508189e5de01d60c9dcd5fa65 \ + --hash=sha256:59a6a5876ca59d1b63af8cd5e7ffffb024c3dc1e9cf9301b21a2e76286505c95 \ + --hash=sha256:5a3a935c3a4e89c733303a2d5a7c257ea44af3a56c8202df486b7f5de40f37e1 \ + --hash=sha256:5c1c8e78426e59b3f8005e9b19f6ff46e5845895adbde20ece9218319eca6506 \ + --hash=sha256:5d63a068f978fc69421fb0e6eb91a9603187527c86b7cd3f534a5b77a592b888 \ + --hash=sha256:667c3777ca571e5dbeb76f331562ff98b957431df140b54c85fd4d52eea8d8f6 \ + --hash=sha256:6da155091429aeba16851ecb10a9104a108bcd32f6c1642867eadaee401c1c41 \ + --hash=sha256:6dc4126390929823e2d2d9dc79ab4046ed74680360fc5f38b585c12c66cdf459 \ + --hash=sha256:7398c222d1d405e796970320036b1b563892b65809d9e5261487bb2c7f7b5c6a \ + --hash=sha256:74c51543498289c0c43656701be6b077f4b265868fa7f8a8859c197006efb608 \ + --hash=sha256:776f352e8329135506a1d6bf16ac3f87bc25b28e765949282dcc627af36123aa \ + --hash=sha256:778a11b15673f6f1df23d9586f83c4846c471a8af693a22e066508b77d201ec8 \ + --hash=sha256:78f7b9e5d6f2fdb88cdde9440dc147259b62b9d3b019924def9f6478be254ac1 \ + --hash=sha256:799345ab092bee59f01a915620b5d014698547afd011e691a208637312db9186 \ + --hash=sha256:7bf6cdf8e07c8151fba6fe85735441240ec7f619f935a5205953d58009aef8c6 \ + --hash=sha256:8009897cdef112072f93a0efdce29cd819e717fd2f649ee3016efd3cd885a7ed \ + --hash=sha256:80f85f0a7cc86e7a54c46d99c9e1318ff01f4687c172ede30fd52d19d1da1c8e \ + --hash=sha256:8585e3bb2cdea02fc88ffa245069c36555557ad3609e83be0ec71f54fd4abb52 \ + --hash=sha256:878be833caa6a3821caf85eb39c5ba92d28e85df26d57afb06b35b2efd937231 \ + --hash=sha256:8a76ea0f0b9dfa06f254ee06053d93a600865b3274358ca48a352ce4f0798450 \ + --hash=sha256:8b7b94a067d1c504ee0b16def57ad5738701e4ba10cec90529f13fa03c833496 \ + --hash=sha256:8d92f1a84bb12d9e56f818b3a746f3efba93c1b63c8387a73dde655e1e42282a \ + --hash=sha256:908bd3f6439f2fef9e85031b59fd4f1297af54415fb60e4254a95f75b3cab3f3 \ + --hash=sha256:92db2bf818d5cc8d9c1f1fc56b897662e24ea5adb36ad1f1d82875bd64e03c24 \ + --hash=sha256:940d4a017dbfed9daf46a3b086e1d2167e7012ee297fef9e1c545c4d022f5178 \ + --hash=sha256:957e7c38f250991e48a9a73e6423db1bb9dd14e722a10f6b8bb8e16a0f55f695 \ + --hash=sha256:96153e77a591c8adc2ee805756c61f59fef4cf4073a9275ee86fe8cba41241f7 \ + --hash=sha256:96f423a119f4777a4a056b66ce11527366a8bb92f54e541ade21f2374433f6d4 \ + --hash=sha256:97260ff46b207a82a7567b581ab4190bd4dfa09f4db8a8b49d1a958f6aa4940e \ + --hash=sha256:974b28cf63cc99dfb2188d8d222bc6843656188164848c4f679e63dae4b0708e \ + --hash=sha256:9ff15928d62a0b80bb875655c39bf517938c7d589554cbd2669be42d97c2cb61 \ + --hash=sha256:a6483e309ca809f1efd154b4d37dc6d9f61037d6c6a81c2dc7a15cb22c8c5dca \ + --hash=sha256:a88f062f072d1589b7b46e951698950e7da00442fc1cacbe17e19e025dc327ad \ + --hash=sha256:ac913f8403b36a2c8610bbfd25b8013488533e71e62b4b4adce9c86c8cea905b \ + --hash=sha256:adbeebaebae3526afc3c96fad434367cafbfd1b25d72369a9e5858453b1bb71a \ + --hash=sha256:b2a095d45c5d46e5e79ba1e5b9cb787f541a8dee0433836cea4b96a2c439dcd8 \ + --hash=sha256:b3210649ee28062ea6099cfda39e147fa1bc039583c8ee4481cb7811e2448c51 \ + --hash=sha256:b37f6d31b3dcea7deb5e9696e529a6aa4a898adc33db82da12e4c60a7c4d2011 \ + --hash=sha256:b4dec9482a65c54a5044486847b8a66bf10c9cb4926d42927ec4e8fd5db7fed8 \ + --hash=sha256:b4f3b365f31c6cd4af24545ca0a244a53688cad8834e32f56831c4923b50a103 \ + --hash=sha256:b6db2185db9be0a04fecf2f241c70b63b1a242e2805be291855078f2b404dd6b \ + --hash=sha256:b9be22a69a014bc47e78072d0ecae716f5eb56c15238acca0f43d6eb8e4a5bda \ + --hash=sha256:bac9c42ba2ac65ddc115d930c78d24ab8d4f465fd3fc473cdedfccadb9429806 \ + --hash=sha256:bf0a7e10b077bf5fb9380ad3ae8ce20ef919a6ad93b4552896419ac7e1d8e042 \ + --hash=sha256:c23c3ff005322a6e16f71bf8692fcf4d5a304aaafe1e262c98c6d4adc7be863e \ + --hash=sha256:c4c800524c9cd9bac5166cd6f55285957fcfc907db323e193f2afcd4d9abd69b \ + --hash=sha256:c7366fe1418a6133d5aa824ee53d406550110984de7637d65a178010f759c6ef \ + --hash=sha256:c8d1634419f39ea6f5c427ea2f90ca85126b54b50837f31497f3bf38266e853d \ + --hash=sha256:c9a63152fe95756b85f31186bddf42e4c02c6321207fd6601a1c89ebac4fe567 \ + --hash=sha256:cb89a7f2de3602cfed448095bab3f178399646ab7c61454315089787df07733a \ + --hash=sha256:cba69cb73723c3f329622e34bdbf5ce1f80c21c290ff04256cff1cd3c2036ed2 \ + --hash=sha256:cee686f1f4cadeb2136007ddedd0aaf928ab95216e7691c63e50a8ec066336d0 \ + --hash=sha256:cf253e0e1c3ceb4aaff6df637ce033ff6535fb8c70a764a8f46aafd3d6ab798e \ + --hash=sha256:d1eaff1d00c7751b7c6662e9c5ba6eb2c17a2306ba5e2a37f24ddf3cc953402b \ + --hash=sha256:d3bb933317c52d7ea5004a1c442eef86f426886fba134ef8cf4226ea6ee1821d \ + --hash=sha256:d4d3214a0f8394edfa3e303136d0575eece0745ff2b47bd2cb2e66dd92d4351a \ + --hash=sha256:d6a5df73acd3399d893dafc71663ad22534b5aa4f94e8a2fabfe856c3c1b6a52 \ + --hash=sha256:d8b7138e5cd0647e4523d6685b0eac5d4be9a184ae9634492f25c6eb38c12a47 \ + --hash=sha256:db1e72ede2d0d7ccb213f218df6a078a9c09a7de257c2fe8fcef16d5925230b1 \ + --hash=sha256:e25ac20a2ef37e91c1b39938b591457666a0fa835c7783c3a8f33ea42870db94 \ + --hash=sha256:e2de870d16a7a53901e41b64ffdf26f2fbb8917b3e6ebf398098d72c5b20bd7f \ + --hash=sha256:e4a3408834f65da56c83528fb52ce7911484f0d1eaf7b761fc66001db1646eff \ + --hash=sha256:eaa352d7047a31d87dafcacbabe89df0aa506abb5b1b85a2fb91bc3faa02d822 \ + --hash=sha256:eab8145831a0d56ec9c4139b6c3e594c7a83c2c8be25d5bcf2d86136a532287a \ + --hash=sha256:ec3cc8c5d4084591b4237c0a272cc4f50a5b03396a47d9caaf76f5d7b38a4f11 \ + --hash=sha256:edee74874ce20a373d62dc28b0b18b93f645633c2943fd90ee9d898550770581 \ + --hash=sha256:eefdba20de0d938cec6a89bd4d70f346a03108a19b9df4248d3cf0d88f1b0f51 \ + --hash=sha256:ef2b7b394f208233e471abc541cc6991f907ffd47dc72584acee3147899d6565 \ + --hash=sha256:f21f00a91358803399890ab167098c131ec2ddd5f8f5fd5fe9c9f2c6fcd91e40 \ + --hash=sha256:f4be2e3d8bc8aabd566f8d5b8ba7ecc09249d74ba3c9ed52e54dc23a293f0b92 \ + --hash=sha256:f57fb59d9f385710aa7060e89410aeb5058b99e62f4d16b08b91986b9a2140c2 \ + --hash=sha256:f6292f1de555ffcc675941d65fffffb0a5bcd992905015f85d0592201793e0e5 \ + --hash=sha256:f833670942247a14eafbb675458b4e61c82e002a148f49e68257b79296e865c4 \ + --hash=sha256:fa47e444b8ba08fffd1c18e8cdb9a75db1b6a27f17507522834ad13ed5922b93 \ + --hash=sha256:fb30f9626572a76dfe4293c7194a09fb1fe93ba94c7d4f720dfae3b646b45027 \ + --hash=sha256:fe3c58d2f5db5fbd18c2987cba06d51b0529f52bc3a6cdc33d3f4eab725104bd + # via + # aiohttp + # aiosignal +fsspec==2026.7.0 \ + --hash=sha256:b57ddbafedfaef7018c1ecab32aa200a9d7ca26b77965f64e48b70061249d279 \ + --hash=sha256:c803c40f4cf860b49dea58ee3e1c33cb9c790520e233537e1340049f89b82a88 + # via + # huggingface-hub + # torch +gensim==4.4.0 \ + --hash=sha256:05a027238b5eb544a17afe73ec227d6a7e0c6b4e2108b1131c0b8f291a0e0e2e \ + --hash=sha256:06704acd354728262f9f32a9435c70945930f8d11b58531b3cb5c699f4757ad4 \ + --hash=sha256:0845b2fa039dbea5667fb278b5414e70f6d48fd208ef51f33e84a78444288d8d \ + --hash=sha256:120d58351f67ef38f3b102a724fb2ece298b20a06fbeae02797f18c1087591ca \ + --hash=sha256:1853fc5be730f692c444a826041fef9a2fc8d74c73bb59748904b2e3221daa86 \ + --hash=sha256:23a2a4260f01c8f71bae5dd0e8a01bb247a2c789480c033e0eaba100b0ad4239 \ + --hash=sha256:3bec3e6a1ecaa6439b21a3e42ceb0ca67ffabc114b646f89b1aab5fe69a39ffc \ + --hash=sha256:484286ff973c77d262776e44e0bc3b958331b7b0f5d61f83014f6cc12f1a814f \ + --hash=sha256:4b73ff30af6ddd0d2ddf9473b1eb44603cd79ec14c87d93b75291802b991916c \ + --hash=sha256:54a32a196502bf0e376cd7ef935be97f7ca96cc0f90ee9514d48406b7bd21bad \ + --hash=sha256:59d0d29099a76dd97d4563e002f3488a43e51f99d46387025da38007ebfeeff9 \ + --hash=sha256:5c4d8f2a5e69bc246931dfd8e03d0ce3f3bcf82adbbdbcf20dfc35c43b8e1035 \ + --hash=sha256:5e2c1d584d1c7d16b2a0fe7d2f6f59a451422df7b5edb7e3ca46c8e462782127 \ + --hash=sha256:6ecb7aed37fb92d24e15a6adbabe693074003263db0fd9ce97c9f4234a9edc1b \ + --hash=sha256:724b93c9b6e92cd15837048c71b7fdd38059276c85dd1f9c0375576f0aea153f \ + --hash=sha256:7590e7313848ca8f3ff064898bcd6ecf6ec71c752cf4d3ec83f7ac992bc7c088 \ + --hash=sha256:7e110e2d3533f5b35239850a96cb2016a586ecd85671d655079b3048332b7169 \ + --hash=sha256:9033b18920b7774e68eafacdbd87252ffa29382ec465ddb88bd036e00fc86365 \ + --hash=sha256:91a7fa5e814e7b1bad4b2dffa8d62c1e55410d5cbdf930714c1997ffb4404db8 \ + --hash=sha256:a3f5b626da5518e79a479140361c663089fe7998df8ba52d56e1ded71ac5bdf5 \ + --hash=sha256:b3a3f9bc8d4178b01d114e1c58c5ab2333f131c7415fb3d8ec8f1ecfe4c5b544 \ + --hash=sha256:b8961b7a2bb5190b46bc6cd26c29d5bfea22f99123ed5f506ebd0aaf65996758 \ + --hash=sha256:d56613fcb77d4068c1be845843508dcd9d384ede34700a61bbeac32b947d1fc3 \ + --hash=sha256:de863f72b97ee142e7ce1c28da8f8e5473b76064ecbfe139da62127f46ab5c07 \ + --hash=sha256:e29a2109819fdf5ff59bef670c8c22c1690d52239fe172b43e408908871de5f6 \ + --hash=sha256:f0977e5e5df03f829f322662e37ac973b93272c526f1432f865d214c0b573f98 + # via semantica (pyproject.toml) +gitdb==4.0.12 \ + --hash=sha256:5ef71f855d191a3326fcfbc0d5da835f26b13fbcba60c32c21091c349ffdb571 \ + --hash=sha256:67073e15955400952c6565cc3e707c554a4eea2e428946f7a4c162fab9bd9bcf + # via gitpython +gitpython==3.1.59 \ + --hash=sha256:0a1475cfdc38a5bfba1a3e9a4a9da52a39749ecec322b772915c019f94e5b7e4 \ + --hash=sha256:67a82f537384578643624c8b2c531938a9b82be431663e575dcf638526631d4c + # via semantica (pyproject.toml) +google-api-core==2.34.0 \ + --hash=sha256:98a779fe72de956eb1c9c2f47ff4c4432a668ece1a002ec38bed07ec2698ae59 \ + --hash=sha256:cdf9c67e7ca2402d86ccbfde5f2503fc83e3cc3f58cc78456ae96cad24a6d2de + # via + # google-cloud-core + # google-cloud-storage +google-auth==2.56.3 \ + --hash=sha256:40e229fc901f0a305b553050e5fce562d509bee0435be053abfa91582b51b90c \ + --hash=sha256:8ec438808f813ad034535000261eed1067475d229d05bbf4216e78c3f2362e53 + # via + # google-api-core + # google-cloud-core + # google-cloud-storage + # google-genai +google-cloud-core==2.6.1 \ + --hash=sha256:1e044b131f2ae097b92312fa195164b0aeb6dc6a88e00231e1210516314c420c \ + --hash=sha256:2682a8a4474a32f56292fb4bca7fa7e4fb0b4af958f6abfe4bca8d195747fd45 + # via google-cloud-storage +google-cloud-storage==3.13.1 \ + --hash=sha256:98208de6c21e85cecd3eb44551894efff33d98365500e178867d4305854a770a \ + --hash=sha256:a80bf8cac2794808aa61c50c5f769ecbbe2d10331bacd0d69d30e59b14b346b2 + # via semantica (pyproject.toml) +google-crc32c==1.8.0 \ + --hash=sha256:014a7e68d623e9a4222d663931febc3033c5c7c9730785727de2a81f87d5bab8 \ + --hash=sha256:01f126a5cfddc378290de52095e2c7052be2ba7656a9f0caf4bcd1bfb1833f8a \ + --hash=sha256:0470b8c3d73b5f4e3300165498e4cf25221c7eb37f1159e221d1825b6df8a7ff \ + --hash=sha256:119fcd90c57c89f30040b47c211acee231b25a45d225e3225294386f5d258288 \ + --hash=sha256:14f87e04d613dfa218d6135e81b78272c3b904e2a7053b841481b38a7d901411 \ + --hash=sha256:17446feb05abddc187e5441a45971b8394ea4c1b6efd88ab0af393fd9e0a156a \ + --hash=sha256:19b40d637a54cb71e0829179f6cb41835f0fbd9e8eb60552152a8b52c36cbe15 \ + --hash=sha256:2a3dc3318507de089c5384cc74d54318401410f82aa65b2d9cdde9d297aca7cb \ + --hash=sha256:3b9776774b24ba76831609ffbabce8cdf6fa2bd5e9df37b594221c7e333a81fa \ + --hash=sha256:3cc0c8912038065eafa603b238abf252e204accab2a704c63b9e14837a854962 \ + --hash=sha256:3d488e98b18809f5e322978d4506373599c0c13e6c5ad13e53bb44758e18d215 \ + --hash=sha256:3ebb04528e83b2634857f43f9bb8ef5b2bbe7f10f140daeb01b58f972d04736b \ + --hash=sha256:450dc98429d3e33ed2926fc99ee81001928d63460f8538f21a5d6060912a8e27 \ + --hash=sha256:4b8286b659c1335172e39563ab0a768b8015e88e08329fa5321f774275fc3113 \ + --hash=sha256:57a50a9035b75643996fbf224d6661e386c7162d1dfdab9bc4ca790947d1007f \ + --hash=sha256:61f58b28e0b21fcb249a8247ad0db2e64114e201e2e9b4200af020f3b6242c9f \ + --hash=sha256:6f35aaffc8ccd81ba3162443fabb920e65b1f20ab1952a31b13173a67811467d \ + --hash=sha256:71734788a88f551fbd6a97be9668a0020698e07b2bf5b3aa26a36c10cdfb27b2 \ + --hash=sha256:864abafe7d6e2c4c66395c1eb0fe12dc891879769b52a3d56499612ca93b6092 \ + --hash=sha256:86cfc00fe45a0ac7359e5214a1704e51a99e757d0272554874f419f79838c5f7 \ + --hash=sha256:87b0072c4ecc9505cfa16ee734b00cd7721d20a0f595be4d40d3d21b41f65ae2 \ + --hash=sha256:87fa445064e7db928226b2e6f0d5304ab4cd0339e664a4e9a25029f384d9bb93 \ + --hash=sha256:89c17d53d75562edfff86679244830599ee0a48efc216200691de8b02ab6b2b8 \ + --hash=sha256:8b3f68782f3cbd1bce027e48768293072813469af6a61a86f6bb4977a4380f21 \ + --hash=sha256:a428e25fb7691024de47fecfbff7ff957214da51eddded0da0ae0e0f03a2cf79 \ + --hash=sha256:b0d1a7afc6e8e4635564ba8aa5c0548e3173e41b6384d7711a9123165f582de2 \ + --hash=sha256:ba6aba18daf4d36ad4412feede6221414692f44d17e5428bdd81ad3fc1eee5dc \ + --hash=sha256:cb5c869c2923d56cb0c8e6bcdd73c009c36ae39b652dbe46a05eb4ef0ad01454 \ + --hash=sha256:d511b3153e7011a27ab6ee6bb3a5404a55b994dc1a7322c0b87b29606d9790e2 \ + --hash=sha256:db3fe8eaf0612fc8b20fa21a5f25bd785bc3cd5be69f8f3412b0ac2ffd49e733 \ + --hash=sha256:e6584b12cb06796d285d09e33f63309a09368b9d806a551d8036a4207ea43697 \ + --hash=sha256:f4b51844ef67d6cf2e9425983274da75f18b1597bb2c998e1c0a0e8d46f8f651 \ + --hash=sha256:f639065ea2042d5c034bf258a9f085eaa7af0cd250667c0635a3118e8f92c69c + # via + # google-cloud-storage + # google-resumable-media +google-genai==2.17.0 \ + --hash=sha256:6b640a2390c82b4a240873eddb9f518c6d2c33244b2de16ed3d14526a6da7f57 \ + --hash=sha256:a4835563c60aee646c9c4b261c507aa4a624710d25017012d20dc65abf3d9a54 + # via semantica (pyproject.toml) +google-resumable-media==2.10.1 \ + --hash=sha256:224975032ddb73f7ed9e2f0f4cc08ed1b06874c52d48cc8533e3eb72980b21a0 \ + --hash=sha256:4e2cbc704207ddc09f23b1f18e8ef4a4ccbfe0f1768b370e5c969704adbd0a1c + # via google-cloud-storage +googleapis-common-protos==1.75.1 \ + --hash=sha256:28a1934bcd33b9c9da66ac301a0a4227e3367f095a17d0375cb98f0a09d93b79 \ + --hash=sha256:d3042c6c5a2d4e67113104d6b6818b59b6bd92a197f2a91508e801fe815cf071 + # via google-api-core +graphviz==0.21 \ + --hash=sha256:20743e7183be82aaaa8ad6c93f8893c923bd6658a04c32ee115edb3c8a835f78 \ + --hash=sha256:54f33de9f4f911d7e84e4191749cac8cc5653f815b06738c54db9a15ab8b1e42 + # via semantica (pyproject.toml) +groq==1.6.0 \ + --hash=sha256:c4237ecf0053ba85fd926bb31cb4b178996310474b4618867994e3e40d8e3c02 \ + --hash=sha256:fa16db582455db324adcff1b5908519474736872e28cd5c8fa2b8bef6860e12a + # via semantica (pyproject.toml) +grpcio==1.83.0 \ + --hash=sha256:009667eaf3dcd5224c713589cdc98e7ca4ed0ff0b61132c6b276e930eb83a2df \ + --hash=sha256:10b3fa0475eb572c9a81a6fe37fa16a9c500c0c91cfc148cac15692b7e3c2867 \ + --hash=sha256:1aa567f8c3f19850ffd5d2858c9a8ea7c80f0db6c01186b71eb31e923ec984f5 \ + --hash=sha256:1c699bbb20f143c8f2bff219de578aa2dc1f919399d67dc702b038b986ee62df \ + --hash=sha256:28f6c35ac8fcf10e4594f138e468f194360089dde40d126a7033e863fc479930 \ + --hash=sha256:2b5e75c34842cd9c1b95285ca395c6a569664b81e3ffa6b714125922942abaaf \ + --hash=sha256:2bb48cb5e6dd005ca12b89ce4b6ac0b48ff3112c747542ee7986ef611a8ca6d9 \ + --hash=sha256:32e11c37f5285b0c6fa3042c05fe06903696689749833fc64e67dec71b9bbe33 \ + --hash=sha256:33898e6a28e4ae598f1577cb1c4fec2a15c033d0ec52b9b45a09610dd045b9da \ + --hash=sha256:35a5b1c192496b6c25956eebfa963468935612206fd2543ac3ce981e6a5e0f03 \ + --hash=sha256:3f351629f6ae16ecc0ec3553e586a6763ffd9f6114044286d0cbec3e09241bfa \ + --hash=sha256:4772402f43517b4824980be4b3b2274a81eec0004a70009473c31b340d43e223 \ + --hash=sha256:4e3eedfc92b6b9f2960115e7e620cf0cbf80bb7849a51ce3820dc54dfd88b6b9 \ + --hash=sha256:4fcaa7c45c45b4a89e2867d1f1785d9481a788399d915e341ed2eb49aeef9dd4 \ + --hash=sha256:5882c1a721b50ce0123ee5e839e1ab059ad72a7ade76cdf2d5bd833b56791acf \ + --hash=sha256:5f20a988480b0f28207f057f7f7ae1313393c3cef0adcfeae8248f9947eaf881 \ + --hash=sha256:61007cd08640abc5c54547ee32505474c482cd733a53cb87551ea81faa6350af \ + --hash=sha256:62003babc444a606dcd1f009cd16391ce23669ae4ad6ec267a873da7937a69f5 \ + --hash=sha256:6662f3b1e07cc7493d437351860dc867bddc6a93c83ecf33bbfdaf0c217ab2d0 \ + --hash=sha256:6755ed67cc3e454d51ae9f6e1915b80d3942fa4de956ef48dacd45ab7f40b727 \ + --hash=sha256:6b6c666a1d5613ff360c9e90f44665e3a88b25a815209ddbc0917eec281931cb \ + --hash=sha256:6be5c807b717be3dd649446f021301fd7907e376318675d2147823071034112a \ + --hash=sha256:6e01ecd9d8ef280abe1365138a4dc318f9a5287f4cb1b41d07816f796653f735 \ + --hash=sha256:6fb8a1dd0c6f0f931e69e9d0dc6d1c406ed2a44fa963414eafba07b7fb685d16 \ + --hash=sha256:7416952ca770477990257206276999056f8316d79196f2f25942393e58a20b49 \ + --hash=sha256:74fe6f9e8a35c7dbf32255ee154d15e3e5338a81ed39173d079d594d2e544cd1 \ + --hash=sha256:7674587248fbbb2ac6e4eecf83a8a0f3d91a928f941de571acfd3a2f007fbc24 \ + --hash=sha256:7936f2a56cf04f6514705c0fedf400971de01b6aa1719327e4718f410a765e2b \ + --hash=sha256:7bd82671b39065ba18cd536e9cd45b27ff649053f81ddd2c6a966d595067080f \ + --hash=sha256:8f6c395e493d20c39b29392ca200e9aaeb78d0bc2f04db0c0a7da7ddc939aa57 \ + --hash=sha256:8fe04f1050a59f875601eb55d42b4f66946fe89817f967e34db1462ccd07dadf \ + --hash=sha256:8ff0b8767ddd62704e0d9571c1890af08d84a3a689ebba1807e62519d0b3277f \ + --hash=sha256:a21cb4eeeba124443f399be2e8b624943cde864dcbe588cb42e5c483a52a906c \ + --hash=sha256:aa074041231f03959cb097dd5517b0677b8ea49215bae01d5710a7b69dd59969 \ + --hash=sha256:aeb339838db07600481ef869507279b75326c75eac6d10f7afa62a0da1d2bcdd \ + --hash=sha256:b0a0be840e51b6b7ee9df9269770faf77bdf4b771053c257c21d12bad607714c \ + --hash=sha256:bb669918fd88936b15599caff4160a77ab74bdeb25f2231f6e45b61282d6107b \ + --hash=sha256:bc60215b5cb9fc8ca72942c498b551ac2305bd08f6ef8d4e3f0d21b64fbecd61 \ + --hash=sha256:c19b454d3d3f28db81f2c7c4dbaee96e7f6fd149721733ffe79d6bc530f17404 \ + --hash=sha256:c6444666317338e903093c7c756e6cc88eee59f798cb8dd41e87725bf54e1617 \ + --hash=sha256:c834e86d8fd2f03d7e4db49a027f7c5b89c5b88eed305543a5295bd6fee61e40 \ + --hash=sha256:cb056f6e171c42639a50460b2929c82241fda51f71cf3dcdd68090fe45095a45 \ + --hash=sha256:cb2906c61db4f9c64cc360054b5df70eeb81846228e9e56a4944bd415a63dadc \ + --hash=sha256:d05ff664100d429335b93c91b8b34ddf9e94a112205e7fa06dede309e44a4e4c \ + --hash=sha256:ee94a4016fdf8699fb1fd8a38652475ff677f1c72074cee44deeeb9a7e95e745 \ + --hash=sha256:f1c3e5689d4b90987b1d72022bcfe866a9a3dc66197484cf856d96b6150e7f45 \ + --hash=sha256:f47d62808b4c0a97b78bff88a6d4ca283a2a492b9a04a87d814af95ca3b9c19c \ + --hash=sha256:f4cee5fc86e84a0cf7ad1574b454c3320e087c07f55b7df5dc0ac6a873fb90c0 \ + --hash=sha256:f5e822a7e7d03282f6ad225e710493c48b9057a353358344a5f7c42b2b37618d \ + --hash=sha256:f5f410d7c2903eabb34789dfd6342eef04af1ad459943936b7e09a9f5bd417b9 \ + --hash=sha256:fba099b716e73512d61b97f71ea3c31a72abb36904036e316bf4dd148ca8dcc8 + # via + # semantica (pyproject.toml) + # grpcio-health-checking + # pymilvus + # qdrant-client + # weaviate-client +grpcio-health-checking==1.83.0 \ + --hash=sha256:7d8b47a5bfbc699d4aee0fc7a27f5d0265eb23a6a64db5ac2d8b749a0f8a9911 \ + --hash=sha256:ad6bc4d5a1103ad704d25ccd82def300dfa27996b185cab3e4cceeef2c6867d4 + # via weaviate-client +h11==0.16.0 \ + --hash=sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1 \ + --hash=sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86 + # via + # agno + # httpcore + # uvicorn +h2==4.4.1 \ + --hash=sha256:0e25f1462b23c9cb82d9eb02e28bc706dac2a68cb457c6a0d74d63c8a2a5d0e6 \ + --hash=sha256:4e866ffb1a869ae14dd9b5e6beb5c24a13da0495ad72b65925ded182521c1516 + # via httpx +hdbscan==0.8.44 \ + --hash=sha256:0f4fa78c76459e1e00282fd8d67a6834ca46ba232e38414f69bb1bacc2c263ce \ + --hash=sha256:1ac6196fabdd42072284b60c9be7b9b504b5f4f25cf7a551a8af29a3c7963a4d \ + --hash=sha256:2351eb81f21491b5b50bdc5ecaa2d88f58608f51f5906774cb38747f21b18053 \ + --hash=sha256:29bffe0ef8a8191e6cd5af3dc7fdf5e7f6334eea8fa39ee8488f2f16911c1cdf \ + --hash=sha256:3400d93a299228e86dec77cc114b834cdd48413f7999eaddaecba045c38ee915 \ + --hash=sha256:5ea248dcaca951861e811411bf3eb9954f932f3a90c8bbe5629b5ee8479e011e \ + --hash=sha256:6089b8accd805a3a7409b20247e29e54dea6118785771127a4fc6c5eda24f0ef \ + --hash=sha256:80101ed4897ecbbb5ec36358c4d4c5c38ce4e53b9da5996923d1ec72d21ce665 \ + --hash=sha256:91dac2f5668b946e3b6335bf6ea4c95fd446af45f7169cf0ab93fea34d4b0e73 \ + --hash=sha256:9256a7028f017b257c5eba05fe332a744a410c0dcb9971eaa9ed6c5efaac50d5 \ + --hash=sha256:be163d32e71a7ca9e3fd0c6867fdaa7a0c1989f3edc5048ba211beb3949eb96d \ + --hash=sha256:c72c1f357adfd9e9c0f402d7cc256bcf38a04ed833754661ec2600cc04e47c1b \ + --hash=sha256:cc4917a57f73984137bc6f14cddd1a140907102522174af8d8add42668a5e196 \ + --hash=sha256:d67f2b3628a80764a07fff3a994df2c6b2d9a6b9c8024edde7b36c184f6657f6 \ + --hash=sha256:df6d6268022747a60c9990cecf446bc7a71621ff92bc51c86f5958d1cd451870 \ + --hash=sha256:e83276c6147d1ec74359ac2d07b2df9d0deb5e248194660967bcde6437a8518c \ + --hash=sha256:f7ee278a8da6043671031079b245488ee27565f48a83616637dae0b2c4886ea1 + # via bertopic +hf-xet==1.6.0 \ + --hash=sha256:0e6e21fa3cdfcdcd76748564bf593870a5e013f47d97cf10aed63aa222cff5b7 \ + --hash=sha256:23379c2f9ec8696d952b16414a2bae72cad86a52df869b050698ba60f538c675 \ + --hash=sha256:2e58454a340b3556dfa4972d5451aff4fba8dd42a236600ba1a1d2b1514f0fef \ + --hash=sha256:35cec30d75c6f9eb9c16a77cef68e85a103b72e24d4b473714ec9ff06428bab9 \ + --hash=sha256:3dc3e35441ba395006af5aaacc40ef2e603c51ef46c3530b9156185f00935ea3 \ + --hash=sha256:4fc74352a17015bd0ee90038bc9efe38db894cde45f268b6712b04fce8cd0acb \ + --hash=sha256:5153e6bb103ad49d6ea9f1b2e230db5a2ea32551ad09a706d2f61d7c7c80d80e \ + --hash=sha256:5789835d7c6bc9436962853192082374297fb72d7eff7e7762ec25ceb7e25338 \ + --hash=sha256:633dc0cd71d32da58ab8c03ad38e2fac452c15c2b0a2866ebf6ededfe0a5061d \ + --hash=sha256:70cbb9c896901600128cb9b6f06e132954fbede1db30f31f7c6c63f84cb7c31d \ + --hash=sha256:75765820ce4700db3750c94acc8fe27c5fae4c9ec000a0dbac3ca082acf97765 \ + --hash=sha256:8fb4f71cba6129110c3374a33f919001ff130488fc23553698e34cc1c2a1198c \ + --hash=sha256:948f15d3a9545cfe5932f6bd8b440f6ae630aee108f14b7bd6c561f7c2dcc522 \ + --hash=sha256:d62671bb130879cef0ee4c9ebe47a14af6c66ec53e6d84dc15936e5ffdfac82f \ + --hash=sha256:f0906082d9932ae0c0057fa194041c22b4e2cdb46b2592ef3b91f020d62a081a \ + --hash=sha256:f2f7278c05c22fd60cb436cda1269649b3e81db65ecdc8496e5e164aa4143e7b \ + --hash=sha256:fb4fadde1b2b70bf4c0c14a6dccbe7194b1c28947fefd5bbe3fed9d940676c3b + # via huggingface-hub +hpack==4.2.0 \ + --hash=sha256:0895cfa3b5531fc65fe439c05eb65144f123bf7a394fcaa56aa423548d8e45c0 \ + --hash=sha256:858ac0b02280fa582b5080d68db0899c62a80375e0e5413a74970c5e518b6986 + # via h2 +html5rdf==1.2.1 \ + --hash=sha256:1f519121bc366af3e485310dc8041d2e86e5173c1a320fac3dc9d2604069b83e \ + --hash=sha256:ace9b420ce52995bb4f05e7425eedf19e433c981dfe7a831ab391e2fa2e1a195 + # via rdflib +httpcore==1.0.9 \ + --hash=sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55 \ + --hash=sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8 + # via httpx +httptools==0.8.0 \ + --hash=sha256:0770728beb05094c809b98e814edff5fef69d26ad7d21185f2f6d5884a0ba683 \ + --hash=sha256:0ea897f0c729581ebf72131a438a7932d9b14efef72d75ada966700cac3caaeb \ + --hash=sha256:159e9ab5f701ccd42e555a12f1ad8ff69702910fc1c996cf2bb66e5fcb7a231b \ + --hash=sha256:19d1ee275bb59ba2643ba9a3a1e51cc0c788caf2b8df506368e03f56fdd08527 \ + --hash=sha256:20b4aac66ff65f7db06a375808b78f42a94970aa22e826b3cb2b43eb09174124 \ + --hash=sha256:2a021c3a8e65cc125390d72f59b968afca3bdcaff25bd67965e0a055a14946ca \ + --hash=sha256:2c032fa028f46871ec7e1fc59fc15e8023eab3e6bbe6ece786a1611719a5d081 \ + --hash=sha256:2d689918c15a013c65ef52d9fd495d766893ab831a2c8d89f2ac5940a5df847c \ + --hash=sha256:384c17174464c8e873398b7af24f0b1f44d992c820328413951a625323155d77 \ + --hash=sha256:425f83884fd6343828d8c565f046cb72b6d19063f6924093e11bcd8e1548cd09 \ + --hash=sha256:48774d39cbb70e2b1f71f88852a3087ae1d3a1eb80482bb48c13067ab080c14f \ + --hash=sha256:52dd695b865fe96d9d2b16b64a895f3f57bf3cb064e8383cd3b5713a069e8085 \ + --hash=sha256:57278e6fa0424c42a8a3e454828ab4f0aff27b40cddf9679579b98c6dce6a376 \ + --hash=sha256:5931891fb7b441b8a3853cf1b85c82c903defce084dd5f6771ca46e31bf862c5 \ + --hash=sha256:5d7fa4ba7292c1139c0526f0b5aad507c6263c948206ea1b1cbca015c8af1b62 \ + --hash=sha256:5eb911c515b96ee44bbd861e42cbefc488681d450545b1d02127f6136e3a86f5 \ + --hash=sha256:614ceea8ea606848bece2338ac03b3ce5324bcb4be8dc7d377ed708012fa4db8 \ + --hash=sha256:6a43c9dd399758ccc0531acb0a3c4a6c299ee893ee9400e9c893b7bdcfae0681 \ + --hash=sha256:6b2a32f18d97e16e90827d7a819ffa8dbd8cc245fc4e1fa9d1095b54ef4bd999 \ + --hash=sha256:7685df791fad561384bfb139e77fde27a1ffd93134e016f95a0db424ffbf77b1 \ + --hash=sha256:7b71e7d7031928c650e1006e6c03e911bf967f7c69c011d37d541c3e7bf55005 \ + --hash=sha256:880490234c10f70a9830743097e8958d6e4b9f5a0ffc24515023afeef984054d \ + --hash=sha256:88bdd940f2b5d487b4d032c6afa5489a7dc4694410d43de3c38c4fb3af0dc45d \ + --hash=sha256:88eead8ec8680a9f146c655bc88445a325bd7921cfd8194c7337e9467282427d \ + --hash=sha256:9518c406d7b310f05adb1a37f80acabac40504a575d7c0da6d3e365c695ac20d \ + --hash=sha256:9878eb2785ba5eb70631ad269b37976f73d647955e26c91d490eb8a4edfda4ba \ + --hash=sha256:9fc1644f415372cec4f8a5be3a64183737398f10dbb1263602a036427fe75247 \ + --hash=sha256:a1afd7c9fbff0d9f5d489c4ce2768bd09c84a46ddefc7161e6aa82ae35c85745 \ + --hash=sha256:a1b4c8e7a489a0d750d91894e9a8cdc295838f1924c0ca903ae993456fddec07 \ + --hash=sha256:a3b7387147361c3fd47a0bde763c5c91b5b4cd4dc9989b8ece84ff436c99843b \ + --hash=sha256:a6f21e2a3b0067bbe7f67e34cfd16276af556e5e52f4c7503be0cb5f90e905e4 \ + --hash=sha256:b15fc622b0f869d19207c4089a501d9bcc63ca5e071ffdd2f03f922df882dcb2 \ + --hash=sha256:b205e5f5523fa039679da0dfe5a10132b2a4abeae6a86fdd1ddc035f7f836557 \ + --hash=sha256:bbb8caadb2b742d293169d2b458b5c001ef70e3158704aa3d3ef9597624c5d1d \ + --hash=sha256:bf3b6f807c8541503cecfbb8a8dffb385640d0d96102f3d112aa8740f9b7c826 \ + --hash=sha256:c08ffe3e79756e0963cbc8fe410139f38a5884874b6f2e17761bef6563fdcd9b \ + --hash=sha256:c0d726cc107fceb7d45f978483b4b70dd8caa836f5914d3434bb18628eb73813 \ + --hash=sha256:c4a9f1707e4823d54dfec6c33fa3697d302aed536ed352a7ebb5a061ddb869d0 \ + --hash=sha256:cd96f29b4bab1d42fa6e3d008711c75e0f79e94e06827330160e3a304227f150 \ + --hash=sha256:d76ad7b951387e3632c8716a9bb03ac5b45c5f16119aa409db0459520887944e \ + --hash=sha256:da684f2e1aa2ee9bdcb083f3f3a68c5956750b375bc5df864d3a5f0c42a40b77 \ + --hash=sha256:de1ed58a974e75d56560acc7e7fed01a454994429456f65209789992e41f2568 \ + --hash=sha256:de242a49b5d18e0a8776e654e9f6bf6d89f3875a5c35b425a0e7ce940feb3fd6 \ + --hash=sha256:df31ef5494f406ab6cf827b7e64a22841c6e2d654100e6a116ea15b46d02d5e8 \ + --hash=sha256:e93c227b595c6926c1acee96891dd9da4be338cfbe82e5cd3bb9d8dd7dc4ac0b \ + --hash=sha256:eb3028cca2fc0a6d720e52ef61d8ebb62fcbfeb1de56874546d858d3f25a26b7 \ + --hash=sha256:ed377e64805bdba4943c82717333f8f8603a13b09aff9cead2717c6c817fb168 \ + --hash=sha256:ef7c3c97f4311c7be57e2986629df89d49cb434dbff78eafcd48c2bff986b15a \ + --hash=sha256:f256d6ce930c52ca1cb2a960b7da03548c454e7d28b06059ad41bfe789036ce0 \ + --hash=sha256:fe2a4c95aeba2209434e7b31172da572846cae8ca0bf1e7013e61b99fbbf5e72 + # via uvicorn +httpx==0.28.1 \ + --hash=sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc \ + --hash=sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad + # via + # semantica (pyproject.toml) + # agno + # agnoctl + # anthropic + # docling-slim + # google-genai + # groq + # huggingface-hub + # jupyterlab + # litellm + # ollama + # openai + # qdrant-client + # weasel + # weaviate-client +huggingface-hub==1.27.0 \ + --hash=sha256:7df6827c2f956c60fbaa64646e979e566db76f619dd0a9729dfb8c5a3eb4f68d \ + --hash=sha256:c1fed40ea82a6b41b477f5243546549b792ae0a93abcea608cff66089bf8f8df + # via + # accelerate + # docling-ibm-models + # docling-slim + # fastembed + # sentence-transformers + # tokenizers + # transformers +hyperframe==6.1.0 \ + --hash=sha256:b03380493a519fce58ea5af42e4a42317bf9bd425596f7a0835ffce80f1a42e5 \ + --hash=sha256:f630908a00854a7adeabd6382b43923a4c4cd4b821fcb527e6ab9e15382a3b08 + # via h2 +identify==2.6.19 \ + --hash=sha256:20e6a87f786f768c092a721ad107fc9df0eb89347be9396cadf3f4abbd1fb78a \ + --hash=sha256:6be5020c38fcb07da56c53733538a3081ea5aa70d36a156f83044bfbf9173842 + # via pre-commit +idna==3.18 \ + --hash=sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2 \ + --hash=sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848 + # via + # anyio + # httpx + # jsonschema + # requests + # yarl +importlib-metadata==8.9.0 \ + --hash=sha256:58850626cef4bd2df100378b0f2aea9724a7b92f10770d547725b047078f99ee \ + --hash=sha256:e0f761b6ea91ced3b0844c14c9d955224d538105921f8e6754c00f6ca79fba7f + # via + # litellm + # pyshacl +iniconfig==2.3.0 \ + --hash=sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730 \ + --hash=sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12 + # via pytest +instructor==1.15.4 \ + --hash=sha256:00e0ecda80fd9746fb6d082d3f9641e193adb1d8849f0775f91519a82aeff968 \ + --hash=sha256:ea2280c3678d0f6891c4d826104f95624b680e69877113a6345b1d7c9027ba0f + # via semantica (pyproject.toml) +ipykernel==7.3.0 \ + --hash=sha256:897eb64da762549ef610698fca5e9675195ec6ac8ec7f19d81ce1ca20c876057 \ + --hash=sha256:9acaaaf97d16355166e4085afe9d225bfbdf2b7ef520f9df3be8f2b248275e09 + # via + # semantica (pyproject.toml) + # jupyter + # jupyter-console + # jupyterlab +ipython==9.16.1 \ + --hash=sha256:4acae635506f6d352d94c4899a19d5f85f8bc4d230932342dca556fdab1c69b4 \ + --hash=sha256:5a3d1f9a47ff216d6cf9cf863124f6a2c1a198d1354c546a4d24a370a283b64c + # via + # ipykernel + # ipywidgets + # jupyter-console + # pyvis +ipython-pygments-lexers==1.1.1 \ + --hash=sha256:09c0138009e56b6854f9535736f4171d855c8c08a563a0dcd8022f78355c7e81 \ + --hash=sha256:a9462224a505ade19a605f71f8fa63c2048833ce50abc86768a0d81d876dc81c + # via ipython +ipywidgets==8.1.8 \ + --hash=sha256:61f969306b95f85fba6b6986b7fe45d73124d1d9e3023a8068710d47a22ea668 \ + --hash=sha256:ecaca67aed704a338f88f67b1181b58f821ab5dc89c1f0f5ef99db43c1c2921e + # via + # semantica (pyproject.toml) + # jupyter +ismember==1.2.0 \ + --hash=sha256:2263aaaad010e29b7a71bd6a6740e6c925d66574051a49ecaa7d1d213414c9b2 \ + --hash=sha256:8ad61db70946ccf312be5779e0f17a6b08e32012b947ec30c03836f316853a9b + # via + # d3blocks + # d3graph +isodate==0.7.2 \ + --hash=sha256:28009937d8031054830160fce6d409ed342816b543597cece116d966c6d99e15 \ + --hash=sha256:4cd1aa0f43ca76f4a6c6c0292a85f40b35ec2e43e315b59f06e6d32171a953e6 + # via azure-storage-blob +isoduration==20.11.0 \ + --hash=sha256:ac2f9015137935279eac671f94f89eb00584f940f5dc49462a0c4ee692ba1bd9 \ + --hash=sha256:b2904c2a4228c3d44f409c8ae8e2370eb21a26f7ac2ec5446df141dde3452042 + # via jsonschema +isort==8.0.1 \ + --hash=sha256:171ac4ff559cdc060bcfff550bc8404a486fee0caab245679c2abe7cb253c78d \ + --hash=sha256:28b89bc70f751b559aeca209e6120393d43fbe2490de0559662be7a9787e3d75 + # via semantica (pyproject.toml) +jedi==0.20.0 \ + --hash=sha256:7bdd9c2634f56713299976f4cbd59cb3fa92165cc5e05ea811fb253480728b67 \ + --hash=sha256:c3f4ccbd276696f4b19c54618d4fb18f9fc24b0aef02acf704b23f487daa1011 + # via ipython +jinja2==3.1.6 \ + --hash=sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d \ + --hash=sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67 + # via + # d3blocks + # d3graph + # instructor + # jupyter-server + # jupyterlab + # jupyterlab-server + # litellm + # nbconvert + # pyvis + # spacy + # torch +jiter==0.14.0 \ + --hash=sha256:004df5fdb8ecbd6d99f3227df18ba1a259254c4359736a2e6f036c944e02d7c5 \ + --hash=sha256:02c4a7ab56f746014874f2c525584c0daca1dec37f66fd707ecef3b7e5c2228c \ + --hash=sha256:02f36a5c700f105ac04a6556fe664a59037a2c200db3b7e88784fac2ddf02531 \ + --hash=sha256:0ac9cbaa86c10996b92bd12c91659b60f939f8e28fcfa6bc11a0e90a774ce95b \ + --hash=sha256:0fbad7aa06f87e8215d660fc6f05a9b07b58751a29967bbd9c81ff22d21dbe8c \ + --hash=sha256:107465250de4fce00fdb47166bcd51df8e634e049541174fe3c71848e44f52ce \ + --hash=sha256:14c0cb10337c49f5eafe8e7364daca5e29a020ea03580b8f8e6c597fed4e1588 \ + --hash=sha256:155dab67beac8d66cec9479c93ee2cbe7bfbc67509e5c2860e02ec2d9b0ecca1 \ + --hash=sha256:1aca29ba52913f78362ec9c2da62f22cdc4c3083313403f90c15460979b84d9b \ + --hash=sha256:1bf7ff85517dd2f20a5750081d2b75083c1b269cf75afc7511bdf1f9548beb3b \ + --hash=sha256:215a6cb8fb7dc702aa35d475cc00ddc7f970e5c0b1417fb4b4ac5d82fa2a29db \ + --hash=sha256:23ad2a7a9da1935575c820428dd8d2490ce4d23189691ce33da1fc0a58e14e1c \ + --hash=sha256:2492e5f06c36a976d25c7cc347a60e26d5470178d44cde1b9b75e60b4e519f28 \ + --hash=sha256:260bf7ca20704d58d41f669e5e9fe7fe2fa72901a6b324e79056f5d52e9c9be2 \ + --hash=sha256:26679d58ba816f88c3849306dd58cb863a90a1cf352cdd4ef67e30ccf8a77994 \ + --hash=sha256:2d45fc7ea86a46bd9b5bceb9e8d43e5d10a392378713fb32cf1ce851b4b0d1f8 \ + --hash=sha256:2e692633a12cda97e352fdcd1c4acc971b1c28707e1e33aeef782b0cbf051975 \ + --hash=sha256:2f7877ed45118de283786178eceaf877110abacd04fde31efff3940ae9672674 \ + --hash=sha256:2fb2ce3a7bc331256dfb14cefc34832366bb28a9aca81deaf43bbf2a5659e607 \ + --hash=sha256:32959d7285d1d0deb5a8c913349e476ad9271b384f3e54cca1931c4075f54c6e \ + --hash=sha256:33a20d838b91ef376b3a56896d5b04e725c7df5bc4864cc6569cf046a8d73b6d \ + --hash=sha256:34f19dcc35cb1abe7c369b3756babf8c7f04595c0807a848df8f26ef8298ef92 \ + --hash=sha256:351bf6eda4e3a7ceb876377840c702e9a3e4ecc4624dbfb2d6463c67ae52637d \ + --hash=sha256:376e9dafff914253bb9d46cdc5f7965607fbe7feb0a491c34e35f92b2770702e \ + --hash=sha256:37826e3df29e60f30a382f9294348d0238ef127f4b5d7f5f8da78b5b9e050560 \ + --hash=sha256:3a99c1387b1f2928f799a9de899193484d66206a50e98233b6b088a7f0c1edb2 \ + --hash=sha256:41eab6c09ceffb6f0fe25e214b3068146edb1eda3649ca2aee2a061029c7ba2e \ + --hash=sha256:42d6ed359ac49eb922fdd565f209c57340aa06d589c84c8413e42a0f9ae1b842 \ + --hash=sha256:432c4db5255d86a259efde91e55cb4c8d18c0521d844c9e2e7efcce3899fb016 \ + --hash=sha256:4927d09b3e572787cc5e0a5318601448e1ab9391bcef95677f5840c2d00eaa6d \ + --hash=sha256:4b77da71f6e819be5fbcec11a453fde5b1d0267ef6ed487e2a392fd8e14e4e3a \ + --hash=sha256:4e9178be60e229b1b2b0710f61b9e24d1f4f8556985a83ff4c4f95920eea7314 \ + --hash=sha256:4ea73187627bcc5810e085df715e8a99da8bdfd96a7eb36b4b4df700ba6d4c9c \ + --hash=sha256:5252a7ca23785cef5d02d4ece6077a1b556a410c591b379f82091c3001e14844 \ + --hash=sha256:5419d4aa2024961da9fe12a9cfe7484996735dca99e8e090b5c88595ef1951ff \ + --hash=sha256:54b3ddf5786bc7732d293bba3411ac637ecfa200a39983166d1df86a59a43c9f \ + --hash=sha256:55bee2b6a2657434984d9144c20cf27ba3b6acd495539539953e447778515efd \ + --hash=sha256:59940ef6ac9f8b34c800838416f105f0503485fa8d71cae99f71d44a7285b01e \ + --hash=sha256:5c001d5a646c2a50dc055dd526dad5d5245969e8234d2b1131d0451e81f3a373 \ + --hash=sha256:5cf4d4c109641f9cfaf4a7b6aebd51654e405cd00fa9ebbf87163b8b97b325aa \ + --hash=sha256:5dec7c0a3e98d2a3f8a2e67382d0d7c3ac60c69103a4b271da889b4e8bb1e129 \ + --hash=sha256:6112f26f5afc75bcb475787d29da3aa92f9d09c7858f632f4be6ffe607be82e9 \ + --hash=sha256:62fe2451f8fcc0240261e6a4df18ecbcd58327857e61e625b2393ea3b468aac9 \ + --hash=sha256:645be49c46f2900937ba0eaf871ad5183c96858c0af74b6becc7f4e367e36e06 \ + --hash=sha256:651a8758dd413c51e3b7f6557cdc6921faf70b14106f45f969f091f5cda990ea \ + --hash=sha256:67f00d94b281174144d6532a04b66a12cb866cbdc47c3af3bfe2973677f9861a \ + --hash=sha256:69539d936fb5d55caf6ecd33e2e884de083ff0ea28579780d56c4403094bb8d9 \ + --hash=sha256:6ae66782ecffb1a266e1a07f5abbfc3832afdd260fc9b478982c3f8e01eba5fa \ + --hash=sha256:6dd689f5f4a5a33747b28686e051095beb214fe28cfda5e9fe58a295a788f593 \ + --hash=sha256:6f396837fc7577871ca8c12edaf239ed9ccef3bbe39904ae9b8b63ce0a48b140 \ + --hash=sha256:7054adcdeb06b46efd17b5734f75817a44a2d06d3748e36c3a023a1bb52af9ec \ + --hash=sha256:71527ce13fd5a0c4e40ad37331f8c547177dbb2dd0a93e5278b6a5eecf748804 \ + --hash=sha256:7282342d32e357543565286b6450378c3cd402eea333fc1ebe146f1fabb306fc \ + --hash=sha256:758d19dae7ea4c4da3cbc463dc323d1660e7353144ef17509ff43beab6da5a47 \ + --hash=sha256:7609cfbe3a03d37bfdbf5052012d5a879e72b83168a363deae7b3a26564d57de \ + --hash=sha256:77f4ea612fe8b84b8b04e51d0e78029ecf3466348e25973f953de6e6a59aa4c1 \ + --hash=sha256:78a4c677fe5689e0e129b39f5affe9210a500b6620ebb0386ebccf5922bee9a6 \ + --hash=sha256:78d918a68b26e9fab068c2b5453577ef04943ab2807b9a6275df2a812599a310 \ + --hash=sha256:7b25beaa0d4447ea8c7ae0c18c688905d34840d7d0b937f2f7bdd52162c98a40 \ + --hash=sha256:7d9d51eb96c82a9652933bd769fe6de66877d6eb2b2440e281f2938c51b5643e \ + --hash=sha256:7e791e247b8044512e070bd1f3633dc08350d32776d2d6e7473309d0edf256a2 \ + --hash=sha256:7ede4331a1899d604463369c730dbb961ffdc5312bc7f16c41c2896415b1304a \ + --hash=sha256:801028dcfc26ac0895e4964cbc0fd62c73be9fd4a7d7b1aaf6e5790033a719b7 \ + --hash=sha256:80381f5a19af8fa9aef743f080e34f6b25ebd89656475f8cf0470ec6157052aa \ + --hash=sha256:834bb5bdabca2e91592a03d373838a8d0a1b8bbde7077ae6913fd2fc51812d00 \ + --hash=sha256:844e73b6c56b505e9e169234ea3bdea2ea43f769f847f47ac559ba1d2361ebea \ + --hash=sha256:85581c4c3e4060fe3424cdfd7f3aa610f2dc5e9dde8b6863358eb68560018472 \ + --hash=sha256:882bcb9b334318e233950b8be366fe5f92c86b66a7e449e76975dfd6d776a01f \ + --hash=sha256:8b39b7d87a952b79949af5fef44d2544e58c21a28da7f1bae3ef166455c61746 \ + --hash=sha256:92cd8b6025981a041f5310430310b55b25ca593972c16407af8837d3d7d2ca01 \ + --hash=sha256:9b8c571a5dba09b98bd3462b5a53f27209a5cbbe85670391692ede71974e979f \ + --hash=sha256:9f541eaf7bb8382367a1a23d6fc3d6aad57f8dd8c18c3c17f838bee20f217220 \ + --hash=sha256:a25ffa2dbbdf8721855612f6dca15c108224b12d0c4024d0ac3d7902132b4211 \ + --hash=sha256:a4d50ea3d8ba4176f79754333bd35f1bbcd28e91adc13eb9b7ca91bc52a6cef9 \ + --hash=sha256:a7e4ccff04ec03614e62c613e976a3a5860dc9714ce8266f44328bdc8b1cab2c \ + --hash=sha256:ab18d11074485438695f8d34a1b6da61db9754248f96d51341956607a8f39985 \ + --hash=sha256:ad425b087aafb4a1c7e1e98a279200743b9aaf30c3e0ba723aec93f061bd9bc8 \ + --hash=sha256:ae039aaef8de3f8157ecc1fdd4d85043ac4f57538c245a0afaecb8321ec951c3 \ + --hash=sha256:af72f204cf4d44258e5b4c1745130ac45ddab0e71a06333b01de660ab4187a94 \ + --hash=sha256:b08997c35aee1201c1a5361466a8fb9162d03ae7bf6568df70b6c859f1e654a4 \ + --hash=sha256:b80c7b41a628e6be2213ad0ece763c5f88aa5ee003fa394d58acaaee1f4b8342 \ + --hash=sha256:bd77945f38866a448e73b0b7637366afa814d4617790ecd88a18ca74377e6c02 \ + --hash=sha256:be808176a6a3a14321d18c603f2d40741858a7c4fc982f83232842689fe86dd9 \ + --hash=sha256:c1dcfbeb93d9ecd9ca128bbf8910120367777973fa193fb9a39c31237d8df165 \ + --hash=sha256:c409578cbd77c338975670ada777add4efd53379667edf0aceea730cabede6fb \ + --hash=sha256:c6279c63849444a4fe9b9abf82e5df0fc7d13dea07f53f084b362485bd1f2bbe \ + --hash=sha256:c8ef8791c3e78d6c6b157c6d360fbb5c715bebb8113bc6a9303c5caff012754a \ + --hash=sha256:cb8b682d10cb0cce7ff4c1af7244af7022c9b01ae16d46c357bdd0df13afb25d \ + --hash=sha256:ce17f8a050447d1b4153bda4fb7d26e6a9e74eb4f4a41913f30934c5075bf615 \ + --hash=sha256:cff5708f7ed0fa098f2b53446c6fa74c48469118e5cd7497b4f1cd569ab06928 \ + --hash=sha256:d597cd1bf6790376f3fffc7c708766e57301d99a19314824ea0ccc9c3c70e1e2 \ + --hash=sha256:d824ca4148b705970bf4e120924a212fdfca9859a73e42bd7889a63a4ea6bb98 \ + --hash=sha256:df63a14878da754427926281626fd3ee249424a186e25a274e78176d42945264 \ + --hash=sha256:e1765c3ef3ea31fe6e282376a16def1a96f5f11a0235055696c18d9d23ff30cb \ + --hash=sha256:e1a7eead856a5038a8d291f1447176ab0b525c77a279a058121b5fccee257f6f \ + --hash=sha256:e52c076f187405fc21523c746c04399c9af8ece566077ed147b2126f2bcba577 \ + --hash=sha256:e74663b8b10da1fe0f4e4703fd7980d24ad17174b6bb35d8498d6e3ebce2ae6a \ + --hash=sha256:e89bcd7d426a75bb4952c696b267075790d854a07aad4c9894551a82c5b574ab \ + --hash=sha256:e8a39e66dac7153cf3f964a12aad515afa8d74938ec5cc0018adcdae5367c79e \ + --hash=sha256:ee4a72f12847ef29b072aee9ad5474041ab2924106bdca9fcf5d7d965853e057 \ + --hash=sha256:f16b76d7d6aadbbaf7f79a76ff3a51dae14b7ebaaf9c1ba61607784ef51c537c \ + --hash=sha256:f2d4c61da0821ee42e0cdf5489da60a6d074306313a377c2b35af464955a3611 \ + --hash=sha256:f4f1c4b125e1652aefbc2e2c1617b60a160ab789d180e3d423c41439e5f32850 \ + --hash=sha256:fb3dbf7cc0d4dbe73cce307ebe7eefa7f73a7d3d854dd119ea0c243f03e40927 \ + --hash=sha256:fbd9e482663ca9d005d051330e4d2d8150bb208a209409c10f7e7dfdf7c49da9 \ + --hash=sha256:fc4ab96a30fb3cb2c7e0cd33f7616c8860da5f5674438988a54ac717caccdbaa \ + --hash=sha256:fc7e37b4b8bc7e80a63ad6cfa5fc11fab27dbfea4cc4ae644b1ab3f273dc348f \ + --hash=sha256:ff3a6465b3a0f54b1a430f45c3c0ba7d61ceb45cbc3e33f9e1a7f638d690baf3 \ + --hash=sha256:ffb2a08a406465bb076b7cc1df41d833106d3cf7905076cc73f0cb90078c7d10 + # via + # anthropic + # instructor + # openai +jmespath==1.1.0 \ + --hash=sha256:472c87d80f36026ae83c6ddd0f1d05d4e510134ed462851fd5f754c8c3cbb88d \ + --hash=sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64 + # via + # boto3 + # botocore +joblib==1.5.3 \ + --hash=sha256:5fc3c5039fc5ca8c0276333a188bbd59d6b7ab37fe6632daa76bc7f9ec18e713 \ + --hash=sha256:8561a3269e6801106863fd0d6d84bb737be9e7631e33aaed3fb9ce5953688da3 + # via + # distfit + # hdbscan + # librosa + # pynndescent + # scikit-learn +joserfc==1.7.4 \ + --hash=sha256:32d46c2cd5e3203c13e87a6c61333cab310b1ba80cd54b4c4f386a848a122463 \ + --hash=sha256:b3bc561672ae541b17a9237053b48a03dacddd92d68047b3ecdfb4b5714a88ed + # via authlib +json5==0.15.0 \ + --hash=sha256:56636a30c0e8a4665fe2179c0212f32eae3796dea89ea6f649b9436ecdb39618 \ + --hash=sha256:7424d1f1eb1d56da6e3d70643f53619862b4ce81440bdb8ecfd6f875e5ba4a71 + # via jupyterlab-server +jsonlines==4.0.0 \ + --hash=sha256:0c6d2c09117550c089995247f605ae4cf77dd1533041d366351f6f298822ea74 \ + --hash=sha256:185b334ff2ca5a91362993f42e83588a360cf95ce4b71a73548502bda52a7c55 + # via docling-ibm-models +jsonpickle==4.1.2 \ + --hash=sha256:7ffe34426bc797684dbf1dc84185558bd864cd25b1ff5fb01b7405e392d0a937 \ + --hash=sha256:8afed18aa189fd81e2e833b426bb4af485594921f0b1d36c2001fc5637a2f210 + # via pyvis +jsonpointer==3.1.1 \ + --hash=sha256:0b801c7db33a904024f6004d526dcc53bbb8a4a0f4e32bfd10beadf60adf1900 \ + --hash=sha256:8ff8b95779d071ba472cf5bc913028df06031797532f08a7d5b602d8b2a488ca + # via jsonschema +jsonref==1.1.0 \ + --hash=sha256:32fe8e1d85af0fdefbebce950af85590b22b60f9e95443176adbde4e1ecea552 \ + --hash=sha256:590dc7773df6c21cbf948b5dac07a72a251db28b0238ceecce0a2abfa8ec30a9 + # via docling-core +jsonschema==4.26.0 \ + --hash=sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326 \ + --hash=sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce + # via + # docling-core + # jupyter-events + # jupyterlab-server + # litellm + # nbformat +jsonschema-specifications==2025.9.1 \ + --hash=sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe \ + --hash=sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d + # via jsonschema +jupyter==1.1.1 \ + --hash=sha256:7a59533c22af65439b24bbe60373a4e95af8f16ac65a6c00820ad378e3f7cc83 \ + --hash=sha256:d55467bceabdea49d7e3624af7e33d59c37fff53ed3a350e1ac957bed731de7a + # via semantica (pyproject.toml) +jupyter-builder==1.2.2 \ + --hash=sha256:6ebcd4c49daf5df6a18068a74a48010406700ed90a76c189fac43eaf85c60c63 \ + --hash=sha256:b6cea88f58e44b2c5eba96f28d2e0d16fd453d3ca6dc9c4492ff8a1f2e97f601 + # via + # jupyterlab + # notebook +jupyter-client==8.9.1 \ + --hash=sha256:0b7a295bc46e8751e9adae84781f726c851c1d911bd793edc4a3bde942e3da81 \ + --hash=sha256:a58f730dd9e728ba16ba1d62ebccf7ffe1ebbdbce4e95cfae941b7321ae1f4fa + # via + # ipykernel + # jupyter-console + # jupyter-server + # nbclient +jupyter-console==6.6.3 \ + --hash=sha256:309d33409fcc92ffdad25f0bcdf9a4a9daa61b6f341177570fdac03de5352485 \ + --hash=sha256:566a4bf31c87adbfadf22cdf846e3069b59a71ed5da71d6ba4d8aaad14a53539 + # via jupyter +jupyter-core==5.9.1 \ + --hash=sha256:4d09aaff303b9566c3ce657f580bd089ff5c91f5f89cf7d8846c3cdf465b5508 \ + --hash=sha256:ebf87fdc6073d142e114c72c9e29a9d7ca03fad818c5d300ce2adc1fb0743407 + # via + # ipykernel + # jupyter-builder + # jupyter-client + # jupyter-console + # jupyter-server + # jupyterlab + # nbclient + # nbconvert + # nbformat +jupyter-events==0.12.1 \ + --hash=sha256:c366585253f537a627da52fa7ca7410c5b5301fe893f511e7b077c2d93ec8bcf \ + --hash=sha256:faff25f77218335752f35f23c5fe6e4a392a7bd99a5939ccb9b8fbf594636cf3 + # via jupyter-server +jupyter-lsp==2.3.1 \ + --hash=sha256:71b954d834e85ff3096400554f2eefaf7fe37053036f9a782b0f7c5e42dadb81 \ + --hash=sha256:fdf8a4aa7d85813976d6e29e95e6a2c8f752701f926f2715305249a3829805a6 + # via jupyterlab +jupyter-server==2.20.0 \ + --hash=sha256:b5778ba337d8015a3dc2b80803ecdd5ac18d3797fddf61a50ea5fb472b4ebe14 \ + --hash=sha256:c3b67c93c471e947c18b5026f04f21614218adb706df8f48227d3ee8e0a7cdcc + # via + # jupyter-lsp + # jupyterlab + # jupyterlab-server + # notebook + # notebook-shim +jupyter-server-terminals==0.5.4 \ + --hash=sha256:55be353fc74a80bc7f3b20e6be50a55a61cd525626f578dcb66a5708e2007d14 \ + --hash=sha256:bbda128ed41d0be9020349f9f1f2a4ab9952a73ed5f5ac9f1419794761fb87f5 + # via jupyter-server +jupyterlab==4.6.3 \ + --hash=sha256:0a1ebc6567186f1eabd99536e94df7ed9e96d1e7c5ddf3e4406ae16e88abacb7 \ + --hash=sha256:2e3db6e3a12495ebd188276e985bf5ac502fbde3d1e8628819920210008de498 + # via + # jupyter + # notebook +jupyterlab-pygments==0.3.0 \ + --hash=sha256:721aca4d9029252b11cfa9d185e5b5af4d54772bb8072f9b7036f4170054d35d \ + --hash=sha256:841a89020971da1d8693f1a99997aefc5dc424bb1b251fd6322462a1b8842780 + # via nbconvert +jupyterlab-server==2.28.0 \ + --hash=sha256:35baa81898b15f93573e2deca50d11ac0ae407ebb688299d3a5213265033712c \ + --hash=sha256:e4355b148fdcf34d312bbbc80f22467d6d20460e8b8736bf235577dd18506968 + # via + # jupyterlab + # notebook +jupyterlab-widgets==3.0.16 \ + --hash=sha256:423da05071d55cf27a9e602216d35a3a65a3e41cdf9c5d3b643b814ce38c19e0 \ + --hash=sha256:45fa36d9c6422cf2559198e4db481aa243c7a32d9926b500781c830c80f7ecf8 + # via ipywidgets +kafka-python==3.0.10 \ + --hash=sha256:06950fed5e705ec5207458ca6ae43b6c0ae1de4146ddbf7877f42a893830100c \ + --hash=sha256:9b2597f194009dcab8f7207ce9a15d59cb5d30bf5aa742fea69dffbd2866b2d1 + # via semantica (pyproject.toml) +kiwisolver==1.5.0 \ + --hash=sha256:012b1eb16e28718fa782b5e61dc6f2da1f0792ca73bd05d54de6cb9561665fc9 \ + --hash=sha256:01808c6d15f4c3e8559595d6d1fe6411c68e4a3822b4b9972b44473b24f4e679 \ + --hash=sha256:0255a027391d52944eae1dbb5d4cc5903f57092f3674e8e544cdd2622826b3f0 \ + --hash=sha256:0b85aad90cea8ac6797a53b5d5f2e967334fa4d1149f031c4537569972596cb8 \ + --hash=sha256:0bf3acf1419fa93064a4c2189ac0b58e3be7872bf6ee6177b0d4c63dc4cea276 \ + --hash=sha256:0c50b89ffd3e1a911c69a1dd3de7173c0cd10b130f56222e57898683841e4f96 \ + --hash=sha256:0cbe94b69b819209a62cb27bdfa5dc2a8977d8de2f89dfd97ba4f53ed3af754e \ + --hash=sha256:0df54df7e686afa55e6f21fb86195224a6d9beb71d637e8d7920c95cf0f89aac \ + --hash=sha256:0e3aafb33aed7479377e5e9a82e9d4bf87063741fc99fc7ae48b0f16e32bdd6f \ + --hash=sha256:12e91c215a96e39f57989c8912ae761286ac5a9584d04030ceb3368a357f017a \ + --hash=sha256:1465387ac63576c3e125e5337a6892b9e99e0627d52317f3ca79e6930d889d15 \ + --hash=sha256:16b85d37c2cbb3253226d26e64663f755d88a03439a9c47df6246b35defbdfb7 \ + --hash=sha256:1b0feb50971481a2cc44d94e88bdb02cdd497618252ae226b8eb1201b957e368 \ + --hash=sha256:1d49a49ac4cbfb7c1375301cd1ec90169dfeae55ff84710d782260ce77a75a02 \ + --hash=sha256:1d9daea4ea6b9be74fe2f01f7fbade8d6ffab263e781274cffca0dba9be9eec9 \ + --hash=sha256:1dd9b0b119a350976a6d781e7278ec7aca0b201e1a9e2d23d9804afecb6ca681 \ + --hash=sha256:1f1489f769582498610e015a8ef2d36f28f505ab3096d0e16b4858a9ec214f57 \ + --hash=sha256:2517e24d7315eb51c10664cdb865195df38ab74456c677df67bb47f12d088a27 \ + --hash=sha256:295d9ffe712caa9f8a3081de8d32fc60191b4b51c76f02f951fd8407253528f4 \ + --hash=sha256:2a075bd7bd19c70cf67c8badfa36cf7c5d8de3c9ddb8420c51e10d9c50e94920 \ + --hash=sha256:32cc0a5365239a6ea0c6ed461e8838d053b57e397443c0ca894dcc8e388d4374 \ + --hash=sha256:332b4f0145c30b5f5ad9374881133e5aa64320428a57c2c2b61e9d891a51c2f3 \ + --hash=sha256:377815a8616074cabbf3f53354e1d040c35815a134e01d7614b7692e4bf8acfa \ + --hash=sha256:38f4a703656f493b0ad185211ccfca7f0386120f022066b018eb5296d8613e23 \ + --hash=sha256:3ac2360e93cb41be81121755c6462cff3beaa9967188c866e5fce5cf13170859 \ + --hash=sha256:3c4923e404d6bcd91b6779c009542e5647fef32e4a5d75e115e3bbac6f2335eb \ + --hash=sha256:3cdcb35dc9d807259c981a85531048ede628eabcffb3239adf3d17463518992d \ + --hash=sha256:41024ed50e44ab1a60d3fe0a9d15a4ccc9f5f2b1d814ff283c8d01134d5b81bc \ + --hash=sha256:413b820229730d358efd838ecbab79902fe97094565fdc80ddb6b0a18c18a581 \ + --hash=sha256:4432b835675f0ea7414aab3d37d119f7226d24869b7a829caeab49ebda407b0c \ + --hash=sha256:4db576bb8c3ef9365f8b40fe0f671644de6736ae2c27a2c62d7d8a1b4329f099 \ + --hash=sha256:4e7f886f47ab881692f278ae901039a234e4025a68e6dfab514263a0b1c4ae05 \ + --hash=sha256:4e9750bc21b886308024f8a54ccb9a2cc38ac9fa813bf4348434e3d54f337ff9 \ + --hash=sha256:5060731cc3ed12ca3a8b57acd4aeca5bbc2f49216dd0bec1650a1acd89486bcd \ + --hash=sha256:50847dca5d197fcbd389c805aa1a1cf32f25d2e7273dc47ab181a517666b68cc \ + --hash=sha256:5092eb5b1172947f57d6ea7d89b2f29650414e4293c47707eb499ec07a0ac796 \ + --hash=sha256:5124d1ea754509b09e53738ec185584cc609aae4a3b510aaf4ed6aa047ef9303 \ + --hash=sha256:51e8c4084897de9f05898c2c2a39af6318044ae969d46ff7a34ed3f96274adca \ + --hash=sha256:530a3fd64c87cffa844d4b6b9768774763d9caa299e9b75d8eca6a4423b31314 \ + --hash=sha256:56fa888f10d0f367155e76ce849fa1166fc9730d13bd2d65a2aa13b6f5424489 \ + --hash=sha256:58f812017cd2985c21fbffb4864d59174d4903dd66fa23815e74bbc7a0e2dd57 \ + --hash=sha256:59cd8683f575d96df5bb48f6add94afc055012c29e28124fcae2b63661b9efb1 \ + --hash=sha256:5ae8e62c147495b01a0f4765c878e9bfdf843412446a247e28df59936e99e797 \ + --hash=sha256:5b233ea3e165e43e35dba1d2b8ecc21cf070b45b65ae17dd2747d2713d942021 \ + --hash=sha256:6176c1811d9d5a04fa391c490cc44f451e240697a16977f11c6f722efb9041db \ + --hash=sha256:62f59da443c4f4849f73a51a193b1d9d258dcad0c41bc4d1b8fb2bcc04bfeb22 \ + --hash=sha256:6783e069732715ad0c3ce96dbf21dbc2235ab0593f2baf6338101f70371f4028 \ + --hash=sha256:6ab8ba9152203feec73758dad83af9a0bbe05001eb4639e547207c40cfb52083 \ + --hash=sha256:70d593af6a6ca332d1df73d519fddb5148edb15cd90d5f0155e3746a6d4fcc65 \ + --hash=sha256:72ec46b7eba5b395e0a7b63025490d3214c11013f4aacb4f5e8d6c3041829588 \ + --hash=sha256:7a32f72973f0f950c1920475d5c5ea3d971b81b6f0ec53b8d0a956cc965f22e0 \ + --hash=sha256:7a4aa69609f40fce3cbc3f87b2061f042eee32f94b8f11db707b66a26461591a \ + --hash=sha256:7c60d3c9b06fb23bd9c6139281ccbdc384297579ae037f08ae90c69f6845c0b1 \ + --hash=sha256:800ee55980c18545af444d93fdd60c56b580db5cc54867d8cbf8a1dc0829938c \ + --hash=sha256:80aa065ffd378ff784822a6d7c3212f2d5f5e9c3589614b5c228b311fd3063ac \ + --hash=sha256:86e0287879f75621ae85197b0877ed2f8b7aa57b511c7331dce2eb6f4de7d476 \ + --hash=sha256:893ff3a711d1b515ba9da14ee090519bad4610ed1962fbe298a434e8c5f8db53 \ + --hash=sha256:89fc958c702ee9a745e4700378f5d23fddbc46ff89e8fdbf5395c24d5c1452a3 \ + --hash=sha256:8c63c91f95173f9c2a67c7c526b2cea976828a0e7fced9cdcead2802dc10f8a4 \ + --hash=sha256:8df31fe574b8b3993cc61764f40941111b25c2d9fea13d3ce24a49907cd2d615 \ + --hash=sha256:8f9baf6f0a6e7571c45c8863010b45e837c3ee1c2c77fcd6ef423be91b21fedb \ + --hash=sha256:9027d773c4ff81487181a925945743413f6069634d0b122d0b37684ccf4f1e18 \ + --hash=sha256:9190426b7aa26c5229501fa297b8d0653cfd3f5a36f7990c264e157cbf886b3b \ + --hash=sha256:940dda65d5e764406b9fb92761cbf462e4e63f712ab60ed98f70552e496f3bf1 \ + --hash=sha256:94eff26096eb5395136634622515b234ecb6c9979824c1f5004c6e3c3c85ccd2 \ + --hash=sha256:9eed0f7edbb274413b6ee781cca50541c8c0facd3d6fd289779e494340a2b85c \ + --hash=sha256:ad4ae4ffd1ee9cd11357b4c66b612da9888f4f4daf2f36995eda64bd45370cac \ + --hash=sha256:b0f172dc8ffaccb8522d7c5d899de00133f2f1ca7b0a49b7da98e901de87bf2d \ + --hash=sha256:b2af221f268f5af85e776a73d62b0845fc8baf8ef0abfae79d29c77d0e776aaf \ + --hash=sha256:b7d335370ae48a780c6e6a6bbfa97342f563744c39c35562f3f367665f5c1de2 \ + --hash=sha256:b83af57bdddef03c01a9138034c6ff03181a3028d9a1003b301eb1a55e161a3f \ + --hash=sha256:bb5136fb5352d3f422df33f0c879a1b0c204004324150cc3b5e3c4f310c9049f \ + --hash=sha256:bc4d8e252f532ab46a1de9349e2d27b91fce46736a9eedaa37beaca66f574ed4 \ + --hash=sha256:bdd3e53429ff02aa319ba59dfe4ceeec345bf46cf180ec2cf6fd5b942e7975e9 \ + --hash=sha256:be12f931839a3bdfe28b584db0e640a65a8bcbc24560ae3fdb025a449b3d754e \ + --hash=sha256:be4a51a55833dc29ab5d7503e7bcb3b3af3402d266018137127450005cdfe737 \ + --hash=sha256:beb7f344487cdcb9e1efe4b7a29681b74d34c08f0043a327a74da852a6749e7b \ + --hash=sha256:bf4679a3d71012a7c2bf360e5cd878fbd5e4fcac0896b56393dec239d81529ed \ + --hash=sha256:c0e1403fd7c26d77c1f03e096dc58a5c726503fa0db0456678b8668f76f521e3 \ + --hash=sha256:c31c13da98624f957b0fb1b5bae5383b2333c2c3f6793d9825dd5ce79b525cb7 \ + --hash=sha256:c438f6ca858697c9ab67eb28246c92508af972e114cac34e57a6d4ba17a3ac08 \ + --hash=sha256:c8277104ded0a51e699c8c3aff63ce2c56d4ed5519a5f73e0fd7057f959a2b9e \ + --hash=sha256:c95cab08d1965db3d84a121f1c7ce7479bdd4072c9b3dafd8fecce48a2e6b902 \ + --hash=sha256:cc0b66c1eec9021353a4b4483afb12dfd50e3669ffbb9152d6842eb34c7e29fd \ + --hash=sha256:cdee07c4d7f6d72008d3f73b9bf027f4e11550224c7c50d8df1ae4a37c1402a6 \ + --hash=sha256:ce9bf03dad3b46408c08649c6fbd6ca28a9fce0eb32fdfffa6775a13103b5310 \ + --hash=sha256:cff8e5383db4989311f99e814feeb90c4723eb4edca425b9d5d9c3fefcdd9537 \ + --hash=sha256:d168fda2dbff7b9b5f38e693182d792a938c31db4dac3a80a4888de603c99554 \ + --hash=sha256:d1ffeb80b5676463d7a7d56acbe8e37a20ce725570e09549fe738e02ca6b7e1e \ + --hash=sha256:d36ca54cb4c6c4686f7cbb7b817f66f5911c12ddb519450bbe86707155028f87 \ + --hash=sha256:d4193f3d9dc3f6f79aaed0e5637f45d98850ebf01f7ca20e69457f3e8946b66a \ + --hash=sha256:d5cd5189fc2b6a538b75ae45433140c4823463918f7b1617c31e68b085c0022c \ + --hash=sha256:d618fd27420381a4f6044faa71f46d8bfd911bd077c555f7138ed88729bfbe79 \ + --hash=sha256:d76e2d8c75051d58177e762164d2e9ab92886534e3a12e795f103524f221dd8e \ + --hash=sha256:daae526907e262de627d8f70058a0f64acc9e2641c164c99c8f594b34a799a16 \ + --hash=sha256:db485b3847d182b908b483b2ed133c66d88d49cacf98fd278fadafe11b4478d1 \ + --hash=sha256:dd952e03bfbb096cfe2dd35cd9e00f269969b67536cb4370994afc20ff2d0875 \ + --hash=sha256:dda366d548e89a90d88a86c692377d18d8bd64b39c1fb2b92cb31370e2896bbd \ + --hash=sha256:e315e5ec90d88e140f57696ff85b484ff68bb311e36f2c414aa4286293e6dee0 \ + --hash=sha256:e4415a8db000bf49a6dd1c478bf70062eaacff0f462b92b0ba68791a905861f9 \ + --hash=sha256:e7a116ae737f0000343218c4edf5bd45893bfeaff0993c0b215d7124c9f77646 \ + --hash=sha256:e7c4c09a490dc4d4a7f8cbee56c606a320f9dc28cf92a7157a39d1ce7676a657 \ + --hash=sha256:ebae99ed6764f2b5771c522477b311be313e8841d2e0376db2b10922daebbba4 \ + --hash=sha256:ec4c85dc4b687c7f7f15f553ff26a98bfe8c58f5f7f0ac8905f0ba4c7be60232 \ + --hash=sha256:ed3a984b31da7481b103f68776f7128a89ef26ed40f4dc41a2223cda7fb24819 \ + --hash=sha256:f18c2d9782259a6dc132fdc7a63c168cbc74b35284b6d75c673958982a378384 \ + --hash=sha256:f1f9f4121ec58628c96baa3de1a55a4e3a333c5102c8e94b64e23bf7b2083309 \ + --hash=sha256:f42c23db5d1521218a3276bb08666dcb662896a0be7347cba864eca45ff64ede \ + --hash=sha256:f443b4825c50a51ee68585522ab4a1d1257fac65896f282b4c6763337ac9f5d2 \ + --hash=sha256:f6764a4ccab3078db14a632420930f6186058750df066b8ea2a7106df91d3203 \ + --hash=sha256:f7c7553b13f69c1b29a5bde08ddc6d9d0c8bfb84f9ed01c30db25944aeb852a7 \ + --hash=sha256:fa6248cd194edff41d7ea9425ced8ca3a6f838bfb295f6f1d6e6bb694a8518df \ + --hash=sha256:fa8eb9ecdb7efb0b226acec134e0d709e87a909fa4971a54c0c4f6e88635484c \ + --hash=sha256:fc20894c3d21194d8041a28b65622d5b86db786da6e3cfe73f0c762951a61167 \ + --hash=sha256:fc4d3f1fb9ca0ae9f97b095963bc6326f1dbfd3779d6679a1e016b9baaa153d3 \ + --hash=sha256:fd40bb9cd0891c4c3cb1ddf83f8bbfa15731a248fdc8162669405451e2724b09 \ + --hash=sha256:ff710414307fefa903e0d9bdf300972f892c23477829f49504e59834f4195398 + # via matplotlib +kombu==5.6.2 \ + --hash=sha256:8060497058066c6f5aed7c26d7cd0d3b574990b09de842a8c5aaed0b92cc5a55 \ + --hash=sha256:efcfc559da324d41d61ca311b0c64965ea35b4c55cc04ee36e55386145dace93 + # via celery +lark==1.3.1 \ + --hash=sha256:b426a7a6d6d53189d318f2b6236ab5d6429eaf09259f1ca33eb716eed10d2905 \ + --hash=sha256:c629b661023a014c37da873b4ff58a817398d12635d3bbb2c5a03be7fe5d1e12 + # via rfc3987-syntax +latex2mathml==3.81.0 \ + --hash=sha256:4b959cdc3cac8686bc0e3e5aece8127dfb1b81ca1241bed8e00ef31b82bb4022 \ + --hash=sha256:d317710393fe20579aea39cfe8928fa2ad9b8780896e585326c75e89c1d1d1a4 + # via docling-core +lazy-loader==0.5 \ + --hash=sha256:717f9179a0dbed357012ddad50a5ad3d5e4d9a0b8712680d4e687f5e6e6ed9b3 \ + --hash=sha256:ab0ea149e9c554d4ffeeb21105ac60bed7f3b4fd69b1d2360a4add51b170b005 + # via librosa +librosa==0.11.0 \ + --hash=sha256:0b6415c4fd68bff4c29288abe67c6d80b587e0e1e2cfb0aad23e4559504a7fa1 \ + --hash=sha256:f5ed951ca189b375bbe2e33b2abd7e040ceeee302b9bbaeeffdfddb8d0ace908 + # via semantica (pyproject.toml) +librt==0.15.0 \ + --hash=sha256:04d5387b908676c0b8d5d2f5fb58373b4ea382d81f7a6f0fab8ea2a462bb4738 \ + --hash=sha256:077471b3182db4e17c36ae91555f36a4d2c00080b267f749bcad34a478a9a302 \ + --hash=sha256:0a15cb554761247d84a3ec0cbdf4078d70725384f0e4662c0fa3b26266eb60ad \ + --hash=sha256:0da0d94cb802f32a0524653e7201f2cef72d5f700a5407678f5290483d4fcd08 \ + --hash=sha256:0e2d0c0acf5b0ada7d045912b7cf787c21315c95b38b1fa939ef72d45d366b3d \ + --hash=sha256:0f0ee3644d951f31055ad07d77d92520e84505dd7a432cc4cd501dd70ee06785 \ + --hash=sha256:1172c6ad2a88b646e7fe3b480e3fac4ab4418b3443fd8a4061fdd531e0622fc7 \ + --hash=sha256:1256589e0b0adb31751d685a68bce29d73407ddf4ef05d4188f49d5dcf9566d9 \ + --hash=sha256:1a1a8cd430c7dd0c083f455cb1b328d7fc682b05c31b940906f7845bdff80881 \ + --hash=sha256:1cd3b721f24c206398b9e26da3c3a9c011e6e89d06f318ba8ebefc30f1003890 \ + --hash=sha256:1e47b8ba865d7ede071a91a7163073bbaeb72541f1ef8a07d512c45c7b5007f2 \ + --hash=sha256:1f4ef2e71db33df4309167ed7f1520c4fae5e611226e159fa9cf33f93e6ddb3d \ + --hash=sha256:2067ff438048cead9d223ca5675bae2a25e520a7c3e6c1498bf9c6892d22caab \ + --hash=sha256:22d6263b9d39d7bbb286fa791945646e3218f1be2d693e36fb630f1d0e59cd13 \ + --hash=sha256:234d8d394721fa0d786af15ebf1f3fb7f3ed82fd1cd0cde45c2f247b5d4281d2 \ + --hash=sha256:256237037a3ab001ae8d9803b2d43562a4c3aa38739843694349e4d5ebb0fd56 \ + --hash=sha256:291bf73caf78b9e88d6fae9bfd693207ff7d832e2fdbe2cf8e746bc13f5f892b \ + --hash=sha256:29c4cab9df457b19672c39be7f384ebb2bc925c4e2684b8780c222b43eb36389 \ + --hash=sha256:2cfd1a81a648806e6a7717be4cc4d1bb392fa229752bf8444ba365e381e984d6 \ + --hash=sha256:2fde98cf1fc4bac144ce23c2c4c017b924ba714509ea9334977b0b27050c837d \ + --hash=sha256:32896a0af72508ea979e0acb4e4c04cbeeae04938167950d535c83c45597167d \ + --hash=sha256:355e3a4c725225a14262004fc1872a552b9d3634b4f791a0dfc80804aafbfd55 \ + --hash=sha256:3722a099730704c9a3d70c879fc0f51daec25fe5f1555672d97bc595abeafb95 \ + --hash=sha256:38c0c7d4b6fc06c3324b3f9162c8391bfc4fd9dde53afe1033ce7edb48d5a714 \ + --hash=sha256:39ffd14646190c454f0d86e0d256b33f00a87a26ab410e619773b841d0e41416 \ + --hash=sha256:3de789c82752730f94782a5ee518baf9c05edf85733aeaf73bb6e518755cdf54 \ + --hash=sha256:3e79f05e4a08b4d880342673312bbc895b56df7765605796f15902eb5367d3ae \ + --hash=sha256:3ff5893a2c23d886aa9ce786de5ac6ddc74aeeaf90743682b74d920e117d2e28 \ + --hash=sha256:411ca4d1b905b860ceba7570dd6717a71dedaddcc4b0f77ece710aa41ee11f8d \ + --hash=sha256:4388184646efe2054911c5b00a1077d6d1ee86a95b7e8ba96dc7850a809f3f40 \ + --hash=sha256:4a6369168d371207339b1e50d4532b06a7121586141f82599505a3f315751d47 \ + --hash=sha256:4bbcc257e3babea20a91715c361b24554ec4e8f51aa578568afc230799fe1a19 \ + --hash=sha256:4e66cbe84437497d951b799d3e1551291b6fb3d643820a7014b3655d57a59162 \ + --hash=sha256:4e6ee93fc3cf848dcbf0cce2eca73d8e7dcd0cc2b6df3a529d57750b30a4c55c \ + --hash=sha256:4eafbaff06b9563f8b1c850621ce51605de05208e09d4d71ce490bc972b7b9e8 \ + --hash=sha256:52e8db01f603f5da0ca30987479acff98769382efc8e142fa3962395dcf3ffdb \ + --hash=sha256:5500eeae393a184d14e1f35645962c27129d20c81afa4069e6ef826ebc2b3aaa \ + --hash=sha256:55456ea87d8df21808446d03817be2f65e20391c1c615d9187440dff28cd08dc \ + --hash=sha256:5563302a8359bc2295bb7084d1a8ed1519df96afb30eb2aa4e0bff7b54228988 \ + --hash=sha256:567b1c430f8bd560e689421468278ac5941bab4a05303b5d95b6ae10db03f451 \ + --hash=sha256:57f5eeb6ad4c180de583b1038e61fe5fbd9796bb69a8a1c1a0c7ddbec4c8c60f \ + --hash=sha256:59fe030d8ae4a57e3fb7756bf35a858de74e04066fc8555c53d0af979132af81 \ + --hash=sha256:5a6526a2a956bbb1e4ae3568c82e650fc99119c66bb011ea60715744955a2b4d \ + --hash=sha256:5a86a5a08c2235316bdb359d5dbb6ce0abfca7fac06363103e2c5af571d92f95 \ + --hash=sha256:5d2a91724463bfed4f573cd7a9fdc856d2e230d0c0e5a61416a93481dccd8605 \ + --hash=sha256:64b0c8c35aa4c4ed79896359f3e0b285cbe4e610042106500da4811c322cc108 \ + --hash=sha256:68242379c9b65a582b6e97318a1e9fbd6d445e58954f2d437991c4804ab11578 \ + --hash=sha256:6912fa5e635d74529ac7cdb1bdf6ca3af4453da8d1edbe0110ee1cb4ad407ebf \ + --hash=sha256:6c013cd3a1721e69e14380ada97eaa4b7b0cdf1c6b96fa765d4ea47c875088db \ + --hash=sha256:6c0eb900c0e91f4aebe680845242e614f1864edfd44106380d0752ac29522bf8 \ + --hash=sha256:6c6624fe268625869485553dd7cc1daf30d22558215bb2a4ff16f67a9801a31a \ + --hash=sha256:6d15a29033c57490cfe2069097c6fc4049e4e65ffbb749be7dc453b7c4c68965 \ + --hash=sha256:6d28a05796b99f749bf8794f17ba9ba1612d0076b802e9cfc62c554634e9ce3b \ + --hash=sha256:6d5225ef8801e4ea5e482fa9b5dfb891dd9ef6f6d870f1f25d449ca2c70ac218 \ + --hash=sha256:6da110e5f314c19ab8478464d02ae18808ae73d522c15260fa4918acdcd64da9 \ + --hash=sha256:6ecfc32dfb46fb7b565bcd6abf9412acf978775a998273d22888a6d7953730dd \ + --hash=sha256:713bd7df21170b982e729e46870f31d6b437bd1a9b4648cffb529bd3c2ec5c4b \ + --hash=sha256:71599e011ac880e8e45d46047d714871894c7d4ab6f25626f8d4f89da21f368d \ + --hash=sha256:7220697efaa6e5348fc3d18ee7f8563d4bfecd9872b37ffb915bfc1d08840622 \ + --hash=sha256:73b30cfa976659b3917c8f6153bdb0591c6a9ec6583599fd24a689b690622022 \ + --hash=sha256:779a6e7c894737e5983e7790a9c78c4000c30e23c9aada08081bdbea53b0fa60 \ + --hash=sha256:80811e1c42386ea95c6fb30571d3250ad43d7863f883f787f70517f441150e59 \ + --hash=sha256:814ff83a25b5fce8b9c80c4dd803153fb5c5599fc74db9e022466938368957ef \ + --hash=sha256:81a398f45b45a59200e13cd5ad1ae1d3f44334de98b148331afe2cdfee701c52 \ + --hash=sha256:823b92cf3c18ecd08afc70c42473888b41b6e8ef5046f3b82c05c154a2fa3d22 \ + --hash=sha256:82909c8f7eb9952656b65d3147afde4cf8e6d5a991eebc86418b5e65843b0ab8 \ + --hash=sha256:83380ffde38062a2e9bb55d83e74474f6614665528b98a6928720fc006dfffbb \ + --hash=sha256:8443e38dcfcfdbcf5add5118c623efd788d65ac2e25756d6251a54a06a4d0aca \ + --hash=sha256:84d244b00604d17df3fc7736c327892d6bba66181254aa4087be807b6c342bdc \ + --hash=sha256:856f743ae607f2c1380eccb566c0038a9fb3eabf0fc2be2704d76d9f73557239 \ + --hash=sha256:85ea21ec6730194d67156b0e0b5430ccb1d61f8b8b907e39b37f9812b74a13f0 \ + --hash=sha256:86a21a7bd3fe3a419512ef424cc1c020f6771d0b29cfddff36d1635a855e63f0 \ + --hash=sha256:88c2a17815c266e6d8180204ff62cb739ab869ada4a746d4c505331526ac58f1 \ + --hash=sha256:89cc46cfd15022e35084355478c9ac809d90b1152222706ac9a7655ec21df6fa \ + --hash=sha256:8ae493ed5f659a7761c43d42f183db514536073ded9bcf671d2d1df47e29a07e \ + --hash=sha256:8b2fdd7ead3c995c37940a790690660d0ca006c302db26cc51933f6766866fc3 \ + --hash=sha256:8b62076030baa2d8b1501a46bf0e19c27a489aa90671c55665bff7887f7660b0 \ + --hash=sha256:8e11699ed745931c395acd3621b07062e0f840efa6935aad87a64ed0995f0915 \ + --hash=sha256:92bfed8deec93df30286b9fe9e3b1dd17329cc076a192b4ee5ec223841d54953 \ + --hash=sha256:96bb17dbe8bab3c0954fbebfc69ed395599de75b6bbc35e3270a878e15d4dd65 \ + --hash=sha256:97335f59082f9fe2ce6c2a9cc6433a0114bbb6cd4d5c09dd76c95c68b9f9a8b0 \ + --hash=sha256:a417149c0cba4d50b61e992e5a15e69eaf96746609b461cc4ed168aeef6b79dd \ + --hash=sha256:a5207ec414d1c4a2a7231b2086970dc036f94293cdf338190984958a013a42f1 \ + --hash=sha256:a54cf9e0ef47b96af580849db5471142200568ce1e02cbf416addab551369570 \ + --hash=sha256:a56a1d4f859a82ca5b99fc4b82c9b027b15e3c455c5cd99e7d0719f27bb20b6c \ + --hash=sha256:a5fa8f1f916988d0bf1afea005bda37f56ac41a18016e813ccf0097a8d460ca4 \ + --hash=sha256:a6cd22c9da0d866558e46a041f1cc0c2bbb26b61b137b2347fa834c332e1d101 \ + --hash=sha256:aa1f1995789dca3698bc550aaceb09a51bd5df0a057ff84ff15296cd1975b801 \ + --hash=sha256:b0411b4066db926b80258c60dcb0e6db4c9cee312eab45b7e8866b17ddf9ada1 \ + --hash=sha256:b230acc1c3bfe2d6f2627ba2b95dc92e58aa494600e9722d0e6ccbc931e59702 \ + --hash=sha256:b30e600e8f337b9bd7f39b86d9fdfedc73cc46e3d0f745931a23a234220bb7e2 \ + --hash=sha256:b845b8d48088fad0cadc84be4b8fda63203be7e9237b71015b3925443c1f35ab \ + --hash=sha256:b87d67e33afaf265262f2a66db578284b88ee2e6fcd224579cb5c15518677ad8 \ + --hash=sha256:bac89069bc496ebdf4f79ebb57bbd10d0b214c8454225deb672d91002bd17e18 \ + --hash=sha256:bc25fb356d0c7810bb49ff3df908ad1fda6995d660ab099ded69244ed7ab6053 \ + --hash=sha256:bccbd8e5b0bffb7106cf18eb1baa3d7194b1cebb3b4b1cdbd4bdb19382a6ee6c \ + --hash=sha256:c16d15ee371643ab48dc8248a3e680ebbeca573a13af2c3dd0c985b142d77162 \ + --hash=sha256:c434e072557ade9cbc642d052c89d031efe47d5c9614523619d0d74a02378e81 \ + --hash=sha256:c47318cd3a61401452de11282242937e3e057c4fd3dbaf601e269d0928a06c0a \ + --hash=sha256:c70bc1b602cf59917e8f0c7a2cbc8bcc6fbc14d5486136b00707a79619121d63 \ + --hash=sha256:c7eec6a42018bc1d45763b1c162d3d2bf7c3b9a1b0ed30d3e91dcba390efefcc \ + --hash=sha256:c802434092b769b1d613ed2e13fac15fbfce1934a74bd10283b03c0fae231cd1 \ + --hash=sha256:cc30523e3f1a23fb7511cc659834a0d01a1042bb9de359bc1c131cc4ec6c9656 \ + --hash=sha256:d00d20d1818e82a07a0ee0aa89a98b17ed7916b92441090b683719cb20a59b6d \ + --hash=sha256:d2813ba2503764f0450680c533d13df7cff9b49df1411062eded5f67db4195b9 \ + --hash=sha256:d2c05c729b589e734c09578bf5964be48a911765484840d017bbc84f49d4c4ad \ + --hash=sha256:d4c7bacb70930f3d0a56f4ecf1be474a1f0d941b01dd73b756f3c256d42cb879 \ + --hash=sha256:d5f51401d102c885b9ca509e62c79b1dbff286e1b9b047fde6f763780789356d \ + --hash=sha256:d8363d7accb0286ac3a0e633f396e93800dafb8150494505daf9515bbda591f3 \ + --hash=sha256:d8bc24219b24c0af375718942ab75e3544b2763085f40f965be4326734ae8328 \ + --hash=sha256:d8edcf6f550e918dca779c069b9e156385c60b406f99fc7641f32c52f7193659 \ + --hash=sha256:da7a94d6a3411f579d72aa3e3bc5fbca7ed4549f3dbd7e5de3aa567333374285 \ + --hash=sha256:db13ca398005abcbe538deda87b686d9bd08b7001cf40c4c06b444960ae10a26 \ + --hash=sha256:dbab647e88d90b3167b91efe7091e248653688ed4337e4f90907a722c7361bb9 \ + --hash=sha256:dbd605739f228912dc49027cb764456b9757750bdc2b6b7773164db7096c6fd1 \ + --hash=sha256:e0b5deec9a8664eb722c797241970fd4aa1894d25fda36a1ddac0f7407606bd6 \ + --hash=sha256:e0d00c708fb2f5822b152429b1ac80a58dbbbc3f6c232c4d13a3f7fcf2ea5b4c \ + --hash=sha256:e1a49adf16a7c9d9646816c2946135527197b6fcf4347c7b8b761cf1bfbf4489 \ + --hash=sha256:e3b461183c5fa7681b48560f91515f53a953122fb30c71e07abc67d7ddf58c38 \ + --hash=sha256:e4c911f15a1652ca94ae9f1abd92e74cbb1b3597d2d92fdd556202f94e8cd455 \ + --hash=sha256:e56b6a368529bed262da40ce13f8fef590db0479819cca84f16a1f01ac356d0b \ + --hash=sha256:e87bc679f86a99aa3b26e3c78eeb821a247c9a28eae48eaafcc32c3bf4c3bb9e \ + --hash=sha256:e8c9a650a188e38bac005048cbe6342e81407782944d01934540ab75e417df21 \ + --hash=sha256:eab9208b00ca55bf75983ec99f7bf13acc746a36102e98953addaad7f7ea1e1b \ + --hash=sha256:ec3ba415afaf951f6951b1dd16d3c8e4f540065fc382d7e70b823a79567ca374 \ + --hash=sha256:ec4b19788f835711a2072f9dbe6b03b3bf32ed1f0fb30cf399bdd59d9f0c33fa \ + --hash=sha256:f395a4a9a03ac062dbe9a9f82e0c720502e590a38feee6a757bc82e9c63afbd8 \ + --hash=sha256:f42b74a53e5f26a0ba0007411a7455b66c67ce4022a39cc1f56fc4efd65bcbab \ + --hash=sha256:f54598964d357b1c5ab77cf5d92f21e598fe0e23cdbe9618480807f81b4eba15 \ + --hash=sha256:f56b397858a23dacf35ede366ed2212fdc03a6a57a1ad36468ad6e9dc5fac091 \ + --hash=sha256:f5de7feedc56337a088eb15cd9fafa9938367362221d8cc62c642b7f94821993 \ + --hash=sha256:f75720477ee05d509a310e856cacc8d909adc182f7b91193c207bcc26d7ee6db \ + --hash=sha256:f779070399f991400fc451719e0ea388eb7de313388bada2c127a35de05f798a \ + --hash=sha256:f9ca190fe9edc0eb08eec558a509a16d28d91c35667b8f043cba40ed5e77a959 \ + --hash=sha256:fa60887537e1d0cd2d9982269d33a709bf54b195cd2b9364fc0a758022af5bd9 \ + --hash=sha256:fc1ed11c4ad0b91af24def2050f2840ea4567828e3dd058fbe608d982f6e5465 \ + --hash=sha256:febb1ce6cac545a54e6b769982824e955a700fdd9fbf3a08a3d82c990968b57d + # via mypy +litellm==1.96.2 \ + --hash=sha256:0168e49cffecb0b45a0d044ecb7e5f7f6fc9e14d974b50f54edfa9d8e0db9c1d \ + --hash=sha256:0aa667c6fc58b20ff04fe5efaac5cf48b1e224bac32dbf22ced2e5ee695bc4ba \ + --hash=sha256:1a42d68b903f6b605bd7744e9700b16a725f897c5edcc448e491cec14cd5a9db \ + --hash=sha256:1f304b8854e385946469d2dcf8ef88e24357120671caeaddea11b085b0302308 \ + --hash=sha256:76e9c72cb6757bb7c35cd23e3e4fc028910795910ae92491076bd50f7b61f205 \ + --hash=sha256:80d477ae092b05ce023b5084542cb3cb75999b52b1dd2e4d834fb348effc9399 \ + --hash=sha256:8c21a57a0f3507176492a4f65361a4302af1a0723c97e96961c7ed0e96934832 \ + --hash=sha256:e5d96d16b37a043e482a134a71b0c1df4e700603fc14df22e4bd27dc9e38f5c4 + # via semantica (pyproject.toml) +llvmlite==0.49.0 \ + --hash=sha256:00f16db782f4a13c78c5804aedc434e46794a77e89999a168f9401106270e50a \ + --hash=sha256:039fa4054a06f537fb39248d4472284ca96be311a142ec09e69f95630ab469cc \ + --hash=sha256:20496a5c9fdb8179fb9300e7d19f6782555d98aeeb4a322264aa7fd99f980618 \ + --hash=sha256:294e2f0b70aef8f92d0ae7b203e2609f08beb39437eee73de59a21669331aae9 \ + --hash=sha256:3a9c9e3af4e214acfefa4f73ebe7bc3fb35854a62b654edb3953f5ae33c08ba3 \ + --hash=sha256:4281a0171d66d2098adce4ba706b8c550b1b10718650f682d64cde16e84e4de5 \ + --hash=sha256:4b0e710880b7cc910392bd6b9f1bbf468fed99b182e4420d51598f36114b3dce \ + --hash=sha256:4ec8ad805e7515cb8440a690eb3cef4d34acb29eef80b705ec4e1c1ad3c43c68 \ + --hash=sha256:6a5b06c1b5fc4ae4c9b169b065f42b719448ef1f873687ef224ef69969b75ec3 \ + --hash=sha256:6acba646d88abbc87d5c113a3d62c1fbf8b8fee11c6493f516803e30f21ae870 \ + --hash=sha256:80a84683d04516bb51da1bbeebddaf2c2f558809c93078a8f91807909ae331f8 \ + --hash=sha256:854941c2267fd4fc5b2ce02b8af8ecdffa79fb7784591d3a89370322039ea09f \ + --hash=sha256:95d1071023ed858b79f6971954fd7cc1f5dbcbab987718a4ccbe1411e47d0b81 \ + --hash=sha256:a1b414dc6b164738ec39dd8987cea73829057b7dd92fc6d91b52838385fc1dd2 \ + --hash=sha256:a8c0fc9d624bdc30a3d2db11eb2fb98f80fb209d20b37604eda516cd9b699cf4 \ + --hash=sha256:b095f15fb12c4d90495df5b1a3772b4732cc408398b204a787dbedd370e09c69 \ + --hash=sha256:b352c14353330c879e339b8f8d7491d565fe94242697714a24e80bd757202384 \ + --hash=sha256:b541c8fac3450db7574d1f53cf9dff83f285bfed9d69bf81fe71fc2a7d4f97fe \ + --hash=sha256:be637e465010bc9c50f070468f7f1cf5385e92fee364d192dd5e6cea790ecba9 \ + --hash=sha256:d3dee64784201b64c13a8df62c48a4f4218858faaa65889866bb29bdc243c038 \ + --hash=sha256:d5555ea1d63928481cbf7fcb1d67452b216c7e5b393a4eb7aa1401e67f2a4fc4 \ + --hash=sha256:da7b64474ac15ca595efa2644d5c6836638ccf70709fad3aba3fc56a55966928 \ + --hash=sha256:ddc7aecd4f56397ed6e8f120ec5dcd5a1a8f0e6032ca4af413462792d4dca2e3 \ + --hash=sha256:e32adb84fdaae28aeb86fdb6253084ee707ee157289a2e98fe3caf48a62bee82 \ + --hash=sha256:ee81e96c15a6f870918f1eb60c913551c16aa23defb4f5f1acfa660d6a0aaac2 \ + --hash=sha256:f3f2ff0aeb17d34fcce9f79b99baac441cfd3efa41b83e233ca4530a72381f72 + # via + # bertopic + # numba + # pynndescent +loguru==0.7.3 \ + --hash=sha256:19480589e77d47b8d85b2c827ad95d49bf31b0dcde16593892eb51dd18706eb6 \ + --hash=sha256:31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c + # via + # semantica (pyproject.toml) + # fastembed +lxml==6.1.1 \ + --hash=sha256:05a82eb6e1530a64f26225b55cbd178113bd0b5af1c2b625f25e5296742c26d2 \ + --hash=sha256:07a4a68e286ee7a1ed7dfb8af83e615757c0ccfe9f18c6b4ea6771388d9ba8c9 \ + --hash=sha256:09dd5b7075dc2f7709654a46543ba1ea3c2e217b2ed8fbd413a8a945a0f40f60 \ + --hash=sha256:0b7e8a14c8634bf6f7a568634cb395305a6d964aeb5b7ee32248094bed3a7e2c \ + --hash=sha256:104c09bda8d2a562824c0e319d0768ce26a779b7601e0931d33b09b53c392ef7 \ + --hash=sha256:126c93f7f56f0eda92f6d8c619edc463a4f23d9252f1c9d0405a76f25fa9f11a \ + --hash=sha256:162af1091cd785f2f27e62d3547ae9bc58ec5c86dd314d67021fd02463708d83 \ + --hash=sha256:17e0e18d4ad8adbd0399291bc44845b69d9dd68439a3cdebdf35ff902ec05072 \ + --hash=sha256:18b73c339ae29b90fd2d06e58ebd555a751bde9cd6bbd36cc0281b9a2c94e9d8 \ + --hash=sha256:19607c6bbff2a44cf3fe8250abccd20942d3462473e0a721d01d379ed017e462 \ + --hash=sha256:19b7ab10b210b0b3ad7985d9ac4eb66ab09a90b20fe6e2f7ba55d01a234345d0 \ + --hash=sha256:1d4962d4c66bf830a7e59ed6cfc17d148149898a3aefa8ec6e59763e6e3ed085 \ + --hash=sha256:1db753c9115ec7100d073b744d17e25e88a8f90f5c39b2f5dd878149af59671f \ + --hash=sha256:1dde6131244bba38a17c745836ba190bc753fd73c9291666287fd0a3fa3dcf30 \ + --hash=sha256:25c6997a9a534e016695a0ba06b2f07945de682731ff01065b6d5a4474179da1 \ + --hash=sha256:26e6eda8d38c1fcab1090dd196ee87cbd13788e531937610e2589085de074e77 \ + --hash=sha256:27acc820660aaffa4f7c087f29120e12980f7779d56d8492d263170111284740 \ + --hash=sha256:2a0217714657e023ef4293500f65aa20fce6164c8fd6b08fa5bd4a859fb14b9b \ + --hash=sha256:2c8daa471358dc2d6fcf02165e80ec68f77871a286df95bc5cc3816153b0fd2c \ + --hash=sha256:30a89d3ac8faec007453fb541f3f46807eeec88edd5826f6e3fe001752a2c621 \ + --hash=sha256:31033dc34636ea6b7d5cc11b1ddbda78a14de858ba9d3e1ed4b69a3085bc521e \ + --hash=sha256:32ab449a5486f6c758e849bb86710d0e45edc24a04e250c01555f8f5653958f8 \ + --hash=sha256:3483644525531e1d5762b0c44a8e18b6efba321b6dcf8a8952de10b037618bca \ + --hash=sha256:34c2d737beabfe35baada43941ed519251e9a12e779031496bcd5d539fcfd730 \ + --hash=sha256:3779def59032b81e44a5f70096ef6bf2082f8d901937dca354474ba09782e245 \ + --hash=sha256:37a58976370f36d9329d118ad0b953c5aeb9119ac9c6a4e258942a225d0573a1 \ + --hash=sha256:3893c14c4b6ac5b2d54ba8cf03e99fe5104e592de491f19bd6b82756c09f8004 \ + --hash=sha256:3a12689be69a28ddaa0ab99a5a1137da2afd5f8f16df7b5680b66f616d3eda1d \ + --hash=sha256:3ab541146f1f6968c462d6c2ac495148e8cdba2f8347700b2141b6ec5a75bf52 \ + --hash=sha256:3abf332af33a74288675d936fe861fd4344da0dd6622193fbc4f2bfbb35536b5 \ + --hash=sha256:3fd9728a2735fda14f4e8235830c86b539e9661e849665bf926d3f867943b4bf \ + --hash=sha256:424aa57aca0897eb922aef34395bd1289b3b6f04e6bae20ea123c0c7e333cffc \ + --hash=sha256:441dd227fa0690eb9fc81edabc63cdcefc212bba99b906dcf6e32cc1a9d3e533 \ + --hash=sha256:469e3618338bd7ab5beb412d2439825479fcf0dab99e394ca563dbc4eaf6c834 \ + --hash=sha256:47402e62c52ff5988c1e8c6c63177f5708bccf48e366dea4e3dcf1e645e04947 \ + --hash=sha256:4f0dd2f01f9f8a89f565d000e03abcf0a13d692a346c8d22f628d49af098777a \ + --hash=sha256:53b7d2b7a10b1c35c0a5e21e9224accf60c1bbfba523990732e521b2b73adef2 \ + --hash=sha256:53c909b62a0532183542fed00c5a7218258c56292d409bc789886fe1cb04c438 \ + --hash=sha256:54a7f95e4de5fb94e2f9f4b9055c6ba33bf3d628fd77a1d647c5923caa2cdcdc \ + --hash=sha256:556e94a63c9b04716f8e4de2abb65775061f846e89331b6c5be79183a24f98ea \ + --hash=sha256:55b03549819867ea141c0202242c4816c82e52ec36e7e648db9d8da5a3dc3ed6 \ + --hash=sha256:581d4c8ae690a6609e64862dd6b7c2489635c2d13907fc2b20f2bc200ff1d21e \ + --hash=sha256:58bb955caba94e467d2a96da17660d2d704e0675894cba21ab8a775b8621fd1c \ + --hash=sha256:5b7328b46d49fc9477d91ae8f6d55340347d827b7734ba3ea33faae0efef1383 \ + --hash=sha256:5ba186ad207446c65d3bb3d3e0412b032b1d9f595e59861e2354798c5703d955 \ + --hash=sha256:5bec7d03d78d853597d6107854c2310ce3f761fd218fe9fe91d5101fcf6c2efe \ + --hash=sha256:5c6bf403fbb3b3e348a561a5f4f0b9961835657981c802a1df03653eef8a9074 \ + --hash=sha256:5f6994074ebae6ffb04447268e37dc16edc304f9859cf91acb86e0af6c1b395c \ + --hash=sha256:62aeb7e85b5d60320b9d77eef2e773994e2c0ce10121b277e0a19804e1654a5a \ + --hash=sha256:63876be28efefa04a1df615b46770e82042cce445cfdce55160522f57b231ccb \ + --hash=sha256:639f6c857d91d9be29bd7502348d6736dab168b54b5158cd899abf11684dc186 \ + --hash=sha256:640f97d43d867bcb9c75b3af013b64850756b746cb6bce8ace83b70da3abba9d \ + --hash=sha256:649dda677cf3bd6ac9ae14007ba0c824ded8ce5808b53fc7431d9140399118c1 \ + --hash=sha256:6540377fbd53fe1b629172288c464fb18db11ce1fa7dc15891da10aa9dcc3e7f \ + --hash=sha256:6689e828a94eee4f139408c337bb198e014724bb8a8c26d3cfac49d119ed69a6 \ + --hash=sha256:68a9198d0fc122d14bb76837de9aa80cf84caed990b5b237f532ed87d3706736 \ + --hash=sha256:6b1761fbf9ec984e2e9d9c589ef5f5fd684b7c19f92aadd567a26c5224958db6 \ + --hash=sha256:70cdfd80589d59e43e18005dd7244e8895e93db8ab6a620b7e23df5445a4e3d2 \ + --hash=sha256:70ef8a7e102a1508f8121aae5b0867abd663f72c14f0a9c937e6554cb4587b7b \ + --hash=sha256:73bc2086f141224ebddb7fc5c6a36ca58b31b94b561e1dfe8e073e3270fad1e7 \ + --hash=sha256:74a9717fd0d82effef5c2854f0d917231d5324b5a3eb7275c43ac9fa32f97a14 \ + --hash=sha256:752d3bbfe874715ccd0aec7f88d7fc623c0f1fd7aa7b3238a084e017bad2a009 \ + --hash=sha256:762ff394d5bd56da0cf034a23dcce4e13923f15321a2adfa2ac00201dc6d3fca \ + --hash=sha256:76447f65250ed2501ead1a1552f5ce8edff159a86f308348e6a9c4acb5e1f1b4 \ + --hash=sha256:766b010012d59470072c1816b5b6c69f1d243e5db36ea5968e94accf430a4635 \ + --hash=sha256:787b2496d0dbe8cd180984e8d29e3a6f76e7ea34db781cb3bd55e4ba1ef8b4ee \ + --hash=sha256:793033d6c5cdf33a573f910d9bea14ef8f5771820411d118da8e1182edb53d5e \ + --hash=sha256:7d47866cb32fb503450b6edc9df355d10dc49836af2e89901bd6ac6b0896d9d9 \ + --hash=sha256:7f7a92e8583f06b1fd49d01158143b8461cfcd135dcb10ec807270a3051bd603 \ + --hash=sha256:80c2dfadb855da477cf73373ad29a333535dedb9b12bad02c9814c8e2b43bf08 \ + --hash=sha256:83b6b30eb131da7a75b601f28c5d6971e6ed3e887919bf6b6a1ad3c2df289080 \ + --hash=sha256:86281fbdd6a8162756f8d603f37e3435bfa38043adb79c6dc6a2dfee065e7525 \ + --hash=sha256:86c89b9d55ebf820ad7c90bc533410f0d098054f293351f10603c0c46ff598f5 \ + --hash=sha256:876e1ff5930ed8bf295ec5ef9a8155e9b6b1876bbf1deed8b3a8069311875a8f \ + --hash=sha256:88136950da4d13c318bde414ce10219931937851327f44328f2df4d2c4614067 \ + --hash=sha256:88d8cb75b9d82858497a5393e3c63cfbf03035225e4b35a49ed7ccb151e4dc0e \ + --hash=sha256:8be8ad51249698103d24b0571df35a10990fbe93dd043b6c024172189485f5e3 \ + --hash=sha256:8d43ca737b20e106e4aebc42b2f3ae19f00ba63d7eb731698ee083d72d15646f \ + --hash=sha256:8dadbe5b217ff35b6a8d16610dd710219b59b76d13f0e3f0d9f36786206e4485 \ + --hash=sha256:9395002973c827b3ed67db77e6ec09f092919a587022174554096a269378fb13 \ + --hash=sha256:96f2ec43df44b1f76249ee0a615334f9b5b060e1c8bd90e706dad2d14d02f383 \ + --hash=sha256:98fc784c2c1440667aeedf8465bdfe10208acf0ead656a2c68627299f546b315 \ + --hash=sha256:9e36f163528fc50cbef305f02a5fd66d404edf7049cdaff211dbc2cba5a7013e \ + --hash=sha256:9eb9b5a968f6e0f6d640092a567e14529ff8cea2e29d00da6f78a79fa49f013c \ + --hash=sha256:9f76acfb5f68ba982635a53fd985a8044be98a35b43232c2a1ee235ffab3e1dd \ + --hash=sha256:a088f287f7d8275a33c07f2cac6c50b9319309a0200a39e7e75d80c707723099 \ + --hash=sha256:a10bd2fd62e8ce916ececb342f348f190724a098c1faa056fdfb2a22ad5e8660 \ + --hash=sha256:a4bbea04c97f6d78a48e3fbc1cb9116d2780b1b39e03a23f6eb9b603fd61f510 \ + --hash=sha256:aa366a1e55b8ebfe8ca8ddc3cfe75c8ebade181aeb0f661d0cb05986b647f72a \ + --hash=sha256:aa49e06d94aba782c6a02eecb7e507969e7e7a41b267f1b359bb35585f295d5b \ + --hash=sha256:aad9aa39483ed8ec44d6d2e59e5b98a0d80676ef0d92f44bfc374836111f62f5 \ + --hash=sha256:aae97dfdb60715c164419ac2532a76d013c3918a665eb6cb7288098b5f349aaf \ + --hash=sha256:abbefa31eee84842140f67acef1c828e28bba8bbf0c3bc6e5492a9af88152c28 \ + --hash=sha256:ac931cdc9442c1763b8a8f6cd62c0c938737eafc5be75eff88df55fc73bc0d00 \ + --hash=sha256:acd7d70b64c0aae0c7922cca83d288a16f5f6da523637697872253415269baef \ + --hash=sha256:add8cf6ddf9a65116119a28ece0f7886e30af27ba724a7594305f1d1b58a92a1 \ + --hash=sha256:aee395f5d0927f947758b4ec119fd5fc8ec71f07a1c5c52077b30b04c0fa6955 \ + --hash=sha256:b1b963fd8f5caa68e99dfae060d54de1fe9cba899b8718b44a00cdca53c3e590 \ + --hash=sha256:b2d444f2e66624d68e9c6b211e28a76e22fff5fcabcfff4deac18b529b7d4137 \ + --hash=sha256:b8d812c6011c08b8111a15e54dd990b8923692d80adf35488bee34026c35accf \ + --hash=sha256:ba96ae44888e0185281e937633a743ea90d5a196c6000f82565ebb0580012d40 \ + --hash=sha256:bdebcc8a75d38c7598dfb2c9ed852d7a9eb4a10d6e2d0764b919b802bf32ac88 \ + --hash=sha256:c07da4cebf6889f03ebac8d238f62318e29f495de0aa18a51ea14e61ae907e2e \ + --hash=sha256:c08e5c694306507275f2290073350c4f32e383db15213b2c69e7ff39c1193840 \ + --hash=sha256:c4f469aebd783bb741c2ecb2a681008fd26bfe5c16a9a72ed5467f834e810df2 \ + --hash=sha256:c5d7152ec39ca7c402d8fb9bad86140a15b9503bd0c54484e3f1bbe3dd37ceca \ + --hash=sha256:c674693f055fa2495de12292cb45e9944199d8eaef5a2dec45175c7c61cb73e3 \ + --hash=sha256:c6ed5141a5c7507cf3ee76bd363b0d6f801e3321adc35b5d825a23115faa5465 \ + --hash=sha256:c921ba5c51e4e9f63b8b00267d06566e1f63407408a0496da2d1d0bfc819c7fc \ + --hash=sha256:c9a4b821dc7055bf9e05ff5719e18ec501f75c0f0bbfabd573b277559780833d \ + --hash=sha256:c9f79d5325907f13e1be0b3e4dacc1049d1dffc4aeee3c995284bea5fe0fab7d \ + --hash=sha256:cd312b9692e831d2ffcad61eab31d91d4b4655a962e61de8fb410472cbcd37aa \ + --hash=sha256:cea3f4c1af79af13cdb2da0c028111d8f8522d4f22a000c82385535f24e5cf3a \ + --hash=sha256:cecdd5dfdc87b1fd87dbf81d4b037a544f47f4c744200a67013771682d67686a \ + --hash=sha256:cf9d57306d848218f3601fee7601fab1a327c942d56e2e97610583cb4dd74206 \ + --hash=sha256:d34bbf07dbc7ca5970671b1512e928991fb5e9d95365636c9b2d8b4f53af405e \ + --hash=sha256:d49514be2f28d895c38cf9d2b72d7b9a07d00314519f456c0b50b53cfcf4c785 \ + --hash=sha256:d680fbcb768404c601ecb43519ecd8461f6954cb11c06a78962f666832ccfca8 \ + --hash=sha256:db1d75f6617a49c1c01bc7023713e0ff59ab32c9579ae62a7674c0e34f3b0b0a \ + --hash=sha256:dcb292aa7fe485ceff7af4f92e46c5af397daec5dff64871a528f0fc47a3cc5b \ + --hash=sha256:e07c65f443c887bbcf31cc1771d932ecc192a5273943589b3c7572b749f1ffb2 \ + --hash=sha256:e902da4b04e6b52e5893900d4b8ab46068f75f3561f01bf1080957f9fd932ed6 \ + --hash=sha256:e9308ff8241c532df3f3e570f9a5aeed6c853f888512ba4b75638d7c11c95ef6 \ + --hash=sha256:eb7c9811bfaa8b1ed5ed319f5d370dfbcaa59d52ea64be2a5a85e18195930354 \ + --hash=sha256:ebe6af670449830d6d9b752c256a983291c766a1365ba5d5460048f9e33a7818 \ + --hash=sha256:ed21202aec73cda4d55d1ce57b389aadb90ffb044e6cd1080b8347efe1b1ec84 \ + --hash=sha256:efe0374196335f93b53269acd811b944f2e6bdc88e8894f214bd636455484909 \ + --hash=sha256:f64ec5397ea6a41fc1b4af0380d79b44a755b5531dcaccd9940fb260dca93038 \ + --hash=sha256:f6ac4ef4d82dff54670227a69c67782ae0b811b5cf6b17954f1e8f7502fc0d1d \ + --hash=sha256:f6f0ce10945fab9c4c06ce14e22af9059d1a87493a9af4501a5b0b9187e21cf2 \ + --hash=sha256:f8844cd288697c6425c9beba919302241e3278871dc6519515e72b04e987abcf \ + --hash=sha256:fe0306bd29505a9177aac19f1877174b0e7422c222a59f70b2cd41633448c3dc \ + --hash=sha256:ff3f333630ab480244a1bff72043e511a91eb22e7595dead8653ee5612dd8f3d \ + --hash=sha256:ffecec8eb889b58ba9be5b95fb1cc78e22ea8eedea38e8736a1568fe1979250e + # via + # semantica (pyproject.toml) + # doclang + # python-docx + # python-pptx +mail-parser==4.6.1 \ + --hash=sha256:7381853eccb551c83090c90f3ef16e433e8c9b320acbe27145a13c0d2fca480f \ + --hash=sha256:f688394e26e3f838fee33039bda5b8ea1876622c81ff0c5f0db5a817379ff62c + # via docling-slim +markdown-it-py==4.2.0 \ + --hash=sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49 \ + --hash=sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a + # via rich +marko==2.2.4 \ + --hash=sha256:c042c66f835425673123d7536b39b4660de3b68e30078c70fd26245b31170683 \ + --hash=sha256:d80510506edba096ec49d4720a09645fa0bb78e7b7b88697f20032fc19730aa9 + # via docling-slim +markupsafe==3.0.3 \ + --hash=sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f \ + --hash=sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a \ + --hash=sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf \ + --hash=sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19 \ + --hash=sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf \ + --hash=sha256:0f4b68347f8c5eab4a13419215bdfd7f8c9b19f2b25520968adfad23eb0ce60c \ + --hash=sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175 \ + --hash=sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219 \ + --hash=sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb \ + --hash=sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6 \ + --hash=sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab \ + --hash=sha256:15d939a21d546304880945ca1ecb8a039db6b4dc49b2c5a400387cdae6a62e26 \ + --hash=sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1 \ + --hash=sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce \ + --hash=sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218 \ + --hash=sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634 \ + --hash=sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695 \ + --hash=sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad \ + --hash=sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73 \ + --hash=sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c \ + --hash=sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe \ + --hash=sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa \ + --hash=sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559 \ + --hash=sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa \ + --hash=sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37 \ + --hash=sha256:3537e01efc9d4dccdf77221fb1cb3b8e1a38d5428920e0657ce299b20324d758 \ + --hash=sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f \ + --hash=sha256:38664109c14ffc9e7437e86b4dceb442b0096dfe3541d7864d9cbe1da4cf36c8 \ + --hash=sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d \ + --hash=sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c \ + --hash=sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97 \ + --hash=sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a \ + --hash=sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19 \ + --hash=sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9 \ + --hash=sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9 \ + --hash=sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc \ + --hash=sha256:591ae9f2a647529ca990bc681daebdd52c8791ff06c2bfa05b65163e28102ef2 \ + --hash=sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4 \ + --hash=sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354 \ + --hash=sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50 \ + --hash=sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698 \ + --hash=sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9 \ + --hash=sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b \ + --hash=sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc \ + --hash=sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115 \ + --hash=sha256:7c3fb7d25180895632e5d3148dbdc29ea38ccb7fd210aa27acbd1201a1902c6e \ + --hash=sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485 \ + --hash=sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f \ + --hash=sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12 \ + --hash=sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025 \ + --hash=sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009 \ + --hash=sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d \ + --hash=sha256:949b8d66bc381ee8b007cd945914c721d9aba8e27f71959d750a46f7c282b20b \ + --hash=sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a \ + --hash=sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5 \ + --hash=sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f \ + --hash=sha256:a320721ab5a1aba0a233739394eb907f8c8da5c98c9181d1161e77a0c8e36f2d \ + --hash=sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1 \ + --hash=sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287 \ + --hash=sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6 \ + --hash=sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f \ + --hash=sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581 \ + --hash=sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed \ + --hash=sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b \ + --hash=sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c \ + --hash=sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026 \ + --hash=sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8 \ + --hash=sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676 \ + --hash=sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6 \ + --hash=sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e \ + --hash=sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d \ + --hash=sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d \ + --hash=sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01 \ + --hash=sha256:df2449253ef108a379b8b5d6b43f4b1a8e81a061d6537becd5582fba5f9196d7 \ + --hash=sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419 \ + --hash=sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795 \ + --hash=sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1 \ + --hash=sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5 \ + --hash=sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d \ + --hash=sha256:e8fc20152abba6b83724d7ff268c249fa196d8259ff481f3b1476383f8f24e42 \ + --hash=sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe \ + --hash=sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda \ + --hash=sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e \ + --hash=sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737 \ + --hash=sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523 \ + --hash=sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591 \ + --hash=sha256:f71a396b3bf33ecaa1626c255855702aca4d3d9fea5e051b41ac59a9c1c41edc \ + --hash=sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a \ + --hash=sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50 + # via + # d3graph + # jinja2 + # nbconvert +matplotlib==3.11.1 \ + --hash=sha256:0c1f44890d435c1b4ef52f701ad5828cb450ea97bcc83918fda6be74965d6cd2 \ + --hash=sha256:11664c551345553db92e61cae6cf1376f138f8c47cafdf13b64b18f3e3e9e464 \ + --hash=sha256:1524e2bdd48a93557aa47ddcfe9c225dfdd57d5a01a5c49128c20f0632980ee1 \ + --hash=sha256:191163532cdefcb1571ca38a6d7e6474baccde64495783e6ba47aa07ec4b9bbb \ + --hash=sha256:1ac697e591c11b6ad04679a73c2d2f9980fe9d9f0311fb414a2e329706343dfb \ + --hash=sha256:216fbb93a74add02ddb4cb38ef5348f59ac00b3e84567eaf16598772d40e150a \ + --hash=sha256:21a67b961a6d597bca54fae826cd20695ba4a6e4d05424a08da6e13e3176fd6b \ + --hash=sha256:2abdee5ffa2fe11b2d19f7a5c63b785fb7c28cc46c7bc1814156341d9d1a33e1 \ + --hash=sha256:30c492d4ba9448595b6fd8708c6725963f8148e25c0d8842948da5b05f0ee8d3 \ + --hash=sha256:3d3fd84082b1afbd9398466c81309e20045be20d48fe0fb18c43504d164cbbb2 \ + --hash=sha256:427258425f9a3fc4ed79a91f9e9b9aaf5a82cb6571e85dc14063cc6fbb993741 \ + --hash=sha256:480194afceca4df2f137c2721227d3cba67121fbf4397b69cee7f83714b0a58a \ + --hash=sha256:54d47b8ae8b579633a3902ca5b4ad6c1e132a5626d64447b2e22a66394e79987 \ + --hash=sha256:5af0dcda57d471440a7b5b623e70e0a61003518443d9098f211a96ecfbbc25be \ + --hash=sha256:5e1f8922ba31959cf6a9dfb51be64b7f7bc582801a3957dc0c2f3afcd3537adf \ + --hash=sha256:5e510088c27a89d53580a752f959146893563e63c330e161d159b0fee652af6f \ + --hash=sha256:6771b0cd7838c6a857a7209814158c0ad09bfef878db3033dd82d70ad101f191 \ + --hash=sha256:67e4c3cd578c65ebd81bdc09a1b6592ceafee6dfafe116dc85dfcb647b5bbb18 \ + --hash=sha256:68408341f2312836fbbdf6b3c78047f65b2d8752f5fd221c3e72d348f5b34f8b \ + --hash=sha256:69647db5746941c793d6e445a4cd349323ffb87d9cc958c2ad84a659b4832d30 \ + --hash=sha256:6be943cb68bc6660ead58c55b3aa6366cba2ef7feb06460fbcce32360376f19f \ + --hash=sha256:7389b77ed2ab0552f46d9a90b81b7b8e6dfcdc42adc36c37a0865799843e0e3e \ + --hash=sha256:7f33a781e12b1e53b278deb2f5373c2e55ec4f10727be3440c0cfb5cda9f944f \ + --hash=sha256:83235693abde86e5e0129998f80ee39fc7f58e6d56a88fafb28a9278833e9d5f \ + --hash=sha256:88a2a27dd9691ae448dfae4b26f59036be90c3c28757edd3553a29559d00859f \ + --hash=sha256:89b193b255f4f6f7948dbcee3691f4f341ab05d9a8874a67b45ddb4182922eda \ + --hash=sha256:8b14eb22961fe865efb0e4ff167e333e428908b00115a8d800ccb65ee108e481 \ + --hash=sha256:9601a1e90be21e4884c53b4f3dc3ee0544654946f9975258d691f1c2e2f119c6 \ + --hash=sha256:96f4bdeea33a8d15a071dbfe6d119451b1d719c733ac666d65357082901a9099 \ + --hash=sha256:9a076f4fc5cdc43fdf510f5981418d25c2db4973418d9f22d8bb3dc8045ada78 \ + --hash=sha256:9fdf1c818ab05d0e74002091ddaf414478a3a449ec9d51c8976d45be7e3a01e2 \ + --hash=sha256:ac104be2768ffdd8655db9e71b768cbb45f2b9aa7b450cf1595e8f65d3822319 \ + --hash=sha256:ae30c6109848ac0f9fa36c5d6270938487614c47ba31860bd5361266dabc5685 \ + --hash=sha256:aee55e9041211bf84302ab55ec3965df18dd90ae19f8b58332a7feaf208bfe83 \ + --hash=sha256:b0a19dcf73406d3746d25a5ed42d713604c9a3e024d129b102852b0d941cb9f3 \ + --hash=sha256:b4c78ceb2f11bcac7389d305cda17aeb1f4586a857854ab5780bd3dd8dbfc407 \ + --hash=sha256:b7cf158e7add54a8d51ac9b5a84abd6d4e13ed4951b4f25f1c5139f41c2addb2 \ + --hash=sha256:b937b9dba5f5f6c1e31c47abe2186c865c0914fd18f2ce0dfc39c9adcef5951d \ + --hash=sha256:ba8f811b8ddfac493734d6af0b2dff96919d0c28ca0d641858dab4262777c6ea \ + --hash=sha256:c52f7ad20ef476806ed212380b1d54d20310c8b86bdc2c9a68b51f0024a44472 \ + --hash=sha256:c90be0b73568da4f662afac580956a76e308437e641b4a45aa08925eeb67d95f \ + --hash=sha256:d2ace7273b9a5061a3b420918a16fae1f2dc5dfee1abcc13aba71b5d94b1820c \ + --hash=sha256:dadfe80797174e2984aae3be0b77594a3c72d2c0a40fbd4a0de48d2728caf3ae \ + --hash=sha256:e15ef41507f3d525f46154ac9e3ae785dacde9f20e593a25de8986267892ef74 \ + --hash=sha256:e4b9ac2f1f607ecda2af90a5232beee2af7582fce1cc30c4b6a1b012dc21ee99 \ + --hash=sha256:f2912f647f3fbe1ccf085f91e213936f9101bead81a5e670565b1f1b3712f4fb + # via + # semantica (pyproject.toml) + # colourmap + # distfit + # scatterd + # seaborn +matplotlib-inline==0.2.2 \ + --hash=sha256:3c821cf1c209f59fb2d2d64abbf5b23b67bcb2210d663f9918dd851c6da1fcf6 \ + --hash=sha256:72f3fe8fce36b70d4a5b612f899090cd0401deddc4ea90e1572b9f4bfb058c79 + # via + # ipykernel + # ipython +mccabe==0.7.0 \ + --hash=sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325 \ + --hash=sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e + # via flake8 +mdurl==0.1.2 \ + --hash=sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8 \ + --hash=sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba + # via markdown-it-py +mistune==3.3.4 \ + --hash=sha256:58b5c96d6fcb61190dfe5fae498d2b2065f99cf61e9649418fd54cf1ada86dfe \ + --hash=sha256:ee015381e955e370962968befe1d729ab60fafb6a715ac6751763fbce38c8d4a + # via nbconvert +mmh3==5.2.1 \ + --hash=sha256:022aa1a528604e6c83d0a7705fdef0b5355d897a9e0fa3a8d26709ceaa06965d \ + --hash=sha256:0634581290e6714c068f4aa24020acf7880927d1f0084fa753d9799ae9610082 \ + --hash=sha256:08043f7cb1fb9467c3fbbbaea7896986e7fbc81f4d3fd9289a73d9110ab6207a \ + --hash=sha256:0a3984146e414684a6be2862d84fcb1035f4984851cb81b26d933bab6119bf00 \ + --hash=sha256:0bbc17250b10d3466875a40a52520a6bac3c02334ca709207648abd3c223ed5c \ + --hash=sha256:0cc21533878e5586b80d74c281d7f8da7932bc8ace50b8d5f6dbf7e3935f63f1 \ + --hash=sha256:0d0b7e803191db5f714d264044e06189c8ccd3219e936cc184f07106bd17fd7b \ + --hash=sha256:113f78e7463a36dbbcea05bfe688efd7fa759d0f0c56e73c974d60dcfec3dfcc \ + --hash=sha256:169e0d178cb59314456ab30772429a802b25d13227088085b0d49b9fe1533104 \ + --hash=sha256:17fbb47f0885ace8327ce1235d0416dc86a211dcd8cc1e703f41523be32cfec8 \ + --hash=sha256:19bbd3b841174ae6ed588536ab5e1b1fe83d046e668602c20266547298d939a9 \ + --hash=sha256:1d9f9a3ce559a5267014b04b82956993270f63ec91765e13e9fd73daf2d2738e \ + --hash=sha256:1e4ecee40ba19e6975e1120829796770325841c2f153c0e9aecca927194c6a2a \ + --hash=sha256:22b0f9971ec4e07e8223f2beebe96a6cfc779d940b6f27d26604040dd74d3a44 \ + --hash=sha256:26fb5b9c3946bf7f1daed7b37e0c03898a6f062149127570f8ede346390a0825 \ + --hash=sha256:2778fed822d7db23ac5008b181441af0c869455b2e7d001f4019636ac31b6fe4 \ + --hash=sha256:28cfab66577000b9505a0d068c731aee7ca85cd26d4d63881fab17857e0fe1fb \ + --hash=sha256:29bc3973676ae334412efdd367fcd11d036b7be3efc1ce2407ef8676dabfeb82 \ + --hash=sha256:2bd9f19f7f1fcebd74e830f4af0f28adad4975d40d80620be19ffb2b2af56c9f \ + --hash=sha256:2d5d542bf2abd0fd0361e8017d03f7cb5786214ceb4a40eef1539d6585d93386 \ + --hash=sha256:30e4d2084df019880d55f6f7bea35328d9b464ebee090baa372c096dc77556fb \ + --hash=sha256:3619473a0e0d329fd4aec8075628f8f616be2da41605300696206d6f36920c3d \ + --hash=sha256:368625fb01666655985391dbad3860dc0ba7c0d6b9125819f3121ee7292b4ac8 \ + --hash=sha256:3737303ca9ea0f7cb83028781148fcda4f1dac7821db0c47672971dabcf63593 \ + --hash=sha256:3a9fed49c6ce4ed7e73f13182760c65c816da006debe67f37635580dfb0fae00 \ + --hash=sha256:3c38d142c706201db5b2345166eeef1e7740e3e2422b470b8ba5c8727a9b4c7a \ + --hash=sha256:3cb61db880ec11e984348227b333259994c2c85caa775eb7875decb3768db890 \ + --hash=sha256:3d74a03fb57757ece25aa4b3c1c60157a1cece37a020542785f942e2f827eed5 \ + --hash=sha256:3f796b535008708846044c43302719c6956f39ca2d93f2edda5319e79a29efbb \ + --hash=sha256:41105377f6282e8297f182e393a79cfffd521dde37ace52b106373bdcd9ca5cb \ + --hash=sha256:41aac7002a749f08727cb91babff1daf8deac317c0b1f317adc69be0e6c375d1 \ + --hash=sha256:44983e45310ee5b9f73397350251cdf6e63a466406a105f1d16cb5baa659270b \ + --hash=sha256:4cbbde66f1183db040daede83dd86c06d663c5bb2af6de1142b7c8c37923dd74 \ + --hash=sha256:4eda76074cfca2787c8cf1bec603eaebdddd8b061ad5502f85cddae998d54f00 \ + --hash=sha256:4fc6cd65dc4d2fdb2625e288939a3566e36127a84811a4913f02f3d5931da52d \ + --hash=sha256:50885073e2909251d4718634a191c49ae5f527e5e1736d738e365c3e8be8f22b \ + --hash=sha256:5174a697ce042fa77c407e05efe41e03aa56dae9ec67388055820fb48cf4c3ba \ + --hash=sha256:54b64fb2433bc71488e7a449603bf8bd31fbcf9cb56fbe1eb6d459e90b86c37b \ + --hash=sha256:54fe8518abe06a4c3852754bfd498b30cc58e667f376c513eac89a244ce781a4 \ + --hash=sha256:55dbbd8ffbc40d1697d5e2d0375b08599dae8746b0b08dea05eee4ce81648fac \ + --hash=sha256:57b52603e89355ff318025dd55158f6e71396c0f1f609d548e9ea9c94cc6ce0a \ + --hash=sha256:58370d05d033ee97224c81263af123dea3d931025030fd34b61227a768a8858a \ + --hash=sha256:5d87a3584093e1a89987e3d36d82c98d9621b2cb944e22a420aa1401e096758f \ + --hash=sha256:623f938f6a039536cc02b7582a07a080f13fdfd48f87e63201d92d7e34d09a18 \ + --hash=sha256:62815d2c67f2dd1be76a253d88af4e1da19aeaa1820146dec52cf8bee2958b16 \ + --hash=sha256:6290289fa5fb4c70fd7f72016e03633d60388185483ff3b162912c81205ae2cf \ + --hash=sha256:67e41a497bac88cc1de96eeba56eeb933c39d54bc227352f8455aa87c4ca4000 \ + --hash=sha256:6c85c38a279ca9295a69b9b088a2e48aa49737bb1b34e6a9dc6297c110e8d912 \ + --hash=sha256:6f01f044112d43a20be2f13a11683666d87151542ad627fe41a18b9791d2802f \ + --hash=sha256:707151644085dd0f20fe4f4b573d28e5130c4aaa5f587e95b60989c5926653b5 \ + --hash=sha256:723b2681ed4cc07d3401bbea9c201ad4f2a4ca6ba8cddaff6789f715dd2b391e \ + --hash=sha256:72d1cc63bcc91e14933f77d51b3df899d6a07d184ec515ea7f56bff659e124d7 \ + --hash=sha256:7374d6e3ef72afe49697ecd683f3da12f4fc06af2d75433d0580c6746d2fa025 \ + --hash=sha256:7501e9be34cb21e72fcfe672aafd0eee65c16ba2afa9dcb5500a587d3a0580f0 \ + --hash=sha256:76219cd1eefb9bf4af7856e3ae563d15158efa145c0aab01e9933051a1954045 \ + --hash=sha256:7aec798c2b01aaa65a55f1124f3405804184373abb318a3091325aece235f67c \ + --hash=sha256:7be6dfb49e48fd0a7d91ff758a2b51336f1cd21f9d44b20f6801f072bd080cdd \ + --hash=sha256:7e4e1f580033335c6f76d1e0d6b56baf009d1a64d6a4816347e4271ba951f46d \ + --hash=sha256:7e8ec5f606e0809426d2440e0683509fb605a8820a21ebd120dcdba61b74ef7f \ + --hash=sha256:7f196cd7910d71e9d9860da0ff7a77f64d22c1ad931f1dd18559a06e03109fc0 \ + --hash=sha256:82f3802bfc4751f420d591c5c864de538b71cea117fce67e4595c2afede08a15 \ + --hash=sha256:85ffc9920ffc39c5eee1e3ac9100c913a0973996fbad5111f939bbda49204bb7 \ + --hash=sha256:8e6c219e375f6341d0959af814296372d265a8ca1af63825f65e2e87c618f006 \ + --hash=sha256:8f767ba0911602ddef289404e33835a61168314ebd3c729833db2ed685824211 \ + --hash=sha256:8ff038d52ef6aa0f309feeba00c5095c9118d0abf787e8e8454d6048db2037fc \ + --hash=sha256:915e7a2418f10bd1151b1953df06d896db9783c9cfdb9a8ee1f9b3a4331ab503 \ + --hash=sha256:92883836caf50d5255be03d988d75bc93e3f86ba247b7ca137347c323f731deb \ + --hash=sha256:960b1b3efa39872ac8b6cc3a556edd6fb90ed74f08c9c45e028f1005b26aa55d \ + --hash=sha256:9aeaf53eaa075dd63e81512522fd180097312fb2c9f476333309184285c49ce0 \ + --hash=sha256:9d8089d853c7963a8ce87fff93e2a67075c0bc08684a08ea6ad13577c38ffc38 \ + --hash=sha256:a4130d0b9ce5fad6af07421b1aecc7e079519f70d6c05729ab871794eded8617 \ + --hash=sha256:a482ac121de6973897c92c2f31defc6bafb11c83825109275cffce54bb64933f \ + --hash=sha256:add7ac388d1e0bf57259afbcf9ed05621a3bf11ce5ee337e7536f1e1aaf056b0 \ + --hash=sha256:b1f12bd684887a0a5d55e6363ca87056f361e45451105012d329b86ec19dbe0b \ + --hash=sha256:b3f99e1756fc48ad507b95e5d86f2fb21b3d495012ff13e6592ebac14033f166 \ + --hash=sha256:b4cce60d0223074803c9dbe0721ad3fa51dafe7d462fee4b656a1aa01ee07518 \ + --hash=sha256:baeb47635cb33375dee4924cd93d7f5dcaa786c740b08423b0209b824a1ee728 \ + --hash=sha256:bbea5b775f0ac84945191fb83f845a6fd9a21a03ea7f2e187defac7e401616ad \ + --hash=sha256:bbfcb95d9a744e6e2827dfc66ad10e1020e0cac255eb7f85652832d5a264c2fc \ + --hash=sha256:bd6e7d363aa93bd3421b30b6af97064daf47bc96005bddba67c5ffbc6df426b8 \ + --hash=sha256:be77c402d5e882b6fbacfd90823f13da8e0a69658405a39a569c6b58fdb17b03 \ + --hash=sha256:c302245fd6c33d96bd169c7ccf2513c20f4c1e417c07ce9dce107c8bc3f8411f \ + --hash=sha256:c88653877aeb514c089d1b3d473451677b8b9a6d1497dbddf1ae7934518b06d2 \ + --hash=sha256:cae6383181f1e345317742d2ddd88f9e7d2682fa4c9432e3a74e47d92dce0229 \ + --hash=sha256:cd471ede0d802dd936b6fab28188302b2d497f68436025857ca72cd3810423fe \ + --hash=sha256:d106493a60dcb4aef35a0fac85105e150a11cf8bc2b0d388f5a33272d756c966 \ + --hash=sha256:d30b650595fdbe32366b94cb14f30bb2b625e512bd4e1df00611f99dc5c27fd4 \ + --hash=sha256:d51fde50a77f81330523562e3c2734ffdca9c4c9e9d355478117905e1cfe16c6 \ + --hash=sha256:d57dea657357230cc780e13920d7fa7db059d58fe721c80020f94476da4ca0a1 \ + --hash=sha256:d771f085fcdf4035786adfb1d8db026df1eb4b41dac1c3d070d1e49512843227 \ + --hash=sha256:dae0f0bd7d30c0ad61b9a504e8e272cb8391eed3f1587edf933f4f6b33437450 \ + --hash=sha256:db0562c5f71d18596dcd45e854cf2eeba27d7543e1a3acdafb7eef728f7fe85d \ + --hash=sha256:dfd51b4c56b673dfbc43d7d27ef857dd91124801e2806c69bb45585ce0fa019b \ + --hash=sha256:e080c0637aea036f35507e803a4778f119a9b436617694ae1c5c366805f1e997 \ + --hash=sha256:e48d4dbe0f88e53081da605ae68644e5182752803bbc2beb228cca7f1c4454d6 \ + --hash=sha256:e8b4b5580280b9265af3e0409974fb79c64cf7523632d03fbf11df18f8b0181e \ + --hash=sha256:e8b5378de2b139c3a830f0209c1e91f7705919a4b3e563a10955104f5097a70a \ + --hash=sha256:e904f2417f0d6f6d514f3f8b836416c360f306ddaee1f84de8eef1e722d212e5 \ + --hash=sha256:eee884572b06bbe8a2b54f424dbd996139442cf83c76478e1ec162512e0dd2c7 \ + --hash=sha256:f1fbb0a99125b1287c6d9747f937dc66621426836d1a2d50d05aecfc81911b57 \ + --hash=sha256:f40a95186a72fa0b67d15fef0f157bfcda00b4f59c8a07cbe5530d41ac35d105 \ + --hash=sha256:f6e0bfe77d238308839699944164b96a2eeccaf55f2af400f54dc20669d8d5f2 \ + --hash=sha256:f963eafc0a77a6c0562397da004f5876a9bcf7265a7bcc3205e29636bc4a1312 \ + --hash=sha256:fb9d44c25244e11c8be3f12c938ca8ba8404620ef8092245d2093c6ab3df260f \ + --hash=sha256:fc78739b5ec6e4fb02301984a3d442a91406e7700efbe305071e7fd1c78278f2 \ + --hash=sha256:fceef7fe67c81e1585198215e42ad3fdba3a25644beda8fbdaf85f4d7b93175a \ + --hash=sha256:fd96476f04db5ceba1cfa0f21228f67c1f7402296f0e73fee3513aa680ad237b + # via fastembed +mpire==2.10.2 \ + --hash=sha256:d627707f7a8d02aa4c7f7d59de399dec5290945ddf7fbd36cbb1d6ebb37a51fb \ + --hash=sha256:f66a321e93fadff34585a4bfa05e95bd946cf714b442f51c529038eb45773d97 + # via semchunk +mpmath==1.3.0 \ + --hash=sha256:7a28eb2a9774d00c7bc92411c19a89209d5da7c4c9a9e227be8330a23a25b91f \ + --hash=sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c + # via sympy +msgpack==1.2.1 \ + --hash=sha256:01e2dd6c9b19d333a00282330cc8a73d38d8dabc306dc5b42cd668c3ac82e833 \ + --hash=sha256:020e881a764b20d8d7ca1a54fc01b8175519d108e3c3f194fddc200bda95951a \ + --hash=sha256:04c721c2c7448767e9e3f2520a475663d8ee0f09c31890f6d2bd70fd636a9647 \ + --hash=sha256:05f340e47e7e47d2da8db9b53e1bb1d294369e9ef45a747441309f6650b8351d \ + --hash=sha256:0a70e3cf2804a300d921bb0940426e35f4e489a23adfb77a808892241db0a064 \ + --hash=sha256:0adcf06ffde0777c0e1a9b771a2b1c4226ba1bbf748c8efcc02fcdeca3299107 \ + --hash=sha256:0c0d9802354507bcba62af19c17918e3eb437cc25e6f50657d511b5856a77aac \ + --hash=sha256:0e2bf9280bceb5efca998435904b5d3e9fdbcc11d90dc9df30aec7973252b720 \ + --hash=sha256:1233ee2dd0cefba127583de50ea654677277047d238303521db35def3d7b2e7c \ + --hash=sha256:146ee4e9ce80b365c6d4c47073da9da7bcec473e58194ceee5dd7620ace77e06 \ + --hash=sha256:1548006a91aa93c5da81f3bdcebc1a0d10cea2d25969754fbe848da622b2b895 \ + --hash=sha256:196300e7e5d6e74d50f1607ab9c06c4a1484c383cd22defd727902591f7e8dde \ + --hash=sha256:1dabedcd0f23559f3596428c6589c1cd8c6eaed3a0d720795b07b0225d769203 \ + --hash=sha256:20466cca18c49c7292a8984bc15d65857b171e7264bdcb5f96baf8be238791fc \ + --hash=sha256:298872ecf9e61950f1c6af4ca969b859ee91783bb920ef6e6172697d0c8aad74 \ + --hash=sha256:29a3f6e9667868429d8240dfd063ea5ffdc1321c13d783aa23827a38de0dcb22 \ + --hash=sha256:2eda0b7ebb1283a98d3e4492ac933c8af6aff59fd3df1c3ed024f536af4b1dc8 \ + --hash=sha256:2ef59c659f289eddf8aa6623823f19fa2f40a4029266889eac7a2505dd210c35 \ + --hash=sha256:2ff164c1b0bcb740b073b99e945234d0212852fa378e44a208c425379140dbeb \ + --hash=sha256:33f14fba63278b714efe6ad07e50ea5f03d91537aa6a1c5f1ceca4cf44013ca9 \ + --hash=sha256:350cb813d0af6e65d2f7ef0d729f7ff5be5a8bce03665892f43e5883d4ecc1b8 \ + --hash=sha256:4202c74688ca06591f78cb18988228bd4cca2cc75d57b60008372892d2f1e6e6 \ + --hash=sha256:4227224aaec8f7fbcbfbd4272319347b2bb4030366502600f8c45588c5187b07 \ + --hash=sha256:491cc39455ca765fad51fb451bf2915eb2cf41192ab5801ce8d67c1d614fe056 \ + --hash=sha256:575957e79cd51903a4e8495a242442949641e08f1efd5197b43bebd3ea7682b4 \ + --hash=sha256:5ad5467fc3f68b5468e06c5f788d712e9f8ffc8b0cd1bcb160c105c1ee92dae7 \ + --hash=sha256:5bb9c386f0a329c035ddbab4b72d1028bf9627add8dda41070288563d57ed1b1 \ + --hash=sha256:5c24aa15d5963051e1a5c62b12c50cd705992502b5ec1f3bece6046f33c9fc24 \ + --hash=sha256:5f6277e5f783c36786a145e0247fc189a03f35f84b251646e53592d2bc12b355 \ + --hash=sha256:60926b75d00c8e816ef98f3034f484a8bc64242d66839cef4cf7e503142316a0 \ + --hash=sha256:633727297ed063441fd1cda2288865487f33ad14eeb8831afb5f0c396a62cfce \ + --hash=sha256:67f6dd22fa72a93752643f07889796d62739a13415ee630169a8ce764f86cf9f \ + --hash=sha256:6d09badf350af2be9d189184e04e64cf54ad93569ab3d96fca58bd3e84aad707 \ + --hash=sha256:6ee967f7c7e1df2890c671ff2ee51a28ded0efc95da3e507176dee881ce36c66 \ + --hash=sha256:74847557e28ce71bd3c438a447ca90e4b507e997ddbdef8a12a7b283b86c156b \ + --hash=sha256:779197a6513bab3c3632265e3d0f7cb3227e62510841a6f34f1eaa37efbb345e \ + --hash=sha256:787c9bebb5833e8f6fc8abca3c0597683d8d87f56a8842b6b89c75a5f3176e2d \ + --hash=sha256:7d31c0ac0c640f877804c67cb2bc9f4e23dc2db97e96c2e67fa27d38283b41f8 \ + --hash=sha256:810b916696c86ef0deb3b74588480224df4c1b071136c34183e4a2a4284d7ac7 \ + --hash=sha256:83efa1c898e0fc5380fc0cabbf75164c52e3b5cbb45973710d75821928380c73 \ + --hash=sha256:85f57e960d877f2977f6430896191b04a21f8901b3b4baf2e4604329f4db5402 \ + --hash=sha256:8b267ce94efb76fbd1b3373511420074ee3187f0f7811bf394531de13294735a \ + --hash=sha256:8c2ed1e48cc0f460bf3c7780e7137ff21a4e18433451916f2442c1b21036cd7d \ + --hash=sha256:8c7b398c56ff125feae96c2737abfec5595f1fa0aa186df60c56040b8accb95c \ + --hash=sha256:8d00f177ca88a77c1cf848d204a38f249751650b601cb6532acc68805d8a8273 \ + --hash=sha256:8ff92d7feeaf5bc26c51495b69e2f99ed97ab79346fb6555f44be7dd2ac6503b \ + --hash=sha256:91054a783328e0ea7954b8771095705c8d2243b814743fbaadf14552c9c52c5d \ + --hash=sha256:98b58bdb89c46190e4609bb36abe17c6d4105ad13f9c5f8f6f64d320f8ced3fb \ + --hash=sha256:a28d076ca7c82b9c8728ad90b7147489449557038bed50e4241eb832395169b4 \ + --hash=sha256:aa6c4be5d1c02a42b066ca6ddb71adf36432868fdcdb6ee87e634e86e0674190 \ + --hash=sha256:aded5bdf32609dc7987a49bbbd15a8ef096193f96dd8bbeb791de729e650acf5 \ + --hash=sha256:afc5febcd4c99effbc02b528e49d6fd0760b2b7d48c05239e345a5fa6e743d9a \ + --hash=sha256:b50b727bd652bdc37d950336c848ef20ec54a4cafc38dce19b1cd86ad625d0f7 \ + --hash=sha256:c1c79a604a2969a868a78b6ebd27a887e00c624f14f66b3038e0590cb23332d1 \ + --hash=sha256:ca0dacff965c47afdc3749a8469d7302a8f801d6a28758d55120d75e66ce6889 \ + --hash=sha256:d3567748a5107cb40cdf66a275430c2f87c07777698f4bfd25c35f44d533258c \ + --hash=sha256:dc871b997a9370d855b7394465f2f350e847a5b806dd38dcc9c989e7d87da155 \ + --hash=sha256:dd3bfe82d53edfe4b7fc9a7ec9761e23a7a5b1dac22264505af428253c29ed24 \ + --hash=sha256:e3dc2feb0876209d9c38aa56cb1de169bd6c4348f1aa48271f241226590993e6 \ + --hash=sha256:e4f1d0f8f98ade9634e01fb704a408f9336c0a8f1117b369f5db83dc7551d8b1 \ + --hash=sha256:ec0e675d59150a6269ddc9139087c722292664a37d071a849c05c473350f1f2d \ + --hash=sha256:ee1d9ed27d0497b848923746cf762ed2e7db24f4be7eec8e5cbe8c766aa707b7 \ + --hash=sha256:f02cf17a6ca1abe29b5f980644f7551f94d71f2011509b26d8625ce038f0df64 \ + --hash=sha256:f12038a35fabd52e56a3547bab42401af49a45caa6dd00b34c44de235bc93ee2 \ + --hash=sha256:f310233ef7fb9c14e201c93639fe5f5260b005f56f0b29048e999c30935596cc \ + --hash=sha256:f9389552ecf4784886345ead0647e4edc96bee37cbab05b75540f542f766c48c + # via librosa +multidict==6.7.1 \ + --hash=sha256:026d264228bcd637d4e060844e39cdc60f86c479e463d49075dedc21b18fbbe0 \ + --hash=sha256:03ede2a6ffbe8ef936b92cb4529f27f42be7f56afcdab5ab739cd5f27fb1cbf9 \ + --hash=sha256:0458c978acd8e6ea53c81eefaddbbee9c6c5e591f41b3f5e8e194780fe026581 \ + --hash=sha256:067343c68cd6612d375710f895337b3a98a033c94f14b9a99eff902f205424e2 \ + --hash=sha256:08ccb2a6dc72009093ebe7f3f073e5ec5964cba9a706fa94b1a1484039b87941 \ + --hash=sha256:0b38ebffd9be37c1170d33bc0f36f4f262e0a09bc1aac1c34c7aa51a7293f0b3 \ + --hash=sha256:0b4c48648d7649c9335cf1927a8b87fa692de3dcb15faa676c6a6f1f1aabda43 \ + --hash=sha256:0d17522c37d03e85c8098ec8431636309b2682cf12e58f4dbc76121fb50e4962 \ + --hash=sha256:0e161ddf326db5577c3a4cc2d8648f81456e8a20d40415541587a71620d7a7d1 \ + --hash=sha256:0e697826df7eb63418ee190fd06ce9f1803593bb4b9517d08c60d9b9a7f69d8f \ + --hash=sha256:10ae39c9cfe6adedcdb764f5e8411d4a92b055e35573a2eaa88d3323289ef93c \ + --hash=sha256:121a34e5bfa410cdf2c8c49716de160de3b1dbcd86b49656f5681e4543bcd1a8 \ + --hash=sha256:128441d052254f42989ef98b7b6a6ecb1e6f708aa962c7984235316db59f50fa \ + --hash=sha256:12fad252f8b267cc75b66e8fc51b3079604e8d43a75428ffe193cd9e2195dfd6 \ + --hash=sha256:14525a5f61d7d0c94b368a42cff4c9a4e7ba2d52e2672a7b23d84dc86fb02b0c \ + --hash=sha256:17207077e29342fdc2c9a82e4b306f1127bf1ea91f8b71e02d4798a70bb99991 \ + --hash=sha256:17307b22c217b4cf05033dabefe68255a534d637c6c9b0cc8382718f87be4262 \ + --hash=sha256:1b99af4d9eec0b49927b4402bcbb58dea89d3e0db8806a4086117019939ad3dd \ + --hash=sha256:1d540e51b7e8e170174555edecddbd5538105443754539193e3e1061864d444d \ + --hash=sha256:1e3a8bb24342a8201d178c3b4984c26ba81a577c80d4d525727427460a50c22d \ + --hash=sha256:1fa6609d0364f4f6f58351b4659a1f3e0e898ba2a8c5cac04cb2c7bc556b0bc5 \ + --hash=sha256:21f830fe223215dffd51f538e78c172ed7c7f60c9b96a2bf05c4848ad49921c3 \ + --hash=sha256:233b398c29d3f1b9676b4b6f75c518a06fcb2ea0b925119fb2c1bc35c05e1601 \ + --hash=sha256:24c0cf81544ca5e17cfcb6e482e7a82cd475925242b308b890c9452a074d4505 \ + --hash=sha256:25167cc263257660290fba06b9318d2026e3c910be240a146e1f66dd114af2b0 \ + --hash=sha256:253282d70d67885a15c8a7716f3a73edf2d635793ceda8173b9ecc21f2fb8292 \ + --hash=sha256:273d23f4b40f3dce4d6c8a821c741a86dec62cded82e1175ba3d99be128147ed \ + --hash=sha256:283ddac99f7ac25a4acadbf004cb5ae34480bbeb063520f70ce397b281859362 \ + --hash=sha256:28ca5ce2fd9716631133d0e9a9b9a745ad7f60bac2bccafb56aa380fc0b6c511 \ + --hash=sha256:2b41f5fed0ed563624f1c17630cb9941cf2309d4df00e494b551b5f3e3d67a23 \ + --hash=sha256:2bbd113e0d4af5db41d5ebfe9ccaff89de2120578164f86a5d17d5a576d1e5b2 \ + --hash=sha256:2e1425e2f99ec5bd36c15a01b690a1a2456209c5deed58f95469ffb46039ccbb \ + --hash=sha256:2e2d2ed645ea29f31c4c7ea1552fcfd7cb7ba656e1eafd4134a6620c9f5fdd9e \ + --hash=sha256:3758692429e4e32f1ba0df23219cd0b4fc0a52f476726fff9337d1a57676a582 \ + --hash=sha256:38fb49540705369bab8484db0689d86c0a33a0a9f2c1b197f506b71b4b6c19b0 \ + --hash=sha256:3943debf0fbb57bdde5901695c11094a9a36723e5c03875f87718ee15ca2f4d2 \ + --hash=sha256:398c1478926eca669f2fd6a5856b6de9c0acf23a2cb59a14c0ba5844fa38077e \ + --hash=sha256:3ab8b9d8b75aef9df299595d5388b14530839f6422333357af1339443cff777d \ + --hash=sha256:3bd231490fa7217cc832528e1cd8752a96f0125ddd2b5749390f7c3ec8721b65 \ + --hash=sha256:3d51ff4785d58d3f6c91bdbffcb5e1f7ddfda557727043aa20d20ec4f65e324a \ + --hash=sha256:3fccb473e87eaa1382689053e4a4618e7ba7b9b9b8d6adf2027ee474597128cd \ + --hash=sha256:401c5a650f3add2472d1d288c26deebc540f99e2fb83e9525007a74cd2116f1d \ + --hash=sha256:41f2952231456154ee479651491e94118229844dd7226541788be783be2b5108 \ + --hash=sha256:432feb25a1cb67fe82a9680b4d65fb542e4635cb3166cd9c01560651ad60f177 \ + --hash=sha256:439cbebd499f92e9aa6793016a8acaa161dfa749ae86d20960189f5398a19144 \ + --hash=sha256:4885cb0e817aef5d00a2e8451d4665c1808378dc27c2705f1bf4ef8505c0d2e5 \ + --hash=sha256:497394b3239fc6f0e13a78a3e1b61296e72bf1c5f94b4c4eb80b265c37a131cd \ + --hash=sha256:497bde6223c212ba11d462853cfa4f0ae6ef97465033e7dc9940cdb3ab5b48e5 \ + --hash=sha256:4cfb48c6ea66c83bcaaf7e4dfa7ec1b6bbcf751b7db85a328902796dfde4c060 \ + --hash=sha256:538cec1e18c067d0e6103aa9a74f9e832904c957adc260e61cd9d8cf0c3b3d37 \ + --hash=sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56 \ + --hash=sha256:563fe25c678aaba333d5399408f5ec3c383ca5b663e7f774dd179a520b8144df \ + --hash=sha256:57b46b24b5d5ebcc978da4ec23a819a9402b4228b8a90d9c656422b4bdd8a963 \ + --hash=sha256:5884a04f4ff56c6120f6ccf703bdeb8b5079d808ba604d4d53aec0d55dc33568 \ + --hash=sha256:59bc83d3f66b41dac1e7460aac1d196edc70c9ba3094965c467715a70ecb46db \ + --hash=sha256:5a37ca18e360377cfda1d62f5f382ff41f2b8c4ccb329ed974cc2e1643440118 \ + --hash=sha256:5c4b9bfc148f5a91be9244d6264c53035c8a0dcd2f51f1c3c6e30e30ebaa1c84 \ + --hash=sha256:5e01429a929600e7dab7b166062d9bb54a5eed752384c7384c968c2afab8f50f \ + --hash=sha256:5fa6a95dfee63893d80a34758cd0e0c118a30b8dcb46372bf75106c591b77889 \ + --hash=sha256:619e5a1ac57986dbfec9f0b301d865dddf763696435e2962f6d9cf2fdff2bb71 \ + --hash=sha256:65573858d27cdeaca41893185677dc82395159aa28875a8867af66532d413a8f \ + --hash=sha256:6704fa2b7453b2fb121740555fa1ee20cd98c4d011120caf4d2b8d4e7c76eec0 \ + --hash=sha256:6aac4f16b472d5b7dc6f66a0d49dd57b0e0902090be16594dc9ebfd3d17c47e7 \ + --hash=sha256:6b10359683bd8806a200fd2909e7c8ca3a7b24ec1d8132e483d58e791d881048 \ + --hash=sha256:6b83cabdc375ffaaa15edd97eb7c0c672ad788e2687004990074d7d6c9b140c8 \ + --hash=sha256:6d3bc717b6fe763b8be3f2bee2701d3c8eb1b2a8ae9f60910f1b2860c82b6c49 \ + --hash=sha256:6f77ce314a29263e67adadc7e7c1bc699fcb3a305059ab973d038f87caa42ed0 \ + --hash=sha256:749aa54f578f2e5f439538706a475aa844bfa8ef75854b1401e6e528e4937cf9 \ + --hash=sha256:7a7e590ff876a3eaf1c02a4dfe0724b6e69a9e9de6d8f556816f29c496046e59 \ + --hash=sha256:7dfb78d966b2c906ae1d28ccf6e6712a3cd04407ee5088cd276fe8cb42186190 \ + --hash=sha256:7eee46ccb30ff48a1e35bb818cc90846c6be2b68240e42a78599166722cea709 \ + --hash=sha256:7ff981b266af91d7b4b3793ca3382e53229088d193a85dfad6f5f4c27fc73e5d \ + --hash=sha256:841189848ba629c3552035a6a7f5bf3b02eb304e9fea7492ca220a8eda6b0e5c \ + --hash=sha256:844c5bca0b5444adb44a623fb0a1310c2f4cd41f402126bb269cd44c9b3f3e1e \ + --hash=sha256:84e61e3af5463c19b67ced91f6c634effb89ef8bfc5ca0267f954451ed4bb6a2 \ + --hash=sha256:8affcf1c98b82bc901702eb73b6947a1bfa170823c153fe8a47b5f5f02e48e40 \ + --hash=sha256:8be1802715a8e892c784c0197c2ace276ea52702a0ede98b6310c8f255a5afb3 \ + --hash=sha256:8f333ec9c5eb1b7105e3b84b53141e66ca05a19a605368c55450b6ba208cb9ee \ + --hash=sha256:9004d8386d133b7e6135679424c91b0b854d2d164af6ea3f289f8f2761064609 \ + --hash=sha256:90efbcf47dbe33dcf643a1e400d67d59abeac5db07dc3f27d6bdeae497a2198c \ + --hash=sha256:935434b9853c7c112eee7ac891bc4cb86455aa631269ae35442cb316790c1445 \ + --hash=sha256:93b1818e4a6e0930454f0f2af7dfce69307ca03cdcfb3739bf4d91241967b6c1 \ + --hash=sha256:95922cee9a778659e91db6497596435777bd25ed116701a4c034f8e46544955a \ + --hash=sha256:960c83bf01a95b12b08fd54324a4eb1d5b52c88932b5cba5d6e712bb3ed12eb5 \ + --hash=sha256:97231140a50f5d447d3164f994b86a0bed7cd016e2682f8650d6a9158e14fd31 \ + --hash=sha256:974e72a2474600827abaeda71af0c53d9ebbc3c2eb7da37b37d7829ae31232d8 \ + --hash=sha256:97891f3b1b3ffbded884e2916cacf3c6fc87b66bb0dde46f7357404750559f33 \ + --hash=sha256:98655c737850c064a65e006a3df7c997cd3b220be4ec8fe26215760b9697d4d7 \ + --hash=sha256:98bc624954ec4d2c7cb074b8eefc2b5d0ce7d482e410df446414355d158fe4ca \ + --hash=sha256:98c5787b0a0d9a41d9311eae44c3b76e6753def8d8870ab501320efe75a6a5f8 \ + --hash=sha256:9b0d9b91d1aa44db9c1f1ecd0d9d2ae610b2f4f856448664e01a3b35899f3f92 \ + --hash=sha256:9c90fed18bffc0189ba814749fdcc102b536e83a9f738a9003e569acd540a733 \ + --hash=sha256:9d624335fd4fa1c08a53f8b4be7676ebde19cd092b3895c421045ca87895b429 \ + --hash=sha256:9f9af11306994335398293f9958071019e3ab95e9a707dc1383a35613f6abcb9 \ + --hash=sha256:a0543217a6a017692aa6ae5cc39adb75e587af0f3a82288b1492eb73dd6cc2a4 \ + --hash=sha256:a088b62bd733e2ad12c50dad01b7d0166c30287c166e137433d3b410add807a6 \ + --hash=sha256:a407f13c188f804c759fc6a9f88286a565c242a76b27626594c133b82883b5c2 \ + --hash=sha256:a90f75c956e32891a4eda3639ce6dd86e87105271f43d43442a3aedf3cddf172 \ + --hash=sha256:a9fc4caa29e2e6ae408d1c450ac8bf19892c5fca83ee634ecd88a53332c59981 \ + --hash=sha256:aa23b001d968faef416ff70dc0f1ab045517b9b42a90edd3e9bcdb06479e31d5 \ + --hash=sha256:ac1c665bad8b5d762f5f85ebe4d94130c26965f11de70c708c75671297c776de \ + --hash=sha256:af959b9beeb66c822380f222f0e0a1889331597e81f1ded7f374f3ecb0fd6c52 \ + --hash=sha256:b0fa96985700739c4c7853a43c0b3e169360d6855780021bfc6d0f1ce7c123e7 \ + --hash=sha256:b26684587228afed0d50cf804cc71062cc9c1cdf55051c4c6345d372947b268c \ + --hash=sha256:b4938326284c4f1224178a560987b6cf8b4d38458b113d9b8c1db1a836e640a2 \ + --hash=sha256:b8c990b037d2fff2f4e33d3f21b9b531c5745b33a49a7d6dbe7a177266af44f6 \ + --hash=sha256:ba0a9fb644d0c1a2194cf7ffb043bd852cea63a57f66fbd33959f7dae18517bf \ + --hash=sha256:bb08271280173720e9fea9ede98e5231defcbad90f1624bea26f32ec8a956e2f \ + --hash=sha256:bdbf9f3b332abd0cdb306e7c2113818ab1e922dc84b8f8fd06ec89ed2a19ab8b \ + --hash=sha256:bfde23ef6ed9db7eaee6c37dcec08524cb43903c60b285b172b6c094711b3961 \ + --hash=sha256:c0abd12629b0af3cf590982c0b413b1e7395cd4ec026f30986818ab95bfaa94a \ + --hash=sha256:c102791b1c4f3ab36ce4101154549105a53dc828f016356b3e3bcae2e3a039d3 \ + --hash=sha256:c3a32d23520ee37bf327d1e1a656fec76a2edd5c038bf43eddfa0572ec49c60b \ + --hash=sha256:c524c6fb8fc342793708ab111c4dbc90ff9abd568de220432500e47e990c0358 \ + --hash=sha256:c5f0c21549ab432b57dcc82130f388d84ad8179824cc3f223d5e7cfbfd4143f6 \ + --hash=sha256:c6b3228e1d80af737b72925ce5fb4daf5a335e49cd7ab77ed7b9fdfbf58c526e \ + --hash=sha256:c76c4bec1538375dad9d452d246ca5368ad6e1c9039dadcf007ae59c70619ea1 \ + --hash=sha256:c9035dde0f916702850ef66460bc4239d89d08df4d02023a5926e7446724212c \ + --hash=sha256:c93c3db7ea657dd4637d57e74ab73de31bccefe144d3d4ce370052035bc85fb5 \ + --hash=sha256:cb2a55f408c3043e42b40cc8eecd575afa27b7e0b956dfb190de0f8499a57a53 \ + --hash=sha256:cdea2e7b2456cfb6694fb113066fd0ec7ea4d67e3a35e1f4cbeea0b448bf5872 \ + --hash=sha256:ce1bbd7d780bb5a0da032e095c951f7014d6b0a205f8318308140f1a6aba159e \ + --hash=sha256:cf37cbe5ced48d417ba045aca1b21bafca67489452debcde94778a576666a1df \ + --hash=sha256:d4f49cb5661344764e4c7c7973e92a47a59b8fc19b6523649ec9dc4960e58a03 \ + --hash=sha256:d54ecf9f301853f2c5e802da559604b3e95bb7a3b01a9c295c6ee591b9882de8 \ + --hash=sha256:d62b7f64ffde3b99d06b707a280db04fb3855b55f5a06df387236051d0668f4a \ + --hash=sha256:d82dd730a95e6643802f4454b8fdecdf08667881a9c5670db85bc5a56693f122 \ + --hash=sha256:da62917e6076f512daccfbbde27f46fed1c98fee202f0559adec8ee0de67f71a \ + --hash=sha256:dd96c01a9dcd4889dcfcf9eb5544ca0c77603f239e3ffab0524ec17aea9a93ee \ + --hash=sha256:df9f19c28adcb40b6aae30bbaa1478c389efd50c28d541d76760199fc1037c32 \ + --hash=sha256:e1c5988359516095535c4301af38d8a8838534158f649c05dd1050222321bcb3 \ + --hash=sha256:e628ef0e6859ffd8273c69412a2465c4be4a9517d07261b33334b5ec6f3c7489 \ + --hash=sha256:e82d14e3c948952a1a85503817e038cba5905a3352de76b9a465075d072fba23 \ + --hash=sha256:e954b24433c768ce78ab7929e84ccf3422e46deb45a4dc9f93438f8217fa2d34 \ + --hash=sha256:eb0ce7b2a32d09892b3dd6cc44877a0d02a33241fafca5f25c8b6b62374f8b75 \ + --hash=sha256:eb304767bca2bb92fb9c5bd33cedc95baee5bb5f6c88e63706533a1c06ad08c8 \ + --hash=sha256:eb351f72c26dc9abe338ca7294661aa22969ad8ffe7ef7d5541d19f368dc854a \ + --hash=sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d \ + --hash=sha256:f2a0a924d4c2e9afcd7ec64f9de35fcd96915149b2216e1cb2c10a56df483855 \ + --hash=sha256:f33dc2a3abe9249ea5d8360f969ec7f4142e7ac45ee7014d8f8d5acddf178b7b \ + --hash=sha256:f537b55778cd3cbee430abe3131255d3a78202e0f9ea7ffc6ada893a4bcaeea4 \ + --hash=sha256:f5dd81c45b05518b9aa4da4aa74e1c93d715efa234fd3e8a179df611cc85e5f4 \ + --hash=sha256:f99fe611c312b3c1c0ace793f92464d8cd263cc3b26b5721950d977b006b6c4d \ + --hash=sha256:fa263a02f4f2dd2d11a7b1bb4362aa7cb1049f84a9235d31adf63f30143469a0 \ + --hash=sha256:fc5907494fccf3e7d3f94f95c91d6336b092b5fc83811720fae5e2765890dfba \ + --hash=sha256:fcee94dfbd638784645b066074b338bc9cc155d4b4bffa4adce1615c5a426c19 + # via + # aiohttp + # yarl +multiprocess==0.70.19 \ + --hash=sha256:02e5c35d7d6cd2bdc89c1858867f7bde4012837411023a4696c148c1bdd7c80e \ + --hash=sha256:0d4b4397ed669d371c81dcd1ef33fd384a44d6c3de1bd0ca7ac06d837720d3c5 \ + --hash=sha256:1bbf1b69af1cf64cd05f65337d9215b88079ec819cd0ea7bac4dab84e162efe7 \ + --hash=sha256:1c3dce098845a0db43b32a0b76a228ca059a668071cfeaa0f40c36c0b1585d45 \ + --hash=sha256:3a56c0e85dd5025161bac5ce138dcac1e49174c7d8e74596537e729fd5c53c28 \ + --hash=sha256:5be9ec7f0c1c49a4f4a6fd20d5dda4aeabc2d39a50f4ad53720f1cd02b3a7c2e \ + --hash=sha256:79576c02d1207ec405b00cabf2c643c36070800cca433860e14539df7818b2aa \ + --hash=sha256:8d5eb4ec5017ba2fab4e34a747c6d2c2b6fecfe9e7236e77988db91580ada952 \ + --hash=sha256:928851ae7973aea4ce0eaf330bbdafb2e01398a91518d5c8818802845564f45c \ + --hash=sha256:952021e0e6c55a4a9fe4cd787895b86e239a40e76802a789d6305398d3975897 \ + --hash=sha256:97404393419dcb2a8385910864eedf47a3cadf82c66345b44f036420eb0b5d87 \ + --hash=sha256:c6b6d78d43a03b68014ca1f0b7937d965393a670c5de7c29026beb2258f2f896 \ + --hash=sha256:d6db91ca6391eebc139c352f34578cea382df6bfa03d3b4146ed12b18b01cc14 \ + --hash=sha256:e5e7dc3e3e1732e88c07aaec17eeb9917f9ed1107d9e60d5ab985cdc14bac43a \ + --hash=sha256:e6c0674d34b8adac22533f6786576b3de4e396aaeda9e0c15378af9b8ada2702 \ + --hash=sha256:e8cc7fbdff15c0613f0a1f1f8744bef961b0a164c0ca29bdff53e9d2d93c5e5f + # via mpire +murmurhash==1.0.15 \ + --hash=sha256:0861cb11039409eaf46878456b7d985ef17b6b484103a6fc367b2ecec846891d \ + --hash=sha256:1349a7c23f6092e7998ddc5bd28546cc31a595afc61e9fdb3afc423feec3d7ad \ + --hash=sha256:189a8de4d657b5da9efd66601b0636330b08262b3a55431f2379097c986995d0 \ + --hash=sha256:213d710fb6f4ef3bc11abbfad0fa94a75ffb675b7dc158c123471e5de869f9af \ + --hash=sha256:2224f30f7729717644745a6f513ea7662517dfe7b1867cf1588177f64c61df3c \ + --hash=sha256:22aa3ceaedd2e57078b491ed08852d512b84ff4ff9bb2ff3f9bf0eec7f214c9e \ + --hash=sha256:231dc7982e1aeae8bbab21f8f21953e3b9fb0a34e3ffe2d2342ff32e4f07af9d \ + --hash=sha256:263807eca40d08c7b702413e45cca75ecb5883aa337237dc5addb660f1483378 \ + --hash=sha256:2680851af6901dbe66cc4aa7ef8e263de47e6e1b425ae324caa571bdf18f8d58 \ + --hash=sha256:26fd7c7855ac4850ad8737991d7b0e3e501df93ebaf0cf45aa5954303085fdba \ + --hash=sha256:32c6fde7bd7e9407003370a07b5f4addacabe1556ad3dc2cac246b7a2bba3400 \ + --hash=sha256:342277d8d7f712d136507fb3ccdba26c076a34ca0f8d1b96f65f0daa556da2e9 \ + --hash=sha256:34e5a91139c40b10f98d0b297907f5d5267b4b1b2e5dd2eb74a021824f751b98 \ + --hash=sha256:3c69b4d3bcd6233782a78907fe10b9b7a796bdc5d28060cf097d067bec280a5d \ + --hash=sha256:43bf4541892ecd95963fcd307bf1c575fc0fee1682f41c93007adee71ca2bb40 \ + --hash=sha256:43cc6ac3b91ca0f7a5ae9c063ba4d6c26972c97fd7c25280ecc666413e4c5535 \ + --hash=sha256:44d211bcc3ec203c47dac06f48ee871093fcbdffa6652a6cc5ea7180306680a8 \ + --hash=sha256:4a70ca4ae19e600d9be3da64d00710e79dde388a4d162f22078d64844d0ebdda \ + --hash=sha256:4fd8189ee293a09f30f4931408f40c28ccd42d9de4f66595f8814879339378bc \ + --hash=sha256:539d8405885d1d19c005f3a2313b47e8e54b0ee89915eb8dfbb430b194328e6c \ + --hash=sha256:55e1a7f65095f0af141c8a460765e026e35d9ec526f0daa2f88fed29a292b2ff \ + --hash=sha256:5678a3ea4fbf0cbaaca2bed9b445f556f294d5f799c67185d05ffcb221a77faf \ + --hash=sha256:58e2b27b7847f9e2a6edf10b47a8c8dd70a4705f45dccb7bf76aeadacf56ba01 \ + --hash=sha256:5a301decfaccfec70fe55cb01dde2a012c3014a874542eaa7cc73477bb749616 \ + --hash=sha256:5d8b43a7011540dc3c7ce66f2134df9732e2bc3bbb4a35f6458bc755e48bde26 \ + --hash=sha256:66395b1388f7daa5103db92debe06842ae3be4c0749ef6db68b444518666cdcc \ + --hash=sha256:671979f15b24817968ff6fab5e3a468e2b05555bac5e92f11155610a37165ffc \ + --hash=sha256:694fd42a74b7ce257169d14c24aa616aa6cd4ccf8abe50eca0557e08da99d055 \ + --hash=sha256:6cb4e962ec4f928b30c271b2d84e6707eff6d942552765b663743cfa618b294b \ + --hash=sha256:7c4280136b738e85ff76b4bdc4341d0b867ee753e73fd8b6994288080c040d0b \ + --hash=sha256:8155d106c63c5a509ec58c19b0c30804f3a0931bc65cca9826efc53373332536 \ + --hash=sha256:847d712136cb462f0e4bd6229ee2d9eb996d8854eb8312dff3d20c8f5181fda5 \ + --hash=sha256:88dc1dd53b7b37c0df1b8b6bce190c12763014492f0269ff7620dc6027f470f4 \ + --hash=sha256:898a629bf111f1aeba4437e533b5b836c0a9d2dd12d6880a9c75f6ca13e30e22 \ + --hash=sha256:899068ba3d7c371e7edd093852c634cce802fefd9aaddfcc0d2fda1d7433c7f9 \ + --hash=sha256:8a181494b5f03ba831f9a13f2de3aab9ef591e508e57239043d65c5c592f5837 \ + --hash=sha256:95d7c52598dce7a8543e5a5f61a893cbb762a62a907c6c9cd025d5f618bb8522 \ + --hash=sha256:9aba94c5d841e1904cd110e94ceb7f49cfb60a874bbfb27e0373622998fb7c7c \ + --hash=sha256:a2ea4546ba426390beff3cd10db8f0152fdc9072c4f2583ec7d8aa9f3e4ac070 \ + --hash=sha256:a32054edb567417ac81f7172b7dd7731846f25c63084edeb20545fa7abc849d7 \ + --hash=sha256:aadac5fd5f3f465094a5e4ecd00d739a2b870651cad06c8d1005153c2e815fb3 \ + --hash=sha256:b3ba6d05de2613535b5a9227d4ad8ef40a540465f64660d4a8800634ae10e04f \ + --hash=sha256:b65a5c4e7f5d71f7ccac2d2b60bdf7092d7976270878cfec59d5a66a533db823 \ + --hash=sha256:bba0e0262c0d08682b028cb963ac477bd9839029486fa1333fc5c01fb6072749 \ + --hash=sha256:bc54facccb32fe1e97d6231edd4f3e2937467c35658b26aa35bbd6a87ebb7cb0 \ + --hash=sha256:c22e56c6a0b70598a66e456de5272f76088bc623688da84ef403148a6d41851d \ + --hash=sha256:c4cd739a00f5a4602201b74568ddabae46ec304719d9be752fd8f534a9464b5e \ + --hash=sha256:cb8ebafae60d5f892acff533cc599a359954d8c016a829514cb3f6e9ee10f322 \ + --hash=sha256:cc93769619b6b42740cab8eebb587709daaa3d8f813372cc60358a571de20d2d \ + --hash=sha256:d37e3ae44746bca80b1a917c2ea625cf216913564ed43f69d2888e5df97db0cb \ + --hash=sha256:d4d681f474830489e2ec1d912095cfff027fbaf2baa5414c7e9d25b89f0fab68 \ + --hash=sha256:d7e47c5746785db6a43b65fac47b9e63dd71dfbd89a8c92693425b9715e68c6e \ + --hash=sha256:dc35606868a5961cf42e79314ca0bddf5a400ce377b14d83192057928d6252ec \ + --hash=sha256:e43a69496342ce530bdd670264cb7c8f45490b296e4764c837ce577e3c7ebd53 \ + --hash=sha256:e525bbd8e26e6b9ab1b56758a59b16c2fffd73bad2f7b8bf361c16f70ff1d980 \ + --hash=sha256:e8e674f02a99828c8a671ba99cd03299381b2f0744e6f25c29cadfc6151dc724 \ + --hash=sha256:ef19f38c6b858eef83caf710773db98c8f7eb2193b4c324650c74f3d8ba299e0 \ + --hash=sha256:f32307fb9347680bb4fe1cbef6362fb39bd994f1b59abd8c09ca174e44199081 \ + --hash=sha256:f3e99a6ee36ef5372df5f138e3d9c801420776d3641a34a49e5c2555f44edba7 \ + --hash=sha256:f4989c16053a9a83b02c520dd00a31f0877d5fd2ab8a9b6b75ed9eba0e25c489 \ + --hash=sha256:f4ac15a2089dc42e6eb0966622d42d2521590a12c92480aafecf34c085302cca \ + --hash=sha256:f9bf47101354fb1dc4b2e313192566f04ba295c28a37e2f71c692759acc1ba3c \ + --hash=sha256:fa1b70b3cc2801ab44179c65827bbd12009c68b34e9d9ce7125b6a0bd35af63c \ + --hash=sha256:fe50dc70e52786759358fd1471e309b94dddfffb9320d9dfea233c7684c894ba \ + --hash=sha256:fe883982114de576c793fd1cf55945c8ee6453ad4c4785ac1a48f84e74fdc650 + # via + # preshed + # spacy + # thinc +mypy==2.3.0 \ + --hash=sha256:04e617030eca5221909c8b7d8d7fd1c637948199aa2100b2ad9813feb07e1491 \ + --hash=sha256:094af99f92638aa92852326188b85a89e50f4a472f44827c03362228482f0762 \ + --hash=sha256:09abd66d8685e73f8f7d17b847c3e104d9a7b164a8706ea87d6c96a3d45816d5 \ + --hash=sha256:0b025a93cffb9781d231f232be07a17912f35f10a313c24f301c81e842870654 \ + --hash=sha256:13b1b16e2fa39f3b2e33fb1c468abc7a69369fa2e886b4b87b5afc81472325cd \ + --hash=sha256:1c6c6bf687b17f90dbfcad95b960d32eaa0154c00da45f03ab50bf8952e047fe \ + --hash=sha256:1fa8d916ac3b705af733c4c1e6c9ebe38fd0d52beb15b105c3e8355b55e6ecdc \ + --hash=sha256:28e1e2af8cd8fff551fd30f2fe4b03fb76764ac8b1ba6c6a1bd00ad32b412db3 \ + --hash=sha256:2d53fc67b9d28a43c6199077f49fea0f05839e36cf6158500331c9549225e5a5 \ + --hash=sha256:3419d00717afbc5265b50dd14b1278f29ea4884dd398ab67873489ac093fd329 \ + --hash=sha256:37fa4de896a84e2dc9200d91e614c22563b43d1a266789d4bbac7b22ebe6192b \ + --hash=sha256:3961a4a34b05f7c74b0f05aa51fbfe99a2d1e126038df40318d15c8f558b7ef3 \ + --hash=sha256:3dd0bed92c4bdec57c42505b96416fb9e6a5aa7be84d2809bcd5f2ecec2860d7 \ + --hash=sha256:3e77244df3843048c3f927182916730e40c124cbaa43905c1fb86cb382aa0805 \ + --hash=sha256:4359424140d985192c778c1ce2c114a10c1ca58a381ed79cfa70d37df94b299f \ + --hash=sha256:465965d41cd9a2726694e983e8ce7113259327bec798115d1e1dfa2a52fb666e \ + --hash=sha256:56c184d2c20ca6b6378d58d1960270a767f41f5e44acbbd27f05effef4f4e1d7 \ + --hash=sha256:5e91adad1ca81742ac7ef9893959911df867752206b37135185e88dfb3c89494 \ + --hash=sha256:691fdc37132b1ae628d834f672e74de83462d9fb4aff621835767fb43a8dd373 \ + --hash=sha256:6b1cdb579446b60432432b2b2403a6201b4b475a004d7f488511c9ba177c9e88 \ + --hash=sha256:6f99ec626e3c3a2f7c0b22c5b90ddb5dabb1c18729c971e9bdaca1f1766d2cee \ + --hash=sha256:7247eb2824f996722a949530183394921ca71deb9680052a338cf53cff7925c2 \ + --hash=sha256:75b0984bb3cbd76bb5c9291a8671f7ae66ca3b51c7584c358fc2e923259f0757 \ + --hash=sha256:75cbb4b9ef04a0c84a957f07abc4504fbf64b8dcc145675101f2d3a78a4b1d6a \ + --hash=sha256:7da939dd335cfd2ad788bdfd081c9f4e47634ab995e5a45eb15fd1e5bc052f8b \ + --hash=sha256:85c5385b93012ffa3b31479ab579aef5415f4f3a32c6cf1ae07a984d2a0ff461 \ + --hash=sha256:91ad22a52ae2c7e621c2f67c94d5a17f66b3209a4cff5cf8a573579835c69e97 \ + --hash=sha256:944c665d984157cb96a679dfb7a4a81dd1d36b24b9c284b699514e6e626b82d4 \ + --hash=sha256:9559ab18a9c9957dfa3004ab57cd4bac5f26a724329a9584e583367f0c2e1117 \ + --hash=sha256:982e3d53dd23d0a4cef67dd66791fdbede0cf38f9eb617bf47663554c51e1e36 \ + --hash=sha256:99ac767cc5d3b64c8d0ae226ead10c96694f94e4e7da1668642225dcd4e75aac \ + --hash=sha256:adebc76aab4f3495a88b41d48aa4aff0c03f2822501da76625afcca5975f19e5 \ + --hash=sha256:aec15d465d477558fd842757b487849007311cf3897849cdda0e3162ac0ac556 \ + --hash=sha256:b1942b9314d4c784b8ea1dbab4972603290e5dd5630f06675f13aec97526bc4c \ + --hash=sha256:b352b7e49f5e6576009e8df730e1ff4f915cb565b851b396d2ffe2f5a6f5da88 \ + --hash=sha256:b5cd2f027a972a4a5f2278a11fac9747f5f81a53a30b714d74950b6807e55568 \ + --hash=sha256:be51653d7669d7d7955d613b8d0bb57d5b652eaf71a873ddf65ac87254dd2595 \ + --hash=sha256:cfca8ee88544090f86b6dcce05ec55d66eb48a762412ac2507810ba4bd793b6f \ + --hash=sha256:d78fcf900b59cb7e82cb7e3a235e31b462d9333d92285bd1e4952d355b8ffba1 \ + --hash=sha256:de121747278144fc9ae7caa2e978cf5df12aebc82933182f5b3b86081a30baef \ + --hash=sha256:de6d2c484742a4d7b0ed6d07b143375624d3b899c5749c7b3c947f56261f48a6 \ + --hash=sha256:ea317b060ce83e26050f8f9e4d7d6bf44ed7597c8ff9990bccffbb9d1d8522db \ + --hash=sha256:f1b3a98dfd21058bc759bb3337d5d1f61d0fdf9f3cf9c00f4291790fb5427bff \ + --hash=sha256:f4ed18f111bfe2d599bca7468e7f9251042c1c2118f762c8de2766a56d773c60 \ + --hash=sha256:fbc00cee7bdbb9291979ddc9d08034a29dfcda4932628c9bbc28c1edd589df0c + # via semantica (pyproject.toml) +mypy-extensions==1.1.0 \ + --hash=sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505 \ + --hash=sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558 + # via + # black + # mypy +narwhals==2.24.0 \ + --hash=sha256:42fdedf44e5b2ca7505630d45b4ac3058f38d8485cba9fe1652ca23152df7489 \ + --hash=sha256:b5c0f684ccd9d7475b564111e319a4964abcf2baf79d3cf6b1003d06ac9b828d + # via + # plotly + # scikit-learn +nbclient==0.11.0 \ + --hash=sha256:04a134a5b087f2c5887f228aca155db50169b8cd9334dee6942c8e927e56081a \ + --hash=sha256:ef7fa0d59d6e1d41103933d8a445a18d5de860ca6b613b87b8574accdb3c2895 + # via nbconvert +nbconvert==7.17.1 \ + --hash=sha256:34d0d0a7e73ce3cbab6c5aae8f4f468797280b01fd8bd2ca746da8569eddd7d2 \ + --hash=sha256:aa85c087b435e7bf1ffd03319f658e285f2b89eccab33bc1ba7025495ab3e7c8 + # via + # jupyter + # jupyter-server +nbformat==5.11.0 \ + --hash=sha256:7dbaed4a69cae28c2b4d44ab7430a6af4544fb89455023f6f21550be757b60c8 \ + --hash=sha256:f70a17f591a9ccd1c601d5e61a4b20972703926df0ba42458ce14bf575766bb6 + # via + # jupyter-server + # nbclient + # nbconvert +neo4j==6.2.0 \ + --hash=sha256:b87abdd13a5cc2e3bd51026926c2f20ac38fa3febe98c340520dce19e97388d0 \ + --hash=sha256:e1e246b65b572bd8ea97f9e0e721b7d40a5ce53e53d0007c29aef63e4f9124d9 + # via semantica (pyproject.toml) +nest-asyncio2==1.7.2 \ + --hash=sha256:1921d70b92cc4612c374928d081552efb59b83d91b2b789d935c665fa01729a8 \ + --hash=sha256:f5dfa702f3f81f6a03857e9a19e2ba578c0946a4ad417b4c50a24d7ba641fe01 + # via ipykernel +networkx==3.6.1 \ + --hash=sha256:26b7c357accc0c8cde558ad486283728b65b6a95d85ee1cd66bafab4c8168509 \ + --hash=sha256:d47fbf302e7d9cbbb9e2555a0d267983d2aa476bac30e90dfbe5669bd57f3762 + # via + # semantica (pyproject.toml) + # d3graph + # python-louvain + # pyvis + # torch +nodeenv==1.10.0 \ + --hash=sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827 \ + --hash=sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb + # via pre-commit +notebook==7.6.2 \ + --hash=sha256:5fe9e09c335cb4b7de21627b860f77210e70e54b1fb1276ad942a4a7e1d858d3 \ + --hash=sha256:cc02b5f0bb972160cccfe44ad8a1a202036206ba3439469c514f03aefa9ae807 + # via jupyter +notebook-shim==0.2.4 \ + --hash=sha256:411a5be4e9dc882a074ccbcae671eda64cceb068767e9a3419096986560e1cef \ + --hash=sha256:b4b2cfa1b65d98307ca24361f5b30fe785b53c3fd07b7a47e89acb5e6ac638cb + # via + # jupyterlab + # notebook +numba==0.67.0 \ + --hash=sha256:00c964a5b94d3ae82d83ac162cd610755875b98dadb779fdde06e6bfcdbca47e \ + --hash=sha256:3fa3d1b27f96f2c0d54513d953d7197886aa1eaa7d2439a0eedc44d993fb181a \ + --hash=sha256:4a2ed006635bbd0fe45681ed49f3b4f4bad1abf0c233bcc5842c9e3a34cabd61 \ + --hash=sha256:4d576e62bf2c9370f61312b51573c4bb1f3fe96798bbab56730847a368a316c4 \ + --hash=sha256:50e2b72406c18cda5dd7431b0082cb85ea94e06c64c33607248fc8bef92cfb81 \ + --hash=sha256:5269245a675abdd3e2c35ec6bb2f250355effa9032514d8f2354f0d2d10854bd \ + --hash=sha256:6004d8d5f28d4028687fb2d972d629295b13685943bd2ed5cd8810c3b848e219 \ + --hash=sha256:694c81c6560b2b47e5fc1dc39c29175b907adf862d9af0af801453400a022a61 \ + --hash=sha256:76d3335aaeffb9dc88309420890e73497a00be08a7530441bc2b58ffe025bfa5 \ + --hash=sha256:77e1c7173fee57a0d84e006c7e70346689d6cb3e7db503489bae58646b4eff7b \ + --hash=sha256:7930748ce8355d2a5a28602abab056a61fdc676d17377f27d17993905428171f \ + --hash=sha256:83ab968b0e0fa744eba03351282dd8000796e6ec8e4518f47bd3ed86c0a20c7b \ + --hash=sha256:88f6e0f5cb6c545e158b6ef0496c01b6d6958a7ccc6634a1576a94bbbab29ff2 \ + --hash=sha256:8c0e88acd4341ddf40779db3c0228b9188aca7fcab5f5f3ce9949a1fc71e9a02 \ + --hash=sha256:8c80c847301dc33dc8f84a97a952004023d9a05578ae4512b087176264cc1960 \ + --hash=sha256:9c4953387c77864b596d8296e2cfbdef82b0eea4166ab4864b05d226c51143e0 \ + --hash=sha256:aa5f002f665bec321b950dacaa26ee009e1d720f6ac9d9856eed5efe1caa03a6 \ + --hash=sha256:b68ad5125fe245339cc8dcc036081fc1ea482c5063387b9612a76ccd83dc91cd \ + --hash=sha256:cd75aa535b33fa05d9d930b1ae8af9f97a2881e96d72dfb38ec9b78284d9f851 \ + --hash=sha256:cfba1ac34f0363fb1a250a10e97240780d11e05227892f7286b26fbfd0ad58ce \ + --hash=sha256:d6c8e9ba3f9602471e8c6f563ffcce8db8046741f0bafb782a052e41dc6b6861 \ + --hash=sha256:e7a7b0121466f1e9a8a074b0545fe90e16389623abf979b5d7c299dca1294d7e \ + --hash=sha256:ed333e0af4386294e7f03e550e01411856b6935e717d859225e0a7338c6b6795 \ + --hash=sha256:f074a8e23db78490f11a3930c940be758316c10ac5985be83d2f298dc080acf7 \ + --hash=sha256:f63d43db06b4756424d6d2484737c902e0ae944a0eec3e8b0b4de2c695b15caa \ + --hash=sha256:f99f880ff25f418a67f9a1d00d0ddfbc63430f627b523e515085a592a7567f4b + # via + # librosa + # pynndescent + # umap-learn +numpy==2.4.6 \ + --hash=sha256:001fbb8e08d942dd57599e781f2472269ee7f2755fae407b4f67b2f0b17da3f1 \ + --hash=sha256:0280e0356c0829a18d9de1cb7eee50ec22ca639878d7240307ca0943d73cd2c4 \ + --hash=sha256:043191bfa8eab18c776647b62723ac9dddece59743b13f49b2016094129c2b3f \ + --hash=sha256:06ca2f61ec4385a07a6977c55ba998a4466c123642b4a32694d3128fce18c079 \ + --hash=sha256:0a041d3d761dc3c35cc56ce0351506a02bcbc25f7b169f652435141a17db9096 \ + --hash=sha256:0ab0a9c4ffb1a6d95ef519fe4247dba8eb6b18ad93999f76b7f657039acabd47 \ + --hash=sha256:0c9136e14ed34a9e343a31c533d78a9813a69a3148332bce5e9821cb2f996e66 \ + --hash=sha256:110f8b71aacb688ec69062bb7f6938a0f8acb01b7c1c4beb453c65b6d234584d \ + --hash=sha256:112b06a867b235ef466ed3508ddf0238050df9c727cafb5301ac385b899189a1 \ + --hash=sha256:17f9ade344e7d9b464a084d69bcf18fc691cb1db67c62ed80820bf4926d78f0e \ + --hash=sha256:1e254a00cdf42b1e4d5b3d68d33af63268d41340d8885df2ab6470f2e1500147 \ + --hash=sha256:1e978ec1e8bd0e0e4de6bb75de9d30cbb74db6b6a2bb727618613703ca0167dd \ + --hash=sha256:25c692919ac5a01f170a3bfcd62d745b24fd095c353d50812637d6fcab442e75 \ + --hash=sha256:260a5d70215b61ab4fadf5c7baacd64821842975eea312125ed3c39a6391b063 \ + --hash=sha256:2803abfebfc990042cd494d8ce2d5f82e9d847af6d35ec486923aa19dbad5e73 \ + --hash=sha256:29a287e0cf63ff528da061de6b9f64a4618da591ca1046aafc54062e40ca7eab \ + --hash=sha256:29cb7f67d10b479ff07c17d33e39f78c07f71c40ef30d63c153d340e96cd3fb4 \ + --hash=sha256:3213d622a0283a39a93d188f3cf72b26862df52fbb4ca3697f51705016523d41 \ + --hash=sha256:33111801a01c12a8a1e3721f0a9232f8cfc8ae2c6b7098167e6f623c6073f402 \ + --hash=sha256:357cc07a6d7b0b182ff02249616a03742827ebb1277546b5c7cd7f7620a45698 \ + --hash=sha256:38efbc8de75c7a0fc1ac190162d892787f3f47b57cc291231aafee36b80982b7 \ + --hash=sha256:4081eb135ac24158bd51cdfbef16f1c64df7063b1143f24731387137c092bec8 \ + --hash=sha256:40fdc1ae7125e518ea98e53e69a4ebc27e1fd50510c47b7ea130cf21e5e1d42b \ + --hash=sha256:4cfe66903cc32a9921a6733d96b19bb6abf310397581bbad89c228f5abaf0ee8 \ + --hash=sha256:511dbaf848decaaaf4b4ca48032619fb3138710c4bf7da7617765edad1ef96b0 \ + --hash=sha256:55cced7c52e981362f708ad635198e97a752dfba412cc03c23bbf3bd8d5cd662 \ + --hash=sha256:56b39e5e0622a09a25bf5baf62f4bcf0cb8a41ae6e2819cf49bbc5a74c083f91 \ + --hash=sha256:5dbbdb29840ca3d91ee0fece42fc29278886d908280bfec0a5846c6f901a3eb0 \ + --hash=sha256:5f9fb9157b4ce2971008323afe46053787b526ef624fea915b261468a8421a0f \ + --hash=sha256:6180d8b35af935aed8ece3a85e0a43f87393ae0ac87c8d2c8bd2c993f7270ef3 \ + --hash=sha256:68a5124b13fa6cc2086764a20005d30bc0548146f7f5322f02fce212ca14317f \ + --hash=sha256:68bb27509ac1b9a3443094260f6326150663b06abe40b73a2f81160623da5b67 \ + --hash=sha256:6f41ae150c4e32db4f3310cdaf64b1593a03dbabe29eec77fc9b50fe64061df6 \ + --hash=sha256:7265a2f3d436e54ef9f2b52b5c937e6be778781bd97a590319d7348f1c1ca997 \ + --hash=sha256:72fbe16c6fac95aedf5937fa873445cec2110be35d8a4e9433d7501fd98dae6b \ + --hash=sha256:7d92c3819208a60205a12a245c91ad70cb0a85336659b19b834205573ac8456e \ + --hash=sha256:8155154c7c691289fe18f510b5d4657c68c67989f293f0535a91360392ff6538 \ + --hash=sha256:81a1cca95ed5bb92aa8b10dd2cdc9a0d3853a50fad926c28b5d7e8ea54389627 \ + --hash=sha256:89cd468399cfd2504718f0ba50e410dca55a170b61a02ad92bb18c8a65186e93 \ + --hash=sha256:8ad03c0965fb3c692200e74d458ca28c1dbb4ce96f9a479a8aa041ad5fabca02 \ + --hash=sha256:90f9849678c75fe7afa2d348ac842c168b0a4d3d61919687216dfc547976d853 \ + --hash=sha256:948424b06129ce883307e8cff868c31396d8dc7630a59c61d70d98dbe70f222c \ + --hash=sha256:9cd5ffd25db4e7ba6a375693b3fc0fc1791ec636c17db3720da19bde7180ec43 \ + --hash=sha256:a0df0043bdb289bde1f62da130d20df23d58b45429f752bc7a8fc5325a225ecd \ + --hash=sha256:a2c306dea656c12c68f51f4cea133cbe78ca7435eb28c735eac1d3ebe73be6e8 \ + --hash=sha256:a7830bab239b79cda9c08c2da014761cafb48da6150e1da17ac06283f43b6089 \ + --hash=sha256:a7c711e21628b52034bb5ab8d1bce291f752fcc5e92accc615778acee1ff4778 \ + --hash=sha256:aaf159caa35993cb1f56fb9b8e4610d35758e7ca005412eb1daa856a78c9c4b1 \ + --hash=sha256:ae506e6902902557576a26ff33eda8695e7ecb3cb36c3b573a0765dee114ebdb \ + --hash=sha256:b507f5c4c1d508876d1819b6bf9a49d365b96320b5d4993426b33a23ca4b8261 \ + --hash=sha256:bf162abab1c1a736333192707cef898e735a5ca00f38f27eeedf44b39d9e85eb \ + --hash=sha256:c1a2af6c6ef86344a6b0db6b97834208bf598db514f2b155042439b62605601a \ + --hash=sha256:c2d37ab77531417474168eb79d6d80b14f821a966818505d03013d0833edb7a8 \ + --hash=sha256:c4fc99836233ea196540b17ab0983aff60ed07941751930f5f4d05bc3b3b7359 \ + --hash=sha256:d581b735e177fdcdce6fed8e7e8880a3fb6ee4e3653a3ac6af01c6f4c03effc5 \ + --hash=sha256:d6da64deb6b8ed903e7560180a92f2d804ee1ba5eeb849ac2748b8c1aba1f6d7 \ + --hash=sha256:d8e8286dd7cea7895157318d1b91cdacac64c479f3cbc8dce548331728484751 \ + --hash=sha256:ddea102b48f9e339f3948bf22040944184627a30fdf7f858667673b9c5f033c8 \ + --hash=sha256:dfa20cc6ca228e6b155b11da03825975ce66aea520985dbbddf0f2a5a495c605 \ + --hash=sha256:e3e5193ef5a3dc73bceee50f7fdc2c90dbb76c42df8d8fae3d1067a583df579e \ + --hash=sha256:e3eeb0aabd6bd5ce64faae67e9935203a6991b4bc2a485a767fbafb2c5125f45 \ + --hash=sha256:e5805d5a22fd19c8ccff10a9561f9df94436b0545619ea579db2d3c35294bce2 \ + --hash=sha256:e85b752a1e912b70eaad4fafbd4d1238007ab221de2009b9a2f5ae7461239895 \ + --hash=sha256:eaf7fa2de5c0be8ae6ff8e9bea2ccd725e980541244521d8d4b5f3354a27babe \ + --hash=sha256:ebfb099f8dcf083deef3ac1ca4c1503f387cf76296fcb3816b66f5ecb5f54fdb \ + --hash=sha256:ece3d2cfe132e7d51f44a832b303895e6f2d499c5e74dfbdb06ee246147a304a \ + --hash=sha256:ed9749eef4cbd126da3dc1d6bcb3a57f5eb7ac6a6484146bdbf743f552dfc577 \ + --hash=sha256:ede83e07a75dd06bc501566c1eca2afc0d61677c1472ac9ad93fdee6e638a48d \ + --hash=sha256:ef4aea96ce4d3b074422cb4f2f64e216bf9e213004bb58ecfdf50ea02ea8eb9a \ + --hash=sha256:f3a3570c4a2a16746ac2c31a7c7c7b0c186b95ce902e33db6f28094ed7387dda \ + --hash=sha256:f407cb6b8e9d6d8c626bc73c945db1706035af8fd632295547bf1c9e46d092d6 \ + --hash=sha256:f74a575920ab21fe304421a3fc28793d82e299cae9eccb37084e9fc7f3617c20 + # via + # semantica (pyproject.toml) + # accelerate + # bertopic + # blis + # colourmap + # contourpy + # d3blocks + # d3graph + # datazets + # distfit + # docling-ibm-models + # docling-slim + # faiss-cpu + # fastembed + # gensim + # hdbscan + # ismember + # librosa + # matplotlib + # numba + # onnxruntime + # opencv-python + # pandas + # patsy + # python-louvain + # qdrant-client + # rapidocr + # safetensors + # scatterd + # scikit-learn + # scipy + # seaborn + # sentence-transformers + # shapely + # soundfile + # soxr + # spacy + # statsmodels + # thinc + # torchvision + # transformers + # umap-learn +nvidia-cublas==13.1.1.3 \ + --hash=sha256:37936a16db8fe4ac1f065c2139360608a543a09275cb1a1af612e08cfa065436 \ + --hash=sha256:b6cdce694e47ff6aadf0a69df1cab6628d696f5ff56e8d16af50309d855fa20f \ + --hash=sha256:b7a210458267ac818974c53038fbec2e969d5c99f305ab15c72522fa9f001dd5 + # via + # cuda-toolkit + # nvidia-cudnn-cu13 + # nvidia-cusolver +nvidia-cuda-cupti==13.0.85 \ + --hash=sha256:4eb01c08e859bf924d222250d2e8f8b8ff6d3db4721288cf35d14252a4d933c8 \ + --hash=sha256:683f58d301548deeefcb8f6fac1b8d907691b9d8b18eccab417f51e362102f00 \ + --hash=sha256:796bd679890ee55fb14a94629b698b6db54bcfd833d391d5e94017dd9d7d3151 + # via cuda-toolkit +nvidia-cuda-nvrtc==13.0.88 \ + --hash=sha256:6bcd4e7f8e205cbe644f5a98f2f799bef9556fefc89dd786e79a16312ce49872 \ + --hash=sha256:ad9b6d2ead2435f11cbb6868809d2adeeee302e9bb94bcf0539c7a40d80e8575 \ + --hash=sha256:d27f20a0ca67a4bb34268a5e951033496c5b74870b868bacd046b1b8e0c3267b + # via + # cuda-toolkit + # nvidia-cublas +nvidia-cuda-runtime==13.0.96 \ + --hash=sha256:7f82250d7782aa23b6cfe765ecc7db554bd3c2870c43f3d1821f1d18aebf0548 \ + --hash=sha256:ef9bcbe90493a2b9d810e43d249adb3d02e98dd30200d86607d8d02687c43f55 \ + --hash=sha256:f79298c8a098cec150a597c8eba58ecdab96e3bdc4b9bc4f9983635031740492 + # via cuda-toolkit +nvidia-cudnn-cu13==9.20.0.48 \ + --hash=sha256:0c45dd8eeb50b603f07995b1b300c62ffe6a1980482b82b3bcf94a4ca9d49304 \ + --hash=sha256:af8139732b99c0118be65ea5aac97f0d46018f8c552889e49d2fb0c6261a4a24 \ + --hash=sha256:e31454ae00094b0c55319d9d15b6fa2fc50a9e1c0f5c8c80fb75258234e731e1 + # via torch +nvidia-cufft==12.0.0.61 \ + --hash=sha256:2708c852ef8cd89d1d2068bdbece0aa188813a0c934db3779b9b1faa8442e5f5 \ + --hash=sha256:2abce5b39d2f5ae12730fb7e5db6696533e36c26e2d3e8fd1750bdd2853364eb \ + --hash=sha256:6c44f692dce8fd5ffd3e3df134b6cdb9c2f72d99cf40b62c32dde45eea9ddad3 + # via cuda-toolkit +nvidia-cufile==1.15.1.6 \ + --hash=sha256:08a3ecefae5a01c7f5117351c64f17c7c62efa5fffdbe24fc7d298da19cd0b44 \ + --hash=sha256:bdc0deedc61f548bddf7733bdc216456c2fdb101d020e1ab4b88d232d5e2f6d1 + # via cuda-toolkit +nvidia-curand==10.4.0.35 \ + --hash=sha256:133df5a7509c3e292aaa2b477afd0194f06ce4ea24d714d616ff36439cee349a \ + --hash=sha256:1aee33a5da6e1db083fe2b90082def8915f30f3248d5896bcec36a579d941bfc \ + --hash=sha256:65b1710aa6961d326b411e314b374290904c5ddf41dc3f766ebc3f1d7d4ca69f + # via cuda-toolkit +nvidia-cusolver==12.0.4.66 \ + --hash=sha256:02c2457eaa9e39de20f880f4bd8820e6a1cfb9f9a34f820eb12a155aa5bc92d2 \ + --hash=sha256:0a759da5dea5c0ea10fd307de75cdeb59e7ea4fcb8add0924859b944babf1112 \ + --hash=sha256:16515bd33a8e76bb54d024cfa068fa68d30e80fc34b9e1090813ea9362e0cb65 + # via cuda-toolkit +nvidia-cusparse==12.6.3.3 \ + --hash=sha256:2b3c89c88d01ee0e477cb7f82ef60a11a4bcd57b6b87c33f789350b59759360b \ + --hash=sha256:80bcc4662f23f1054ee334a15c72b8940402975e0eab63178fc7e670aa59472c \ + --hash=sha256:cbcf42feb737bd7ec15b4c0a63e62351886bd3f975027b8815d7f720a2b5ea79 + # via + # cuda-toolkit + # nvidia-cusolver +nvidia-cusparselt-cu13==0.8.1 \ + --hash=sha256:4dca476c50bf4780d46cd0bfbd82e2bc10a08e4fef7950917ce8d7578d22a23f \ + --hash=sha256:786ce87568c303fadb5afcc7102d454cd3040d75f6f8626f5db460d1871f4dd0 \ + --hash=sha256:dccbd362f91a7b9024d1f55ee9f548ac065027ff15d8c8b0db889ab3a8f31215 + # via torch +nvidia-nccl-cu13==2.29.7 \ + --hash=sha256:674a12383e3c38a1bcccae7d4f3633b37852230b6047883cb2f4c2d1b36d9bf5 \ + --hash=sha256:edd81538446786ec3b73972543e53bb43bcaf0bfc8ef76cb679fcc390ffe136d + # via torch +nvidia-nvjitlink==13.3.33 \ + --hash=sha256:26a6de7fb4c8fdaa7703d3dad720d6d427ddfea5c48a528fd97c11733ad830e5 \ + --hash=sha256:4297ee49639b4f2e07255a1d69b3acc7ab2d011bb892b403e91ac98368962e3b \ + --hash=sha256:ce48b37dfeb3cb1eae4cf85adacb47d7a6539ea2272870c9a3628ce275c2037e + # via + # cuda-toolkit + # nvidia-cufft + # nvidia-cusolver + # nvidia-cusparse +nvidia-nvshmem-cu13==3.4.5 \ + --hash=sha256:290f0a2ee94c9f3687a02502f3b9299a9f9fe826e6d0287ee18482e78d495b80 \ + --hash=sha256:6dc2a197f38e5d0376ad52cd1a2a3617d3cdc150fd5966f4aee9bcebb1d68fe9 + # via torch +nvidia-nvtx==13.0.85 \ + --hash=sha256:4936d1d6780fbe68db454f5e72a42ff64d1fd6397df9f363ae786930fd5c1cd4 \ + --hash=sha256:cb7780edb6b14107373c835bf8b72e7a178bac7367e23da7acb108f973f157a6 \ + --hash=sha256:d66ea44254dd3c6eacc300047af6e1288d2269dd072b417e0adffbf479e18519 + # via cuda-toolkit +olefile==0.47 \ + --hash=sha256:543c7da2a7adadf21214938bb79c83ea12b473a4b6ee4ad4bf854e7715e13d1f \ + --hash=sha256:599383381a0bf3dfbd932ca0ca6515acd174ed48870cbf7fee123d698c192c1c + # via python-oxmsg +ollama==0.6.2 \ + --hash=sha256:3ad7daab28e5a973445c36a73882a3ef698c2ebb00e21e308652741577509f7d \ + --hash=sha256:936d55daa684f474364c098611c933626f8d6c7d67065c5b7ae0c477b508b07f + # via semantica (pyproject.toml) +omegaconf==2.3.1 \ + --hash=sha256:3d701d14e9a8828f1edd28bb70b725908b34277cdd72cf7d6a83f94dadc6b6a0 \ + --hash=sha256:e5e7de64aeebeddaf8e6d3f7a783b32ac2a01c0fbd9c878012caecb891a1f42a + # via rapidocr +onnxruntime==1.28.0 \ + --hash=sha256:07fb3cbe990d6bf0ab3c22bfbbfb0e314151266046ea6edb4a07f556b4258c5f \ + --hash=sha256:0a83bdb70d143cede762b677789bf2a7acca54b3fb82565601d5c30695aa933c \ + --hash=sha256:0d650aeee29368414367b65529e90afe4bf1bab76254789063b8b2f7ea3013c8 \ + --hash=sha256:0faf85fb447a663c9cdadc39bd6b19bdf7bedded6699e45731b9b36c46fd993d \ + --hash=sha256:1a1a19175464665c9b8d50bc916f216cc0b569110045b7bbca8f9f290b186f58 \ + --hash=sha256:26ff0fdd06efb6c155bae95387a09db1a2be89c7a03e4d0bffd5a171cc2826da \ + --hash=sha256:31410f544674f534c2f27348af52ef81682ca9c8719154bf4d48f0ef23823b1e \ + --hash=sha256:4e81a23df16e7acb9d51b06d30cc098e49315ef9180f97bc2221d167b4b04d9c \ + --hash=sha256:4f6e92367ddce1e4d33cf295024f40192be6c6171a09208f515ba169ced06c8e \ + --hash=sha256:54fa221d669282bd8f582708ce4c96010a7e9fb0661f9006b37fe2fedafb73fe \ + --hash=sha256:6afdc83f1317c136e92fc29f5ee9f058de59d87c0b22cee3fdbfbaa0ccc2098a \ + --hash=sha256:8adff67a3f28257b37cfe945a7e952e4122666aa8c91a0380862e9fd4c2ed19f \ + --hash=sha256:8d66f9ceb29909c70839e4e4fb3435c7b490050d8f162bd5f3aba4ca01ee517f \ + --hash=sha256:a166b78ee04f3a37fa1ef82034b6a3ce96d9684e582d4d30b296de83e9998bb5 \ + --hash=sha256:ac301f53b1930402fc46c368e268acfed02f3207272aaff05070d7e09f96f031 \ + --hash=sha256:bc2565e487b4896fb988d6383577d875d958e071fc5f6c3550bd5d02ae98264b \ + --hash=sha256:c35064f9b3c43c81c5d5d282091401d0f1ff22796d93ccade4ea2ece5e137ab8 \ + --hash=sha256:cfab507abe09d6ffeb817eee07944d452fdc0b00fdcef34cab4db10a45e378c7 \ + --hash=sha256:e02feeb0165c5f13b4cc954738078d59b90128516ac12b671ee24a530242bf02 \ + --hash=sha256:e562d6e36a749f6764481c0ddb0f2af3d0b5a3c164291361d08803c557f369af \ + --hash=sha256:f2a3b9e30ce880d4ca54999cb313569e36da4f62eefe25f87be18f43e9a3a4d5 \ + --hash=sha256:f5c5daabd28aad610f83fdcf32acec8fb57e6adc6c6a39fe2a3c755db957b410 \ + --hash=sha256:f649dd6f6452d12a8059888aa489fe519e062e18793dac72b9efa0f9fdb64135 \ + --hash=sha256:f7f022a1103cae591c75fc4565589a515f2ddd14a6ac8e8a05812dfeda142e28 + # via + # semantica (pyproject.toml) + # fastembed +openai==2.54.0 \ + --hash=sha256:89089789197ccdb87f173a03145ed1598d00795220c93e96cf712b1cbf5e5f2b \ + --hash=sha256:e3e6f8bc1ba30ddf381ace1a14340eed381cb984a1a59bd0f34b5be3b5d49cfa + # via + # semantica (pyproject.toml) + # instructor + # litellm +opencv-python==5.0.0.93 \ + --hash=sha256:08d5d91d967b58d6db86073b2ad3eaef88ca4ebdfd45c9059bf59f5ded0c7ad2 \ + --hash=sha256:198a75138241810206a17c829dbcc40a7cb1841cda538ca86cbbfc6c7d95f898 \ + --hash=sha256:4b4b1a34c79bf8d3738e3cfe9a9e67b51a79663f6b692cbdad8c31f570da4157 \ + --hash=sha256:66aac3e5b5faa48d4025816592f3af19e4bfc2c68dec067bae2dbb4ca10aa9e2 \ + --hash=sha256:6bbc32f59e1b1a7db7b39c81f63d00625f041d333037fd8702f6da52cc39108b \ + --hash=sha256:c8de2dec111122a02e8beb28e16c31904992dfd6186560b142a92c71403c1039 \ + --hash=sha256:e2b4272e736836f66c2d176e43ab8101f3a00d45654916399f52e150c58981ac \ + --hash=sha256:f8b6d0a212253dd26ad338c812f1f23ca118fdf05a9c8c6b9444f161aa8c5881 \ + --hash=sha256:f90ba04b8f73bc5c3814037699739f0156f597338a98f05956c684e7c3ca10d2 + # via + # semantica (pyproject.toml) + # rapidocr +openpyxl==3.1.5 \ + --hash=sha256:5282c12b107bffeef825f4617dc029afaf41d0ea60823bbb665ef3079dc79de2 \ + --hash=sha256:cf0e3cf56142039133628b5acffe8ef0c12bc902d2aadd3e0fe5878dc08d1050 + # via + # semantica (pyproject.toml) + # docling-slim +opentelemetry-api==1.43.0 \ + --hash=sha256:107d0d03857ea8fc7c5fcbbbd83f800c281f0d560553d61c1d675fccfd1761c1 \ + --hash=sha256:20acf45e9b21851926835292e4045d290acade1edd2ff3de86d2f069687ba1fd + # via + # semantica (pyproject.toml) + # opentelemetry-instrumentation + # opentelemetry-sdk + # opentelemetry-semantic-conventions +opentelemetry-instrumentation==0.64b0 \ + --hash=sha256:133ab7ffca796557aec059bf6be3190a34b6dea987f25be3d9409e230cbdad8b \ + --hash=sha256:b47d528dead6271d7743114417eb67fc915bd9258111c48dbf9a4951d2efa88d + # via semantica (pyproject.toml) +opentelemetry-sdk==1.43.0 \ + --hash=sha256:d1323a547c1ce69d6a069a17a44b7da82bb8b332051ecb074041f87642c86823 \ + --hash=sha256:d8187c81c162df9913e4003dd6485f7390d9a24fc17026ec7387b8b8218b08e9 + # via semantica (pyproject.toml) +opentelemetry-semantic-conventions==0.64b0 \ + --hash=sha256:72f76fb2d1582d9d033dd1fcd84532e961e6ff3d90d24ba6fabc72975a83864c \ + --hash=sha256:ea77e85e354b8f604ddbe5f3d9135216f982fa4d77e5859ac30f6d8a50505aa6 + # via + # semantica (pyproject.toml) + # opentelemetry-instrumentation + # opentelemetry-sdk +orjson==3.11.9 \ + --hash=sha256:011382e2a60fda9d46f1cdee31068cfc52ffe952b587d683ec0463002802a0f4 \ + --hash=sha256:03db380e3780fa0015ed776a90f20e8e20bb11dde13b216ce19e5718e3dfba62 \ + --hash=sha256:051b102c93b4f634e89f3866b07b9a9a98915ada541f4ec30f177067b2694979 \ + --hash=sha256:08f4d8ebb44925c794e535b2bebc507cebf32209df81de22ae285fb0d8d66de0 \ + --hash=sha256:0b34789fa0da61cf7bef0546b09c738fb195331e017e477096d129e9105ab03d \ + --hash=sha256:0e4eed3b200023042814d2fc8a5d2e880f13b52e1ed2485e83da4f3962f7dc1a \ + --hash=sha256:115ab5f5f4a0f203cc2a5f0fb09aee503a3f771aa08392949ab5ca230c4fbdbd \ + --hash=sha256:135869ef917b8704ea0a94e01620e0c05021c15c52036e4663baffe75e72f8ce \ + --hash=sha256:147302878da387104b66bb4a8b0227d1d487e976ce41a8501916161072ed87b1 \ + --hash=sha256:14ed654580c1ed2bc217352ec82f91b047aef82951aa71c7f64e0dcb03c0e180 \ + --hash=sha256:16969c9d369c98eb084889c6e4d2d39b77c7eb38ceccf8da2a9fff62ae908980 \ + --hash=sha256:19b72ed11572a2ee51a67a903afbe5af504f84ed6f529c0fe44b0ab3fb5cc697 \ + --hash=sha256:231742b4a11dad8d5380a435962c57e91b7c37b79be858f4ef1c0df1a259897e \ + --hash=sha256:25e4aed0312d292c09f61af25bba34e0b2c88546041472b09088c39a4d828af1 \ + --hash=sha256:26a473dbb4162108b27901492546f83c76fdcea3d0eadff00ae7a07e18dcce09 \ + --hash=sha256:277fefe9d76ee17eb14debf399e3533d4d63b5f677a4d3719eb763536af1f4bd \ + --hash=sha256:2d057a602cdd19a0ad680417527c45b6961a095081c0f46fe0e03e304aac6470 \ + --hash=sha256:32ef5f4283a3be81913947d19608eacb7c6608026851123790cd9cc8982af34b \ + --hash=sha256:33d7d766701847dc6729846362dc27895d2f2d2251264f9d10e7cb9878194877 \ + --hash=sha256:34fd2317602587321faab75ab76c623a0117e80841a6413654f04e47f339a8fb \ + --hash=sha256:3513550321f8c8c811a7c3297b8a630e82dc08e4c10216d07703c997776236cd \ + --hash=sha256:380cdce7ba24989af81d0a7013d0aaec5d0e2a21734c0e2681b1bc4f141957fe \ + --hash=sha256:3a81d52442a7c99b3662333235b3adf96a1715864658b35bb797212be7bddb97 \ + --hash=sha256:3ebca4179031ee716ed076ffadc29428e900512f6fccee8614c9983157fcf19c \ + --hash=sha256:48ee05097750de0ff69ed5b7bbcf0732182fd57a24043dcc2a1da780a5ead3a5 \ + --hash=sha256:4bab1b2d6141fe7b32ae71dac905666ece4f94936efbfb13d55bb7739a3a6021 \ + --hash=sha256:4d4e98d6f3b8afed8bc8cd9718ec0cdf46661826beefb53fe8eafb37f2bf0362 \ + --hash=sha256:4d7fde5501b944f83b3e665e1b31343ff6e154b15560a16b7130ea1e594a4206 \ + --hash=sha256:4da3c38a2083ca4aaf9c2a36776cce3e9328e6647b10d118948f3cfb4913ffe4 \ + --hash=sha256:4e39364e726a8fff737309aff059ff67d8a8c8d5b677be7bb49a8b3e84b7e218 \ + --hash=sha256:4fd66214623f1b17501df9f0543bef0b833979ab5b6ded1e1d123222866aa8c9 \ + --hash=sha256:4fef17e1f8722c11587a6ef18e35902450221da0028e65dbaaa543619e68e48f \ + --hash=sha256:53b50b0e14084b8f7e29c5ce84c5af0f1160169b30d8a6914231d97d2fe297d4 \ + --hash=sha256:57ea77fb70a448ce87d18fca050193202a3da5e54598f6501ca5476fb66cfe02 \ + --hash=sha256:59e403b1cc5a676da8eaf31f6254801b7341b3e29efa85f92b48d272637e77be \ + --hash=sha256:5b192c6cf397e4455b11523c5cf2b18ed084c1bbd61b6c0926344d2129481972 \ + --hash=sha256:5f63aaf97afd9f6dec5b1a68e1b8da12bfccb4cb9a9a65c3e0b6c847849e7586 \ + --hash=sha256:63e0efbc991250c0b3143488fa57d95affcabbfc63c99c48d625dd37779aafe2 \ + --hash=sha256:6cc7923789694fd58f001cbcac7e47abc13af4d560ebbfcf3b41a8b1a0748124 \ + --hash=sha256:71e63adb0e1f1ed5d9e168f50a91ceb93ae6420731d222dc7da5c69409aa47aa \ + --hash=sha256:71f3db16e69b667b132e0f305a833d5497da302d801508cbb051ed9a9819da47 \ + --hash=sha256:844417969855fc7a41be124aafe83dc424592a7f77cd4501900c67307122b92c \ + --hash=sha256:8697ab6a080a5c46edaad50e2bc5bd8c7ca5c66442d24104fa44ec74910a8244 \ + --hash=sha256:87e4d4ab280b0c87424d47695bec2182caf8cfc17879ea78dab76680194abc13 \ + --hash=sha256:8aff7da9952a5ad1cef8e68017724d96c7b9a66e99e91d6252e1b133d67a7b10 \ + --hash=sha256:8ecc30f10465fa1e0ce13fd01d9e22c316e5053a719a8d915d4545a09a5ff677 \ + --hash=sha256:97d0d932803c1b164fde11cb542a9efcb1e0f63b184537cca65887147906ff48 \ + --hash=sha256:97db4c94a7db398a5bd636273324f0b3fd58b350bbbac8bb380ceb825a9b40f4 \ + --hash=sha256:9af678d6488357948f1f84c6cd1c1d397c014e1ae2f98ae082a44eb48f602624 \ + --hash=sha256:9ef6fe90aadef185c7b128859f40beb24720b4ecea95379fc9000931179c3a49 \ + --hash=sha256:9f78cf8fec5bd627f4082b8dfeac7871b43d7f3274904492a43dab39f18a19a0 \ + --hash=sha256:a028425d1b440c5d92a6be1e1a020739dfe67ea87d96c6dbe828c1b30041728b \ + --hash=sha256:a6082706765a95a6680d812e1daf1c0cfe8adec7831b3ff3b625693f3b461b1c \ + --hash=sha256:a8f5f8bc7ce7d59f08d9f99fa510c06496164a24cb5f3d34537dbd9ca30132e2 \ + --hash=sha256:aaea64f3f467d22e70eeed68bdccb3bc4f83f650446c4a03c59f2cba28a108db \ + --hash=sha256:ace6c58523302d3b97b6ac5c38a5298a54b473762b6be82726b4265c41029f92 \ + --hash=sha256:b3afcf569c15577a9fe64627292daa3e6b3a70f4fb77a5df246a87ec21681b94 \ + --hash=sha256:b6ef1979adc4bc243523f1a2ba91418030a8e29b0a99cbe7e0e2d6807d4dce6e \ + --hash=sha256:be4fa4f0af7fa18951f7ab3fc2148e223af211bf03f59e1c6034ec3f97f21d61 \ + --hash=sha256:c2d3dc759490128c5c1711a53eeaa8ee1d437fd0038ffd2b6008abf46db3f882 \ + --hash=sha256:c5d001196b89fa9cf0a4ab79766cd835b991a166e4b621ba95089edc50c429ff \ + --hash=sha256:cce9127885941bd28f080cecf1f1d288336b7e0d812c345b08be88b572796254 \ + --hash=sha256:cde1a448023ba7d5bb4c01c5afb48894380b5e4956e0627266526587ef4e535f \ + --hash=sha256:d4087e5c0209a0a8efe4de3303c234b9c44d1174161dcd851e8eea07c7560b32 \ + --hash=sha256:d8ea516b3726d190e1b4297e6f4e7a8650347ae053868a18163b4dd3641d1fff \ + --hash=sha256:e30ab17845bb9fa54ccf67fa4f9f5282652d54faa6d17452f47d0f369d038673 \ + --hash=sha256:e5c9b8f28e726e97d97696c826bc7bea5d71cecd63576dba92924a32c1961291 \ + --hash=sha256:ea407d4ccf5891d667d045fecae97a7a1e5e87b3b97f97ae1803c2e741130be0 \ + --hash=sha256:ea5c46eb2d3af39e806b986f4b09d5c2706a1f5afde3cbf7544ce6616127173c \ + --hash=sha256:eebdbdeef0094e4f5aefa20dcd4eb2368ab5e7a3b4edea27f1e7b2892e009cf9 \ + --hash=sha256:f01c4818b3fc9b0da8e096722a84318071eaa118df35f6ed2344da0e73a5444f \ + --hash=sha256:f36b7f32c7c0db4a719f1fc5824db4a9c6f8bd1a354debb91faf26ebf3a4c71e \ + --hash=sha256:f5d89a2ed90731df3be64bab0aa44f78bff39fdc9d71c291f4a8023aa46425b7 \ + --hash=sha256:ffe02797b5e9f3a9d8292ddcd289b474ad13e81ad83cd1891a240811f1d2cb81 + # via pymilvus +overrides==7.7.0 \ + --hash=sha256:55158fa3d93b98cc75299b1e67078ad9003ca27945c76162c1c0766d6f91820a \ + --hash=sha256:c7ed9d062f78b8e4c1a7b70bd8796b35ead4d9f510227ef9c5dc7626c60d7e49 + # via jupyter-server +owlrl==7.6.2 \ + --hash=sha256:83347bf7f133979e87b2b18695d51d25510b99cec3f6919b5df05d4fbf058ae0 \ + --hash=sha256:c743f35c2d908396e77823852bb1ebbce88340cd49961493983bec42c93283a8 + # via pyshacl +packaging==26.3 \ + --hash=sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79 \ + --hash=sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c + # via + # accelerate + # agno + # black + # d3graph + # deprecation + # distfit + # faiss-cpu + # huggingface-hub + # ipykernel + # jupyter-events + # jupyter-server + # jupyterlab + # jupyterlab-server + # kombu + # lazy-loader + # matplotlib + # nbconvert + # onnxruntime + # opentelemetry-instrumentation + # plotly + # pooch + # pyshacl + # pytest + # spacy + # statsmodels + # thinc + # transformers + # weasel +pandas==3.0.5 \ + --hash=sha256:08d24fe11a17dc33bd6e937dc9c665f9cba08fbdc9f657f405713515febe300d \ + --hash=sha256:0d298e951f23016ce4699951d044ae6418dbc91bf68cefca0f77666fcbb4e5c6 \ + --hash=sha256:0fac0010c75e4efb6b99e249c183a8993ce0dc95c240f9b120a5e67c727b7928 \ + --hash=sha256:1c10461f6eeb35d8f05b6184c65c8b9991663b66c46b1d559b682cb34ae7c6ea \ + --hash=sha256:25ff585b972a18ef1fe9ffa3ac6544d9950508aa76832e5147640b6022821e49 \ + --hash=sha256:2946e77e4a53cd248cbde631a12f0e51c8324ce354c3eba4d20147c1ad6f4282 \ + --hash=sha256:2a29c53d85ea98c5e792c59ef82ee9fbe6ca902c0d0adb6b23f45ef894cd7bf6 \ + --hash=sha256:2c0cf1dd9b55a22d105fc46c1b489af3bd42264fcba7c66297bf47a9a1d9c78a \ + --hash=sha256:2f264fc46911cc8131a7322a16199bbf8e353d27c10bb211f5bd0c814324dc36 \ + --hash=sha256:303da736987d481074ca720ada325f8bd80c64ebc2d45ed79b29df3aaa4a26ca \ + --hash=sha256:3b2801bbb049d0136f6c213eae02b5fca969384fc2064dd728d8620552aa49da \ + --hash=sha256:3c5015fd1730fbf883647e88068176c839c102cea883ba1769a6f4593bfc1f8c \ + --hash=sha256:3c5ed2e7c06e91d340dfd091d7934f9bc82e4a36b95f647f090b9d1c9ac649da \ + --hash=sha256:4b11c36e218331d0387cbe3a0a5f75162357a1d92d57b2b08a336ff94b19b2be \ + --hash=sha256:5183427f5a8156d480f30333777bc978be93650a49a7c01db26adffe95b31e85 \ + --hash=sha256:53730687fcd161883b24e10411c06d6a4c0f2275d2faf3bb2bc25deb4ba8007c \ + --hash=sha256:66266d3442a5e8b3c90274c2b8b230bee42dd1c286bc822cc2f9f2c7e12b883e \ + --hash=sha256:679f4e85b30ddb1515458ab1e788d3e260eae369b1f78da7a3aa4cac8ebf4a2a \ + --hash=sha256:71ecc8fb7ed1a7aa4392316b5309a6347e8e7f832f38fd897846b3a1457a9298 \ + --hash=sha256:73fa87b08a7ef706f8aafda39ddaccf2a99047bea62d8c88a0361bcafb2237bc \ + --hash=sha256:80a611068e8a3ac23f7398c6c14eb46dc974e5cc9997f653e2dcfd1da74edd41 \ + --hash=sha256:960d3ebcf249f75206899fcd2c6de53f736b7265759ced0d3e559df0b8b709b0 \ + --hash=sha256:9e94c2c5ca43bd3ca32bf64d32308887b65e5f9bfd8023ea52755107a999f93b \ + --hash=sha256:a5ad3b02ed6bc7d7ae9b70804b2c6aa31827489d150f8e623ce82491b82085d7 \ + --hash=sha256:b1261758dfb6cf12c3cff8300e21cefad30e7ec709abb4c24ac7318e6a52462a \ + --hash=sha256:b173f5951ff6b8b0ec7675e20dff3c97b7e7a57dfcce387c2d7c5afe87cb7899 \ + --hash=sha256:b2acb4650527eec6822c3dadb2b771277b65e7dae7a267d4bccf65fd1bb3fbce \ + --hash=sha256:b58b1b39d46a5862e3fb18f50d1a201398619d16a0f9f73f57eea5583cf0e63c \ + --hash=sha256:b86765f268b56f7e665b93bce9d5df69dee7f99e595cf8fb839483ab315942a3 \ + --hash=sha256:c1c05a767fe8e5b4fe9e1c29806829c582052eaedb9120a3da83ba3f69e24a5b \ + --hash=sha256:c2e26bb46934b8a2ca0c3de1d3d606fc5f6746584791b2db264d58cf370e08dc \ + --hash=sha256:c597ecf5616b5c420372c1d4d4c00dbbfba7398bea857dcc984347e1ea48417b \ + --hash=sha256:cce3a9d11d2b1f82c69a27ec1f4948a170e2c403c4bbfa8cca62e3fdebe2ef3a \ + --hash=sha256:cd8f7c6dc98527058ee6264219343f5392240a6f1bfa654fc5d79023020d0c92 \ + --hash=sha256:cf52e1f61d229496da17dc7ab54acdee627357e7008fd4fecba3d0ba2937fa58 \ + --hash=sha256:d373ce03ffd84010ed9839fa73672a9c8256990532e158440c0085db7d914b34 \ + --hash=sha256:db172144bb56422bd157812f3b021eacc255451470b31e2c633c349490a1cfee \ + --hash=sha256:dca3734d6ab7c906e6730f0788b0a1dbb9f2467731f9711f77995c8e9d62d712 \ + --hash=sha256:e2759e890db96dfcffdbd9b86c3c2cb6afaf58def482820317e06163ec1066cd \ + --hash=sha256:e819dd5f62966b481a8cb649d3299ebd886a1ea91ed5a99bf7ce77c98d18ab94 \ + --hash=sha256:ef01af4d8dc6cd2c8d6c7736f149574ef93fe043811eeb5e445f2647154b5040 \ + --hash=sha256:fa290c16964d4963fbfbc358928239cf3bd755b20e988ce944877def2f44471d + # via + # semantica (pyproject.toml) + # bertopic + # d3blocks + # d3graph + # datazets + # distfit + # docling-core + # pymilvus + # seaborn + # statsmodels +pandocfilters==1.5.1 \ + --hash=sha256:002b4a555ee4ebc03f8b66307e287fa492e4a77b4ea14d3f934328297bb4939e \ + --hash=sha256:93be382804a9cdb0a7267585f157e5d1731bbe5545a85b268d6f5fe6232de2bc + # via nbconvert +parso==0.8.7 \ + --hash=sha256:a8926eb2a1b915486941fdbd31e86a4baf88fe8c210f25f2f35ecec5b574ca1c \ + --hash=sha256:eaaac4c9fdd5e9e8852dc778d2d7405897ec510f2a298071453e5e3a07914bb1 + # via jedi +pathspec==1.1.1 \ + --hash=sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a \ + --hash=sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189 + # via + # black + # mypy +patsy==1.0.2 \ + --hash=sha256:37bfddbc58fcf0362febb5f54f10743f8b21dd2aa73dec7e7ef59d1b02ae668a \ + --hash=sha256:cdc995455f6233e90e22de72c37fcadb344e7586fb83f06696f54d92f8ce74c0 + # via statsmodels +pexpect==4.9.0 \ + --hash=sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523 \ + --hash=sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f + # via ipython +pgvector==0.5.0 \ + --hash=sha256:07a9dcf735696879406983afc6eba9a787cef7c0cf6c367ca1a5779f036dee74 \ + --hash=sha256:fedc9800894e6da2be51358d7b7c574bf34f247ca741a5a09513622135f5964f + # via semantica (pyproject.toml) +pika==1.4.4 \ + --hash=sha256:48de960c97a93b55db06b8be4c53eb977c9c8a2754c57cdae9097abcbd70ce04 \ + --hash=sha256:8cfc8b33a5cb16e733bd60cffca9732c0d1d761ecd80a89f34ed7df2cd38d6d6 + # via semantica (pyproject.toml) +pillow==12.3.0 \ + --hash=sha256:00808c5e14ef63ac5161091d242999076604ff74b883423a11e5d7bbb38bf756 \ + --hash=sha256:04f01d28a6aaff387bf842a13be313df23ba0597a44f1a976c9feb3c6ff4711a \ + --hash=sha256:06ff022112bc9cbf83b60f8e028d94ad87b60621706487e65f673de61610ab59 \ + --hash=sha256:0740a512dc522224c77d9aa5a8d70d8b7d73fb91f2c21125d8d025d3b8990e45 \ + --hash=sha256:0847a763afefb695bc912d7c131e7e0632d4edc1d8698f58ddabec8e46b8b6d3 \ + --hash=sha256:0dd2064cbc55aaec028ef5fbb60fa47bb6c3e7918e07ff17935284b227a9d2df \ + --hash=sha256:0feb2e9d6ad6c9e3c06effe9d00f3f1e618a6643273576b016f591e9315a7139 \ + --hash=sha256:10e41f0fbf1eec8cfd234b8fe17a4caac7c9d0db4c204d3c173a8f9f6ef3232b \ + --hash=sha256:1182d52bc2d5e5d7d0949503aa7e36d12f42205dc287e4883f407b1988820d39 \ + --hash=sha256:164b31cd1a0490ab6efae01aa5df49da7061be0af1b30e035b6e9a1bfe34ee6e \ + --hash=sha256:1657923d2d45afb66526e5b933e5b3052e6bdea196c90d3abb2424e18c77dae8 \ + --hash=sha256:186941b6aef820ad110fb01fb06eb925374dc3a21b17e37ec9a53b250c6fe2d1 \ + --hash=sha256:1cca606cd25738df4ed873d5ad46bbdb3d83b5cbca291f6b4ff13a4df6b0bbe8 \ + --hash=sha256:21900ce7ba264168cd50defae43cd75d25c833ad4ad6e73ffc5596d12e25ac89 \ + --hash=sha256:236ff70b9312fb68943c703aa842ca6a758abfa45ac187a5e7c1452e96ef72b5 \ + --hash=sha256:23aceaa007d6172b02c277f0cd359c79492bbb14f7072b4ede9fbcaf20648130 \ + --hash=sha256:23d27a3e0307ec2244cc51e7287b919aa68d097504ebe19df4e76a98a3eea5bd \ + --hash=sha256:24870b09b224f7ae3c39ed07d10e819d06f8720bc551847b1d623832b5b0e28d \ + --hash=sha256:251bf95b67017e27b13d82f5b326234ca62d70f9cf4c2b9032de2358a3b12c7b \ + --hash=sha256:25b9b82bb22e6e2b3cd07b39c68b7b862001226cb3dff7130d1cb914121b39ed \ + --hash=sha256:28ce87c5ab450a9dd970b52e5aca5fe63ed432d18a2eaddd1979a00a1ba24ace \ + --hash=sha256:300557495eb45ebb8aec96c2da9c4be642fbf7cd937278b4013ba894ea8eb0eb \ + --hash=sha256:30f2aa603c41533cc25c05acd0da21636e84a315768feb631c937177db558931 \ + --hash=sha256:331b624368d4f1d069149002f25f44bc61c8919ce8ddb3c45bdad8f6e2d89510 \ + --hash=sha256:37d6d0a00072fd2948eb22bce7e1475f34569d90c87c59f7a2ec59541b77f7a6 \ + --hash=sha256:37dc8f7bbb66efe481bb60defacef820c950c24713fb44962ed6aa2a50966de1 \ + --hash=sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce \ + --hash=sha256:3edce1d53195db527e0191f84b71d02022de0540bf43a16ed734ed7537b07385 \ + --hash=sha256:446c34dcc4324b084a53b705127dc15717b22c5e140ae0a3c38349d4efec071e \ + --hash=sha256:4998562bf62a445225f22e07c896bb04b35b1b1f2eb6d760584c9c51d7a5f78c \ + --hash=sha256:4b0a7fe987b14c31ebda6083f74f22b561fd3739bc0ac51e019622e3d72668c7 \ + --hash=sha256:4e8c2a84d977f50b9daed6eeaf3baef67d00d5d74d932288f02cb94518ee3ace \ + --hash=sha256:4f883547d4b7f0495ebe7056b0cc2aea76094e7a4abc8e933540f3271df27d9c \ + --hash=sha256:514435a37670e3e5e08f3945b68718b6ed329bb84367777e16f9f4dfe1e61a0f \ + --hash=sha256:53aa02d20d10c3d814d536aa4e5ac9b84ca0ff5a88377963b085ad6822f93e64 \ + --hash=sha256:5594fc43d548a7ed94949d139aa1341b270f1863f11cfd37f5a6c8b778a6b67f \ + --hash=sha256:571b9fcb07b97ef3a492028fb3d2dc0993ca23a06138b0315286566d29ef718a \ + --hash=sha256:57b3d78c95ba9059768b10e28b813002261d3f3dfc55cc48b0c988f625175827 \ + --hash=sha256:5afb51d599ea772b8365ae807ae557f18bccfe46ab261fd1c2a9ed700fc6eb17 \ + --hash=sha256:6b02afb9b97f65fbca5f31db6a2a3ba21aa93030225f150fa3f249717e938fb4 \ + --hash=sha256:6c0016e7b354317c4e9e525b937ac8596c38d2d232b419529b9cd7a1cd46e39a \ + --hash=sha256:71d6097b330eea8fd15097780c8e89cb1a8ce7838669f48c5bacd6f663dd4701 \ + --hash=sha256:756c768d0c9c2955feb7a56c37ea24aea2e369f8d36a88da270b6a9f19e62b5e \ + --hash=sha256:78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91 \ + --hash=sha256:7a743ff716f746fc19a9557f60dab1600d4613255f8a7aeb3cdde4db7eb15a66 \ + --hash=sha256:85f998ea1848bc6757289e739cfbdda3a04adfd58b02fc018ce54d754a5ce468 \ + --hash=sha256:8728f216dcdb6e6d555cf971cb34076139ad74b31fc2c14da4fafc741c5f6217 \ + --hash=sha256:877c3f311ff35410f690861c4409e7ccbf0cd2f878e50628a28e5a0bb689e658 \ + --hash=sha256:8cd2f7bdda092d99c9fc2fb7391354f306d01443d22785d0cbfafa2e2c8bb418 \ + --hash=sha256:8e95e1385e4998ae9694eeaa4730ba5457ff61185b3a55e2e7bea0880aef452a \ + --hash=sha256:962864dc93511324d51ddbb5b9f8731bf71675b93ca612a07441896f4688fb8c \ + --hash=sha256:9cf95fe4d0f84c82d282745d9bb08ad9f926efa00be4697e767b814ce40d4330 \ + --hash=sha256:9e881fca225083806662a5c43d627d215f258ff43c890f831966c7d7ba9c7402 \ + --hash=sha256:a2b55dd6b2a4c4b7d87ffa56bdb33fdc5fdb9a462173861a7bc097f17d91cb09 \ + --hash=sha256:a45650e8ce7fafffd731db8550230db6b0d306d181a90b67d3e6bca2f1990930 \ + --hash=sha256:a876864214e136f0eb367788dbd7df045f4806801518e2cfe9e13229cfe06d8f \ + --hash=sha256:ae26d61dfa7a47befdc7572b521024e8745f3d809bd95ca9505a7bba9ef849ec \ + --hash=sha256:af8d94b0db561cf68b88a267c5c44b49e134f525d0dc2cb7ed413a66bc23559a \ + --hash=sha256:b343699e8308bdc51978310e1c959c584e7869cc8c40780058c87da7781a1e94 \ + --hash=sha256:b3c777e849237620b022f7f297dd67705f9f5cf1685f09f02e46f93e92725468 \ + --hash=sha256:b629de27fda84b42cde7edef0d85f13b958b47f6e9bbcbba9b673c562a89bd8b \ + --hash=sha256:ba09209fbe443b4acccebe845d8a138b89a8f4fbaeedd44953490b5315d5e965 \ + --hash=sha256:ba54cfebe86920a559a7c4d6b9050791c20513650a1952ebe3368c7dc70306f8 \ + --hash=sha256:bcb46e2f9feff8d06323983bd83ed00c201fdcab3d74973e7072a889b3979fcd \ + --hash=sha256:bcc33feacfaefce60c12fd500a277533bdc02b10a19f7f6d348763d8140bbba7 \ + --hash=sha256:bf16ba1b4d0b6b7c8e534936632270cf70eb00dbe09005bc345b2677b726855c \ + --hash=sha256:cf1845d02ad822a369a49f2bb9345b1614744267682e7a03527dc3bf6eea1777 \ + --hash=sha256:d69141514cc30b774ceea5e3ed3a6635c8d8a96edf664689b890f4089111fb35 \ + --hash=sha256:d9c7f76c0673154f044e9d78c8655fb4213f6ca31a836df48b40fe5d187717b9 \ + --hash=sha256:dbce0b29841537a2fa4a214c2bbf14de3587c9680caa9b4e217568472490b28f \ + --hash=sha256:dc624f6bc473dacdf7ef7eb8678d0d08edf15cd94fad6ae5c7d6cc67a4e4902f \ + --hash=sha256:e158cb00350dc278f3b91551101aa7d12415a66ebf2c91d8d5ac14e56ddd3ad0 \ + --hash=sha256:e491916b378fba47242221bb9ead245211b70d504f495d105d17b14a24b4907c \ + --hash=sha256:e795b7eb908249c4e43c7c99fac7c2c75dab0c43566e37db472a355f63693d71 \ + --hash=sha256:e7e480451b9fa137494bccd3a7d69adbe8ac65a87d97be61e11f1b1050a5bac3 \ + --hash=sha256:e91206ee562682b51b98ef4b26a6ef48fd84e15fd4c4bc5ec768eb641d206838 \ + --hash=sha256:e9871b1ffbfa9656b60aeee92ed5136a5742696006fa322b29ea3d8da0ecc9cf \ + --hash=sha256:e9aeb04d6aef139de265b29683e119b638208f88cf73cdd1658aa07221165321 \ + --hash=sha256:ebaea975e03d3141d9d3a507df75c9b3ec90fa9d2ffd07567b3a978d9d790b26 \ + --hash=sha256:f0606c8bf2cdefea14a43530f7657cbbb7ecf1c4222512492ef4a4434a9501ec \ + --hash=sha256:f13c32a3abd6079a66d9526e18dad9b6d280384d49d7c54040cd57b6424041d9 \ + --hash=sha256:f7401aebd7f581d7f83a439d87d474999317ee099218e5ad25d125290990ba65 \ + --hash=sha256:fa4ecea169a355be7a3ade2c783e2ed12f0e40d2c5621cda8b3297faf7fbb9f5 \ + --hash=sha256:fbd139c8447d25dd750ab79ee274cc5e1fe80fc56340ab10b18a195e1b6eca3e \ + --hash=sha256:fdafc9cce40277e0f7a0feabce0ee50dd2fa1800f3b38015e51296b5e814048d \ + --hash=sha256:fe3cca2e4e8a592be0f269a1ca4835c25199d9f3ce815c8491048f785b0a0198 \ + --hash=sha256:ffd0c5368496f41b0944be820fcb7a838aa6e623d250b01acf2643939c3f99d7 + # via + # semantica (pyproject.toml) + # docling-core + # docling-ibm-models + # docling-parse + # docling-slim + # fastembed + # matplotlib + # python-pptx + # rapidocr + # torchvision +pinecone-client==6.0.0 \ + --hash=sha256:d81a9e73cae441e4ab6dfc9c1d8b51c9895dae2488cda64f3e21b9dfc10c8d94 \ + --hash=sha256:f224fc999205e4858c4737c40922bdf42d178b361c8859bc486ec00d45b359a9 + # via semantica (pyproject.toml) +pinecone-plugin-interface==0.0.7 \ + --hash=sha256:875857ad9c9fc8bbc074dbe780d187a2afd21f5bfe0f3b08601924a61ef1bba8 \ + --hash=sha256:b8e6675e41847333aa13923cc44daa3f85676d7157324682dc1640588a982846 + # via pinecone-client +platformdirs==4.11.2 \ + --hash=sha256:3a2ae5fca3520a01ab1be8b45613537f52ddf5b5f6f53d88233892dfbf0cd82d \ + --hash=sha256:7f89089b6ea71bda7962953edcf784b2e2d9d285b40ad88be2bb75c6e9d82ab4 + # via + # black + # jupyter-core + # pooch + # virtualenv +plotly==6.9.0 \ + --hash=sha256:36bebe2f1bb13884774fe61689c329071446f6ce4a8927fb1f0d6fb24f581236 \ + --hash=sha256:967ad33e8c704fed051800d11d985eb206a9c795c14206b30a6f463ed9c67d0d + # via + # semantica (pyproject.toml) + # bertopic +pluggy==1.6.0 \ + --hash=sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3 \ + --hash=sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746 + # via + # docling-slim + # pytest + # pytest-cov +polyfactory==3.3.0 \ + --hash=sha256:237258b6ff43edf362ffd1f68086bb796466f786adfa002b0ac256dbf2246e9a \ + --hash=sha256:686abcaa761930d3df87b91e95b26b8d8cb9fdbbbe0b03d5f918acff5c72606e + # via docling-slim +pooch==1.9.0 \ + --hash=sha256:de46729579b9857ffd3e741987a2f6d5e0e03219892c167c6578c0091fb511ed \ + --hash=sha256:f265597baa9f760d25ceb29d0beb8186c243d6607b0f60b83ecf14078dbc703b + # via librosa +portalocker==3.2.0 \ + --hash=sha256:1f3002956a54a8c3730586c5c77bf18fae4149e07eaf1c29fc3faf4d5a3f89ac \ + --hash=sha256:3cdc5f565312224bc570c49337bd21428bba0ef363bbcf58b9ef4a9f11779968 + # via qdrant-client +pre-commit==4.6.2 \ + --hash=sha256:8f5d7bfb021ecdbcd9d49d89847082dd24172ccde534390081a679ad046e2441 \ + --hash=sha256:e2dde9a75d3bce11bd3831c26d134df00a2803c1d818be6a0383c3dcda25dc4e + # via semantica (pyproject.toml) +preshed==3.0.13 \ + --hash=sha256:04d8f13f2986e5d11af5ac51f55ce3106c70c41b483d20ea392e6180bdd0f870 \ + --hash=sha256:09397592d333a77f88454e72b7f1f941b2afaf040b392b9e74898dbc4648cdf5 \ + --hash=sha256:09f96b477c987755b3c945df214ea1c1c80bfb350e9f34e78da89585535b77e8 \ + --hash=sha256:0e5b2865aecbd2e1e10e5d19bb8bfad765863c1307c6c3e51f2a08bd64122409 \ + --hash=sha256:183b339956a9e1d7a4a00038a3b9587a734db9e8bd915939a49791bd1b372156 \ + --hash=sha256:19318dc1cd8cac6663c6c830bf7e0002d2de853769fb03e056774e97c21bedfd \ + --hash=sha256:208dcebbe294bf1881ce33fb015d56ab2a7587aece85a09147727174207892e4 \ + --hash=sha256:2b704e46cb7b88f656ef16a3e5347b36525a1c53721d327a4ba1457404101f85 \ + --hash=sha256:2e77bed56aded7cbe5d28d6bd2178bc5b13eda0e0e464dab205fb578fa915000 \ + --hash=sha256:35d6c5acb3ee3b12b87a551913063f0cec784055c2af16e028c19fe875f079d0 \ + --hash=sha256:3e3528f6628329349e281b607aad746ae3c06c15ba59fd5b6599c7c2fd77911b \ + --hash=sha256:40e9445911051bc67cf84ba12745e3be4d010aaf4f8ade3ba3def82fdb45d18e \ + --hash=sha256:42c58b07e8b431e33d0ad9922e896632453821cad8b09171b619b8c61101916f \ + --hash=sha256:461327f8dd36520dcf1fd55a671e0c3c2c97a2d95e22fc85faa31173f4785dda \ + --hash=sha256:4a7bc48220de579be6bdb0a8715482cf36e2a625a6fd5ad26c9f43485a4a23b5 \ + --hash=sha256:4e9ae86c982e49f58620d45eeb51e073e50feb88f52ac10a95fe117c1d222a84 \ + --hash=sha256:4f8856ca3d88e9b250630d70abb4f260d8933151ddfb413024784b25b009868e \ + --hash=sha256:502f93f49a22788203f02d3067d4ea077a0cca3864de6a792eae12e7ce589e14 \ + --hash=sha256:5268c0e6fa96f50cdf87f516c2d4b32563c12706ee768e75c00e8d0098acd545 \ + --hash=sha256:5d14eea14bd01291388928991d7df7d60b9fd19ae970e55006eb4d29b0c1e8eb \ + --hash=sha256:5e2753779832e411e93eb727f3d409c0a6b7408e5ce4dd868076d8ece48c7693 \ + --hash=sha256:62cf7f3113132891d6bba70ff547ad81c6fe50a31930bbbb8499f1d47cd122b7 \ + --hash=sha256:670db59a52e1823b5f088c764df474e65b686592d4093adbeef14581c95ee2cb \ + --hash=sha256:70d502e081348df207d90f347f21770ed596822bb04eb3c3b32b7281579e90c6 \ + --hash=sha256:7557963d0125a3a7bcdb2eb6948f3e45da31b5a7f066b55320de3dea22d7557f \ + --hash=sha256:7770987c2e57497cd26124a9be5f652b5b3ccd0def89859ab0da8bca6144a3de \ + --hash=sha256:7c333f18e9a81c8a6de0603fd8781e17115324b117c445ca91abdf7bfb1abe49 \ + --hash=sha256:7da9d931e7660dcdd757e5870269f0c159126d682ed73ed313971d199eb0f334 \ + --hash=sha256:867aa73abbf4ee3b4d7662148091c33a8c039271269e3a7f1e0ca995f91995c8 \ + --hash=sha256:8b82d7a7bb63d248a6cbbfcabb4a570c993d54d964e39dc5d85c14018ba2079e \ + --hash=sha256:8b8de3f58043070a354477995acdd98626ce43e4193c708ebd0f694e467f5155 \ + --hash=sha256:8d6acc1f5031a535a55a6f7148e2f274554a8343a16309c700cebea0fe7aee8c \ + --hash=sha256:985cb9b097beda76cd13c01a0499707103e8915f888fa30f8aa8324ef2cc6b08 \ + --hash=sha256:9ca43ecbc3783eda4d6ab3416ae2ecd9ef23dca5f53995843f69f7457bcd0677 \ + --hash=sha256:a06e27f4e5b9d7943840087828c6a0dae4a3475576d12c2e95b71abbb325a80b \ + --hash=sha256:a3ac301b065e67e9541f8e3ab3f67533e53deb57c2d258395c5bb98f9723f99b \ + --hash=sha256:a8682988e47739adba369bf43789fc870b554a21e2d1f30a3d17ed336d05f451 \ + --hash=sha256:acd4d89abeca3678c5d8c89b3cd351314465bc67c7fa053d2644f8513e543386 \ + --hash=sha256:b03e21b0bf95eb56e23973f32cabb930e94f352228652f81c0955dbd6967d904 \ + --hash=sha256:b980f3ea9bb74b7f94464bc3d6eb3c9162b6b79b531febd14c6465c24344d2cc \ + --hash=sha256:bef84b225d226af43adfee78ce5ddede72a6155ce5292c1a41dcd1f0b9c87c30 \ + --hash=sha256:c046736239cc8d72670749b79b526e4111839a2fc461a58545d212797649129c \ + --hash=sha256:c0d0c14187dc0078d8a63bf190ec045a4d13e7748b6caeb557a7d575e411410b \ + --hash=sha256:c4bc60dc994864095d784b7e4d77dba3e64188d169ac88722b699d175561fddb \ + --hash=sha256:c8596e41a258ff213553a441e0bb3eb388fd8158e84a7bf3aae6d8ede2c166d3 \ + --hash=sha256:cf8e1a7a1823b2a7765121446c630140ac6e8650c07a6efbf375e168d1fef4f7 \ + --hash=sha256:d0e114300e5577e806c17fb1cc9f07bc6584188d84545401f66e690c8315feef \ + --hash=sha256:d2f1efae396cadab5f3890a2fd43d2ee65373ef9096ccbb805e51e8d8bcc563b \ + --hash=sha256:d4ae5cfe075bb7a07982e382bca44f41ddf041f4d24cbd358e8cccfc049259b8 \ + --hash=sha256:d75f718bbfd97e992f7827e0fa7faf6a91bdd9c922d5baa4b50d62731396cb89 \ + --hash=sha256:dbd7c735a613857ae39ac23bf4690b0d92adc30add977828529b50ba09e33fbc \ + --hash=sha256:de87fbabb0f37c3c92d4dd9b94fc82ab73cdab4247cdfbd57ab3926caa983919 \ + --hash=sha256:df642547a1a94079978a0ea8f4593ab4b8d3bd43f767bef0ef64d9a214f8c4c9 \ + --hash=sha256:e1ab099b2f5843b19e875502b64001b0705e375fb5bd1ca6240aa14e4ffc31e4 \ + --hash=sha256:e5c8462472f790c16708306aef3a102a762bd19dfe3d2f8ee08bd5e12f51b835 \ + --hash=sha256:f05b08ce92399c0655b5e0eb5a1cc1f9e295703ed3aabdfaf6538dfa8ae23d57 \ + --hash=sha256:f8e6fe0620ed0f96a246d46447055c447e071cd8222731a045c235e8a758c918 + # via + # spacy + # thinc +prettytable==3.18.0 \ + --hash=sha256:439217116152244369caf3d9f1caf2f9fe29b03bd79e88d2928c8e718c95d680 \ + --hash=sha256:b3346e0e6f79180833aebaac088ae926340586cf6d7d991b9eb125b65f72313a + # via pyshacl +prometheus-client==0.26.0 \ + --hash=sha256:04a91bcf94e2cf74a44a1a874d651a2e853ed354b6e822f3b7487751465d5c2b \ + --hash=sha256:fa93d06737aa02bacd05794768508bb97d2fbee28cb3bca04eaae92f0ca953d6 + # via + # semantica (pyproject.toml) + # jupyter-server +prompt-toolkit==3.0.53 \ + --hash=sha256:01c0891d7f9237d5e339f7d3e42cdae80b7534abb1c7c0e3352efba6231492f2 \ + --hash=sha256:9ec8a0ad96d5c56148b3f914aa79c1564c3fde5d2e6b876e7bc327e353cf8fa6 + # via + # click-repl + # ipython + # jupyter-console +propcache==0.5.2 \ + --hash=sha256:01c4fc7480cd0598bb4b57022df55b9ca296da7fc5a8760bd8451a7e63a7d427 \ + --hash=sha256:04dc2390d9edbbaef7461f33322555976ffddf0b650a038649d026358714e6c5 \ + --hash=sha256:06187263ddad280d05b4d8a8b3bb7d164cbebd469236544a42e6d9b28ac6a4fa \ + --hash=sha256:0958834041a0166d343b8d2cedcd8bcbaeb4fdbe0cf08320c5379f143c3be6e7 \ + --hash=sha256:099aaf4b4d1a02265b92a977edf00b5c4f63b3b17ac6de39b0d637c9cac0188a \ + --hash=sha256:0d2c9bf8528f135dbb805ce027567e09164f7efa51a2be07458a2c0420f292d0 \ + --hash=sha256:0fd59b5af35f74da48d905dcbad55449ba13be91823cb05a9bd590bbf5b61660 \ + --hash=sha256:10734b5484ea113152ee25a91dccedf81631791805d2c9ccb054958e51842c94 \ + --hash=sha256:13fef48778b5a2a756523fdb781326b028ca75e32858b04f2cdd19f394564917 \ + --hash=sha256:178b4a2cdaac1818e2bf1c5a99b94383fa73ea5382e032a48dec07dc5668dc42 \ + --hash=sha256:196913dea116aeb5a2ba95af4ddcb7ea85559ae07d8eee8751688310d09168c3 \ + --hash=sha256:1b31822f4474c4036bae62de9402710051d431a606d6a0f907fec79935a071aa \ + --hash=sha256:1ca071adabaab6e9219924bbe00af821f1ee7de113a9eca1cdc292de3d120f4d \ + --hash=sha256:1d1ad32d9d4355e2be65574fd0bfd3677e7066b009cd5b9b2dee8aa6a6393b33 \ + --hash=sha256:1dbcf7675229b35d31abb6547d8ebc8c27a830ac3f9a794edff6254873ec7c0a \ + --hash=sha256:2293949b855ce597f2826452d17c2d545fb5622379c4ea6fdf525e9b8e8a2511 \ + --hash=sha256:26a4dca084132874e639895c3135dfad5eb20bae209f62d1aeb31b03e601c3c0 \ + --hash=sha256:2800a4a8ead6b28cccd1ec54b59346f0def7922ee1c7598e8499c733cfbb7c84 \ + --hash=sha256:29cbaac5ea0212663e6845e04b5e188d5a6ae6dd919810ac835bf1d3b42c3f4c \ + --hash=sha256:29f9309a2e42b0d273be006fdb4be2d6c39a47f6f57d8fb1cf9f81481df81b66 \ + --hash=sha256:2d7aa89ebca5acc98cba9d1472d976e394782f587bad6661003602a619fd1821 \ + --hash=sha256:2f22cbbac9e26a8e864c0985ff1268d5d939d53d9d9411a9824279097e03a2cb \ + --hash=sha256:2f8ea531c794b9d6274acd4e8d2c2ebcac590a4361d27482edd3010b79f1325e \ + --hash=sha256:3115559b8effafd63b142ea5ed53d63a16ea6469cbc63dce4ee194b42db5d853 \ + --hash=sha256:32775082acd2d807ee3db715c7770d38767b817870acfa08c29e057f3c4d5b56 \ + --hash=sha256:3430bb2bfe1331885c427745a751e774ee679fd4344f80b97bf879815fe8fa55 \ + --hash=sha256:3b199b9b2b3d6a7edf3183ba8a9a137a22b97f7df525feb5ae1eccf026d2a9c6 \ + --hash=sha256:40314bca9ac559716fe374094fc81c11dcc34b64fd6c585360f5775690505704 \ + --hash=sha256:44e488ef40dbb452700b2b1f8188934121f6648f52c295055662d2191959ff82 \ + --hash=sha256:452b5065457eb9991ec5eb38ff41d6cd4c991c9ac7c531c4d5849ae473a9a13f \ + --hash=sha256:45f11346f884bc47444f6e6647131055844134c3175b629f84952e2b5cd62b64 \ + --hash=sha256:46088abff4cba581dea21ae0467a480526cb25aa5f3c269e909f800328bc3999 \ + --hash=sha256:4621064bbf28fa77ff64dd5d94367c04684c67d3a5bf1dff25f0cd0d98a38f3b \ + --hash=sha256:4bc8ff1feffc6a61c7002ffe84634c41b822e104990ae009f44a0834430070bb \ + --hash=sha256:4db0ba63d693afd40d249bd93f842b5f144f8fcbb83de05660373bcf30517b1d \ + --hash=sha256:51f96d685ab16e88cab128cd37a52c5da540809c8b879fa047731bfcb4ad35a4 \ + --hash=sha256:54adaa85a22078d1e306304a40984dc5be99d599bf3dc0a24dc98f7daeab89ab \ + --hash=sha256:552ffadf6ad409844bc5919c42a0a83d88314cedddaea0e41e80a8b8fffe881f \ + --hash=sha256:5538d2c13d93e4698af7e092b57bc7298fd35d1d58e656ae18f23ee0d0378e03 \ + --hash=sha256:5570dbcc97571c15f68068e529c92715a12f8d54030e272d264b377e22bd17a5 \ + --hash=sha256:5671d09a36b06d0fd4a3da0fccbcae360e9b1570924171a15e9e0997f0249fba \ + --hash=sha256:583c19759d9eec1e5b69e2fbef36a7d9c326041be9746cb822d335c8cedc2979 \ + --hash=sha256:5aaa2b923c1944ac8febd6609cb373540a5563e7cbcb0fd770f75dace2eb817b \ + --hash=sha256:5dbc581d2814337da56222fab8dc5f161cd798a434e49bac27930aaef798e144 \ + --hash=sha256:5fcb98e7598b1ee0addab320d90f65b530297a867dbfe9de52ea838077e16e3d \ + --hash=sha256:6041d31504dc1779d700e1edcfb08eea334b357620b06681a4eabb57a74e574e \ + --hash=sha256:66ea454f095ddf5b6b14f56c064c0941c4788be11e18d2464cf643bf7203ff67 \ + --hash=sha256:68ce1c44c7a813a7f71ea04315a8c7b330b63db99d059a797a4651bb6f69f117 \ + --hash=sha256:6a997d0489e9668a384fcfd5061b857aa5361de73191cac204d04b889cfbbafa \ + --hash=sha256:6bf3be92233808fcd338eba0fb4d0b59ec5772af4f4ecfcec450d1bfc0f8b5eb \ + --hash=sha256:6de8bd93ddde9b992cf2b2e0d796d501a19026b5b9fd87356d7d0779531a8d96 \ + --hash=sha256:6e7b8719005dd1175be4ab1cd25e9b98659a5e0347331506ec6760d2773a7fb5 \ + --hash=sha256:6f328175a2cde1f0ff2c4ed8ce968b9dcfb55f3a7153f39e2957ed994da13476 \ + --hash=sha256:72d61e16dd78228b58c5d47be830ff3da7e5f139abdf0aef9d86cde1c5cf2191 \ + --hash=sha256:74b70780220e2dd89175ca24b81b68b67c83db499ae611e7f2313cb329801c78 \ + --hash=sha256:79aa3ff0a9b566633b642fa9caf7e21ed1c13d6feca718187873f199e1514078 \ + --hash=sha256:7afa37062e6650640e932e4cc9297d81f9f42d9944029cc386b8247dea4da837 \ + --hash=sha256:80168e2ebe4d3ec6599d10ad8f520304ae1cad9b6c5a95372aef1b66b7bfb53a \ + --hash=sha256:806719138ecd720339a12410fb9614ac9b2b2d3a5fdf8235d56981c36f4039ba \ + --hash=sha256:8114f28879e0904748e831c3a7774261bd9e75f49be089f389a76f959dcd13fe \ + --hash=sha256:81e3a30b0bb60caa22033dd0f8a3618d1d67356212514f62c57db75cb0ef410c \ + --hash=sha256:823581fd5cb08b12a48bfa11fe962a7916766b6170c17b028fbdf762b85eb9bf \ + --hash=sha256:85341b12b9d55bad0bded24cac341bb34289469e03a11f3f583ea1cc1db0326c \ + --hash=sha256:857187f381f88c8e2fa2fe56ab94879d011b883d5a2ee5a1b60a8cd2a06846d9 \ + --hash=sha256:8a90efd5777e996e42d568db9ac740b944d691e565cbfd31b2f7832f9184b2b8 \ + --hash=sha256:8b73ab70f1a3351fbc71f663b3e645af6dd0329100c353081cf69c37433fc6fe \ + --hash=sha256:8c7972d8f193740d9175f0998ab38717e6cd322d5935c5b0fef8c0d323fd9031 \ + --hash=sha256:8e778ebd44ef4f66ed60a0416b06b489687db264a9c0b3620362f26489492913 \ + --hash=sha256:9282fb1a3bccd038da9f768b927b24a0c753e466c086b7c4f3c6982851eefb2d \ + --hash=sha256:949c91d1a990cf3b2e8188dfcfb25005e0b834a06c63fa4ef9f360878ce21ecf \ + --hash=sha256:95f1e3f4760d404b13c9976c0229b2b49a3c8e2c62a9ce92efdd2b11ada75e3f \ + --hash=sha256:97797ebb098e670a2f92dd66f32897e30d7615b14e7f59711de23e30a9072539 \ + --hash=sha256:a0e399a2eccb91ed18721f86aa85757727400b6865c89e88934781deb9c8498b \ + --hash=sha256:a473b3440261e0c60706e732b2ed2f517857344fc21bf48fdfe211e2d98eb285 \ + --hash=sha256:a4840ab0ae0216d952f4b53dc6d0b992bfc2bedbfe360bdd9b548bc184c08959 \ + --hash=sha256:a592f5f3da71c8691c788c13cb6734b6d17663d2e1cb8caddf0673d01ef8847d \ + --hash=sha256:a6ae2198be502c10f09b2516e7b5d019816924bc3183a43ce792a7bd6625e6f4 \ + --hash=sha256:a6ddc6ac9e25de626c1f129c1b467d7ecd33ce2237d3fd0c4e429feef0a7ee1f \ + --hash=sha256:acd2c8edba48e31e58a363b8cf4e5c7db3b04b3f9e371f601df30d9b0d244836 \ + --hash=sha256:b05d643f944a8c3c4bd86d65ffd87bf3264b617f87791940302bc474d2ff5274 \ + --hash=sha256:b96db7141a592cbc968daf1feea83a118e6ab378af4abbc72b248c895414c22d \ + --hash=sha256:ba338430e87ceb9c8f0cf754de38a9860560261e56c00376debd628698a7364f \ + --hash=sha256:ba57fffe4ac99c5d30076161b5866336d97600769bad35cc68f7774b15298a4e \ + --hash=sha256:be1ddfcbb376e3de5d2e2db1d58d6d67463e6b4f9f040c000de8e300295465fe \ + --hash=sha256:c0cb9ed24c8964e172768d455a38254c2dd8a552905729ce006cad3d3dda59b1 \ + --hash=sha256:c60462af8e6dc30c35407c7237ea908d777b22862bbee27bc4699c0d8bcdc45a \ + --hash=sha256:c66afea89b1e43725731d2004732a046fe6fe955d51f952c3e95a7314a284a39 \ + --hash=sha256:c6844ba6364fb12f403928a82cfd295ab103a2b315c77c747b2dbe4a41894ea7 \ + --hash=sha256:c80f4ba3e8f00189165999a742ee526ebeccedf6c3f7beb0c7df821e9772435a \ + --hash=sha256:cafca7e56c12bb02ae16d283742bef25a61122e9dab2b5b3f2ccbe589ce32164 \ + --hash=sha256:cc1177027eda740fdb152706bd215a3f124e3eea15afc39f2cb9fe351b50619e \ + --hash=sha256:cc49723e2f60d6b32a0f0b08a3fd6d13203c07f1cd9566cfce0f12a917c967a2 \ + --hash=sha256:cc6fc3cc62e8501d3ed62894425040d2728ecddb1ed072737a5c70bd537aa9f0 \ + --hash=sha256:cd416c1de191973c52ff1a12a57446bfc7642797b282d7caf2162d7d1b8aa9a0 \ + --hash=sha256:cd645f03898405cabe694fb8bc35241e3a9c332ec85627584fe3de201452b335 \ + --hash=sha256:cef6cea3922890dd6c9654971001fa797b526c16ab5e1e46c05fd6f877be7568 \ + --hash=sha256:cfa21e036ce1e1db2be04ba3b85d2df1bb1702fa01932d984c5464c665228ff4 \ + --hash=sha256:d0326e2e5e1f3163fa306c834e48e8d490e5fae607a097a40c0648109b47ba80 \ + --hash=sha256:d310c013aad2c72f1c3f2f8dd3279d460a858c551f97aeb8c63e4693cca7b4d2 \ + --hash=sha256:d447bb0b3054be5818458fbb171208b1d9ff11eba14e18ca18b90cbb45767370 \ + --hash=sha256:d4dc37dec6c6cdad0b57881a5658fd14fbf53e333b1a86cf86559f190e1d9ec4 \ + --hash=sha256:d5a81be28596d6559f6131ef33e10200de6e17643b3c74ce03f9eb103be6ae8b \ + --hash=sha256:d9ee8826a7d47863a08ac44e1a5f611a462eefc3a194b492da242128bec75b42 \ + --hash=sha256:db2b80ea58eab4f86b2beec3cc8b39e8ff9276ac20e96b7cce43c8ae84cd6b5a \ + --hash=sha256:decfca4c79dd53ebab484b00cc4b6717d8c369f86e74aa4ca395a64ac651495e \ + --hash=sha256:dfed59d0a5aeb01e242e66ff0300bc4a265a7c05f612d30016f0b60b1017d757 \ + --hash=sha256:e00820e192c8dbebcafb383ebbf99030895f09905e7a0eb2e0340a0bcc2bc825 \ + --hash=sha256:e4294d04a94dcab1b3bccd8b66d962dcad411a1d19414b2a41d1445f1de32ad0 \ + --hash=sha256:e59bc9e66329185b93dab73f210f1a37f81cb40f321501db8017c9aea15dba27 \ + --hash=sha256:e5cbfac9f61484f7e9f3597775500cd3ebe8274e9b050c38f9525c77c97520bf \ + --hash=sha256:f064f8d2b59177878b7615df1735cd8fe3462ed6be8c7b217d17a276489c2b7f \ + --hash=sha256:f156a3529f38063b6dbaf356e15602a7f95f8055b1295a438433a6386f10463d \ + --hash=sha256:f19bb891234d72535764d703bfed1153cc34f4214d5bd7150aee1eec9e8f4366 \ + --hash=sha256:f7467da8a9822bf1a55336f877340c5bcbd3c482afc43a99771169f74a26dedc \ + --hash=sha256:f78abfa8dfc32376fd1aacf597b2f2fbbe0ea751419aee718af5d4f82537ef8c \ + --hash=sha256:f7eabc04151c78a9f4d5bbb5f1faf571e4defeb4b585e0fe95b60ff2dbe4d3d7 \ + --hash=sha256:f814362777a9f841adddb200ecdf8f5cb1e5a3c4b7a86378edbd6ccb26edd702 \ + --hash=sha256:fc299c129490f55f254cd90be0deca4764e36e9a7c08b4aa588479a3bbed3098 \ + --hash=sha256:fc76378c62a0f04d0cd82fbb1a2cd2d7e28fcb40d5873f28a6c44e388aaa2751 \ + --hash=sha256:fc88b26f08d634f7bc819a7852e5214f5802641ab8d9fd5326892292eee1993e \ + --hash=sha256:fe67a3d11cd9b4efabfa45c3d00ffba2b26811442a73a581a94b67c2b5faccf6 + # via + # aiohttp + # yarl +proto-plus==1.28.3 \ + --hash=sha256:5f91b30dafa6bb38d432c5557a6ee1d35ffd40b4b1e0e3ca27260448560b91d9 \ + --hash=sha256:dc76880b8ee951cca002098574376cf71e055f9f16d9ba6570fb8a06f726d281 + # via google-api-core +protobuf==7.35.1 \ + --hash=sha256:11d6b0ec246892d85215b0a13ca6e0233cf5284b68f0ac02646427f4ff88a799 \ + --hash=sha256:230a75ddfc2de4806e56696ce9640c1cdfdb6543b7cfce98d42a4c0a0e7bdb87 \ + --hash=sha256:24f857477359a85c0c235261b8ba905fd51b2562f4a64ca1df5473f29850cbf6 \ + --hash=sha256:353652e4efd0bca5b5fc2656abf8307ef351f0cf938c9eba09f0e09c20a25c30 \ + --hash=sha256:4bc97768d8fe4ad6743c8a19403e314511ed9f6d13205b687e52421c023ac1b9 \ + --hash=sha256:74758715c53d7158fb76caf4f0cfdacc5329a4b1bb994f865d6cf302d413a1c4 \ + --hash=sha256:b73f9489a4b8b1c9cb1f8ed951c736392592edb24b9d6819f36d2e10b171d5b4 \ + --hash=sha256:ce115a26fe0c39a2c29973d914d327e516a6455464489fe3cd1e51a1b354f81a + # via + # semantica (pyproject.toml) + # google-api-core + # googleapis-common-protos + # grpcio-health-checking + # onnxruntime + # proto-plus + # pymilvus + # qdrant-client +psutil==7.2.2 \ + --hash=sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372 \ + --hash=sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9 \ + --hash=sha256:11fe5a4f613759764e79c65cf11ebdf26e33d6dd34336f8a337aa2996d71c841 \ + --hash=sha256:1a571f2330c966c62aeda00dd24620425d4b0cc86881c89861fbc04549e5dc63 \ + --hash=sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979 \ + --hash=sha256:1fa4ecf83bcdf6e6c8f4449aff98eefb5d0604bf88cb883d7da3d8d2d909546a \ + --hash=sha256:2edccc433cbfa046b980b0df0171cd25bcaeb3a68fe9022db0979e7aa74a826b \ + --hash=sha256:7b6d09433a10592ce39b13d7be5a54fbac1d1228ed29abc880fb23df7cb694c9 \ + --hash=sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee \ + --hash=sha256:917e891983ca3c1887b4ef36447b1e0873e70c933afc831c6b6da078ba474312 \ + --hash=sha256:ab486563df44c17f5173621c7b198955bd6b613fb87c71c161f827d3fb149a9b \ + --hash=sha256:ae0aefdd8796a7737eccea863f80f81e468a1e4cf14d926bd9b6f5f2d5f90ca9 \ + --hash=sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e \ + --hash=sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc \ + --hash=sha256:c7663d4e37f13e884d13994247449e9f8f574bc4655d509c3b95e9ec9e2b9dc1 \ + --hash=sha256:e452c464a02e7dc7822a05d25db4cde564444a67e58539a00f929c51eddda0cf \ + --hash=sha256:e78c8603dcd9a04c7364f1a3e670cea95d51ee865e4efb3556a3a63adef958ea \ + --hash=sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988 \ + --hash=sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486 \ + --hash=sha256:eed63d3b4d62449571547b60578c5b2c4bcccc5387148db46e0c2313dad0ee00 \ + --hash=sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8 + # via + # accelerate + # ipykernel + # ipython +psycopg==3.3.4 \ + --hash=sha256:b6bbc25ccf05c8fad3b061d9db2ef0909a555171b84b07f29458a447253d679a \ + --hash=sha256:e21207764952cff81b6b8bdacad9a3939f2793367fdac2987b3aac36a651b5bc + # via semantica (pyproject.toml) +psycopg-binary==3.3.4 \ + --hash=sha256:018fbed325936da502feb546642c982dcc4b9ffdea32dfef78dbf3b7f7ad4070 \ + --hash=sha256:0579252a1202cd73e4da137a1426e2dae993ae44e757605344282af3a082848c \ + --hash=sha256:136f199a407b5348b9b857c504aff60c77622a28482e7195839ce1b51238c4cc \ + --hash=sha256:13a7f380824c35896dcac7fe0f61440f7ca49d6dc73f3c13a9a4471e6a3b302e \ + --hash=sha256:17a21953a9e5ff3a16dab692625a3676e2f101db5e40072f39dbee2250194d68 \ + --hash=sha256:1dc1f79fd16bb1f3f4421417a514607539f17804d95c7ed617265369d1981cae \ + --hash=sha256:1fbaa292a3c8bb61b45df1ad3da1908ccee7cb889db9425e3557d9e34e2a4829 \ + --hash=sha256:22cdbf5f91ef7bb91fe0c5757e1962d3127a8010256eefd9c61fcaf441802097 \ + --hash=sha256:26df2717e59c0473e4465a97dfb1b7afebaa479277870fd5784d1436470db47c \ + --hash=sha256:276904e3452d6a23d474ef9a21eee19f20eed3d53ddd2576af033827e0ba0992 \ + --hash=sha256:28b7398fdd19db3232c884fb24550bdfe951221f510e195e233299e4c9b78f97 \ + --hash=sha256:2c09aad7051326e7603c14e50636db9c01f78272dc54b3accff03d46370461e6 \ + --hash=sha256:32a6fbf8481e3a370d0d72b860d35948a693cb01281da217f7b2f307636e591a \ + --hash=sha256:41f2ec0fea529832982bcb6c9415de3c86264ebe562b77a467c0fbcd7efbba8d \ + --hash=sha256:46893c26858be12cc49ca4226ed6a60b4bfccadd946b3bebb783a60b38788228 \ + --hash=sha256:47c656a8a7ba6eb0cff1801a4caaa9c8bdc12d03080e273aff1c8ac39971a77e \ + --hash=sha256:494ca54901be8cf9eb7e02c25b731f2317c378efa44f43e8f9bd0e1184ae7be4 \ + --hash=sha256:514404ed543efd620c85602b747df2a23cf1241b4067199e1a66f2d2757aaa41 \ + --hash=sha256:574ea21a9651958f1535c5a1c649c7409e9168bcbffa29a3f2f961f58b322949 \ + --hash=sha256:580ae30a5f95ccd90008ec697d3ed6a4a2047a516407ad904283fa42086936e9 \ + --hash=sha256:5ab28a2a7649df3b72e6b674b4c190e448e8e77cf496a65bd846472048de2089 \ + --hash=sha256:5c4ab71be17bdca30cb34c34c4e1496e2f5d6f20c199c12bad226070b22ef9bf \ + --hash=sha256:612a627d733f695b1de1f9b4bd511c15f999a5d8b915d444bbd7dd71cf3370da \ + --hash=sha256:6402a9d8146cf4b3974ded3fd28a971e83dc6a0333eb7822524a3aa20b546578 \ + --hash=sha256:6b9016b1714da4dd5ecaaa75b82098aa5a0b87854ce9b092e21c27c4ae23e014 \ + --hash=sha256:71e55ccbdfae79a2ed9c6369c3008a3025817ff9d7e27b32a2d84e2a4267e66e \ + --hash=sha256:7465bfe6087d2d5b42d4c53b9b11ca9f218e477317a4a162a10e3c19e984ba8e \ + --hash=sha256:75a9067e236f9b9ae3535b66fe99bddb33d39c0de10112e49b9ab11eee53dc31 \ + --hash=sha256:773d573e11f437ce0bdb95b7c18dc58390494f96d43f8b45b9760436114f7652 \ + --hash=sha256:77df19583501ea288eaf15ac0fe7ad01e6d8091a91d5c41df5c718f307d8e31b \ + --hash=sha256:7f7668f30b9dd5163197e5cbf4e0efd54e00f0a859cc566ce56cfc31f4054839 \ + --hash=sha256:8c0056529e68dbe9184cd4019a1f3d8f3a4ead2f6fc7a5afcf27d3314edd1277 \ + --hash=sha256:94596f9e7633ee3f6440711d43bb70aa31cc0a46a900ab8b4201a366ace5c9e7 \ + --hash=sha256:ab8cca8ef8fb1ccf5b048ae5bd78ba55b9e4b5d472e3ce5ca39ff4d2a9c249e4 \ + --hash=sha256:ad3bc94054876155549fdaedf4a46d1ec69d39a5bcee377148afe498e84c4b8e \ + --hash=sha256:b56b603ebcea8aa10b46228b8410ba7f13e7c2ee54389d4d9be0927fd8ce2a70 \ + --hash=sha256:b6f5a29e9c775b9f12a1a717aa7a2c80f9e1db6f27ba44a5b59c80ac61d2ffcf \ + --hash=sha256:b7bfff1ca23732b488cbca3076fc11bc98d520ee122514fdb17a8e20d3338f5a \ + --hash=sha256:bdef84570ebbce1d42b4e7ea952d21c414c5f118ad02fee00c5625f35e134429 \ + --hash=sha256:c37e024c07308cd06cf3ec51bfd0e7f6157585a4d84d1bce4a7f5f7913719bf8 \ + --hash=sha256:c677c4ad433cb7150c8cd304a0769ae3bcfbe5ea0676eb53faa7b1443b16d0d3 \ + --hash=sha256:cf7f73a4a792bc5db58a4b385d8a1467e8d468f7548702fb0ed1e9b7501b1c13 \ + --hash=sha256:cffc3408d77a27973f33e5d909b624cce683db5fc25964b02fe0aae7886c1007 \ + --hash=sha256:d7b4d40c153fa352ab3cca530f3a0baedf7621b2ebcbd7f084009522c21788fc \ + --hash=sha256:dbfdb9b6cc79f31104a7b162a2b921b765fcc62af6c00540a167a8de47e4ed38 \ + --hash=sha256:df1d567fc430f6df15c9fcf67d87685fc49bdb325adc0db5af1adfb2f44eb5c9 \ + --hash=sha256:e2631da29253a98bd496e6c4813b24e09a4fe3fb2a9e88513305d6f8747cce95 \ + --hash=sha256:e7510c37550f91a187e3660a8cc50d4b760f8c3b8b2f89ebc5698cd2c7f2c85d \ + --hash=sha256:eb05ee1c2b817d27c537333224c9e83c7afb86fe7296ba970990068baf819b16 \ + --hash=sha256:eb4eed2079c01a4850bf467deacfab56d356d4225040170af03dc9958321242d \ + --hash=sha256:ee17a2cf4943cde261adfad1bbc5bf38d6b3776d7afff74c7cabcbeaeb08c260 \ + --hash=sha256:f80e3f2b5331dbbf0901bcb658056c03eeb2c1ef31d774afb0d61598b242e744 \ + --hash=sha256:f9b1c2533af01cd7648378599f82b0b8ae32f293296e6eec5753a625bc97ef28 \ + --hash=sha256:fa1cbc10768a796c96d3243656016bf4e337c81c71097270bb7b0ad6210d9765 \ + --hash=sha256:fbd1d4ed566895ad2d3bf4ddfd8bae90026930ddf29df3b9d91d32c8c47866a7 + # via psycopg +psycopg-pool==3.3.1 \ + --hash=sha256:2af5b432941c4c9ad5c87b3fa410aec910ec8f7c122855897983a06c45f2e4b5 \ + --hash=sha256:b10b10b7a175d5cc1592147dc5b7eec8a9e0834eb3ed2c4a92c858e2f51eb63c + # via psycopg +psycopg2-binary==2.9.12 \ + --hash=sha256:00814e40fa23c2b37ef0a1e3c749d89982c73a9cb5046137f0752a22d432e82f \ + --hash=sha256:049366c6d884bdcd65d66e6ca1fdbebe670b56c6c9ba46f164e6667e90881964 \ + --hash=sha256:0dc9228d47c46bda253d2ecd6bb93b56a9f2d7ad33b684a1fa3622bf74ffe30c \ + --hash=sha256:1006fb62f0f0bc5ce256a832356c6262e91be43f5e4eb15b5eaf38079464caf2 \ + --hash=sha256:127467c6e476dd876634f17c3d870530e73ff454ff99bff73d36e80af28e1115 \ + --hash=sha256:1c8ad4c08e00f7679559eaed7aff1edfffc60c086b976f93972f686384a95e2c \ + --hash=sha256:29d4d134bd0ab46ffb04e94aa3c5fa3ef582e9026609165e2f758ff76fc3a3be \ + --hash=sha256:3471336e1acfd9c7fe507b8bad5af9317b6a89294f9eb37bd9a030bb7bebcdc6 \ + --hash=sha256:36512911ebb2b60a0c3e44d0bb5048c1980aced91235d133b7874f3d1d93487c \ + --hash=sha256:398fcd4db988c7d7d3713e2b8e18939776fd3fb447052daae4f24fa39daede4c \ + --hash=sha256:3d999bd982a723113c1a45b55a7a6a90d64d0ed2278020ed625c490ff7bef96c \ + --hash=sha256:40e7b28b63aaf737cb3a1edc3a9bbc9a9f4ad3dcb7152e8c1130e4050eddcb7d \ + --hash=sha256:411e85815652d13560fbe731878daa5d92378c4995a22302071890ec3397d019 \ + --hash=sha256:4413d0caef93c5cf50b96863df4c2efe8c269bf2267df353225595e7e15e8df7 \ + --hash=sha256:4766ab678563054d3f1d064a4db19cc4b5f9e3a8d9018592a8285cf200c248f3 \ + --hash=sha256:4dfcf8e45ebb0c663be34a3442f65e17311f3367089cd4e5e3a3e8e62c978777 \ + --hash=sha256:527e6342b3e44c2f0544f6b8e927d60de7f163f5723b8f1dfa7d2a84298738cd \ + --hash=sha256:54a0dfecab1b48731f934e06139dfe11e24219fb6d0ceb32177cf0375f14c7b5 \ + --hash=sha256:5a0253224780c978746cb9be55a946bcdaf40fe3519c0f622924cdabdafe2c39 \ + --hash=sha256:5ac9444edc768c02a6b6a591f070b8aae28ff3a99be57560ac996001580f294c \ + --hash=sha256:5c7cb4cbf894a1d36c720d713de507952c7c58f66d30834708f03dbe5c822ccf \ + --hash=sha256:5c8ce6c61bd1b1f6b9c24ee32211599f6166af2c55abb19456090a21fd16554b \ + --hash=sha256:5cdc05117180c5fa9c40eea8ea559ce64d73824c39d928b7da9fb5f6a9392433 \ + --hash=sha256:612b965daee295ae2da8f8218ce1d274645dc76ef3f1abf6a0a94fd57eff876d \ + --hash=sha256:63a3ebbd543d3d1eda088ac99164e8c5bac15293ee91f20281fd17d050aee1c4 \ + --hash=sha256:66a7685d7e548f10fb4ce32fb01a7b7f4aa702134de92a292c7bd9e0d3dbd290 \ + --hash=sha256:6f3b3de8a74ef8db215f22edffb19e32dc6fa41340456de7ec99efdc8a7b3ec2 \ + --hash=sha256:6f9cae1f848779b5b01f417e762c40d026ea93eb0648249a604728cda991dde3 \ + --hash=sha256:718e1fc18edf573b02cb8aea868de8d8d33f99ce9620206aa9144b67b0985e94 \ + --hash=sha256:77b348775efd4cdab410ec6609d81ccecd1139c90265fa583a7255c8064bc03d \ + --hash=sha256:7af18183109e23502c8b2ae7f6926c0882766f35b5175a4cd737ad825e4d7a1b \ + --hash=sha256:7c729a73c7b1b84de3582f73cdd27d905121dc2c531f3d9a3c32a3011033b965 \ + --hash=sha256:83946ba43979ebfdc99a3cd0ee775c89f221df026984ba19d46133d8d75d3cd9 \ + --hash=sha256:840066105706cd2eb29b9a1c2329620056582a4bf3e8169dec5c447042d0869f \ + --hash=sha256:863f5d12241ebe1c76a72a04c2113b6dc905f90b9cef0e9be0efd994affd9354 \ + --hash=sha256:864c261b3690e1207d14bbfe0a61e27567981b80c47a778561e49f676f7ce433 \ + --hash=sha256:89d19a9f7899e8eb0656a2b3a08e0da04c720a06db6e0033eab5928aabe60fa9 \ + --hash=sha256:8ffdb59fe88f99589e34354a130217aa1fd2d615612402d6edc8b3dbc7a44463 \ + --hash=sha256:96937c9c5d891f772430f418a7a8b4691a90c3e6b93cf72b5bd7cad8cbca32a5 \ + --hash=sha256:98062447aebc20ed20add1f547a364fd0ef8933640d5372ff1873f8deb9b61be \ + --hash=sha256:995ce929eede89db6254b50827e2b7fd61e50d11f0b116b29fffe4a2e53c4580 \ + --hash=sha256:9b818ceff717f98851a64bffd4c5eb5b3059ae280276dcecc52ac658dcf006a4 \ + --hash=sha256:9fe06d93e72f1c048e731a2e3e7854a5bfaa58fc736068df90b352cefe66f03f \ + --hash=sha256:a46fe069b65255df410f856d842bc235f90e22ffdf532dda625fd4213d3fd9b1 \ + --hash=sha256:a7e39a65b7d2a20e4ba2e0aaad1960b61cc2888d6ab047769f8347bd3c9ad915 \ + --hash=sha256:a99eaab34a9010f1a086b126de467466620a750634d114d20455f3a824aae033 \ + --hash=sha256:ab29414b25dcb698bf26bf213e3348abdcd07bbd5de032a5bec15bd75b298b03 \ + --hash=sha256:ace94261f43850e9e79f6c56636c5e0147978ab79eda5e5e5ebf13ae146fc8fe \ + --hash=sha256:b4a9eaa6e7f4ff91bec10aa3fb296878e75187bced5cc4bafe17dc40915e1326 \ + --hash=sha256:b6937f5fe4e180aeee87de907a2fa982ded6f7f15d7218f78a083e4e1d68f2a0 \ + --hash=sha256:b9a339b79d37c1b45f3235265f07cdeb0cb5ad7acd2ac7720a5920989c17c24e \ + --hash=sha256:ba3df2fc42a1cfa45b72cf096d4acb2b885937eedc61461081d53538d4a82a86 \ + --hash=sha256:c41321a14dd74aceb6a9a643b9253a334521babfa763fa873e33d89cfa122fb5 \ + --hash=sha256:c5ee5213445dd45312459029b8c4c0a695461eb517b753d2582315bd07995f5e \ + --hash=sha256:c6528cefc8e50fcc6f4a107e27a672058b36cc5736d665476aeb413ba88dbb06 \ + --hash=sha256:cb4a1dacdd48077150dc762a9e5ddbf32c256d66cb46f80839391aa458774936 \ + --hash=sha256:cfa2517c94ea3af6deb46f81e1bbd884faa63e28481eb2f889989dd8d95e5f03 \ + --hash=sha256:d2fa0d7caca8635c56e373055094eeda3208d901d55dd0ff5abc1d4e47f82b56 \ + --hash=sha256:d3227a3bc228c10d21011a99245edca923e4e8bf461857e869a507d9a41fe9f6 \ + --hash=sha256:d6fcbba8c9fed08a73b8ac61ea79e4821e45b1e92bb466230c5e746bbf3d5256 \ + --hash=sha256:e4e184b1fb6072bf05388aa41c697e1b2d01b3473f107e7ec44f186a32cfd0b8 \ + --hash=sha256:ee2d84ef5eb6c04702d2e9c372ad557fb027f26a5d82804f749dfb14c7fdd2ab \ + --hash=sha256:f12ae41fcafadb39b2785e64a40f9db05d6de2ac114077457e0e7c597f3af980 \ + --hash=sha256:f625abb7020e4af3432d95342daa1aa0db3fa369eed19807aa596367ba791b10 \ + --hash=sha256:f921f3cd87035ef7df233383011d7a53ea1d346224752c1385f1edfd790ceb6a \ + --hash=sha256:fb1828cf3da68f99e45ebce1355d65d2d12b6a78fb5dfb16247aad6bdef5f5d2 \ + --hash=sha256:ffdd7dc5463ccd61845ac37b7012d0f35a1548df9febe14f8dd549be4a0bc81e + # via semantica (pyproject.toml) +ptyprocess==0.7.0 \ + --hash=sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35 \ + --hash=sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220 + # via + # pexpect + # terminado +pulsar-client==3.13.0 \ + --hash=sha256:03244a8af1886591ee56afb1228b85f8b678f980464a2847ebb9a152d8b5e36a \ + --hash=sha256:0e76acbc9ca5515650e940a0bb10c45860aa14707bb755dde01d0eb011606d77 \ + --hash=sha256:21c241e7a7f359928f677a97bf7e9b31723f575cc9adc0e09350f5e5a5c1be53 \ + --hash=sha256:25d56f51895a01ae885e71ae3f65369496462139bb506450fa11c1c28941177a \ + --hash=sha256:2cbcae67dc46c3b85288984a51ee0c194f8898cba54fc31b08eeffef8b113a30 \ + --hash=sha256:3111678f649afb78ec4d807d7fd352836912537a9cd1ca2ad43c1f992eb83301 \ + --hash=sha256:43aa8b5532aa4ca9417342a4c5a0fcf9e03f9b1a64177be5a2420dea08547685 \ + --hash=sha256:55a060c9a7b58e4daa80aa8126e340d378e0f55e5616523574d3144f5a649347 \ + --hash=sha256:56109a622fd5373f7971a0e052732dbbcdd0030b7e69c06377cd49763903df20 \ + --hash=sha256:6060e6856dad29231aba5464941c3154f5ef3175d7d8f98c9edf994df6cd8820 \ + --hash=sha256:61c4b8f478cbe843a9793f3e145709efb407ca6979a4b23b8ee7abd585770720 \ + --hash=sha256:6410dacd0c2cef0a0c5539acea448d487a1824ff1c69230ae8d366762938020e \ + --hash=sha256:7b510a8be6ce5fc6dffb772b80baa00900a4b8a7342be38023fcd38cc7582ad0 \ + --hash=sha256:7c87a6944d5531b59b556b6078b9a0fb6d2dceff7a2edc8875bba43ed37c7f6a \ + --hash=sha256:7cc1cb49a29a85b8469b42d3a088c0fde639887e0b06120d92c34a9412296528 \ + --hash=sha256:7de4a2541059e54c1f6cdf5d961306a6fc7f50cd415425b185b653d7dfa5ec39 \ + --hash=sha256:863fd7b8cdc162f3cc0ea7217c37604c66a7bc9bb5cd554566c5d07f89a50e5e \ + --hash=sha256:8ce5c596a82b4b43d677d7ba82e6f8a553c6ea50ac7cb42bde986868dc851437 \ + --hash=sha256:8e4bd5a7237829b9fb670f477e54c16ff6dbfde363b40f252e957a50c1fba0d9 \ + --hash=sha256:afa143cbadbe0e13c3afcc3925442ee67a3b611d28110462fbcd37fc95cdcd94 \ + --hash=sha256:bc0337146a525837c5bb945c50241427806f8200d96ad839c8b6b08cd631c987 \ + --hash=sha256:cf62d0ea76b3dee86536115b9e5b5ecc8087920196afea23fd04d8b38ff0c275 \ + --hash=sha256:d2f65f2e697d20dbef2a9f04763bada0e20ff34e13650204f95d9acbfed1ec1e \ + --hash=sha256:da4fe1a58e81d1a3131aa8edb44eac91c9931ad6f8e21b2e3409cb8ee8e9173d \ + --hash=sha256:edd8ffd12cf3dd4b72b080e815652f0c2f18045b5e9b779e63e43caad470e834 \ + --hash=sha256:f2eb3afc2e1c002bc74eadd3f3ba49fb4c337d59e83f0861c79a901b62098ec0 \ + --hash=sha256:f2f24bacf9ad7999a2c9813db68b438b776a2bc5440ed1bbb9cc62acbfc4f85d \ + --hash=sha256:f3f769e3128366df63296d7caa4934721f70083def652cb34fb0b55538ba325b \ + --hash=sha256:f5d5dcfc45f73e05db754f2372d278c1183a674c6e2b91416a4f6c829911365b \ + --hash=sha256:f958a6149b28cfe354d33f2af229e2257d0bc4e1f195eefbd4cbae5d7fd7961d + # via semantica (pyproject.toml) +pure-eval==0.2.3 \ + --hash=sha256:1db8e35b67b3d218d818ae653e27f06c3aa420901fa7b081ca98cbedc874e0d0 \ + --hash=sha256:5f4e983f40564c576c7c8635ae88db5956bb2229d7e9237d03b3c0b0190eaf42 + # via stack-data +py-rust-stemmers==0.1.8 \ + --hash=sha256:08c258deab6d994551a92e9468ce88e58f97e636e73d9c5763978a57d7675a13 \ + --hash=sha256:0a68745d4b3c7f5abc778ca967e8711df6154873abcfe4e62a6631fa2363cc32 \ + --hash=sha256:0f1d2135974bbbea2c15087a7d8cec8697338b2a748c9694c92943775f4d6c14 \ + --hash=sha256:13b25ce65509ff7e37725bd38c62704f32ae0604ac0899f43c8cce41d5543212 \ + --hash=sha256:15af4e12e1288de2e5241eec375afc6ad6be4c125a28ca010599d9f92db23f01 \ + --hash=sha256:1686fc009869ff8bcc1d5a305f071eeb8c3b3612a9827bcadd4e61fdb5727179 \ + --hash=sha256:21ed8055cec1f78d666afad8ffd7a51775ba419d2c615b8a1df7b32ca7f33e2b \ + --hash=sha256:22d037a82920bed8fccbec62cf5ef47d821ac3966a3d098fa48a2053397ea6b7 \ + --hash=sha256:234fdcb58f4d907877ed03c9358668a149b5a66d096abcf43c324a4f5697d36d \ + --hash=sha256:245e2c61c52e073341893a9682cd1396b61047154548aee30bb1af3d8ed4b4cc \ + --hash=sha256:25bb9b0b6b8d79b32c151c7f5f94af9af9aea201ca8736e6f117c841b017f028 \ + --hash=sha256:2b607f0b270951fb66479baf4b68716cc63a981585cbd898b0b6b5c359efde7e \ + --hash=sha256:2e86ad68fe297a6652f0f0390625ea81858b6f27862fd4c5ee1214bf5af29b9d \ + --hash=sha256:3007ad4ec51e0c352ae410234a24a9ac75fab0c1e06c585fbac9fcced69385f8 \ + --hash=sha256:342b6cc9eb833f102d86e146ee71bccb3c1ed1e8320db8e6553cc81b716b1b14 \ + --hash=sha256:35570098da02eb439afcd7270a12bf850bbe874b85cb912e0fb2d87a6e703920 \ + --hash=sha256:36b952ce65a794faf15553b8f5b60431483c2d5bec00bc6982bf490e727250f9 \ + --hash=sha256:3bef8062d28251b465299cc676de7c11dde003858caf2c2b5c14de7298dc63db \ + --hash=sha256:40c86be90cee4a709ad84fde4db7f11ca44d65630a56b77ec86fe84c23adfc09 \ + --hash=sha256:451ee1c02a3f5cf1e161b46ba9032cdda4ba10a8b03ff9ee61c1d34d42a0bc81 \ + --hash=sha256:45d0c42346f8e5d04b86a0b0f895bb15c53788bf551e7fad36be1dad093e856f \ + --hash=sha256:479c77c32d8be692f3cfcde7e19273f02ac81d6f45c6aef49887ef95cab7abbb \ + --hash=sha256:4a1e11d22a240318dc917266eb3c85919455b6ea834445b95997712d9ede6b93 \ + --hash=sha256:4b1159a38a198eabeabd908015f9425c4220b61b42c6603c58870481ff2b50bb \ + --hash=sha256:4b90fc81411943b114e8eb4988a876ba3b12bd2d20741559803eddc4131575dc \ + --hash=sha256:515884bcfb47b10335146648f276930d0c1201ae5e8b7b400fb46d8ea05c0ec2 \ + --hash=sha256:51d0042d2a92ef0f7048bfc06b6c2a02306af31ea47f09d24b34e4b7e63c4e80 \ + --hash=sha256:526b58958c6ffa36c4a805326cfb624ecbd665d16ba435027dbed0bcbcaa09d2 \ + --hash=sha256:56cc2c2df742fa6529285b7d204720f34b7da789ed78eb578442f93c6de97d89 \ + --hash=sha256:5bd15b89203ecd886960e237124d1aa6e55498d76418c36c967d3b12168d43dc \ + --hash=sha256:5cc8fab9d0f1b274a26935a632362b8278f03e81b65e8b8644d5ca3f62a5a1a4 \ + --hash=sha256:6a9a4b8733d0b307bd0879ab7e321aa8a0bfd054a75a5cb23c647df5ca7d17c3 \ + --hash=sha256:6b0f6f48bc54d607aed802de872fcd5a71bae969a6760976dc78ce55e8eaf3da \ + --hash=sha256:6c92733b020534470ca5a0d7fe8b85c85622ff383d4f37fec75a1c677aa84921 \ + --hash=sha256:769f37882905da2311cb720681b112eb70a4e6bd56fb424d473427b5379c8396 \ + --hash=sha256:7cc0cc0b8eb45d2158c28ea43e2f338c110aad63052ad3bd00bc7446a595e12f \ + --hash=sha256:870afb2d1d4731bd2d74b715b34439b29734e4dc94c55342096f07669f7f9fa0 \ + --hash=sha256:89d3d34094b9b6078a8ea6fe1c7044e5fd32f14e76c94818c5008f49ae075f08 \ + --hash=sha256:8b0327b151ab8a338fb54fdac114ba34394327fc1e2c4c425ad1caf2013e5de3 \ + --hash=sha256:931d13570962b093417e5443a9d1bd63d73fa239ebb81e5b1d346663571403e4 \ + --hash=sha256:9ab605a86c950ba7e8ab1392cf91296c0bec3084babb897a4aecf90a10c82395 \ + --hash=sha256:ae773e1d01e9aa328d175f461475d0cd7074a82bfcc71de6dc5765e51f1cc9f7 \ + --hash=sha256:af749b3b9f6531342250dd05854c0ae93e01f79b0049a8769012e0b50e9aba5b \ + --hash=sha256:bfc185b599e646a0e39d11df3f5e6d15edefb110496601556385d33b55fed5de \ + --hash=sha256:c03f51280d5d72f7f9b07101ad248845279dc1c82c47a74149303d25937464b7 \ + --hash=sha256:c786235275c5c2abb7f206b8236aee3ca0bc53c7497daf7fb7b01d3491469547 \ + --hash=sha256:d396dd25c473c1bc4248c79cd223f4b36356b55a124652f015c6a001547f81ac \ + --hash=sha256:da0326c913070d5f3fabd56393ca4118167bb0b13c2932a77c7a1b31f85f651a \ + --hash=sha256:dab8a862fa8e4c9e715848e9d64c317229d7a2c37238cd1c73237b85d655ab7e \ + --hash=sha256:dadd0e369703817fc7026987b3093f461f9f58d8dde74e689d546184bc8f3451 \ + --hash=sha256:dca0ae40715238582d6f1824b61d09ea3982359a061b69798ab5732b3ba0d4c5 \ + --hash=sha256:dd967eea2f808a1e73aa71ecccef0f4925a4cca4eb02ced94057afe3303153ef \ + --hash=sha256:eee4af7ada2ce9cb3ec59ffe8458148c3933a86507d816bf954ee506a0e45b61 \ + --hash=sha256:f16deb1557b8253d8c11693047bec4ed67d6b09ae0f84c8b896ea03ac2fc8925 \ + --hash=sha256:fa42f5f8feb694aaaa869eedf477fcaf66f67a192cd64d94302d06920c33864a + # via fastembed +pyarrow==25.0.1 \ + --hash=sha256:0b1edbb2f385a6a65e9711b62ba86ac54a7816a3f8d17bb3e8a5929d65fb2485 \ + --hash=sha256:0b726ad7e7b669be982b0c71c07fe4b037d654354130da79a7902a669e93a66b \ + --hash=sha256:0befcf816e45a1af33ac775a9970b749e4868a230c7372f0ae5e932bee27039f \ + --hash=sha256:0fe7c8b6c03969b49c8c66182e4a18e3819ab92d07cfab5d8370c531b9369ef0 \ + --hash=sha256:119297a6dc197e45d9c6d4415f7814a67ffa36c180d26f68c154c58067ae782d \ + --hash=sha256:169d3429d5be7c752125890620f75a60776d38b0035eddae939651640822332e \ + --hash=sha256:25f8720bf6387d5dc2ebd2622112de630760419e4b66134405dd24110d15f37e \ + --hash=sha256:31e49a7888fcdf3a835da33ae777f6bb9a866334e5a789282fc26dcf426f7f15 \ + --hash=sha256:35935cd5de130aa5cf4dea052a63e6bf2e17006c35c3a468194242b9b2bf5956 \ + --hash=sha256:38a9a4b4b9613380e200641891495a56c3d5a98a092db4a870af9975e220471d \ + --hash=sha256:3f89685964f46e4216103c75483aac0c0692a5f72212d7ca835adba5ede56ce3 \ + --hash=sha256:4288f27577352d608ca08553b0865e4a9b3aa14820c5d95b53337218d609835b \ + --hash=sha256:4340f0ba6c1d2e13f21658de1d7c662ca2545018568d0030a1e9afca159d87e3 \ + --hash=sha256:44a9120ce5bd81936b8ab9a88076e3fd47c2c6838e0e43630fed83626aca81d9 \ + --hash=sha256:4facd65742a024a4a366328a1d2292062d72d6e023c1b7dda8d4c37544933a25 \ + --hash=sha256:51093dd9e10325fbdb3c10a2ae7c4806e5c822d94e74ae4938b26524a3323fee \ + --hash=sha256:514ddb60285631af068875550c90eddc181db3e8e63a032b1559be189e82f056 \ + --hash=sha256:5389cdf79447ed1515c9e31620e6e1e2302249564d603f2ad727d4f6d313e4c3 \ + --hash=sha256:59a2de54c0cbd954da861eee4d1d330f8e909c45b53455baef696380f2c55033 \ + --hash=sha256:60e89d8f13861a1f7f8d950fa54aebb8023b30734d0ac51ffa80beabe2df4bba \ + --hash=sha256:6109c94d8b9f3b17a041daca16cacb2f651ad8f1ef70a4232c2c0f37a23da2a8 \ + --hash=sha256:62cd0d785b8aa6675ee355f9fc02252a340f4441257c42674937826fd7594325 \ + --hash=sha256:6943e2fe7954d29d84de45d29d34c8dc36ce96570e67d89aa9976e650a4a9138 \ + --hash=sha256:6a1fdfc6659b6b19022f2e50627fb5cf7156a66c46bf4299379955cbe742382a \ + --hash=sha256:880523be3d29efcf83d3998835d206118ccf35e3871dbd2fb60408cf6b007a80 \ + --hash=sha256:8858d7bfc22e3f51529aeaa4077225029724623e4595dc9eff8c793935c34140 \ + --hash=sha256:9150a83248bfed9813ea3c3af74c3856c1984d444aa28e58bf7733b9750ddf6a \ + --hash=sha256:9171748cdf796972d85a4b60157c279913e242992e350c90c7450182a9838b2a \ + --hash=sha256:a4d6d5e9a3d1879a97c08ded0c797579b7965eafd0f0c26c30b45ccc06db939b \ + --hash=sha256:a4dd8bf99a8fac133efc0ed6a92f5fddbe2adba0d0f6dd720e39ba9855cea85c \ + --hash=sha256:aa0559502e1cd6254d6814614085dd9c5a3dd0419362978a936a3f68a9e5c3df \ + --hash=sha256:b7a296aac7a71fa0886c08e155ddb6c636a50013f801f6178daafa0f9e726188 \ + --hash=sha256:bddd0c4f7630c2a3ddf6347c1bdaa79d97bcf6bd445f9e60c816b7d77c85a5ae \ + --hash=sha256:bf0b672390cdcb640d7288f96b826d71ff4e9abb254a86c89890baf51a29cee6 \ + --hash=sha256:c7c534ec03c358a76ea3e505e74c1b6aef290af90c444dfd092dbfe23e755b85 \ + --hash=sha256:cab40b1edfef0262e0e5251aa2c58d75630f24d06dd7794480243acc001a1d7d \ + --hash=sha256:cc4aa407fde9fc660be3939e49ea31f50f3e9fec17c0ec63159f7711edd3efc9 \ + --hash=sha256:d51592cb7561e87877c506113e7adbf1342ab579e6c21f0ef44b8ba41cb74c80 \ + --hash=sha256:dda9470024204d7bbf2042b47c6e8a0e47a3eeb8e34405882dfaea6577e0c153 \ + --hash=sha256:df961f2e7ae9cf496459259d798652c70625f6c080650d6952f8c04053c58ee9 \ + --hash=sha256:eb6203482ff3746a5632303a7279ae0b5a304c46985b49ed1378cb350ea6728d \ + --hash=sha256:f3831aaa25c67a99f99dc8b05873cb9d64560390372e2aa197ce9dd4a3f06a44 \ + --hash=sha256:f729cfdbd36fd99d543b67a914d2de044c84ebe45be8b34902b299b608c15c8f + # via semantica (pyproject.toml) +pyasn1==0.6.4 \ + --hash=sha256:9c447d8431c947fe4c8febc4ed9e760bc29011a5b01e5c74b67025bd9fb8ce81 \ + --hash=sha256:deda9277cfd454080ec40b207fb6df82206a3a2688735233cdcd8d3d565f088b + # via pyasn1-modules +pyasn1-modules==0.4.2 \ + --hash=sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a \ + --hash=sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6 + # via google-auth +pyclipper==1.4.0 \ + --hash=sha256:0a4d2736fb3c42e8eb1d38bf27a720d1015526c11e476bded55138a977c17d9d \ + --hash=sha256:0b74a9dd44b22a7fd35d65fb1ceeba57f3817f34a97a28c3255556362e491447 \ + --hash=sha256:0b8c2105b3b3c44dbe1a266f64309407fe30bf372cf39a94dc8aaa97df00da5b \ + --hash=sha256:14c8bdb5a72004b721c4e6f448d2c2262d74a7f0c9e3076aeff41e564a92389f \ + --hash=sha256:1b6c8d75ba20c6433c9ea8f1a0feb7e4d3ac06a09ad1fd6d571afc1ddf89b869 \ + --hash=sha256:222ac96c8b8281b53d695b9c4fedc674f56d6d4320ad23f1bdbd168f4e316140 \ + --hash=sha256:29dae3e0296dff8502eeb7639fcfee794b0eec8590ba3563aee28db269da6b04 \ + --hash=sha256:37bfec361e174110cdddffd5ecd070a8064015c99383d95eb692c253951eee8a \ + --hash=sha256:3ef44b64666ebf1cb521a08a60c3e639d21b8c50bfbe846ba7c52a0415e936f4 \ + --hash=sha256:58e29d7443d7cc0e83ee9daf43927730386629786d00c63b04fe3b53ac01462c \ + --hash=sha256:6a97b961f182b92d899ca88c1bb3632faea2e00ce18d07c5f789666ebb021ca4 \ + --hash=sha256:6c317e182590c88ec0194149995e3d71a979cfef3b246383f4e035f9d4a11826 \ + --hash=sha256:773c0e06b683214dcfc6711be230c83b03cddebe8a57eae053d4603dd63582f9 \ + --hash=sha256:7c87480fc91a5af4c1ba310bdb7de2f089a3eeef5fe351a3cedc37da1fcced1c \ + --hash=sha256:81d8bb2d1fb9d66dc7ea4373b176bb4b02443a7e328b3b603a73faec088b952e \ + --hash=sha256:8d42b07a2f6cfe2d9b87daf345443583f00a14e856927782fde52f3a255e305a \ + --hash=sha256:9882bd889f27da78add4dd6f881d25697efc740bf840274e749988d25496c8e1 \ + --hash=sha256:98b2a40f98e1fc1b29e8a6094072e7e0c7dfe901e573bf6cfc6eb7ce84a7ae87 \ + --hash=sha256:9bc45f2463d997848450dbed91c950ca37c6cf27f84a49a5cad4affc0b469e39 \ + --hash=sha256:a8d2b5fb75ebe57e21ce61e79a9131edec2622ff23cc665e4d1d1f201bc1a801 \ + --hash=sha256:a9f11ad133257c52c40d50de7a0ca3370a0cdd8e3d11eec0604ad3c34ba549e9 \ + --hash=sha256:adcb7ca33c5bdc33cd775e8b3eadad54873c802a6d909067a57348bcb96e7a2d \ + --hash=sha256:b3b3630051b53ad2564cb079e088b112dd576e3d91038338ad1cc7915e0f14dc \ + --hash=sha256:bafad70d2679c187120e8c44e1f9a8b06150bad8c0aecf612ad7dfbfa9510f73 \ + --hash=sha256:bbc827b77442c99deaeee26e0e7f172355ddb097a5e126aea206d447d3b26286 \ + --hash=sha256:c9a3faa416ff536cee93417a72bfb690d9dea136dc39a39dbbe1e5dadf108c9c \ + --hash=sha256:ce1f83c9a4e10ea3de1959f0ae79e9a5bd41346dff648fee6228ba9eaf8b3872 \ + --hash=sha256:d1e5498d883b706a4ce636247f0d830c6eb34a25b843a1b78e2c969754ca9037 \ + --hash=sha256:d1f807e2b4760a8e5c6d6b4e8c1d71ef52b7fe1946ff088f4fa41e16a881a5ca \ + --hash=sha256:d49df13cbb2627ccb13a1046f3ea6ebf7177b5504ec61bdef87d6a704046fd6e \ + --hash=sha256:d4b2d7c41086f1927d14947c563dfc7beed2f6c0d9af13c42fe3dcdc20d35832 \ + --hash=sha256:e9b973467d9c5fa9bc30bb6ac95f9f4d7c3d9fc25f6cf2d1cc972088e5955c01 \ + --hash=sha256:f160a2c6ba036f7eaf09f1f10f4fbfa734234af9112fb5187877efed78df9303 \ + --hash=sha256:f2a50c22c3a78cb4e48347ecf06930f61ce98cf9252f2e292aa025471e9d75b1 \ + --hash=sha256:f3672dbafbb458f1b96e1ee3e610d174acb5ace5bd2ed5d1252603bb797f2fc6 \ + --hash=sha256:fd24849d2b94ec749ceac7c34c9f01010d23b6e9d9216cf2238b8481160e703d + # via rapidocr +pycodestyle==2.14.0 \ + --hash=sha256:c4b5b517d278089ff9d0abdec919cd97262a3367449ea1c8b49b91529167b783 \ + --hash=sha256:dd6bf7cb4ee77f8e016f9c8e74a35ddd9f67e1d5fd4184d86c3b98e07099f42d + # via flake8 +pycparser==3.0 \ + --hash=sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29 \ + --hash=sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992 + # via cffi +pydantic==2.13.4 \ + --hash=sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba \ + --hash=sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6 + # via + # semantica (pyproject.toml) + # agno + # anthropic + # docling-core + # docling-ibm-models + # docling-parse + # docling-slim + # fastapi + # google-genai + # groq + # instructor + # litellm + # ollama + # openai + # pydantic-settings + # qdrant-client + # spacy + # thinc + # weasel + # weaviate-client +pydantic-core==2.46.4 \ + --hash=sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0 \ + --hash=sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262 \ + --hash=sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda \ + --hash=sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0 \ + --hash=sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e \ + --hash=sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b \ + --hash=sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594 \ + --hash=sha256:10e17cbb10a330363733efc4d7c4d0dd827ac0909b8f6a6542298fed1ea62f29 \ + --hash=sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2 \ + --hash=sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c \ + --hash=sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d \ + --hash=sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398 \ + --hash=sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d \ + --hash=sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3 \ + --hash=sha256:19e51f073cd3df251856a8a4189fbdf1de4012c3ebacfb1884f94f1eb406079f \ + --hash=sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb \ + --hash=sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7 \ + --hash=sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5 \ + --hash=sha256:228ee9bae8bef5b1e97ec58302f80357c37199e0d0a99174e138d28e6957b9d9 \ + --hash=sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462 \ + --hash=sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4 \ + --hash=sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b \ + --hash=sha256:2f84c03c8607173d16b5a854ec68a2f9079ae03237a54fb506d13af47e1d018d \ + --hash=sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df \ + --hash=sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2 \ + --hash=sha256:3447661d99f75a3683a4cf5c87da72f2161964611864dbbeac7fbb118bb4bfc0 \ + --hash=sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519 \ + --hash=sha256:395aebd9183f9d112f569aeb5b2214d1a10a33bec8456447f7fbdfa51d38d4cd \ + --hash=sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7 \ + --hash=sha256:3be77f45df024d789a672ae34f8b06fb346c4f9f46ea714956660ea4862e89ac \ + --hash=sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6 \ + --hash=sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565 \ + --hash=sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898 \ + --hash=sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb \ + --hash=sha256:432c179df7874eeb73307aad2df0755e1ae0efa61ff0ea89b93e194411ae3928 \ + --hash=sha256:4a05d69cba51d852c5c3e92758653245a50c0b646ced0cf05bd793ed592839d6 \ + --hash=sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3 \ + --hash=sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a \ + --hash=sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596 \ + --hash=sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987 \ + --hash=sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e \ + --hash=sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d \ + --hash=sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712 \ + --hash=sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008 \ + --hash=sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd \ + --hash=sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1 \ + --hash=sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be \ + --hash=sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea \ + --hash=sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292 \ + --hash=sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33 \ + --hash=sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3 \ + --hash=sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4 \ + --hash=sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b \ + --hash=sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826 \ + --hash=sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac \ + --hash=sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7 \ + --hash=sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d \ + --hash=sha256:8358a950c8909158e3df31538a7e4edc2d7265a7c54b47f0864d9e5bae9dcebf \ + --hash=sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4 \ + --hash=sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc \ + --hash=sha256:8b9bab013d1c7a79d3501ff86d0bc9c31bf587db4551677b96bec07df78c6b15 \ + --hash=sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3 \ + --hash=sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b \ + --hash=sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914 \ + --hash=sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04 \ + --hash=sha256:905a0ed8ea6f2d61c1738835f99b699348d7857379083e5fc497fa0c967a407c \ + --hash=sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b \ + --hash=sha256:91a06d2e259ecfbd8c901d70c3c507900458498142b3026a296b7de4d1322cc9 \ + --hash=sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce \ + --hash=sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4 \ + --hash=sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a \ + --hash=sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f \ + --hash=sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424 \ + --hash=sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894 \ + --hash=sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9 \ + --hash=sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76 \ + --hash=sha256:9f444c499b3eefd3a92e348059471ea0c3a6e303d9c1cec09fa748fd9f895201 \ + --hash=sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb \ + --hash=sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109 \ + --hash=sha256:a396dcc17e5a0b164dbe026896245a4fa9ff402edca1dff0be3d53a517f74de4 \ + --hash=sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848 \ + --hash=sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526 \ + --hash=sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0 \ + --hash=sha256:b078afbc25f3a1436c7a1d2cd3e322497ee99615ba97c563566fdf46aff1ee01 \ + --hash=sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458 \ + --hash=sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e \ + --hash=sha256:bb63e0198ca18aad131c089b9204c23079c3afa95487e561f4c522d519e55aba \ + --hash=sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a \ + --hash=sha256:c1747f85cee84c26985853c6f3d9bd3e75da5212912443fa111c113b9c246f39 \ + --hash=sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c \ + --hash=sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000 \ + --hash=sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b \ + --hash=sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf \ + --hash=sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4 \ + --hash=sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd \ + --hash=sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28 \ + --hash=sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9 \ + --hash=sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30 \ + --hash=sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983 \ + --hash=sha256:d80ee3d731373b24cebbc10d689ca4ee1875caf0d5703a245db18efd4dd37fc1 \ + --hash=sha256:d995260fdf4e1db774581b4900e0f832abe3c7c84996726bbc161b19c8f29e76 \ + --hash=sha256:da4b951fe36dc7c3a1ccb4e3cd1747c3542b8c9ceede8fc86cae054e764485f5 \ + --hash=sha256:daa27d92c36f24388fe3ad306b174781c747627f134452e4f128ea00ce1fe8c4 \ + --hash=sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7 \ + --hash=sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c \ + --hash=sha256:e68b7a074f65a2fd746c52a7ce6142ab7006074ac269ace0c25cd8ba171f8066 \ + --hash=sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3 \ + --hash=sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02 \ + --hash=sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89 \ + --hash=sha256:ea793e075b70290d89d8142074262885d3f7da19634845135751bd6344f73b50 \ + --hash=sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76 \ + --hash=sha256:f13a646d65d09fbf1bc6b3a9635d30095c8e7e5cc419ff35ecc563c5fd04cd49 \ + --hash=sha256:f47286a97f0bc9b8859519809077b91b2cefe4ae47fcbf5e466a009c1c5d742b \ + --hash=sha256:f747929cf940cddb5b3668a390056ddd5ba2e5010615ea2dcf4f9c4f3ab8791d \ + --hash=sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7 \ + --hash=sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4 \ + --hash=sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c \ + --hash=sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e \ + --hash=sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff \ + --hash=sha256:fd8b3d9fd264be37976686c7f65cd52a83f5e84f4bfd2adf9c1d469676bbb6ae + # via + # instructor + # pydantic +pydantic-settings==2.15.0 \ + --hash=sha256:0ba092c291c94baceb5eff768aa0d56400a457585bc0175925a5a5510303da42 \ + --hash=sha256:694b793e84f766ba76a90ebdefc01d0a9a045dab0382bee70393da93712ad117 + # via + # agno + # docling-core + # docling-slim + # litellm +pyflakes==3.4.0 \ + --hash=sha256:b24f96fafb7d2ab0ec5075b7350b3d2d2218eab42003821c06344973d3ea2f58 \ + --hash=sha256:f742a7dbd0d9cb9ea41e9a24a918996e8170c799fa528688d40dd582c8265f4f + # via flake8 +pygments==2.20.0 \ + --hash=sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f \ + --hash=sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176 + # via + # ipython + # ipython-pygments-lexers + # jupyter-console + # mpire + # nbconvert + # pytest + # rich +pylatexenc==2.11 \ + --hash=sha256:305a072a99ce736246049c9da05841b9d718c0f7ea8888f5f596cf15cb621053 \ + --hash=sha256:e78e7391d6c104f1ed150e21cfaa58016cdb50aa54406a2eecb793649ffdfdd0 + # via docling-slim +pymilvus==3.0.1 \ + --hash=sha256:c02389059088b18d6e598cd175541e445c772fab4926c5e527c4913be34887f1 \ + --hash=sha256:c5a8d5c1fa1de7b416e3529d383d8cc2e7da2170433ffa4a2d9087e14f70171a + # via semantica (pyproject.toml) +pynndescent==0.6.0 \ + --hash=sha256:7ffde0fb5b400741e055a9f7d377e3702e02250616834231f6c209e39aac24f5 \ + --hash=sha256:dc8c74844e4c7f5cbd1e0cd6909da86fdc789e6ff4997336e344779c3d5538ef + # via umap-learn +pyoxigraph==0.5.9 \ + --hash=sha256:09071f6c08b9489723dec96e2d96f64a15b9be2d165d3bbf67d40712884ba7a3 \ + --hash=sha256:276ca12ca2cc20b123812af78a489d35f871f1ee5579d9f98d6c65f038fcd9ee \ + --hash=sha256:379bef7f8fc38f638f358b1e12bc5bdc908a0e7d47157f4399bf103c727a66da \ + --hash=sha256:3bd1925a8185320bcb8c549cccafbb423cc3957513890e6f420b8e355055052a \ + --hash=sha256:4558d430bbad6e6b4ba98e0e89a2e28402211069d38e6e9b00083ae2d9d2d175 \ + --hash=sha256:4f8ff48b873157ab38e2595a56d6d2008471a45853f5fffc645658c6f69c07db \ + --hash=sha256:56b78aab5a5688ede88404372574785ba74e8d82b2cd1c0b0623a03b7069967f \ + --hash=sha256:57f3619c7860f4c95ddab077e4a3dedb7ca4cf191bd81096db835264d414ec5b \ + --hash=sha256:5a8a1b2debadb5fe79f8b89cbe1193e9c0e6fc1cf0c9431b6be706234beeabbe \ + --hash=sha256:5c9f93db5e14a03ac1e3934cece3fb6f7c0a9f4bde33082c72c788c12bf65ba4 \ + --hash=sha256:68f8daf082ea4bf9583abd10b64e23cd2c4a3285338a5ec24254181d45e63083 \ + --hash=sha256:6ab699861035163e89bc512ce20aa6e91b654e4d33114c9f5facab08f0fe3d7e \ + --hash=sha256:70ac4792acee8c86f795b0db785b467afbb02daf58e2beb6e6ef3c3f43f4c222 \ + --hash=sha256:70ffb46ae49f52b18a49c3fb63d906ae9c189de4fe4dbf5453279bda4d27af4e \ + --hash=sha256:71dba053e5efc0002fbd4ace3119b9d3aec8a6c5b164ed7409a2429bf71171b8 \ + --hash=sha256:79caf78136a8312e506beb607910cc5a93662a05a173aa9b560ce9d08801384f \ + --hash=sha256:8b998bc479a54a8905cdeaad621d0f7fed212abf9f1cbededfde4c51fc8e3bb8 \ + --hash=sha256:917d976dcb813d613d0ddd7da1c9dec6ad02ee815015f393c703cf0804946653 \ + --hash=sha256:94c2a8b52c1ed6e445a235a4f89cd460eea936f399d28df5e9927826bf52f032 \ + --hash=sha256:95347d64417299f91128ccfee486dcb14d2c6674a9b9a62e5c6978b651a2ccf2 \ + --hash=sha256:afe19bd1835a7245caad06cc9bb1a5c861882dc074fdfa24ba2626e3bbf9866a \ + --hash=sha256:b829233ea4445ccd1032d02e9189432a77e16888a79313498aa501b8731dc925 \ + --hash=sha256:b8884b0ce3ccbac99ebc2c995614a13dc5f5d86b0adb847f36aeb1c713d12946 \ + --hash=sha256:baffb41d914b761b06cde61eeb0a35dd5f0fa4808f71ae9902fdd1179e70e553 \ + --hash=sha256:bcac65148bddcd0ae24ee1bf20a2e89cc225a926b9e9996eb64dcce60400d1a3 \ + --hash=sha256:c711156407663e2182e4ea07c959f8e471f1b6ecaee1f00ce3accca7a53d9917 \ + --hash=sha256:d04806073905f448a48811b217115e71224be7f1d4075d1f5f5ec07a016f42ae \ + --hash=sha256:dd3a801b56c383cf4b078bd51cc1b86498b1a1f6e3e2f56406a00c3239fc97ca \ + --hash=sha256:e9ca7cd7666336fcbafd9a2ec7d598dd859b7bd2ca7b0838a0f7b92dd3828c28 \ + --hash=sha256:eee3db30ecb6836fdc05ddcbc6aa79ed521afcbfa707a8561b7e5891c4fb4ff8 \ + --hash=sha256:efd3d03bd2a36f9b0bdf3ce70d76ce5278c481fe961d14c2bb6efcac10f57ae2 \ + --hash=sha256:f39a6175a80a55c837981d4d68f42380071bb1d45af124de488fcc8a61a81af3 \ + --hash=sha256:f619aac7199b2ba91cade2fe69f64b4c73abb1e6b33735b0ff7205a753e609cd \ + --hash=sha256:f9154bea122c0bab11eda7604b27ceb424ab8ba1637250503008b8c6632ea405 \ + --hash=sha256:fe2bea0f41f5284b6dad99ea718d7ff03600068cdf8736b63a9e6cd05f056b19 + # via semantica (pyproject.toml) +pyparsing==3.3.2 \ + --hash=sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d \ + --hash=sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc + # via + # matplotlib + # rdflib +pypdfium2==5.12.1 \ + --hash=sha256:05bab9b1ba2de7fc299ae2af25cb9c8a0543bc8bb893e879fe8c9ba8310e9ce4 \ + --hash=sha256:05bfa20a08a96584253bbe38b60e13f81a037eac31c5579e607ec1480ad25dbf \ + --hash=sha256:07eeebb2784f4cd38d386b924235df43217a397442796673296bb6efbdaad1d0 \ + --hash=sha256:236dbdc88aa54f14b27937ccb2ebe3dcf08c10dbb8652f432ea982dc9af39732 \ + --hash=sha256:4648f0905441bcb141687ca2263bbf38a1aa056b943eef06019f91cff3e1da4a \ + --hash=sha256:5c3e6cbe43581af79526184643920ab03a9401a0c79f2226bea9d4d1e3d34008 \ + --hash=sha256:5f257bb40fa44ce9ba18d2c919777dbd3f16bf22548b1d68fd56c7c92f1de530 \ + --hash=sha256:66a9ed40d70a5d728cd42148fecb9d7a0917c6161d6bb67c844093a4ed1df089 \ + --hash=sha256:6eabf028ad8e7bc7811c9acf3a72718c180569b624b844d2c6cc974609784275 \ + --hash=sha256:715ae16b34ea1d64884d58800155179ba700e9ea65a2f583b020666acd2bfb12 \ + --hash=sha256:7857cfa6642ec5a09db12ff8f5cf6b6494585b5e3a605399fddc4fb862837b63 \ + --hash=sha256:847378a5ab41332998b2621b21bab2e96dc8c3eff36a08bce26695b964163983 \ + --hash=sha256:9609be73a6701a68f29dffe0335f7a2e4b3ba581542ed65d35d49f761a4600ca \ + --hash=sha256:974082344172da76a5c3c0782eaedfe6069dbe88db77d8c671ef36b61e9b14e2 \ + --hash=sha256:9c8856ce7dd77a7827476c7d75afe1197d6cd505f5cb4167b6aacf661f3f8ea5 \ + --hash=sha256:9f059f7bdbdf4352eb83691071096940d769d6ae5930b8734237fdb1bd78fbc2 \ + --hash=sha256:afc0b7e0c975a429abc75875209ce17b66d749f6ac5cbe8ba72470e83901e304 \ + --hash=sha256:bdff622181fab64f32328591c9c8287cdc745c9a1f2afc26ca3feba39e3e6645 \ + --hash=sha256:d0e0648fb2e28f50efcd1ec0a5a18ced9f4d66b2c227fae9b603f0a883b2d13f \ + --hash=sha256:d4ee061e566a6422b660cdddaaa799a2d1cbf2f016921bcaf24d61426d01d942 \ + --hash=sha256:e10cbf41b21233ec5e20adfc170cf60edd77abead86a97dc708fff55a8a886c7 \ + --hash=sha256:e5358d2ce4ebc5c899aab1df9ca5d215357244e9168aa443225d3c1e649c7eac + # via docling-slim +pypickle==2.0.1 \ + --hash=sha256:0cc1ee65293e4dfa90f0db6435e8021c6a83346be98d0fee81aceb2dad1fb091 \ + --hash=sha256:894afd81d26443e8589d21361a3cc04bd9f5c1535aaa627c3bee1212b58bdf74 + # via distfit +pyshacl==0.40.1 \ + --hash=sha256:011e3cf1a68b31747cb762ba3d755ae1bdcc464c8fad0dc212a9adc550719552 \ + --hash=sha256:27dd58c8ddfa103303b4a8c40b2c666332ffc912dbcd3137f7adc7b7bc5e6bda + # via semantica (pyproject.toml) +pytest==9.1.1 \ + --hash=sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313 \ + --hash=sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c + # via + # semantica (pyproject.toml) + # pytest-asyncio + # pytest-cov +pytest-asyncio==1.4.0 \ + --hash=sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1 \ + --hash=sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42 + # via semantica (pyproject.toml) +pytest-cov==7.1.0 \ + --hash=sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2 \ + --hash=sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678 + # via semantica (pyproject.toml) +python-dateutil==2.9.0.post0 \ + --hash=sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3 \ + --hash=sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427 + # via + # arrow + # botocore + # celery + # falkordb + # jupyter-client + # matplotlib + # pandas + # pinecone-client +python-discovery==1.5.2 \ + --hash=sha256:3e338c2d0f15dfaeea57493f4c2c6caebe0e998ea815c30ae8bf8ee21f1112d3 \ + --hash=sha256:45fd4f20a4e3f9b7bf2e0817870bc8e3b320a19658da177af800768c82dbf354 + # via virtualenv +python-docx==1.2.0 \ + --hash=sha256:3fd478f3250fbbbfd3b94fe1e985955737c145627498896a8a6bf81f4baf66c7 \ + --hash=sha256:7bc9d7b7d8a69c9c02ca09216118c86552704edc23bac179283f2e38f86220ce + # via + # semantica (pyproject.toml) + # docling-slim +python-dotenv==1.2.2 \ + --hash=sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a \ + --hash=sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3 + # via + # semantica (pyproject.toml) + # docling-slim + # litellm + # pydantic-settings + # pymilvus + # uvicorn +python-json-logger==4.1.0 \ + --hash=sha256:132994765cf75bf44554be9aa49b06ef2345d23661a96720262716438141b6b2 \ + --hash=sha256:b396b9e3ed782b09ff9d6e4f1683d46c83ad0d35d2e407c09a9ebbf038f88195 + # via jupyter-events +python-louvain==0.16 \ + --hash=sha256:b7ba2df5002fd28d3ee789a49532baad11fe648e4f2117cf0798e7520a1da56b + # via + # semantica (pyproject.toml) + # d3graph +python-multipart==0.0.32 \ + --hash=sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e \ + --hash=sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23 + # via semantica (pyproject.toml) +python-oxmsg==0.0.2 \ + --hash=sha256:22be29b14c46016bcd05e34abddfd8e05ee82082f53b82753d115da3fc7d0355 \ + --hash=sha256:a6aff4deb1b5975d44d49dab1d9384089ffeec819e19c6940bc7ffbc84775fad + # via docling-slim +python-pptx==1.0.2 \ + --hash=sha256:160838e0b8565a8b1f67947675886e9fea18aa5e795db7ae531606d68e785cba \ + --hash=sha256:479a8af0eaf0f0d76b6f00b0887732874ad2e3188230315290cd1f9dd9cc7095 + # via docling-slim +pytokens==0.4.1 \ + --hash=sha256:0fc71786e629cef478cbf29d7ea1923299181d0699dbe7c3c0f4a583811d9fc1 \ + --hash=sha256:11edda0942da80ff58c4408407616a310adecae1ddd22eef8c692fe266fa5009 \ + --hash=sha256:140709331e846b728475786df8aeb27d24f48cbcf7bcd449f8de75cae7a45083 \ + --hash=sha256:24afde1f53d95348b5a0eb19488661147285ca4dd7ed752bbc3e1c6242a304d1 \ + --hash=sha256:26cef14744a8385f35d0e095dc8b3a7583f6c953c2e3d269c7f82484bf5ad2de \ + --hash=sha256:27b83ad28825978742beef057bfe406ad6ed524b2d28c252c5de7b4a6dd48fa2 \ + --hash=sha256:292052fe80923aae2260c073f822ceba21f3872ced9a68bb7953b348e561179a \ + --hash=sha256:29d1d8fb1030af4d231789959f21821ab6325e463f0503a61d204343c9b355d1 \ + --hash=sha256:2a44ed93ea23415c54f3face3b65ef2b844d96aeb3455b8a69b3df6beab6acc5 \ + --hash=sha256:30f51edd9bb7f85c748979384165601d028b84f7bd13fe14d3e065304093916a \ + --hash=sha256:34bcc734bd2f2d5fe3b34e7b3c0116bfb2397f2d9666139988e7a3eb5f7400e3 \ + --hash=sha256:3ad72b851e781478366288743198101e5eb34a414f1d5627cdd585ca3b25f1db \ + --hash=sha256:3f901fe783e06e48e8cbdc82d631fca8f118333798193e026a50ce1b3757ea68 \ + --hash=sha256:42f144f3aafa5d92bad964d471a581651e28b24434d184871bd02e3a0d956037 \ + --hash=sha256:4a14d5f5fc78ce85e426aa159489e2d5961acf0e47575e08f35584009178e321 \ + --hash=sha256:4a58d057208cb9075c144950d789511220b07636dd2e4708d5645d24de666bdc \ + --hash=sha256:4e691d7f5186bd2842c14813f79f8884bb03f5995f0575272009982c5ac6c0f7 \ + --hash=sha256:5502408cab1cb18e128570f8d598981c68a50d0cbd7c61312a90507cd3a1276f \ + --hash=sha256:584c80c24b078eec1e227079d56dc22ff755e0ba8654d8383b2c549107528918 \ + --hash=sha256:5ad948d085ed6c16413eb5fec6b3e02fa00dc29a2534f088d3302c47eb59adf9 \ + --hash=sha256:670d286910b531c7b7e3c0b453fd8156f250adb140146d234a82219459b9640c \ + --hash=sha256:682fa37ff4d8e95f7df6fe6fe6a431e8ed8e788023c6bcc0f0880a12eab80ad1 \ + --hash=sha256:6d6c4268598f762bc8e91f5dbf2ab2f61f7b95bdc07953b602db879b3c8c18e1 \ + --hash=sha256:79fc6b8699564e1f9b521582c35435f1bd32dd06822322ec44afdeba666d8cb3 \ + --hash=sha256:8bdb9d0ce90cbf99c525e75a2fa415144fd570a1ba987380190e8b786bc6ef9b \ + --hash=sha256:8fcb9ba3709ff77e77f1c7022ff11d13553f3c30299a9fe246a166903e9091eb \ + --hash=sha256:941d4343bf27b605e9213b26bfa1c4bf197c9c599a9627eb7305b0defcfe40c1 \ + --hash=sha256:967cf6e3fd4adf7de8fc73cd3043754ae79c36475c1c11d514fc72cf5490094a \ + --hash=sha256:970b08dd6b86058b6dc07efe9e98414f5102974716232d10f32ff39701e841c4 \ + --hash=sha256:97f50fd18543be72da51dd505e2ed20d2228c74e0464e4262e4899797803d7fa \ + --hash=sha256:9bd7d7f544d362576be74f9d5901a22f317efc20046efe2034dced238cbbfe78 \ + --hash=sha256:add8bf86b71a5d9fb5b89f023a80b791e04fba57960aa790cc6125f7f1d39dfe \ + --hash=sha256:b35d7e5ad269804f6697727702da3c517bb8a5228afa450ab0fa787732055fc9 \ + --hash=sha256:b49750419d300e2b5a3813cf229d4e5a4c728dae470bcc89867a9ad6f25a722d \ + --hash=sha256:d31b97b3de0f61571a124a00ffe9a81fb9939146c122c11060725bd5aea79975 \ + --hash=sha256:d70e77c55ae8380c91c0c18dea05951482e263982911fc7410b1ffd1dadd3440 \ + --hash=sha256:d9907d61f15bf7261d7e775bd5d7ee4d2930e04424bab1972591918497623a16 \ + --hash=sha256:da5baeaf7116dced9c6bb76dc31ba04a2dc3695f3d9f74741d7910122b456edc \ + --hash=sha256:dc74c035f9bfca0255c1af77ddd2d6ae8419012805453e4b0e7513e17904545d \ + --hash=sha256:dcafc12c30dbaf1e2af0490978352e0c4041a7cde31f4f81435c2a5e8b9cabb6 \ + --hash=sha256:ee44d0f85b803321710f9239f335aafe16553b39106384cef8e6de40cb4ef2f6 \ + --hash=sha256:f66a6bbe741bd431f6d741e617e0f39ec7257ca1f89089593479347cc4d13324 + # via black +pytz==2026.3.post1 \ + --hash=sha256:2211d3fcf9a797d3405cac96ac7f61d80e6a644f72a3309607282fe8a2010c5d \ + --hash=sha256:dd95840dd199baea12d9cc096a1d452caa6596a1c1e4b5f3dbd1541855d5e815 + # via neo4j +pyvis==0.3.2 \ + --hash=sha256:5720c4ca8161dc5d9ab352015723abb7a8bb8fb443edeb07f7a322db34a97555 + # via semantica (pyproject.toml) +pyyaml==6.0.3 \ + --hash=sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c \ + --hash=sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a \ + --hash=sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3 \ + --hash=sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956 \ + --hash=sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6 \ + --hash=sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c \ + --hash=sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65 \ + --hash=sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a \ + --hash=sha256:1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0 \ + --hash=sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b \ + --hash=sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1 \ + --hash=sha256:22ba7cfcad58ef3ecddc7ed1db3409af68d023b7f940da23c6c2a1890976eda6 \ + --hash=sha256:27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7 \ + --hash=sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e \ + --hash=sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007 \ + --hash=sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310 \ + --hash=sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4 \ + --hash=sha256:3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9 \ + --hash=sha256:3ff07ec89bae51176c0549bc4c63aa6202991da2d9a6129d7aef7f1407d3f295 \ + --hash=sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea \ + --hash=sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0 \ + --hash=sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e \ + --hash=sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac \ + --hash=sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9 \ + --hash=sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7 \ + --hash=sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35 \ + --hash=sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb \ + --hash=sha256:5cf4e27da7e3fbed4d6c3d8e797387aaad68102272f8f9752883bc32d61cb87b \ + --hash=sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69 \ + --hash=sha256:5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5 \ + --hash=sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b \ + --hash=sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c \ + --hash=sha256:6344df0d5755a2c9a276d4473ae6b90647e216ab4757f8426893b5dd2ac3f369 \ + --hash=sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd \ + --hash=sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824 \ + --hash=sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198 \ + --hash=sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065 \ + --hash=sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c \ + --hash=sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c \ + --hash=sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764 \ + --hash=sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196 \ + --hash=sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b \ + --hash=sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00 \ + --hash=sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac \ + --hash=sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8 \ + --hash=sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e \ + --hash=sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28 \ + --hash=sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3 \ + --hash=sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5 \ + --hash=sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4 \ + --hash=sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b \ + --hash=sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf \ + --hash=sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5 \ + --hash=sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702 \ + --hash=sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8 \ + --hash=sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788 \ + --hash=sha256:b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da \ + --hash=sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d \ + --hash=sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc \ + --hash=sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c \ + --hash=sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba \ + --hash=sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f \ + --hash=sha256:c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917 \ + --hash=sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5 \ + --hash=sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26 \ + --hash=sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f \ + --hash=sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b \ + --hash=sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be \ + --hash=sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c \ + --hash=sha256:efd7b85f94a6f21e4932043973a7ba2613b059c4a000551892ac9f1d11f5baf3 \ + --hash=sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6 \ + --hash=sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926 \ + --hash=sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0 + # via + # semantica (pyproject.toml) + # accelerate + # agno + # docling-core + # huggingface-hub + # jupyter-events + # omegaconf + # pre-commit + # rapidocr + # transformers + # uvicorn +pyzmq==27.1.0 \ + --hash=sha256:01c0e07d558b06a60773744ea6251f769cd79a41a97d11b8bf4ab8f034b0424d \ + --hash=sha256:01f9437501886d3a1dd4b02ef59fb8cc384fa718ce066d52f175ee49dd5b7ed8 \ + --hash=sha256:03ff0b279b40d687691a6217c12242ee71f0fba28bf8626ff50e3ef0f4410e1e \ + --hash=sha256:05b12f2d32112bf8c95ef2e74ec4f1d4beb01f8b5e703b38537f8849f92cb9ba \ + --hash=sha256:0790a0161c281ca9723f804871b4027f2e8b5a528d357c8952d08cd1a9c15581 \ + --hash=sha256:08363b2011dec81c354d694bdecaef4770e0ae96b9afea70b3f47b973655cc05 \ + --hash=sha256:08e90bb4b57603b84eab1d0ca05b3bbb10f60c1839dc471fc1c9e1507bef3386 \ + --hash=sha256:0c996ded912812a2fcd7ab6574f4ad3edc27cb6510349431e4930d4196ade7db \ + --hash=sha256:0de3028d69d4cdc475bfe47a6128eb38d8bc0e8f4d69646adfbcd840facbac28 \ + --hash=sha256:15c8bd0fe0dabf808e2d7a681398c4e5ded70a551ab47482067a572c054c8e2e \ + --hash=sha256:1779be8c549e54a1c38f805e56d2a2e5c009d26de10921d7d51cfd1c8d4632ea \ + --hash=sha256:18339186c0ed0ce5835f2656cdfb32203125917711af64da64dbaa3d949e5a1b \ + --hash=sha256:18770c8d3563715387139060d37859c02ce40718d1faf299abddcdcc6a649066 \ + --hash=sha256:190cbf120fbc0fc4957b56866830def56628934a9d112aec0e2507aa6a032b97 \ + --hash=sha256:19c9468ae0437f8074af379e986c5d3d7d7bfe033506af442e8c879732bedbe0 \ + --hash=sha256:1c179799b118e554b66da67d88ed66cd37a169f1f23b5d9f0a231b4e8d44a113 \ + --hash=sha256:1f0b2a577fd770aa6f053211a55d1c47901f4d537389a034c690291485e5fe92 \ + --hash=sha256:1f8426a01b1c4098a750973c37131cf585f61c7911d735f729935a0c701b68d3 \ + --hash=sha256:226b091818d461a3bef763805e75685e478ac17e9008f49fce2d3e52b3d58b86 \ + --hash=sha256:250e5436a4ba13885494412b3da5d518cd0d3a278a1ae640e113c073a5f88edd \ + --hash=sha256:346e9ba4198177a07e7706050f35d733e08c1c1f8ceacd5eb6389d653579ffbc \ + --hash=sha256:3837439b7f99e60312f0c926a6ad437b067356dc2bc2ec96eb395fd0fe804233 \ + --hash=sha256:3970778e74cb7f85934d2b926b9900e92bfe597e62267d7499acc39c9c28e345 \ + --hash=sha256:43ad9a73e3da1fab5b0e7e13402f0b2fb934ae1c876c51d0afff0e7c052eca31 \ + --hash=sha256:448f9cb54eb0cee4732b46584f2710c8bc178b0e5371d9e4fc8125201e413a74 \ + --hash=sha256:452631b640340c928fa343801b0d07eb0c3789a5ffa843f6e1a9cee0ba4eb4fc \ + --hash=sha256:49d3980544447f6bd2968b6ac913ab963a49dcaa2d4a2990041f16057b04c429 \ + --hash=sha256:4a19387a3dddcc762bfd2f570d14e2395b2c9701329b266f83dd87a2b3cbd381 \ + --hash=sha256:4c618fbcd069e3a29dcd221739cacde52edcc681f041907867e0f5cc7e85f172 \ + --hash=sha256:50081a4e98472ba9f5a02850014b4c9b629da6710f8f14f3b15897c666a28f1b \ + --hash=sha256:507b6f430bdcf0ee48c0d30e734ea89ce5567fd7b8a0f0044a369c176aa44556 \ + --hash=sha256:508e23ec9bc44c0005c4946ea013d9317ae00ac67778bd47519fdf5a0e930ff4 \ + --hash=sha256:510869f9df36ab97f89f4cff9d002a89ac554c7ac9cadd87d444aa4cf66abd27 \ + --hash=sha256:53b40f8ae006f2734ee7608d59ed661419f087521edbfc2149c3932e9c14808c \ + --hash=sha256:544b4e3b7198dde4a62b8ff6685e9802a9a1ebf47e77478a5eb88eca2a82f2fd \ + --hash=sha256:5bbf8d3630bf96550b3be8e1fc0fea5cbdc8d5466c1192887bd94869da17a63e \ + --hash=sha256:677e744fee605753eac48198b15a2124016c009a11056f93807000ab11ce6526 \ + --hash=sha256:6bb54ca21bcfe361e445256c15eedf083f153811c37be87e0514934d6913061e \ + --hash=sha256:6df079c47d5902af6db298ec92151db82ecb557af663098b92f2508c398bb54f \ + --hash=sha256:6f3afa12c392f0a44a2414056d730eebc33ec0926aae92b5ad5cf26ebb6cc128 \ + --hash=sha256:7200bb0f03345515df50d99d3db206a0a6bee1955fbb8c453c76f5bf0e08fb96 \ + --hash=sha256:722ea791aa233ac0a819fc2c475e1292c76930b31f1d828cb61073e2fe5e208f \ + --hash=sha256:726b6a502f2e34c6d2ada5e702929586d3ac948a4dbbb7fed9854ec8c0466027 \ + --hash=sha256:753d56fba8f70962cd8295fb3edb40b9b16deaa882dd2b5a3a2039f9ff7625aa \ + --hash=sha256:75a2f36223f0d535a0c919e23615fc85a1e23b71f40c7eb43d7b1dedb4d8f15f \ + --hash=sha256:7be883ff3d722e6085ee3f4afc057a50f7f2e0c72d289fd54df5706b4e3d3a50 \ + --hash=sha256:7ccc0700cfdf7bd487bea8d850ec38f204478681ea02a582a8da8171b7f90a1c \ + --hash=sha256:8085a9fba668216b9b4323be338ee5437a235fe275b9d1610e422ccc279733e2 \ + --hash=sha256:80d834abee71f65253c91540445d37c4c561e293ba6e741b992f20a105d69146 \ + --hash=sha256:849ca054d81aa1c175c49484afaaa5db0622092b5eccb2055f9f3bb8f703782d \ + --hash=sha256:90e6e9441c946a8b0a667356f7078d96411391a3b8f80980315455574177ec97 \ + --hash=sha256:93ad4b0855a664229559e45c8d23797ceac03183c7b6f5b4428152a6b06684a5 \ + --hash=sha256:9541c444cfe1b1c0156c5c86ece2bb926c7079a18e7b47b0b1b3b1b875e5d098 \ + --hash=sha256:96c71c32fff75957db6ae33cd961439f386505c6e6b377370af9b24a1ef9eafb \ + --hash=sha256:9a916f76c2ab8d045b19f2286851a38e9ac94ea91faf65bd64735924522a8b32 \ + --hash=sha256:9c1790386614232e1b3a40a958454bdd42c6d1811837b15ddbb052a032a43f62 \ + --hash=sha256:9ce490cf1d2ca2ad84733aa1d69ce6855372cb5ce9223802450c9b2a7cba0ccf \ + --hash=sha256:a1aa0ee920fb3825d6c825ae3f6c508403b905b698b6460408ebd5bb04bbb312 \ + --hash=sha256:a5b42d7a0658b515319148875fcb782bbf118dd41c671b62dae33666c2213bda \ + --hash=sha256:ac0765e3d44455adb6ddbf4417dcce460fc40a05978c08efdf2948072f6db540 \ + --hash=sha256:ac25465d42f92e990f8d8b0546b01c391ad431c3bf447683fdc40565941d0604 \ + --hash=sha256:ad68808a61cbfbbae7ba26d6233f2a4aa3b221de379ce9ee468aa7a83b9c36b0 \ + --hash=sha256:add071b2d25f84e8189aaf0882d39a285b42fa3853016ebab234a5e78c7a43db \ + --hash=sha256:b1267823d72d1e40701dcba7edc45fd17f71be1285557b7fe668887150a14b78 \ + --hash=sha256:b2e592db3a93128daf567de9650a2f3859017b3f7a66bc4ed6e4779d6034976f \ + --hash=sha256:b721c05d932e5ad9ff9344f708c96b9e1a485418c6618d765fca95d4daacfbef \ + --hash=sha256:bafcb3dd171b4ae9f19ee6380dfc71ce0390fefaf26b504c0e5f628d7c8c54f2 \ + --hash=sha256:bd67e7c8f4654bef471c0b1ca6614af0b5202a790723a58b79d9584dc8022a78 \ + --hash=sha256:bf7b38f9fd7b81cb6d9391b2946382c8237fd814075c6aa9c3b746d53076023b \ + --hash=sha256:c0bb87227430ee3aefcc0ade2088100e528d5d3298a0a715a64f3d04c60ba02f \ + --hash=sha256:c17e03cbc9312bee223864f1a2b13a99522e0dc9f7c5df0177cd45210ac286e6 \ + --hash=sha256:c65047adafe573ff023b3187bb93faa583151627bc9c51fc4fb2c561ed689d39 \ + --hash=sha256:c895a6f35476b0c3a54e3eb6ccf41bf3018de937016e6e18748317f25d4e925f \ + --hash=sha256:c9f7f6e13dff2e44a6afeaf2cf54cee5929ad64afaf4d40b50f93c58fc687355 \ + --hash=sha256:ce980af330231615756acd5154f29813d553ea555485ae712c491cd483df6b7a \ + --hash=sha256:cedc4c68178e59a4046f97eca31b148ddcf51e88677de1ef4e78cf06c5376c9a \ + --hash=sha256:cf44a7763aea9298c0aa7dbf859f87ed7012de8bda0f3977b6fb1d96745df856 \ + --hash=sha256:d54530c8c8b5b8ddb3318f481297441af102517602b569146185fa10b63f4fa9 \ + --hash=sha256:da96ecdcf7d3919c3be2de91a8c513c186f6762aa6cf7c01087ed74fad7f0968 \ + --hash=sha256:dc5dbf68a7857b59473f7df42650c621d7e8923fb03fa74a526890f4d33cc4d7 \ + --hash=sha256:dd2fec2b13137416a1c5648b7009499bcc8fea78154cd888855fa32514f3dad1 \ + --hash=sha256:df7cd397ece96cf20a76fae705d40efbab217d217897a5053267cd88a700c266 \ + --hash=sha256:e2687c2d230e8d8584fbea433c24382edfeda0c60627aca3446aa5e58d5d1831 \ + --hash=sha256:e30a74a39b93e2e1591b58eb1acef4902be27c957a8720b0e368f579b82dc22f \ + --hash=sha256:e343d067f7b151cfe4eb3bb796a7752c9d369eed007b91231e817071d2c2fec7 \ + --hash=sha256:e829529fcaa09937189178115c49c504e69289abd39967cd8a4c215761373394 \ + --hash=sha256:eca6b47df11a132d1745eb3b5b5e557a7dae2c303277aa0e69c6ba91b8736e07 \ + --hash=sha256:f30f395a9e6fbca195400ce833c731e7b64c3919aa481af4d88c3759e0cb7496 \ + --hash=sha256:f328d01128373cb6763823b2b4e7f73bdf767834268c565151eacb3b7a392f90 \ + --hash=sha256:f605d884e7c8be8fe1aa94e0a783bf3f591b84c24e4bc4f3e7564c82ac25e271 \ + --hash=sha256:fbb4f2400bfda24f12f009cba62ad5734148569ff4949b1b6ec3b519444342e6 \ + --hash=sha256:ff8d114d14ac671d88c89b9224c63d6c4e5a613fe8acd5594ce53d752a3aafe9 + # via + # ipykernel + # jupyter-client + # jupyter-console + # jupyter-server +qdrant-client==1.19.0 \ + --hash=sha256:13602a2b3478a95ecdf42f97b93d7f703b63a3361cd912a04495a33a5ac14121 \ + --hash=sha256:365395a04b0a26c309b25b7d8b1c99ef2071ec9a2b74bc8a5fd3b7a3642fe963 + # via semantica (pyproject.toml) +rapidocr==3.9.2 \ + --hash=sha256:04d6b8d151f823d930bd91910555f57bea897c0c44fa6794267b94cf9c1ef9a0 + # via docling-slim +rdflib==7.6.0 \ + --hash=sha256:30c0a3ebf4c0e09215f066be7246794b6492e054e782d7ac2a34c9f70a15e0dd \ + --hash=sha256:6c831288d5e4a5a7ece85d0ccde9877d512a3d0f02d7c06455d00d6d0ea379df + # via + # semantica (pyproject.toml) + # owlrl + # pyshacl +redis==8.1.0 \ + --hash=sha256:6e1a19beef9225c83efd689c7e6b7da2d5215b1f42cd13b7fc3714d0a09c7b25 \ + --hash=sha256:a4fe1aac3d3b3cc791d4b3d5931c5a956045dc951ee74d1c913ee3ac4d2ee9fb + # via + # semantica (pyproject.toml) + # falkordb +referencing==0.37.0 \ + --hash=sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231 \ + --hash=sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8 + # via + # jsonschema + # jsonschema-specifications + # jupyter-events +regex==2026.7.19 \ + --hash=sha256:062f8cb7a9739c4835d22bd96f370c59aba89f257adcfa53be3cc209e08d3ae0 \ + --hash=sha256:064f1760a5a4ade65c5419be23e782f29147528e8a66e0c42dd4cedb8d4e9fc6 \ + --hash=sha256:09523a592938aa9f587fb74467c63ff0cf88fc3df14c82ab0f0517dcf76aaa62 \ + --hash=sha256:09d3007fc76249a83cdd33de160d50e6cb77f54e09d8fa9e7148e10607ce24af \ + --hash=sha256:09f3e5287f94f17b709dc9a9e70865855feee835c861613be144218ce4ca82cc \ + --hash=sha256:0c41c63992bf1874cebb6e7f56fd7d3c007924659a604ae3d90e427d40d4fd13 \ + --hash=sha256:0e9554c8785eac5cffe6300f69a91f58ba72bc88a5f8d661235ad7c6aa5b8ccd \ + --hash=sha256:1123ef4211d763ee771d47916a1596e2f4915794f7aabdc1adcb20e4249a6951 \ + --hash=sha256:15b364b9b98d6d2fe1a85034c23a3180ff913f46caddc3895f6fd65186255ccc \ + --hash=sha256:1649eb39fcc9ea80c4d2f110fde2b8ab2aef3877b98f02ab9b14e961f418c511 \ + --hash=sha256:17ed5692f6acc4183e98331101a5f9e4f64d72fe58b753da4d444a2c77d05b12 \ + --hash=sha256:199535629f25caf89698039af3d1ad5fcae7f933e2112c73f1cdf49165c99518 \ + --hash=sha256:1c398716054621aa300b3d411f467dda903806c5da0df6945ab73982b8d115db \ + --hash=sha256:1d3372064506b94dd2c67c845f2db8062e9e9ba84d04e33cb96d7d33c11fe1ae \ + --hash=sha256:1d58561843f0ff7dc78b4c28b5e2dc388f3eff94ebc8a232a3adba961fc00009 \ + --hash=sha256:1d793a7988e04fcb1e2e135567443d82173225d657419ec09414a9b5a145b986 \ + --hash=sha256:1ebac3474b8589fce2f9b225b650afd61448f7c73a5d0255a10cc6366471aed1 \ + --hash=sha256:20568e182eb82d39a6bf7cff3fd58566f14c75c6f74b2c8c96537eecf9010e3a \ + --hash=sha256:22a992de9a0d91bda927bf02b94351d737a0302905432c88a53de7c4b9ce62e2 \ + --hash=sha256:2955907b7157a6660f27079edf7e0229e9c9c5325c77a2ef6a890cba91efa6f0 \ + --hash=sha256:2c4e61e2e1be56f63ec3cc618aa9e0de81ef6f43d177205451840022e24f5b78 \ + --hash=sha256:2cc3460cedf7579948486eab03bc9ad7089df4d7281c0f47f4afe03e8d13f02d \ + --hash=sha256:2ce9e679f776649746729b6c86382da519ef649c8e34cc41df0d2e5e0f6c36d4 \ + --hash=sha256:2ef7eeb108c47ce7bcc9513e51bcb1bf57e8f483d52fce68a8642e3527141ae0 \ + --hash=sha256:3080a7fd38ef049bd489e01c970c97dd84ff446a885b0f1f6b26d9b1ad13ce11 \ + --hash=sha256:343a4504e3fb688c47cad451221ca5d4814f42b1e16c0065bde9cbf7f473bd52 \ + --hash=sha256:36aacfb15faaff3ced55afbf35ec72f50d4aee22082c4f7fe0573a33e2fca92e \ + --hash=sha256:3d3143f159261b1ce5b24c261c590e5913370c3200c5e9ebbb92b5aa5e111902 \ + --hash=sha256:40b34dd88658e4fedd2fddbf0275ac970d00614b731357f425722a3ed1983d11 \ + --hash=sha256:4458124d71339f505bf1fb94f69fd1bb8fa9d2481eebfef27c10ef4f2b9e12f6 \ + --hash=sha256:4896db1f4ce0576765b8272aa922df324e0f5b9bb2c3d03044ff32a7234a9aba \ + --hash=sha256:4a0530bb1b8c1c985e7e2122e2b4d3aedd8a3c21c6bfddae6767c4405668b56e \ + --hash=sha256:4aa5435cdb3eb6f55fe98a171b05e3fbcd95fadaa4aa32acf62afd9b0cfdbcac \ + --hash=sha256:4c3501bfa814ab07b5580741f9bf78dfdfe146a04057f82df9e2402d2a975939 \ + --hash=sha256:4e5413bd5f13d3a4e3539ca98f70f75e7fca92518dd7f117f030ebedd10b60cb \ + --hash=sha256:4e6883a021db30511d9fb8cfb0f222ce1f2c369f7d4d8b0448f449a93ba0bdfc \ + --hash=sha256:52579c60a6078be70a0e49c81d6e56d677f34cd439af281a0083b8c7bc75c095 \ + --hash=sha256:555497390743af1a65045fa4527782d10ff5b88970359412baa4a1e628fe393b \ + --hash=sha256:56ad4d9f77df871a99e25c37091052a02528ec0eb059de928ee33956b854b45b \ + --hash=sha256:571fde9741eb0ccde23dd4e0c1d50fbae910e901fa7e629faf39b2dda740d220 \ + --hash=sha256:572fc57b0009c735ee56c175ea021b637a15551a312f56734277f923d6fd0f6c \ + --hash=sha256:59787bd5f8c70aa339084e961d2996b53fbdeab4d5393bba5c1fe1fc32e02bae \ + --hash=sha256:5a2721c8720e2cb3c209925dfb9200199b4b07361c9e01d321719404b21458b3 \ + --hash=sha256:5cc26a66e212fa5d6c6170c3a40d99d888db3020c6fdab1523250d4341382e44 \ + --hash=sha256:5ebee1ee89c39c953baac6924fcde08c5bb427c4057510862f9d7c7bdb3d8665 \ + --hash=sha256:60be8693a1dadc210bbcbc0db3e26da5f7d01d1d5a3da594e99b4fa42df404f5 \ + --hash=sha256:618a0aed532be87294c4477b0481f3aa0f1520f4014a4374dd4cf789b4cd2c97 \ + --hash=sha256:61bb1bd45520aacd56dd80943bd34991fb5350afdd1f36f2282230fd5154a218 \ + --hash=sha256:6383cd2ed53a646c659ba1fe65727db76437fdaa069e697a0b44a51d5843d864 \ + --hash=sha256:64729333167c2dcaaa56a331d40ee097bd9c5617ffd51dabb09eaddafb1b532e \ + --hash=sha256:64b6ca7391a1395c2638dd5c7456d67bea44fc6c5e8e92c5dc8aa6a8f23292b4 \ + --hash=sha256:65dcd28d3eba2ab7c2fd906485cc301392b47cc2234790d27d4e4814e02cdfda \ + --hash=sha256:65fa6cb38ed5e9c3637e68e544f598b39c3b86b808ed0627a67b68320384b459 \ + --hash=sha256:66bd62c59a5427746e8c44becae1d9b99d22fb13f30f492083dfb9ad7c45cc18 \ + --hash=sha256:6e44c0e7c5664be20aee92085153150c0a7967310a73a43c0f832b7cd35d0dd3 \ + --hash=sha256:6f8c6e7a1cfa3dc9d0ee2de0e65e834537fa29992cc3976ffec914afc35c5dd5 \ + --hash=sha256:7322ec6cc9fba9d49ab888bb82d67ac5625627aa168f0165139b17018df3fb8a \ + --hash=sha256:73b133a9e6fb512858e7f065e96f1180aa46646bc74a83aea62f1d314f3dd035 \ + --hash=sha256:73f272fba87b8ccfe70a137d02a54af386f6d27aa509fbffdd978f5947aae1aa \ + --hash=sha256:7e77b324909c1617cbb4c668677e2c6ae13f44d7c1de0d4f15f2e3c10f3315b5 \ + --hash=sha256:80115dd39481fd3a4b4080220799dbcacb921a844de4b827264ececacbe17c78 \ + --hash=sha256:87ccab0db8d5f4fbb0272642113c1adb2ffc698c16d3a0944580222331fa7a20 \ + --hash=sha256:89dfee3319f5ae3f75ebd5c2445a809bb320252ba5529ffdafea4ef25d79cf1a \ + --hash=sha256:8ac59a0900474a52b7c04af8196affc22bd9842acb0950df12f7b813e983609a \ + --hash=sha256:8cae6fd77a5b72dae505084b1a2ee0360139faf72fedbab667cd7cc65aae7a6a \ + --hash=sha256:8d3469c91dd92ee41b7c95280edbd975ef1ba9195086686623a1c6e8935ce965 \ + --hash=sha256:90c633e7e8d6bf4e992b8b36ce69e018f834b641dd6de8cea6d78c06ffa119c5 \ + --hash=sha256:93db40c8de0815baab96a06e08a984bac71f989d13bab789e382158c5d426797 \ + --hash=sha256:9724e6cb5e478cd7d8cabf027826178739cb18cf0e117d0e32814d479fa02276 \ + --hash=sha256:98c6ac18480fcdb33f35439183f1d2e79760ab41930309c6d951cb1f8e46694c \ + --hash=sha256:9a15e785f244f3e07847b984ce8773fc3da10a9f3c131cc49a4c5b4d672b4547 \ + --hash=sha256:9b60d7814174f059e5de4ab98271cc5ba9259cfea55273a81544dceea32dc8d9 \ + --hash=sha256:9be2a6647740dd3cca6acb24e87f03d7632cd280dbce9bbe40c26353a215a45d \ + --hash=sha256:9c7472192ebfad53a6be7c4a8bfb2d64b81c0e93a1fc8c57e1dd0b638297b5d1 \ + --hash=sha256:9dce8ec9695f531a1b8a6f314fd4b393adcccf2ea861db480cdf97a301d01a68 \ + --hash=sha256:9e50d748a32da622f256e8d505867f5d3c43a837c6a9f0efb149655fadd1042a \ + --hash=sha256:a81758ed242b861b72e778ba34d41366441a2e10b16b472784c88da2dea7e2dd \ + --hash=sha256:ac777001cdfc28b72477d93c8564bb7583081ea8fb45cdca3d568e0a4f87183c \ + --hash=sha256:b2b506b1788df5fecd270a10d5e70a95fe77b87ea2b370a318043f6f5f817ee6 \ + --hash=sha256:b2ea4a3e8357be8849e833beeae757ac3c7a6b3fc055c03c808a53c91ad30d82 \ + --hash=sha256:bf1516fe58fc104f39b2d1dbe2d5e27d0cd45c4be2e42ba6ee0cc763701ec3c7 \ + --hash=sha256:c0d702548d89d572b2929879bc883bb7a4c4709efafe4512cadee56c55c9bd15 \ + --hash=sha256:c10b82c2634df08dfb13b1f04e38fe310d086ee092f4f69c0c8da234251e556e \ + --hash=sha256:c42572142ed0b9d5d261ba727157c426510da78e20828b66bbb855098b8a4e38 \ + --hash=sha256:c4585c3e64b4f9e583b4d2683f18f5d5d872b3d71dcf24594b74ecc23602fa96 \ + --hash=sha256:c639ea314df70a7b2811e8020448c75af8c9445f5a60f8a4ced81c306a9380c2 \ + --hash=sha256:c670fe7be5b6020b76bc6e8d2196074657e1327595bca93a389e1a76ab130ad8 \ + --hash=sha256:cc1b2440423a851fad781309dd87843868f4f66a6bcd1ddb9225cf4ec2c84732 \ + --hash=sha256:cd3584591ea4429026cdb931b054342c2bcf189b44ff367f8d5c15bc092a2966 \ + --hash=sha256:d15df07081d91b76ff20d43f94592ee110330152d617b730fdbe5ef9fb680053 \ + --hash=sha256:d19662dbedbe783d323196312d38f5ba53cf56296378252171985da6899887d3 \ + --hash=sha256:d24ecb4f5e009ea0bd275ee37ad9953b32005e2e5e60f8bbae16da0dbbf0d3a0 \ + --hash=sha256:d446c6ac40bb6e05025ccee55b84d80fe9bf8e93010ffc4bb9484f13d498835f \ + --hash=sha256:d51ffd3427640fa2da6ade574ceba932f210ad095f65fcc450a2b0a0d454868e \ + --hash=sha256:d6ce43a0269d68cee79a7d1ade7def53c20f8f2a047b92d7b5d5bcc73ae88327 \ + --hash=sha256:d721e53758b2cca74990185eb0671dd466d7a388a1a45d0c6f4c13cef41a68ac \ + --hash=sha256:d7da47a0f248977f08e2cb659ff3c17ddc13a4d39b3a7baa0a81bf5b415430f6 \ + --hash=sha256:db47b561c9afd884baa1f96f797c9ca369872c4b65912bc691cfa99e68340af2 \ + --hash=sha256:dbe6493fbd27321b1d1f2dd4f5c7e5bd4d8b1d7cab7f32fd67db3d0b2ed8248a \ + --hash=sha256:dbece16025afda5e3031af0c4059207e61dcf73ef13af844964f57f387d1c435 \ + --hash=sha256:ddd67571c10869f65a5d7dde536d1e066e306cc90de57d7de4d5f34802428bb5 \ + --hash=sha256:de9208bb427130c82a5dbfd104f92c8876fc9559278c880b3002755bbbe9c83d \ + --hash=sha256:e30d40268a28d54ce0437031750497004c22602b8e3ab891f759b795a003b312 \ + --hash=sha256:e8b0abe7d870f53ca5143895fef7d1041a0c831a140d3dc2c760dd7ba25d4a8b \ + --hash=sha256:f035d9dc1d25eff9d361456572231c7d27b5ccd473ca7dc0adfce732bd006d40 \ + --hash=sha256:f04b9f56b0e0614c0126be12c2c2d9f8850c1e57af302bd0a63bed379d4af974 \ + --hash=sha256:f0fa4fa9c3632d708742baf2282f2055c11d888a790362670a403cbf48a2c404 \ + --hash=sha256:f2e7f8e2ab6c2922be02c7ec45185aa5bd771e2e57b95455ee343a44d8130dff \ + --hash=sha256:f8f6fa298bb4f7f58a33334406218ba74716e68feddf5e4e54cd5d8082705abf \ + --hash=sha256:fbf300e2070bb35038660b3be1be4b91b0024edb41517e6996320b49b92b4175 \ + --hash=sha256:fce7760bf283405b2c7999cab3da4e72f7deca6396013115e3f7a955db9760da \ + --hash=sha256:fcee38cd8e5089d6d4f048ba1233b3ad76e5954f545382180889112ff5cb712d \ + --hash=sha256:fe31f28c94402043161876a258a9c6f757cb485905c7614ce8d6cd40e6b7bdc1 \ + --hash=sha256:ffd8893ccc1c2fce6e0d6ca402d716fe1b29db70c7132609a05955e31b2aa8f2 + # via + # tiktoken + # transformers +requests==2.34.2 \ + --hash=sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0 \ + --hash=sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed + # via + # semantica (pyproject.toml) + # azure-core + # d3blocks + # datazets + # docling-slim + # fastembed + # google-api-core + # google-auth + # google-cloud-storage + # google-genai + # instructor + # jupyterlab-server + # pooch + # pymilvus + # rapidocr + # spacy + # tiktoken +rfc3339-validator==0.1.4 \ + --hash=sha256:138a2abdf93304ad60530167e51d2dfb9549521a836871b88d7f4695d0022f6b \ + --hash=sha256:24f6ec1eda14ef823da9e36ec7113124b39c04d50a4d3d3a3c2859577e7791fa + # via + # jsonschema + # jupyter-events +rfc3986-validator==0.1.1 \ + --hash=sha256:2f235c432ef459970b4306369336b9d5dbdda31b510ca1e327636e01f528bfa9 \ + --hash=sha256:3d44bde7921b3b9ec3ae4e3adca370438eccebc676456449b145d533b240d055 + # via + # jsonschema + # jupyter-events +rfc3987-syntax==1.1.0 \ + --hash=sha256:6c3d97604e4c5ce9f714898e05401a0445a641cfa276432b0a648c80856f6a3f \ + --hash=sha256:717a62cbf33cffdd16dfa3a497d81ce48a660ea691b1ddd7be710c22f00b4a0d + # via jsonschema +rich==14.3.4 \ + --hash=sha256:07e7adb4690f68864777b1450859253bed81a99a31ac321ac1817b2313558952 \ + --hash=sha256:817e02727f2b25b40ef56f5aa2217f400c8489f79ca8f46ea2b70dd5e14558a9 + # via + # semantica (pyproject.toml) + # agno + # agnoctl + # docling-slim + # instructor + # typer +rpds-py==2026.6.3 \ + --hash=sha256:0be972be84cfcaf46c8c6edf690ca0f154ac17babf1f6a955a51579b34ad2dc5 \ + --hash=sha256:127565fead0a10943b282957bd5447804ff3160ad79f2ad2635e6d249e380680 \ + --hash=sha256:127e08c0642d880cf32ca47ec2a4a77b901f7e2dd1ad9762adb13955d72ffcc9 \ + --hash=sha256:166cf54d9f44fc6ceb53c7860258dde44a81406646de79f8ed3234fca3b6e538 \ + --hash=sha256:168c733a7112e071bb7a66460e667edfcff06c017a3c523f7a8a8e08d0140804 \ + --hash=sha256:1967debc37f64f2c4dc90a7f563aec558b471966e12adcac4e1c4240496b6ebf \ + --hash=sha256:1cebd1337c242e4ec2293e541f712b2da849b29f48f0c293684b71c0632625d4 \ + --hash=sha256:1cf01971c4f2c5553b772a542e4aaf191789cd331bc2cd4ff0e6e65ba49e1e97 \ + --hash=sha256:1e5822dfc2f0d4ab7e745eaa6d85945069329beeccef965af3f3bb26058fcab6 \ + --hash=sha256:22bffe6042b9bcb0822bcd1955ec00e245daf17b4344e4ed8e9551b976b63e96 \ + --hash=sha256:23a439f31ccbeff1574e24889128821d1f7917470e830cf6544dced1c662262a \ + --hash=sha256:24e9c5386e16669b674a69c156c8eeefcb578f3b3397b713b08e6d60f3c7b187 \ + --hash=sha256:270b293dae9058fc9fcedab50f13cebf46fb8ed1d1d54e0521a9da5d6b211975 \ + --hash=sha256:29dfa0533a5d4c94d4dfa1b694fcb56c9c63aad8330ffdd816fd225d0a7a162f \ + --hash=sha256:2a9c6f195058cb45335e8cc3802745c603d716eb96bc9625950c1aac71c0c703 \ + --hash=sha256:2bfd04c19ddbd6640de0b51894d764bd2758854d5b75bd102d2ef10cb9c293a9 \ + --hash=sha256:2c54a076ca4d370980ab57bc0e31df57bbe8d41340436a90ef8b1219a3cbb127 \ + --hash=sha256:2c958bf94822e9290a40aaf2a822d4bc5c88099093e3948ad6c571eca9272e5f \ + --hash=sha256:2c99f7e8ccb3dd6e3e4bfeac657a7b208c9bac8075f4b078c02d7404c34107fa \ + --hash=sha256:2f7c26fbc5acd2522b95d4177fe4710ffd8e9b20529e703ffbf8db4d93903f05 \ + --hash=sha256:30c6dc199b24a5e3e81d50da0f00858c5bbdb2617a750395687f4339c5818171 \ + --hash=sha256:38a2fea2787428f811719ceb9114cb78964a3138838320c29ac39526c79c16ba \ + --hash=sha256:3a83ae6c67b7676b9878378547ca8e93ed77a580037bcbcd1d32f739e1e6089c \ + --hash=sha256:3cfe765c1da0072636ca06628261e0ea05688e160d5c8a03e0217c3854037223 \ + --hash=sha256:421aba32367055614287a4292b6a17f1939c9452299f7a0209c117e990b646d4 \ + --hash=sha256:425560c6fa0415f27261727bb20bd097568485e5eb0c121f1949417d1c516885 \ + --hash=sha256:4470ce197d4090875cf6affbf1f853338387428df97c4fb7b7106317b8214698 \ + --hash=sha256:4cf2d36a2357e4d07bb5a4f98801265327b48256867816cfd2ceb001e9754a8f \ + --hash=sha256:4f4bca01b63096f606e095734dd56e74e175f94cfbf24ff3d63281cec61f7bb7 \ + --hash=sha256:501f9f04a588d6a09179368c57071301445191767c64e4b52a6aa9871f1ef5ed \ + --hash=sha256:536bceea4fa4acf7e1c61da2b5786304367c816c8895be71b8f537c480b0ea1f \ + --hash=sha256:538949e262e46caa31ac01bdb3c1e8f642622922cacbabbae6a8445d9dc33eaf \ + --hash=sha256:539d75de9e0d536c84ff18dfeb805398e58227001ce09231a26a08b9aed1ee0e \ + --hash=sha256:54f45a148e28767bf343d33a684693c70e451c6f4c0e9904709a723fafbdfc1f \ + --hash=sha256:55927d532399c2c646100ff7feb48eaa940ad70f42cd68e1328f3ded9f81ca24 \ + --hash=sha256:58eadac9cd119677b60e1cf8ac4052f35949d71b8a9e5556efccbe82533cf22a \ + --hash=sha256:5e8d07bddee435a2ff6f1920e18feff28d0bc4533e42f4bf6927fbd073312c41 \ + --hash=sha256:62698275682bf121181861295c9181e789030a2d516071f5b8f3c23c170cd0fc \ + --hash=sha256:639c8929aa0afe81be836b04de888460d6bed38b9c54cfc18da8f6bfabf5af5d \ + --hash=sha256:67e3a721ffc5d8d2210d3671872298c4a84e4b8035cfe42ffd7cde35d772b146 \ + --hash=sha256:6de4744d05bd1aa1be4ed7ea1189e3979196808008113bbbf899a460966b925e \ + --hash=sha256:6e84adbcf4bf841aed8116a8264b9f50b4cb3e7bd89b516122e616ac56ca269e \ + --hash=sha256:7491ee23305ac3eb59e492b6945881f5cd77a6f731061a3f25b77fd40f9e99a4 \ + --hash=sha256:79486287de1730dbaff3dbd124d0ca4d2ef7f9d29bf2544f1f93c09b5bcbbd12 \ + --hash=sha256:7b689145a1485c335569bd056464f3243a29af7ed3871c7be31ad624ba239bc7 \ + --hash=sha256:7f88d653e7b3b779d71ae7454e20dcc9b6bae903f33c269db9f2be41bda3f261 \ + --hash=sha256:8020133a74bd81b4572dd8e4be028a6b1ebcd70e6726edc3918008c08bee6ee6 \ + --hash=sha256:808345f53cb952433ca2816f1604ff3515608a81784954f38d4452acfe8e61d5 \ + --hash=sha256:83e35b57523816c8613fd0776b40cd8bb9f596b37ddd2692eb4a6bb5ab2f8c93 \ + --hash=sha256:842e7b070435622248c7a2c44ae53fa1440e073cc3023bc919fed570884097a7 \ + --hash=sha256:847927daf4cffbd4e90e42bc890069897101edd015f956cb8721b3473372edda \ + --hash=sha256:882076c00c0a608b131187055ddc5ae29f2e7eaf870d6168980420d58528a5c8 \ + --hash=sha256:8b95977e7211527ab0ba576e286d023389fbeeb32a6b7b771665d333c60e5342 \ + --hash=sha256:8bb68f03f395eb793220b45c097bd4d8c32944393da0fad8b999efac0868fc8c \ + --hash=sha256:8c2642a7603ec0b16ed77da4555db3b4b472341904873788327c0b0d7b95f1bb \ + --hash=sha256:8c3d1e9c15b9d51ca0391e13da1a25a0a4df3c58a37c9dc368e0736cf7f69df0 \ + --hash=sha256:8c6e5a2f750cc71c3e3b11d71661f21d6f9bc6cebc6564b1466417a1ec03ec77 \ + --hash=sha256:8d2294a31386bfa251d8c8a39472beee17db67d4f1a6eabea665d35c9a4461c3 \ + --hash=sha256:8e4320744c1ffdd95a603def63344bfab2d33edeab301c5007e7de9f9f5b3885 \ + --hash=sha256:8e65860d238379ed982fd9ba690579b5e95af2f4840f99c772816dbe573cb826 \ + --hash=sha256:8f2e5c5ee828d42cb11760761c0af6507927bec42d0ad5458f97c9203b054617 \ + --hash=sha256:900a67df3fd1660b035a4761c4ce73c382ea6b35f90f9863c36c6fd8bf8b09bb \ + --hash=sha256:913ca42ccad3f8cc6e292b587ae8ae49c8c823e5dce51a736252fc7c7cdfa577 \ + --hash=sha256:9250a9a0a6fd4648b3f868da8d91a4c52b5811a62df58e753d50ae4454a36f80 \ + --hash=sha256:931908d9fc855d8f74783377822be318edb6dcb19e47169dc038f9a1bf60b06e \ + --hash=sha256:9826217f048f620d9a712672818bf231442c1b35d96b227a07eabd11b4bb6945 \ + --hash=sha256:9891e594296ab9dada6551c8e7b387b2721f27a67eecd528412e8906247a7b90 \ + --hash=sha256:9c1255b302953c86a486b81d330d5ee1d5bd937691ce271b6be0ef0e299eaab7 \ + --hash=sha256:a0811d33247c3d6128a3001d763f2aa056bb3425204335400ac54f89eec3a0d0 \ + --hash=sha256:a136d453475ac0fcbda502ef1e6504bd28d6d904700915d278deeab0d00fe140 \ + --hash=sha256:a214c993455f99a89aaeadc9b21241900037adc9d97203e374d75513c5911822 \ + --hash=sha256:a3086b538543802f84c843911242db20447de00d8752dd0efc936dbcf02218ba \ + --hash=sha256:a3450b693fde92133e9f51060568a4c31fcca76d5e53bbd611e689ca446517e9 \ + --hash=sha256:a550fb4950a06dde3beb4721f5ad4b25bf4513784665b0a8522c792e2bd822a4 \ + --hash=sha256:a9f4645593036b81bbdb36b9c8e0ea0d1c3fee968c4d59db0344c14087ef143a \ + --hash=sha256:aca6c1ef08a82bfe327cc156da694660f599923e2e6665b6d81c9c2d0ac9ffc8 \ + --hash=sha256:acac386b453c2516111b50985d60ce46e7fadb5ea71ae7b25f4c946935bf27cf \ + --hash=sha256:acc992ab27b15f852c76755eb2ab7dce86585ddadba6fa5946e58556088845b4 \ + --hash=sha256:ae3d4fe8c0b9213624fdce7279d70e3b148b682ca20719ebd193a23ebfa47324 \ + --hash=sha256:ae50181a047c871561212bb97f7932a2d45fb53e947bd9b57ebad85b529cbc53 \ + --hash=sha256:ae6dd8f10bd17aad820876d24caec9efdafd80a318d16c0a48edb5e136902c6b \ + --hash=sha256:af05d726809bff6b141be124d4c7ce998f9c9c7f30edb1f46c07aa103d540b41 \ + --hash=sha256:afd70d95892096cdb26f15a00c45907b17817577aa8d1c76b2dcc2788391f9e9 \ + --hash=sha256:b5c2dc92304aa48a4a60443b548bb12f12e119d4b72f314015e67b9e1be97fca \ + --hash=sha256:bc0011654b91cc4fb2ae701bec0a0ba1e552c0714247fa7af6c59e0ccfa3a4e1 \ + --hash=sha256:bcfbcf66006befb9fd2aeaa9e01feaf881b4dc330a02ba07d2322b1c11be7b5d \ + --hash=sha256:bdbd97738551fca3917c1bd7188bec1920bb520104f28e7e1007f9ceb17b7690 \ + --hash=sha256:c60924535c75f1566b6eb75b5c31a48a43fef04fa2d0d201acbad8a9969c6107 \ + --hash=sha256:c7b9a2f8f4d8e90af72571d3d495deebdd7e3c75451f5b41719aee166e940fc2 \ + --hash=sha256:ca6546b66be9dc4738b1b043d5ebd5488c66c578c5ff0fd0e8065313fe3afb76 \ + --hash=sha256:ccffae9a092a00deb7efd545fe5e2c33c33b88e7c054337e9a74c179347d0b7d \ + --hash=sha256:cdc7e35386f3847df728fbcb5e887e2d79c19e2fa1eba9e51b6621d23e3243af \ + --hash=sha256:d15fde0e6fb0d88a60d221204873743e5d9f0b7d29165e62cd86d0413ad74ba6 \ + --hash=sha256:d34c20167764fbcf927194d532dd7e0c56772f0a5f943fa5ef9e9afbba8fb9db \ + --hash=sha256:d483fe17f01ad64b7bf7cc38fcefff1ca9fb83f8c2b2542b68f97ffe0611b369 \ + --hash=sha256:d7469697dce35be237db177d42e2a2ee26e6dcc5fc052078a6fefabd288c6edd \ + --hash=sha256:db08f45aecde626498fb3df07bcf6d2ec040af42e859a4f5040d79c200342911 \ + --hash=sha256:dc319e5a1de4b6913aac94bf6a2f9e847371e0a140a43dd4991db1a09bc2d504 \ + --hash=sha256:de3eceba0b683bcbb1ab93da016d0270df1f9ae7be716b40214c5dafac6ea45a \ + --hash=sha256:dfcc8b909769d19db55c7cc9541eb64b9b774b1057ffffb4f1048070475bb9f9 \ + --hash=sha256:e059c5dde6452b44424bd1834557556c226b57781dee1227af23518459722b13 \ + --hash=sha256:e4316bf32babbed84e691e352faf967ce2f0f024174a8643c37c94a1080374fc \ + --hash=sha256:e52655eaf81e32593abedaa4bfe33170c8cfedf3365ed9be6e11e07f148f0278 \ + --hash=sha256:e55d236be29255554da47abe5c577637db7c24a02b8b46f0ca9524c855801868 \ + --hash=sha256:ea7bb13b7c9a29791f87a0387ba7d3ad3a6d783d827e4d3f27b40a0ff44495e2 \ + --hash=sha256:ea964164cc9afa72d4d9b23cc28dafae93693c0a53e0b42acbff15b22c3f9ddd \ + --hash=sha256:ec829541c45bca16e61c7ae50c20501f213605beb75d1aba91a6ee37fbbb56a4 \ + --hash=sha256:ecabd69db66de867690f9797f2f8fa27ba501bbc24540cbdbdc649cd15888ba6 \ + --hash=sha256:ed0c1e5d10cdc7135537988c74a0188da68e2f3c30813ba3744ab1e42e0480f9 \ + --hash=sha256:f0840b5b17057f7fd918b76183a4b5a0635f43e14eb2ce60dce1d4ee4707ea00 \ + --hash=sha256:f4d78253f6996be4901669ad25319f842f740eccf4d58e3c7f3dd39e6dde1d8f \ + --hash=sha256:f56f1695bc5c0871cbc33dc0130fcf503aab0c57dcc5a6700a4f49eba4f2652e \ + --hash=sha256:f826877d462181e5eb1c26a0026b8d0cab05d99844ecb6d8bf3627a2ca0c0442 \ + --hash=sha256:f8f23ead891a3b762f35ab3b04623da7056545b48aa60d59957e6789914545da \ + --hash=sha256:f90938e92afda60266da758ee7d363447f7f0138c9559f9e1811629580582d90 \ + --hash=sha256:faa679d19a6696fd54259ad321251ad77a13e70e03dd834daa762a44fb6196ef + # via + # jsonschema + # referencing +rtree==1.4.1 \ + --hash=sha256:12de4578f1b3381a93a655846900be4e3d5f4cd5e306b8b00aa77c1121dc7e8c \ + --hash=sha256:3d46f55729b28138e897ffef32f7ce93ac335cb67f9120125ad3742a220800f0 \ + --hash=sha256:a7e48d805e12011c2cf739a29d6a60ae852fb1de9fc84220bbcef67e6e595d7d \ + --hash=sha256:b558edda52eca3e6d1ee629042192c65e6b7f2c150d6d6cd207ce82f85be3967 \ + --hash=sha256:c6b1b3550881e57ebe530cc6cffefc87cd9bf49c30b37b894065a9f810875e46 \ + --hash=sha256:d672184298527522d4914d8ae53bf76982b86ca420b0acde9298a7a87d81d4a4 \ + --hash=sha256:efa8c4496e31e9ad58ff6c7df89abceac7022d906cb64a3e18e4fceae6b77f65 \ + --hash=sha256:efe125f416fd27150197ab8521158662943a40f87acab8028a1aac4ad667a489 \ + --hash=sha256:f155bc8d6bac9dcd383481dee8c130947a4866db1d16cb6dff442329a038a0dc + # via + # docling-ibm-models + # docling-slim +s3transfer==0.19.2 \ + --hash=sha256:ba0309fd86be3c27dbf78cdd813c13c5e1df16e5874b99d2535ebbdfb9892993 \ + --hash=sha256:d8168eccca828cbb2cd573675333f3bddd254313a9c42494b84c76b539e8ba25 + # via boto3 +safetensors==0.8.0 \ + --hash=sha256:040070828e36dc8e122178bbbd5830ff9e97920affb84cbe0f46442497bed358 \ + --hash=sha256:096ec1a98435df7beb08853bb5aa9081a84f23d0adc67ed1a0a10550f608373f \ + --hash=sha256:2ddf52eac562eda224f99acfa7889d02968c1fd59a5b011ae7d8137c37e9c02d \ + --hash=sha256:3ae091f16662658bdc019a4ff6cb4c085bb7d725eb5978b183ffd265863b6d2d \ + --hash=sha256:4124502b78f03534117c848f87a39b8f31e577b15eff423bf8bfb95f2a8c30d0 \ + --hash=sha256:4a95ae2b05d7726d751da4ebf626a2ca782b706e101bd894c95bc2450b1cffcc \ + --hash=sha256:7a46e5ff292c356d6991e60942ba7f79817682d3a2cef0702136448cb9c4d235 \ + --hash=sha256:7bc0a787ba8a35be368ee3574edfa2b1ad389eebd0a72e482ae275490e3f6c98 \ + --hash=sha256:87eec7ffed2b809f05a398a8becb7d013f19f7837cd15d9748580d6cf30dbaf4 \ + --hash=sha256:8e080062fcde23be189565e1c3305d16751a218ecf9412c8601e64204eb6f846 \ + --hash=sha256:8e9f537aa183a38ace122d27303dcd986b26bd2a7591f9181d7f0c396f4677ca \ + --hash=sha256:c554f85858e05226d3c2828e32395e677434685d6d94594a41643361c5e837f0 \ + --hash=sha256:c80201d22cbf405b80647a60ada77bba06c8fba2da2743ba1e89cdcc39a81f25 \ + --hash=sha256:f7838e5135a406ad3e02efdcb8cf2e5397d368b0154537c4fec682dbc544d452 \ + --hash=sha256:fabaf3e0f18a6618d9b36560682562157f77c2b71fcffc7b432be2baed9d753d \ + --hash=sha256:fcdd41ec4628fee5799f807c73c353629130fbd942aa23d83c623dd6c9d52d78 \ + --hash=sha256:fd6f3f93c9a0a7cc2788ee63fb763353d4bd2e89b0751bc78fcf7dda00bea774 + # via + # accelerate + # docling-ibm-models + # transformers +scatterd==1.4.2 \ + --hash=sha256:30e2a5da8f99ef338f962041d98da9ab54ae784de14cab7eb3b12dc0465f128b \ + --hash=sha256:d168230f82f424434a1a343138dcb9479f2041732d48611c910e96a7a7d5705a + # via distfit +scikit-learn==1.9.0 \ + --hash=sha256:051075bda8b7aab87b1906ab3d4740a1e1224a19d7b3781a576736edc94e76aa \ + --hash=sha256:056c92bb67ad4c28463c2f2653d9701449201e7e7a9e94e321be0f71c4fef2b8 \ + --hash=sha256:147e9329ef0e39f75d4cffa02b2aa48d827832684926cd5210d9a2cb5c57246b \ + --hash=sha256:1b944b6db288f6b926e3650026ddafb988929de95d11fc2cc5fa117773c9ba42 \ + --hash=sha256:1fea2cc5677ab49d6f5bade978c866da44957b712d92e9635e8b4f723013c3cb \ + --hash=sha256:24360002ae845e7866522b0a5bbf690802e7bc388cac8663502e78aa98598aa2 \ + --hash=sha256:26e22435f63bcdcf396b574273f29f13dd531f5ea035801f5be10ba1540a4e60 \ + --hash=sha256:2bd41b0d201bc81575531b96b713d3eb5e5f50fb0b82101ff0f92294fdc236ac \ + --hash=sha256:366652351f092b219c248f1e72821e841960a63d8f358f1dcfd54dc1cbdbbc28 \ + --hash=sha256:38c3dcb9a1ffb85505ec53d54c7b4aea0cff70050425a7760c2af661ac85df05 \ + --hash=sha256:4306775fad04cc4b472a1b15af1ae9cede1540fbfcc17fbce3767cd8dc7ae283 \ + --hash=sha256:4ccacf04ca5f4b492158a5f28afe0ace43f81b2571e4b9a66d34848b46128949 \ + --hash=sha256:5162ad10a418c8a282dde04c9aa06965de3e9a65f33c1440c0ae69bb1a09d913 \ + --hash=sha256:5808d98f15c6bf6d9d96d2348c1997392a5888ce7097e664105f930c4bca1277 \ + --hash=sha256:5b934c45c252844a91d69fda3a34cff5e7307e1db10d77cb10a3980312c74713 \ + --hash=sha256:5bad8f8b9950321b54c965fdcbac6c6c55e79e16646b49977bcf3668d3870a1a \ + --hash=sha256:5be45aa4a42a68a533913a6ed736cf309de2226411c79ef8d609a5456f1939b1 \ + --hash=sha256:5dc1818c77575d149e25fce9ef82dd7b7263ae372f03494158668ad632a69759 \ + --hash=sha256:5e50ed4da51974e86e940690e9a3d82e729b62b5a49f7c9bac534d515d39d86f \ + --hash=sha256:64fa347efc1c839c487433e40c5144d38c336e8a2b59c81aa8660373945c2673 \ + --hash=sha256:78fc56eafd4edb9575d2d8950d1dd152061abb573341a1cb7e099fc40f6c6666 \ + --hash=sha256:80746d63bd4b6eaca54d36fe5feaf4d28bb38dc6f9470f81c7cad7c40155f119 \ + --hash=sha256:8833266989d3a5110178a9fae30783675460724d0e1efb13b14901d2c660c557 \ + --hash=sha256:9656acd4e93f74e0b66c8a36c88830a99252dfa900044d36bc2212ae89a47162 \ + --hash=sha256:9db6f4d34e68c8899e4cab27fdf8eafe6ed21f2ba52ceb25ea250cd237f8e47b \ + --hash=sha256:d77f54c017633791bc0225a43e2f8d03745fdcfe4880268fcc4df15f505dec2e \ + --hash=sha256:da76d09304a4706db7cc1e3ebaa3b6b98a67365cc11d2996c4f1e58ba47df714 \ + --hash=sha256:ee1a8db2c18c08e34c7412d4b10be1cac214cd4ea7dc9715a6a327eb49a37c96 \ + --hash=sha256:f401448645a3e7bc115aa3c094097865155b34bff1cba8101857d9104e99074c \ + --hash=sha256:f7e254636164090da847715a27f8e5478feb98c40a9e0ee90cbd277de9e5ceb8 \ + --hash=sha256:fd3a8ef0c758555a3b23c03adaa858af32f7736785ded50ad5991f59c4ed03fa + # via + # semantica (pyproject.toml) + # bertopic + # hdbscan + # librosa + # pynndescent + # sentence-transformers + # umap-learn +scipy==1.17.1 \ + --hash=sha256:010f4333c96c9bb1a4516269e33cb5917b08ef2166d5556ca2fd9f082a9e6ea0 \ + --hash=sha256:02ae3b274fde71c5e92ac4d54bc06c42d80e399fec704383dcd99b301df37458 \ + --hash=sha256:08b900519463543aa604a06bec02461558a6e1cef8fdbb8098f77a48a83c8118 \ + --hash=sha256:131f5aaea57602008f9822e2115029b55d4b5f7c070287699fe45c661d051e39 \ + --hash=sha256:158dd96d2207e21c966063e1635b1063cd7787b627b6f07305315dd73d9c679e \ + --hash=sha256:1cc682cea2ae55524432f3cdff9e9a3be743d52a7443d0cba9017c23c87ae2f6 \ + --hash=sha256:1f95b894f13729334fb990162e911c9e5dc1ab390c58aa6cbecb389c5b5e28ec \ + --hash=sha256:200e1050faffacc162be6a486a984a0497866ec54149a01270adc8a59b7c7d21 \ + --hash=sha256:2040ad4d1795a0ae89bfc7e8429677f365d45aa9fd5e4587cf1ea737f927b4a1 \ + --hash=sha256:2b64ca7d4aee0102a97f3ba22124052b4bd2152522355073580bf4845e2550b6 \ + --hash=sha256:2ceb2d3e01c5f1d83c4189737a42d9cb2fc38a6eeed225e7515eef71ad301dce \ + --hash=sha256:35c3a56d2ef83efc372eaec584314bd0ef2e2f0d2adb21c55e6ad5b344c0dcb8 \ + --hash=sha256:37425bc9175607b0268f493d79a292c39f9d001a357bebb6b88fdfaff13f6448 \ + --hash=sha256:3877ac408e14da24a6196de0ddcace62092bfc12a83823e92e49e40747e52c19 \ + --hash=sha256:3fd1fcdab3ea951b610dc4cef356d416d5802991e7e32b5254828d342f7b7e0b \ + --hash=sha256:41b71f4a3a4cab9d366cd9065b288efc4d4f3c0b37a91a8e0947fb5bd7f31d87 \ + --hash=sha256:43af8d1f3bea642559019edfe64e9b11192a8978efbd1539d7bc2aaa23d92de4 \ + --hash=sha256:45abad819184f07240d8a696117a7aacd39787af9e0b719d00285549ed19a1e9 \ + --hash=sha256:4b400bdc6f79fa02a4d86640310dde87a21fba0c979efff5248908c6f15fad1b \ + --hash=sha256:4eb6c25dd62ee8d5edf68a8e1c171dd71c292fdae95d8aeb3dd7d7de4c364082 \ + --hash=sha256:581b2264fc0aa555f3f435a5944da7504ea3a065d7029ad60e7c3d1ae09c5464 \ + --hash=sha256:5cf36e801231b6a2059bf354720274b7558746f3b1a4efb43fcf557ccd484a87 \ + --hash=sha256:5e3c5c011904115f88a39308379c17f91546f77c1667cea98739fe0fccea804c \ + --hash=sha256:6609bc224e9568f65064cfa72edc0f24ee6655b47575954ec6339534b2798369 \ + --hash=sha256:6e3dcd57ab780c741fde8dc68619de988b966db759a3c3152e8e9142c26295ad \ + --hash=sha256:6fac755ca3d2c3edcb22f479fceaa241704111414831ddd3bc6056e18516892f \ + --hash=sha256:744b2bf3640d907b79f3fd7874efe432d1cf171ee721243e350f55234b4cec4c \ + --hash=sha256:74cbb80d93260fe2ffa334efa24cb8f2f0f622a9b9febf8b483c0b865bfb3475 \ + --hash=sha256:766e0dc5a616d026a3a1cffa379af959671729083882f50307e18175797b3dfd \ + --hash=sha256:7bdf2da170b67fdf10bca777614b1c7d96ae3ca5794fd9587dce41eb2966e866 \ + --hash=sha256:7ff200bf9d24f2e4d5dc6ee8c3ac64d739d3a89e2326ba68aaf6c4a2b838fd7d \ + --hash=sha256:844e165636711ef41f80b4103ed234181646b98a53c8f05da12ca5ca289134f6 \ + --hash=sha256:8a604bae87c6195d8b1045eddece0514d041604b14f2727bbc2b3020172045eb \ + --hash=sha256:94055a11dfebe37c656e70317e1996dc197e1a15bbcc351bcdd4610e128fe1ca \ + --hash=sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0 \ + --hash=sha256:9cdc1a2fcfd5c52cfb3045feb399f7b3ce822abdde3a193a6b9a60b3cb5854ca \ + --hash=sha256:9ecb4efb1cd6e8c4afea0daa91a87fbddbce1b99d2895d151596716c0b2e859d \ + --hash=sha256:a3472cfbca0a54177d0faa68f697d8ba4c80bbdc19908c3465556d9f7efce9ee \ + --hash=sha256:a4328d245944d09fd639771de275701ccadf5f781ba0ff092ad141e017eccda4 \ + --hash=sha256:a48a72c77a310327f6a3a920092fa2b8fd03d7deaa60f093038f22d98e096717 \ + --hash=sha256:a720477885a9d2411f94a93d16f9d89bad0f28ca23c3f8daa521e2dcc3f44d49 \ + --hash=sha256:a77cbd07b940d326d39a1d1b37817e2ee4d79cb30e7338f3d0cddffae70fcaa2 \ + --hash=sha256:a9956e4d4f4a301ebf6cde39850333a6b6110799d470dbbb1e25326ac447f52a \ + --hash=sha256:adb2642e060a6549c343603a3851ba76ef0b74cc8c079a9a58121c7ec9fe2350 \ + --hash=sha256:beeda3d4ae615106d7094f7e7cef6218392e4465cc95d25f900bebabfded0950 \ + --hash=sha256:c80be5ede8f3f8eded4eff73cc99a25c388ce98e555b17d31da05287015ffa5b \ + --hash=sha256:cc90d2e9c7e5c7f1a482c9875007c095c3194b1cfedca3c2f3291cdc2bc7c086 \ + --hash=sha256:cd96a1898c0a47be4520327e01f874acfd61fb48a9420f8aa9f6483412ffa444 \ + --hash=sha256:d2650c1fb97e184d12d8ba010493ee7b322864f7d3d00d3f9bb97d9c21de4068 \ + --hash=sha256:d30e57c72013c2a4fe441c2fcb8e77b14e152ad48b5464858e07e2ad9fbfceff \ + --hash=sha256:d59c30000a16d8edc7e64152e30220bfbd724c9bbb08368c054e24c651314f0a \ + --hash=sha256:dbc12c9f3d185f5c737d801da555fb74b3dcfa1a50b66a1a93e09190f41fab50 \ + --hash=sha256:e18f12c6b0bc5a592ed23d3f7b891f68fd7f8241d69b7883769eb5d5dfb52696 \ + --hash=sha256:e19ebea31758fac5893a2ac360fedd00116cbb7628e650842a6691ba7ca28a21 \ + --hash=sha256:e30bdeaa5deed6bc27b4cc490823cd0347d7dae09119b8803ae576ea0ce52e4c \ + --hash=sha256:eb092099205ef62cd1782b006658db09e2fed75bffcae7cc0d44052d8aa0f484 \ + --hash=sha256:eee2cfda04c00a857206a4330f0c5e3e56535494e30ca445eb19ec624ae75118 \ + --hash=sha256:f4115102802df98b2b0db3cce5cb9b92572633a1197c77b7553e5203f284a5b3 \ + --hash=sha256:f590cd684941912d10becc07325a3eeb77886fe981415660d9265c4c418d0bea \ + --hash=sha256:f8885db0bc2bffa59d5c1b72fad7a6a92d3e80e7257f967dd81abb553a90d293 \ + --hash=sha256:fcb310ddb270a06114bb64bbe53c94926b943f5b7f0842194d585c65eb4edd76 + # via + # semantica (pyproject.toml) + # colourmap + # distfit + # docling-slim + # gensim + # hdbscan + # librosa + # pynndescent + # scatterd + # scikit-learn + # sentence-transformers + # statsmodels + # umap-learn +seaborn==0.13.2 \ + --hash=sha256:636f8336facf092165e27924f223d3c62ca560b1f2bb5dff7ab7fad265361987 \ + --hash=sha256:93e60a40988f4d65e9f4885df477e2fdaff6b73a9ded434c1ab356dd57eefff7 + # via + # semantica (pyproject.toml) + # scatterd +semchunk==3.2.5 \ + --hash=sha256:ee15e9a06a69a411937dd8fcf0a25d7ef389c5195863140436872a02c95b0218 \ + --hash=sha256:fd09cc5f380bd010b8ca773bd81893f7eaf11d37dd8362a83d46cedaf5dae076 + # via docling-core +send2trash==2.1.0 \ + --hash=sha256:0da2f112e6d6bb22de6aa6daa7e144831a4febf2a87261451c4ad849fe9a873c \ + --hash=sha256:1c72b39f09457db3c05ce1d19158c2cbef4c32b8bedd02c155e49282b7ea7459 + # via jupyter-server +sentence-transformers==5.7.0 \ + --hash=sha256:b78141da3d8137e70d965866e2ca43190b9266f3d4d8752e250ded75e7136730 \ + --hash=sha256:fd8c8fc35e6323631dff9f3760969ebf7980dc3cfda0ab1354bc6a774cc0e5d8 + # via + # semantica (pyproject.toml) + # bertopic +setuptools==84.0.0 \ + --hash=sha256:51a52592b3b99e102b609654876bd65f19f999935166d1352678931132b0c670 \ + --hash=sha256:f4695c21257f0d9b537ec2692c941d02ee143b7cc1276941349a546573b2ef73 + # via + # spacy + # thinc + # torch +shapely==2.1.2 \ + --hash=sha256:0036ac886e0923417932c2e6369b6c52e38e0ff5d9120b90eef5cd9a5fc5cae9 \ + --hash=sha256:01d0d304b25634d60bd7cf291828119ab55a3bab87dc4af1e44b07fb225f188b \ + --hash=sha256:0bd308103340030feef6c111d3eb98d50dc13feea33affc8a6f9fa549e9458a3 \ + --hash=sha256:136ab87b17e733e22f0961504d05e77e7be8c9b5a8184f685b4a91a84efe3c26 \ + --hash=sha256:16a9c722ba774cf50b5d4541242b4cce05aafd44a015290c82ba8a16931ff63d \ + --hash=sha256:16c5d0fc45d3aa0a69074979f4f1928ca2734fb2e0dde8af9611e134e46774e7 \ + --hash=sha256:19efa3611eef966e776183e338b2d7ea43569ae99ab34f8d17c2c054d3205cc0 \ + --hash=sha256:1d0bfb4b8f661b3b4ec3565fa36c340bfb1cda82087199711f86a88647d26b2f \ + --hash=sha256:1e7d4d7ad262a48bb44277ca12c7c78cb1b0f56b32c10734ec9a1d30c0b0c54b \ + --hash=sha256:1f2f33f486777456586948e333a56ae21f35ae273be99255a191f5c1fa302eb4 \ + --hash=sha256:1ff629e00818033b8d71139565527ced7d776c269a49bd78c9df84e8f852190c \ + --hash=sha256:21952dc00df38a2c28375659b07a3979d22641aeb104751e769c3ee825aadecf \ + --hash=sha256:2d93d23bdd2ed9dc157b46bc2f19b7da143ca8714464249bef6771c679d5ff40 \ + --hash=sha256:2ed4ecb28320a433db18a5bf029986aa8afcfd740745e78847e330d5d94922a9 \ + --hash=sha256:2fa78b49485391224755a856ed3b3bd91c8455f6121fee0db0e71cefb07d0ef6 \ + --hash=sha256:346ec0c1a0fcd32f57f00e4134d1200e14bf3f5ae12af87ba83ca275c502498c \ + --hash=sha256:361b6d45030b4ac64ddd0a26046906c8202eb60d0f9f53085f5179f1d23021a0 \ + --hash=sha256:40d784101f5d06a1fd30b55fc11ea58a61be23f930d934d86f19a180909908a4 \ + --hash=sha256:4a44bc62a10d84c11a7a3d7c1c4fe857f7477c3506e24c9062da0db0ae0c449c \ + --hash=sha256:5860eb9f00a1d49ebb14e881f5caf6c2cf472c7fd38bd7f253bbd34f934eb076 \ + --hash=sha256:5ebe3f84c6112ad3d4632b1fd2290665aa75d4cef5f6c5d77c4c95b324527c6a \ + --hash=sha256:61edcd8d0d17dd99075d320a1dd39c0cb9616f7572f10ef91b4b5b00c4aeb566 \ + --hash=sha256:6305993a35989391bd3476ee538a5c9a845861462327efe00dd11a5c8c709a99 \ + --hash=sha256:6ddc759f72b5b2b0f54a7e7cde44acef680a55019eb52ac63a7af2cf17cb9cd2 \ + --hash=sha256:743044b4cfb34f9a67205cee9279feaf60ba7d02e69febc2afc609047cb49179 \ + --hash=sha256:7ae48c236c0324b4e139bea88a306a04ca630f49be66741b340729d380d8f52f \ + --hash=sha256:7ed1a5bbfb386ee8332713bf7508bc24e32d24b74fc9a7b9f8529a55db9f4ee6 \ + --hash=sha256:8cff473e81017594d20ec55d86b54bc635544897e13a7cfc12e36909c5309a2a \ + --hash=sha256:8d8382dd120d64b03698b7298b89611a6ea6f55ada9d39942838b79c9bc89801 \ + --hash=sha256:9111274b88e4d7b54a95218e243282709b330ef52b7b86bc6aaf4f805306f454 \ + --hash=sha256:91121757b0a36c9aac3427a651a7e6567110a4a67c97edf04f8d55d4765f6618 \ + --hash=sha256:980c777c612514c0cf99bc8a9de6d286f5e186dcaf9091252fcd444e5638193d \ + --hash=sha256:9a522f460d28e2bf4e12396240a5fc1518788b2fcd73535166d748399ef0c223 \ + --hash=sha256:9c3a3c648aedc9f99c09263b39f2d8252f199cb3ac154fadc173283d7d111350 \ + --hash=sha256:a1fd0ea855b2cf7c9cddaf25543e914dd75af9de08785f20ca3085f2c9ca60b0 \ + --hash=sha256:a444e7afccdb0999e203b976adb37ea633725333e5b119ad40b1ca291ecf311c \ + --hash=sha256:a84e0582858d841d54355246ddfcbd1fce3179f185da7470f41ce39d001ee1af \ + --hash=sha256:b510dda1a3672d6879beb319bc7c5fd302c6c354584690973c838f46ec3e0fa8 \ + --hash=sha256:b54df60f1fbdecc8ebc2c5b11870461a6417b3d617f555e5033f1505d36e5735 \ + --hash=sha256:b705c99c76695702656327b819c9660768ec33f5ce01fa32b2af62b56ba400a1 \ + --hash=sha256:ba4d1333cc0bc94381d6d4308d2e4e008e0bd128bdcff5573199742ee3634359 \ + --hash=sha256:c64d5c97b2f47e3cd9b712eaced3b061f2b71234b3fc263e0fcf7d889c6559dc \ + --hash=sha256:c8876673449f3401f278c86eb33224c5764582f72b653a415d0e6672fde887bf \ + --hash=sha256:ca2591bff6645c216695bdf1614fca9c82ea1144d4a7591a466fef64f28f0715 \ + --hash=sha256:cc4f7397459b12c0b196c9efe1f9d7e92463cbba142632b4cc6d8bbbbd3e2b09 \ + --hash=sha256:cf831a13e0d5a7eb519e96f58ec26e049b1fad411fc6fc23b162a7ce04d9cffc \ + --hash=sha256:dc3487447a43d42adcdf52d7ac73804f2312cbfa5d433a7d2c506dcab0033dfd \ + --hash=sha256:df90e2db118c3671a0754f38e36802db75fe0920d211a27481daf50a711fdf26 \ + --hash=sha256:e38a190442aacc67ff9f75ce60aec04893041f16f97d242209106d502486a142 \ + --hash=sha256:e9eddfe513096a71896441a7c37db72da0687b34752c4e193577a145c71736fc \ + --hash=sha256:eba6710407f1daa8e7602c347dfc94adc02205ec27ed956346190d66579eb9ea \ + --hash=sha256:ef4a456cc8b7b3d50ccec29642aa4aeda959e9da2fe9540a92754770d5f0cf1f \ + --hash=sha256:f67b34271dedc3c653eba4e3d7111aa421d5be9b4c4c7d38d30907f796cb30df \ + --hash=sha256:f6f6cd5819c50d9bcf921882784586aab34a4bd53e7553e175dece6db513a6f0 \ + --hash=sha256:fe2533caae6a91a543dec62e8360fe86ffcdc42a7c55f9dfd0128a977a896b94 \ + --hash=sha256:fe7b77dc63d707c09726b7908f575fc04ff1d1ad0f3fb92aec212396bc6cfe5e \ + --hash=sha256:fe9627c39c59e553c90f5bc3128252cb85dc3b3be8189710666d2f8bc3a5503e + # via rapidocr +shellingham==1.5.4 \ + --hash=sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686 \ + --hash=sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de + # via typer +six==1.17.0 \ + --hash=sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274 \ + --hash=sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81 + # via + # python-dateutil + # rapidocr + # rfc3339-validator +smart-open==8.0.1 \ + --hash=sha256:18b1c4496003c6902be17c15f032b5c319f307c89c6ae9e6b028b508bed8b2cf \ + --hash=sha256:3e97f90e92a952cb57863dfe132082c400a52eeeb27c067692fb51dbcc5b0089 + # via + # gensim + # weasel +smmap==5.0.3 \ + --hash=sha256:4d9debb8b99007ae47165abc08670bd74cb74b5227dda7f643eccc4e9eb5642c \ + --hash=sha256:c106e05d5a61449cf6ba9a1e650227ecfb141590d2a98412103ff35d89fc7b2f + # via gitdb +sniffio==1.3.1 \ + --hash=sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2 \ + --hash=sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc + # via + # anthropic + # google-genai + # groq + # openai +soundfile==0.14.0 \ + --hash=sha256:0a6ae43c50c71b4e020cc55382925cb89451c1ed1a0c3d0f5d802da269226849 \ + --hash=sha256:19be05428da76ed61a4cad29b8e4bcf43a3e5c100089d2ec81dc961eed1b0dd4 \ + --hash=sha256:1e38bac1853412871318e82a1ba69a8be677619b56025bbfcccdb41b6cafe82d \ + --hash=sha256:299491d3499460fb1b74bb4bd78b57ffc2d243a5fafa7b6ec1b264875c78453e \ + --hash=sha256:8ba81ae3a89fd5ab3bef8a8eb481fbbe794e806309675a89b4df48b8d31908a8 \ + --hash=sha256:ba1c1a2d618bca5c406647c83b89f07cc8810fa506a50622a6993ba130c1de11 \ + --hash=sha256:d828d35a059626da52f1415b5faee610aeab393319cb3fc4a9aef47b619fc14c \ + --hash=sha256:e090704718e124e7c844695236f1fce8d18a5e761eaf7c82dfcd124620805f98 \ + --hash=sha256:e85724a90bc99a6e8062c0b4ddf725f53b2a3b70afd4da875e9d2cfc4e92f377 + # via librosa +soupsieve==2.9.2 \ + --hash=sha256:4a55d8cf158a9c2e587fa4922f1bbb91d68ac829e2d6f25403a85747c71daf74 \ + --hash=sha256:8089a26fd974ca7a1f30276d3d8492ab266ab15af581642dfe8aa162e0c1c823 + # via beautifulsoup4 +soxr==1.1.0 \ + --hash=sha256:1577865e993f98ffb261257c3060fa76ec3db44ed3f181b16464268000424464 \ + --hash=sha256:26925618945f1a44dfbd783cc572874f0685e9ecdf46b96f4000f6b8c9c8b825 \ + --hash=sha256:318925f7281df61dfa7f17fe343952eb10cefd3954f2423a733fabe3a517bab2 \ + --hash=sha256:33525740fb7dbed8b09970bf0cd4219b365538845053987b11cc235b20562e09 \ + --hash=sha256:34cc92208c3c412c046813e69da639c04a792c6a41fbfd7d909d359cd3e97a2d \ + --hash=sha256:3b033078e86f3c4a658e5697fac8995764fad9e799563616b630136b613167f1 \ + --hash=sha256:3da87e3ffa3e41823d873b051c7ecb2acebd8d1b6b46b752f5facf10a0d84ab9 \ + --hash=sha256:474aabb9283f177e899747510d60661730538052fca0ed93a943d4686d6655b1 \ + --hash=sha256:52c9ca84e3dc656d83acc424574770e20ea8e0704dc3842d4e27b0fe9d3ba449 \ + --hash=sha256:588c7de1abafe59e66face9a074514658ac0398c85a774cdbb8efac131192692 \ + --hash=sha256:6ae2a174bffea94e8ead857dad85999d3f49f091774dbad5b046c0417d7092f4 \ + --hash=sha256:868a24d864c25024f60ca964f851a759f2ada5352608fc194d927b7facc2e28b \ + --hash=sha256:8e11e26f1718b5c2e5b96f2f71b9f00e31d247b065289661e3a6996c758669d9 \ + --hash=sha256:9443e5eb82152d8952422b7285692192cc7dcffa5218bb511b096203018bc273 \ + --hash=sha256:9564d82f7fa6bf548e5f18bb86235dff20eea8bd30727b64d49783c95c34fb8d \ + --hash=sha256:9f228ae21c78fa9359ca98d8a5e8e91f30639e438e574133dace62c5b5309e44 \ + --hash=sha256:a941f5aaa0b8abced24318105c1ea22576afcc1138c19f625716ce4e2f76ad64 \ + --hash=sha256:ae30c48ac795378cf23ba3c7c640b8ff794af714ac388b9fd6b31a40b39e6e86 \ + --hash=sha256:b2e94c713b7d96fb92841947b785bcee6606124bc852273fab70454b51bfe270 \ + --hash=sha256:bd30f7201eac896ebf5db7b09156e6f1a1b82601900d29d9c8449bdad8365b11 \ + --hash=sha256:bf98c0d7b7d5ef5bf072fee8d3020e8b664f2d195933ea7bc5089267c2e22a06 \ + --hash=sha256:d6a7ad82b8d5f3fcc04b1d2ca055562b96af571e1d4fa7c6c61d0fb509ac43b4 \ + --hash=sha256:e0e09fa633ce2e67df08b298afced4d184f6e753fc330f241022250f1d0d61da \ + --hash=sha256:e17d4ef9b0185214b2c0935605ae63f827ea423bc74964be44763d68d2b6c21e \ + --hash=sha256:f4977323ef9c3aa3c2a26ff5fe0191c84b8fd759daf7afb1f25a91a55ad8b730 \ + --hash=sha256:feebcba99ac99adb8009d46c8f4c1956b8c167576b0ae8a6fb47502e9a6f78e7 + # via librosa +spacy==3.8.15 \ + --hash=sha256:0262f86956e751ace6e47e530cf9841d66f2354d8cd91cfeeb39eee4c5f2962f \ + --hash=sha256:1c8a27500b409b472a743c2b0b2af2dff42f4287ace36f2b776e4592d65ab493 \ + --hash=sha256:2cbd33b0801ed0fed71454cdfaefbabb18b1b854471f7683c5df2f78be00e8cc \ + --hash=sha256:35060be85c952df84e9095713ba05f8515473e127ad0c6b43cd22a1b42d4cdbc \ + --hash=sha256:37f370f579c1bb56aa767e6d585672133f37e89c1181ebbd251fd946777f0ef5 \ + --hash=sha256:39ac175bbf8a8381c41b8e0abbdede3ff29d1f29f3cac42644f5719671e20884 \ + --hash=sha256:4031de613e8ba666392fef107a06b5144270f0f64b6a61df37faf6a329d61b5a \ + --hash=sha256:59c1ad50c8d0afe8397a06e28f39f3cebced0cdf53b14607dfca44c9822d7650 \ + --hash=sha256:6b27d0abf1138644837536705578dc1ea48a693796b283a49202e1dbde0a3b02 \ + --hash=sha256:7863c35506ec6f7e3fc13836330a4badd723514b9dc067ed79a8b0c593b824b4 \ + --hash=sha256:81ca434f0a08062fe5d5fd05858196b3c007c755d8b54e2019c071949a93eefe \ + --hash=sha256:8397a6e76b85d7a1d42b2654ed0bbad053e9100b80f41efcf608a8cf02df76f6 \ + --hash=sha256:9ad71e9ce6c4e1b984a91bc82b2e4e08df23581f5cc670bbacf29a07a9822d5a \ + --hash=sha256:9b22d269dfa6aa3c6a000e576b261bee46c277afaf915a3fe1e0a81ad227ee7e \ + --hash=sha256:aaa70356876c152f0235ff5bc4869f7a45133392ff73aa881113376f7eec0caa \ + --hash=sha256:b4527b7824e8228f2abed18774b224de7aad9b900d47ff66483abdf16d0c8a5e \ + --hash=sha256:c8b187654941e417c4cc0378ed7860bf6aad7bbbc370b925994a9cebeb8ca615 \ + --hash=sha256:c9279132fee6e131b295f5336b8f9e46c16e5ec430e6c58a56fc9ef134cc1f5a \ + --hash=sha256:c9683078efb96dee8b1a751bc0bfa9271a6604b7257547849b4254f285ce77d9 \ + --hash=sha256:cb0782680cf930e9ab984a73b2078c981343f77c5c407773c704cbf8c7df13c0 \ + --hash=sha256:ce66d75279ee84b749eea058ff3ea79a31adf0ff53f887943dced258ceb6ce2a \ + --hash=sha256:d41166f5f763ff3a3e085d4314b6cf34cf1cd2bf0845aaafa1acc266e9a46ef7 \ + --hash=sha256:d5806db3034618ef426e360dd8519283dc08f5be1690ce5bc3bb2c628f86215f \ + --hash=sha256:e1aaf79b42c0e8c5dd4801b474c22a61fc68edb347fd3a6e09d5cba7e1aed5db \ + --hash=sha256:e7ca79280762f1de0a7a5c1ce8ccfe1f54b772e8761ea7d9408e535427592749 \ + --hash=sha256:f04bc083600ff688fe500a736dd4d8ac3d06527f7808c2d7932d4174d8f3b751 \ + --hash=sha256:f1c0f054365fdfd95cfe96acbb4c5c1dbefb787905e4e63013eda54d3c815050 \ + --hash=sha256:f5714a76826756cea2257d35524233ebe49c6f70b6510b224e46036a4a341a79 \ + --hash=sha256:fa9df68fc8887c0a6440b84d1d307980e594d99b45f19a37d733e58caa9a6682 \ + --hash=sha256:ff1a616862d6c07a9e7ae13b911005f89c064ce884ec31784f8032adb3f86829 + # via semantica (pyproject.toml) +spacy-legacy==3.0.12 \ + --hash=sha256:476e3bd0d05f8c339ed60f40986c07387c0a71479245d6d0f4298dbd52cda55f \ + --hash=sha256:b37d6e0c9b6e1d7ca1cf5bc7152ab64a4c4671f59c85adaf7a3fcb870357a774 + # via spacy +spacy-loggers==1.0.5 \ + --hash=sha256:196284c9c446cc0cdb944005384270d775fdeaf4f494d8e269466cfa497ef645 \ + --hash=sha256:d60b0bdbf915a60e516cc2e653baeff946f0cfc461b452d11a4d5458c6fe5f24 + # via spacy +sqlite-vec==0.1.9 \ + --hash=sha256:1515727990b49e79bcaf75fdee2ffc7d461f8b66905013231251f1c8938e7786 \ + --hash=sha256:1b62a7f0a060d9475575d4e599bbf94a13d85af896bc1ce86ee80d1b5b48e5fb \ + --hash=sha256:1d52e30513bae4cc9778ddbf6145610434081be4c3afe57cd877893bad9f6b6c \ + --hash=sha256:4a28dc12fa4b53d7b1dced22da2488fade444e96b5d16fd2d698cd670675cf32 \ + --hash=sha256:4e921e592f24a5f9a18f590b6ddd530eb637e2d474e3b1972f9bbeb773aa3cb9 + # via semantica (pyproject.toml) +srsly==2.5.3 \ + --hash=sha256:0017c7d2a0cd9a4f1bdc00d946b45edcf90bb0e271e8f084c1ce542bf6708c32 \ + --hash=sha256:06a43d63bde2e8cccadb953d7fff70b18196ca286b65dd2ad16006d65f3f8166 \ + --hash=sha256:07d682679e639eb46ff7e6da4a92714f4d5ffe351d088ee66f221e9b1f8865bb \ + --hash=sha256:08f98dbecbff3a31466c4ae7c833131f59d3655a0ad8ac749e6e2c149e2b0680 \ + --hash=sha256:0f106b0a700ab56e4a7c431b0f1444009ab6cb332edc7bbf6811c2a43f4722cb \ + --hash=sha256:111805927f05f5db440aeeacb85ce43da0b19ce7b2a09567a9ef8d30f3cc4d83 \ + --hash=sha256:14c930767cc169611a2dc14e23bc7638cfb616d6f79029700ade033607343540 \ + --hash=sha256:1a3d6e03c65e3af15bfb1ad18f1888ba0a8482903218c1a5b5ed6fd66f5b0fb1 \ + --hash=sha256:1c9129c4abe31903ff7996904a51afdd5428060de6c3d12af49a4da5e8df2821 \ + --hash=sha256:1d93c22f42dfc4383a89ff3fcbe89ed9b286cf1d7e762cba533f2fac5ed36b28 \ + --hash=sha256:1fd6c35c65c4d2435ae5bfb57b59682cf9b61606318a2a761856be9d7cc2d9e3 \ + --hash=sha256:21cf09e417d3e4f3fbf7dd337fd6d948c97abd01896b9b4cb80e81cd9778a73a \ + --hash=sha256:29d5d01ba4c2e9c01f936e5e6d5babc4a47b38c9cbd6e1ec23f6d5a49df32605 \ + --hash=sha256:2f2d464f0d0237e32fb53f0ec6f05418652c550e772b50e9918e83a1577cba4d \ + --hash=sha256:2f73c0db911552e94fe2016e1759d261d2f47926f68826664cada3723c87006a \ + --hash=sha256:2f76a2507cc2debf0aeb31120c8d04d752eb0ca8bd84599a62461796a3c0f71f \ + --hash=sha256:348c231b4477d8fe86603131d0f166d2feac9c372704dfc4398be71cc5b6fb07 \ + --hash=sha256:3576c125c486ce2958c2047e8858fe3cfc9ea877adfa05203b0986f9badee355 \ + --hash=sha256:39c13d552a9f9674a12cdcdc66b0c2f02f3430d0cd04c5f9cf598824c2bd3d65 \ + --hash=sha256:4b1b721cd3ad1a9b2343519aadc786a4d09d5c0666962d49852eb12d6ec3fe26 \ + --hash=sha256:4ca4a068f6e14d84113a02fcb875c6b50a6285a12938c0e7a157eb3a63c50a86 \ + --hash=sha256:4d6ebaeac9baa5c85b6b56c180458f1b4fef9213bc36b62fcbecf5b3d8a3001c \ + --hash=sha256:565f69083d33cb329cfc74317da937fb3270c0f40fabc1b4488702d8074b4a3e \ + --hash=sha256:598f1e494c18cacb978299d77125415a586417081959f8ec3f068b32d97f8933 \ + --hash=sha256:5c1ac27ae5f4bb9163c7d2c45fc8ec173aac3d92e32086d9472b326c5c6e570e \ + --hash=sha256:5c8df4039426d99f0148b5743542842ab96b82daded0b342555e15a639927757 \ + --hash=sha256:5f6a837954429ecbe6dcdd27390d2fb4c7d01a3f99c9ffcf9ce66b2a6dd1b738 \ + --hash=sha256:5fb59c42922e095d1ea36085c55bc16e2adb06a7bfe57b24d381e0194ae699f2 \ + --hash=sha256:63c0f4c088ddf0c736a24f7d7be1f6e2896a71822630c2805569b14de93eb393 \ + --hash=sha256:66ebae2c70305987341519ec1a720072a3cb3e4b1d52ac0e9e841f4d02658d3d \ + --hash=sha256:6a02d7dcc16126c8fae1c1c09b2072798a1dc482ab5f9c52b12c7114dac47325 \ + --hash=sha256:71d4cbe2b2a1335c76ed0acae2dc862163787d8b01a705e1949796907ed94ccd \ + --hash=sha256:71e51c046ccbeefb86524c6b1e17574f579c6ac4dc8ea4a09437d3e8f88342d3 \ + --hash=sha256:7326bc048073b04e4e7d59dd4a2d737c8e7cc270ef74ea1acb764382e995590c \ + --hash=sha256:785a09216ac31570fb301ddb9f61ee73d1f18f8b9561f712dce0b8ac8628bc88 \ + --hash=sha256:7ea5412ea229e571ac9738cbe14f845cc06c8e4e956afb5f42061ccd087ef31f \ + --hash=sha256:808cfafc047f0dec507a34c8fa8e4cda5722737fd33577df73452f52f7aca644 \ + --hash=sha256:8ac016ffaeac35bc010992b71bf8afdd39d458f201c8138d84cf78778a936e6c \ + --hash=sha256:8d3988970b4cf7d03bdd5b5169302ff84562dd2e1e0f84aeb34df3e5b5dc19bf \ + --hash=sha256:8e0542d85d6b55cf2934050d6ffcb1cd76c768dcf9572e7467002cf087bb366d \ + --hash=sha256:91688edb1f49110870d2c215db2cf445f1763c14173698ead0818908c51fb2a1 \ + --hash=sha256:916edb6dc1051732610e37863232e5a1d64bed7b130fed067f7dbc80d12d0065 \ + --hash=sha256:953d77ba0d8c96b29657622492c2c0bc7c161a304c069043a14c368aec20aeef \ + --hash=sha256:99026bcd9cbd3211cc36517400b04ca0fc5d3e412b14daf84ee6e65f67d9a2d8 \ + --hash=sha256:9ffc97e22730ea97b00f7c303ccc60b1305e786afadb2a4a46578dafa4d29da0 \ + --hash=sha256:a595958d0b1ff6d59c2570a3f0d1c8e36ab9f89d6e1b9c96fa7eb5e1a8698510 \ + --hash=sha256:b0938c2978c91ae1ef9c1f2ba35abb86330e198fb23469e356eba311e02233ee \ + --hash=sha256:b9df76d5a6bbf50967589bd42df3c522dd88babea2be745a507f56b41ab40626 \ + --hash=sha256:bc0ad5be2aeb9ff29c8512848d39d7c63fdd4bfbb5516bc523f5de5a77e55e6d \ + --hash=sha256:c378afcb7dd7c42f426a66112496c949fc39e5883de6817d86e60afa51720ccc \ + --hash=sha256:c812302a9acfe171e82f680b7ad642014cd017380b2c678441b3da4fb513c498 \ + --hash=sha256:d18933248a5bb0ad56a1bae6003a9a7f37daac2ecb0c5bcbfaaf081b317e1c84 \ + --hash=sha256:d2b8cfd8aee4d06ab335d359e4095d206102300a5e105a4b4bc69acca42427a6 \ + --hash=sha256:d822083fe26ec6728bd8c273ac121fc4ab3864a0fdf0cf0ff3efb188fcd209ed \ + --hash=sha256:e283fa2a8f7350fb9fb70ecdee28d59d39c92f4c7f1cc90a44d6b86db3b3a8b3 \ + --hash=sha256:e67b6bbacbfadea5e100266d2797f2d4cec9883ea4dc84a5537673850036a8d8 \ + --hash=sha256:f09b551f6c3e334652831ac68c770ee4284741ce0a3895bf1ccf2a1178d66cdd + # via + # spacy + # thinc + # weasel +stack-data==0.6.3 \ + --hash=sha256:836a778de4fec4dcd1dcd89ed8abff8a221f58308462e1c4aa2a3cf30148f0b9 \ + --hash=sha256:d5558e0c25a4cb0853cddad3d77da9891a08cb85dd9f9f91b9f8cd66e511e695 + # via ipython +starlette==1.6.0 \ + --hash=sha256:a86dd39d14bb45f85a3d18525215a9ef0cfd1f192ac793220e72598c90335f0c \ + --hash=sha256:d4e3ac5e546444960c710297a3c9fc3f7ebae1b7e963f3d36173b49da535be9b + # via fastapi +statsmodels==0.14.6 \ + --hash=sha256:00781869991f8f02ad3610da6627fd26ebe262210287beb59761982a8fa88cae \ + --hash=sha256:0444e88557df735eda7db330806fe09d51c9f888bb1f5906cb3a61fb1a3ed4a8 \ + --hash=sha256:06eec42d682fdb09fe5d70a05930857efb141754ec5a5056a03304c1b5e32fd9 \ + --hash=sha256:0f52ef0f0b63b8fd11e1ef1c2a1e73a410720b8715c9a83a26d733b6815597fe \ + --hash=sha256:109012088b3e370080846ab053c76d125268631410142daad2f8c10770e8e8d9 \ + --hash=sha256:151b73e29f01fe619dbce7f66d61a356e9d1fe5e906529b78807df9189c37721 \ + --hash=sha256:19b58cf7474aa9e7e3b0771a66537148b2df9b5884fbf156096c0e6c1ff0469d \ + --hash=sha256:26d4f0ed3b31f3c86f83a92f5c1f5cbe63fc992cd8915daf28ca49be14463a1c \ + --hash=sha256:2738a00fca51196f5a7d44b06970ace6b8b30289839e4808d656f8a98e35faa7 \ + --hash=sha256:3414e40c073d725007a6603a18247ab7af3467e1af4a5e5a24e4c27bc26673b4 \ + --hash=sha256:341fa68a7403e10a95c7b6e41134b0da3a7b835ecff1eb266294408535a06eb6 \ + --hash=sha256:3bef39f8587754f2d644b2e831e102fa08ace9a5a1af4b583b122e6fd3e083ab \ + --hash=sha256:47ee7af083623d2091954fa71c7549b8443168f41b7c5dce66510274c50fd73e \ + --hash=sha256:4d0c1b0f9f6915619e2a0d3853e5763d4d66876892ad352e7d7b93a737556978 \ + --hash=sha256:4d17873d3e607d398b85126cd4ed7aad89e4e9d89fc744cdab1af3189a996c2a \ + --hash=sha256:6ad5c2810fc6c684254a7792bf1cbaf1606cdee2a253f8bd259c43135d87cfb4 \ + --hash=sha256:730f3297b26749b216a06e4327fe0be59b8d05f7d594fb6caff4287b69654589 \ + --hash=sha256:73f305fbf31607b35ce919fae636ab8b80d175328ed38fdc6f354e813b86ee37 \ + --hash=sha256:8021271a79f35b842c02a1794465a651a9d06ec2080f76ebc3b7adce77d08233 \ + --hash=sha256:81e7dcc5e9587f2567e52deaff5220b175bf2f648951549eae5fc9383b62bc37 \ + --hash=sha256:89ee7d595f5939cc20bf946faedcb5137d975f03ae080f300ebb4398f16a5bd4 \ + --hash=sha256:9e0fc891d6358bf376cc0ae1fee10a650478172ae9ba359daba1785fc496cd1a \ + --hash=sha256:9e8d2e519852adb1b420e018f5ac6e6684b2b877478adf7fda2cfdb58f5acb5d \ + --hash=sha256:a3764ba8195c9baf0925a96da0743ff218067a269f01d155ca3558deed2658ca \ + --hash=sha256:a518d3f9889ef920116f9fa56d0338069e110f823926356946dae83bc9e33e19 \ + --hash=sha256:aa60d82e29fcd0a736e86feb63a11d2380322d77a9369a54be8b0965a3985f71 \ + --hash=sha256:b328eafa86a2a67303fdb1d25677d15b70cd2a5229aabec7670ec5ea840f1375 \ + --hash=sha256:b5eb07acd115aa6208b4058211138393a7e6c2cf12b6f213ede10f658f6a714f \ + --hash=sha256:bdf1dfe2a3ca56f5529118baf33a13efed2783c528f4a36409b46bbd2d9d48eb \ + --hash=sha256:d8c00a42863e4f4733ac9d078bbfad816249c01451740e6f5053ecc7db6d6368 \ + --hash=sha256:e443e7077a6e2d3faeea72f5a92c9f12c63722686eb80bb40a0f04e4a7e267ad \ + --hash=sha256:e83a9abe653835da3b37fb6ae04b45480c1de11b3134bd40b09717192a1456ea \ + --hash=sha256:e93bd5d220f3cb6fc5fc1bffd5b094966cab8ee99f6c57c02e95710513d6ac3f \ + --hash=sha256:f1c08befa85e93acc992b72a390ddb7bd876190f1360e61d10cf43833463bc9c \ + --hash=sha256:f4ff0649a2df674c7ffb6fa1a06bffdb82a6adf09a48e90e000a15a6aaa734b0 \ + --hash=sha256:fe76140ae7adc5ff0e60a3f0d56f4fffef484efa803c3efebf2fcd734d72ecb5 + # via distfit +structlog==26.1.0 \ + --hash=sha256:e081a26d6c373e6d201eca24eede26d8ffab07f88f477822e679183428d3d91e \ + --hash=sha256:f63a716cbd1b1291cf7661de7794b455acfa4c43c5bcf1630e6ad5ddc1adb3b7 + # via semantica (pyproject.toml) +sympy==1.14.0 \ + --hash=sha256:d3d3fe8df1e5a0b42f0e7bdf50541697dbe7d23746e894990c030e2b05e72517 \ + --hash=sha256:e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5 + # via torch +tabulate==0.10.0 \ + --hash=sha256:e2cfde8f79420f6deeffdeda9aaec3b6bc5abce947655d17ac662b126e48a60d \ + --hash=sha256:f0b0622e567335c8fabaaa659f1b33bcb6ddfe2e496071b743aa113f8774f2d3 + # via docling-core +tenacity==9.1.4 \ + --hash=sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55 \ + --hash=sha256:adb31d4c263f2bd041081ab33b498309a57c77f9acf2db65aadf0898179cf93a + # via + # google-genai + # instructor +terminado==0.18.1 \ + --hash=sha256:a4468e1b37bb318f8a86514f65814e1afc977cf29b3992a4500d9dd305dcceb0 \ + --hash=sha256:de09f2c4b85de4765f7714688fff57d3e75bad1f909b589fde880460c753fd2e + # via + # jupyter-server + # jupyter-server-terminals +thinc==8.3.13 \ + --hash=sha256:0355c37e40d1a9fc2a1b8e9c2e294d8586f6baa97bcac6b9002f2dddb4b82ae9 \ + --hash=sha256:0a0fa13dcfe4b319c3a396432c1dbff30d3de37dbbdee559e76600ee2b9486df \ + --hash=sha256:11754fada9ad5ba2e02d5f3f234f940e24015b82333db58372f4a6aedad9b43f \ + --hash=sha256:303477eb51b9b39c94a7fc7967ee8a039eca1ca37d95dcce1234c83b95b4ee9f \ + --hash=sha256:3710d318b4e5460cf366a6f7b5ddbefb5d39dbd4cfa408222750fdc6c27c4411 \ + --hash=sha256:3dac18a0fb0a42f711c2ce9c02cbb090385aecae92089aa17b9dfd808a542013 \ + --hash=sha256:433e3826e018da489f1a8068e6de677f6eff3cc93991a599d90f12cd1bc26cdc \ + --hash=sha256:4565102638038a01a2193c7f5d41ccbd6233fbdcb1f1b184322a06add4f51f18 \ + --hash=sha256:4b5ec9ff313819e7d8667794a3559463fa89ff45aaa73e3fd8d6273b1e0d7a7f \ + --hash=sha256:5593e6300cb1ebe0c0e546e9c9fb49e7c2627a0aa688795cd4f995a8b820d2ec \ + --hash=sha256:565300b7e13de799e5abff00d445f537e9256cf7da4dcb0d0f005fc16748a29e \ + --hash=sha256:5a08c87143a6d20177652dca1ec0dc815d88216d8fc62594a57e8bc45bf5ed49 \ + --hash=sha256:5c9a48f2bc1e04f138240ed5f9b815a9141a5de26accd0f08fa0137fcefed258 \ + --hash=sha256:68e658549fc1eb3ff92aed5147fcbb9c15d6e9cc0e623b4d0998d16522ffb4f9 \ + --hash=sha256:723949cab11d1925c15447928513a718276316cec6e0de28337cca0a62be0521 \ + --hash=sha256:77a41f66285321d20aaedaea1e87d7cd48dca6d2427bed1867ec7cba7109fc8d \ + --hash=sha256:79a29a44d76bd02f5ac0624268c6e42b3576ae472c791a8ae9c2d813ae789b59 \ + --hash=sha256:7a99d0e242d1ccd23f9ae6bea7cd502f8626efa65c156b91d84581d0356696c3 \ + --hash=sha256:7badb0be4825535e6362c19e8a41872b65409e9da46d3453a391b843a0720865 \ + --hash=sha256:81337dfbee37f58f36c0c70f9a819dce1b32cdc13d959181e10de079621f6ac6 \ + --hash=sha256:84fb50fe572a1860165f2e7a640c7cb70d43d6962366e69f643fa9a27e4a2127 \ + --hash=sha256:859fbd9d9b16af5278da23589b4afbe2ab6b0dd615df4d3229b7c4e67cd3107e \ + --hash=sha256:8ad40307f20e83f77af28ff5c6be0b86af7a8b251d1231c545508d2763157d8f \ + --hash=sha256:a518d5c761a0f2341e530e867de133dc3ed814558365b2a68ec53b89c482a43f \ + --hash=sha256:a61a31fd0ce3c2771cf4901ba6df70e774ffe32febf1024c5b43d63575cd58fe \ + --hash=sha256:ba8119daf84a12259ae4d251d36426417bafa0b34108890b4b7e2b50966bd990 \ + --hash=sha256:c17cef1900a1aba7e1487493d16b8aa0a8633116f1b2a51c6649a4000697f17b \ + --hash=sha256:c2811dfd8d46d8b5d3b39051b23e64006b2994a5143b1978b436938018792af8 \ + --hash=sha256:c6a049703a6011c8fe26ee41af7e70272145594140d82f79bb23de619c6a6525 \ + --hash=sha256:cd8a2b714c061969eee65802965167a6ada1fe708d82fe176d98dcb95ebe182a \ + --hash=sha256:d7a9654f9ca362a4be7f5e590fdfee26e2e2084da9fd3306032ec037e99f2f8e \ + --hash=sha256:e08b1577a56e7315770af280aabd8fa5f2a1fb6afd1c50a4183c06e907faf558 \ + --hash=sha256:e1f8d13bf92ee10595c40692fd4cf8e7bbe73bd9f260107e975fd5dbee1af42b \ + --hash=sha256:e676edd21a747afbe3e6b9f3fca8b962e36d146ded03b070cb0c28e2dfbe9499 \ + --hash=sha256:e7f046d8914055cad51e83ff0da1a892acb73cd58556d7c1a5d4015a3766a899 \ + --hash=sha256:e9c7c5c104737b414c8c4ec578e67d78b6c859afe25cbc0684402e721415bd7f \ + --hash=sha256:ed1dc709ac4f2f03b710457889e4e02f05de51bc8456980c241d0b28798bc7cb \ + --hash=sha256:f4f26d1eec9b2a6a8f2e0298a5515d13eb06d70730d0d9e1040bb329e12bf3fb \ + --hash=sha256:f697174d3fb474966ce50b430bbafa101a6d2f7ffb559dac4b5c59389ef72d22 \ + --hash=sha256:fbc0ee16edd260c6a4a9e365ff36d0a682c9e7ca6d7b985682659ef2e3e73826 + # via spacy +threadpoolctl==3.6.0 \ + --hash=sha256:43a0b8fd5a2928500110039e43a5eed8480b918967083ea48dc3ab9f13c4a7fb \ + --hash=sha256:8ab8b4aa3491d812b623328249fab5302a68d2d71745c8a4c719a2fcaba9f44e + # via scikit-learn +tiktoken==0.13.0 \ + --hash=sha256:059c8ecf554eb5b41e6e054ba467b871b03277d267dee7244380aca4359747d4 \ + --hash=sha256:115c4f26ffa11caac8b54eea35c2ad38c612c20a48d35dd15d70a02ac6f51f58 \ + --hash=sha256:125bc05005e747f993a83dc67934249932d6e4209854452cd4c0b1d53fba3ba2 \ + --hash=sha256:165cf1820ea4a354985c2490a5205d4cc74661c934aca79dd0368232fff94e0f \ + --hash=sha256:2a3b536c55802fe42f4b4644d2be4f04bf788506b48de0a0a658cb58f8bce232 \ + --hash=sha256:2b920b35805cd64585a37c3dc7ce65fba4d2d36016be01e1d7942482ca29093a \ + --hash=sha256:2c397ddda233208345b01bd30f2fca79ff730e55731d0108a603f9bc57f6af3b \ + --hash=sha256:303f7d91b4fce3baddbcde05c139091d4caa5026ac7214c1dc7ff7a71ee429ff \ + --hash=sha256:32ac870a806cfb260a02d0cb70426aef02e038297f8ad50df5040bb5af360791 \ + --hash=sha256:32e0c12305105002c047b3bb1070b0dd9a73b0cb3b2856a8972b810e7a4f5881 \ + --hash=sha256:35e1ea1e0631c04f551297284a1ab7e1f65a3c55a9a48728d5e0f66b4527c04a \ + --hash=sha256:36217497eaffc158607a3b26f065300db2aefd43b115263f3b9688ce38146173 \ + --hash=sha256:3f277ebea5edd7b8bf03c6f9431e1d67d517530115572b2dc1d465326e8f88c7 \ + --hash=sha256:43cee3e5400573b2046fbf092cc7a5bc30164f9e4c95ce20714da929df48737a \ + --hash=sha256:44733b99bfd72b590cd0936b1c01b3b4dd73122db2d544bc1ceeb18a7678c910 \ + --hash=sha256:472527e9132952f2fbf77cd290658bacf003d4d5a3fabc18e5fbd407cbae4d9b \ + --hash=sha256:477c9a38e20d0ed248090509acf1e839ad3967a4f00b4b0f958210049f656dee \ + --hash=sha256:47b1df8d73390a24f94980c75158cdd5c56d256f16d55f30cb49c230caba9ba4 \ + --hash=sha256:493af3aa28a4aaf2e3d2600a2ee717252c9bf5ab38fff94eb5a02db5ab77e5ad \ + --hash=sha256:4d9980f11429ed2d737c463bb1fb78cf330caa026adf002f714aced7849a687b \ + --hash=sha256:4e2f67d27c9626cdd25fe33d9313c5cdb3d8d82da646b68d6eb8e7e9c20e6448 \ + --hash=sha256:51384448aa508e4df84c0f7c1dc3211c7f7b8096325660ee5fc82f3e11b381ce \ + --hash=sha256:5ba5fd62507a932d1241346179e3b39bc7bf7408f03c272652d93b3bedf5db24 \ + --hash=sha256:5cb65b60b9408563676d874a3a4ee573370066f0dc4e29d84e82e989c6517424 \ + --hash=sha256:5d48843bee149630eb735a99e1f4a85b47308d21868ea63163f6e87768d3cfed \ + --hash=sha256:5df5d1507bd245f1ccad4a074698240021239e455eb0bb4ced4e3d7181872154 \ + --hash=sha256:5e6358911cab4adee6712da27d65573496a4f68cf8a2b5fca6a4ad10fc5748cf \ + --hash=sha256:6644c9c2b5cf3916f5a3641d7d12fdb3f006a7b3d9ff6acdaec44e29ab1ff91e \ + --hash=sha256:6b1615f0ff71953d19729ceb18865429c185b0a23c5353f1bbca34a394bf60f7 \ + --hash=sha256:6c43a675ca14f6f2749ba7f12075d37456015a24b859f2517b9beb4ef30807ec \ + --hash=sha256:6eb4a5bfbc6426938026b1a334e898ac53541360d62d8c689870160cc80abd67 \ + --hash=sha256:75ab9bc99fa020a4c283424590ecd7f3afd70c1c281cb3fa3192a6c3af9f9615 \ + --hash=sha256:7ab10f4a21c2999846940113f6dbd72e0fa06a24119feddd74cc47e85818e06d \ + --hash=sha256:7bfe1849caa65d1e1d9871817170ec497bbb7984e182012e1bdce72f66608cdb \ + --hash=sha256:7d40c6c5aab171dcd6eb8455bc567bde404bb9def60cdb8c1299cc782b242bb9 \ + --hash=sha256:7de52e3f566d19b3b11bd37eea552c6c305ad74081f736882bd44d148ed4c48d \ + --hash=sha256:85b78cc3a2c3d48723ca751fa981f1fedccd54194ca0471b957364353a898b07 \ + --hash=sha256:8f2d16e7a7c783ad81f36e457d046d1f1c8af70b22aec8a13238efe531977c41 \ + --hash=sha256:8fe806a50664e83a6ffd56cbd1e4f5dcc6cd32a3e7538f70dc38b1a271384545 \ + --hash=sha256:91c180fe255bd5a86d8316210d2833a1d4d33d026cd86a67812f4773743c8d26 \ + --hash=sha256:95097e4f89b06403976e498abf61a0ee73a7497e73fb599cb211d8197a054d91 \ + --hash=sha256:975cbd78d085d75d26b59660e262736dcaed1e35f8f142cd6291025c01d25486 \ + --hash=sha256:9b842981fa91accdffd48ff6408a977b7a91c3fbda55d353c3c68114d5c9d69e \ + --hash=sha256:9b8858b29804b3a0add25ce9e62fb00f89f621dc754d75d03ca419d17e8ddf67 \ + --hash=sha256:a116178fa7e1b4065bff05214360373a65cac22f965be7b3f73d00a0dbfe7649 \ + --hash=sha256:a2937ad042d49d50eac6e1ba07c5661d4bd3942a5b1e0c0d08475c4df83676e1 \ + --hash=sha256:b8ac2d6420ff05841a89ba5205c6d45f56c4f6843454f3c884b7eb1a2a8dddb2 \ + --hash=sha256:b967dfb9d0adf9a631953b1b40717684f04478270fc51bbccdd2f838d67a2f00 \ + --hash=sha256:c9435714c3a84c2319499de9a300c0e604449dd0799ff246458b3bb6a7f433c1 \ + --hash=sha256:ca8b310bd93b3772cb1b7922d915446864860f562bdfe4825c63a0aed3fb28cd \ + --hash=sha256:cb99cb5127449f58d0a2d5f5ccfb390d8dbdfd919c221246caaee29d8725ed51 \ + --hash=sha256:d108bc2d470fc53c8ecd24f2c0fd2b5f98c33e87cdb6aa2e9b8c5dced703d273 \ + --hash=sha256:da86f8c96ac1c235d7a3b3eebff1eacfdbcfb8ad792706943268d4d2938fbafe \ + --hash=sha256:e28157350f7ebf35008dd8e9e0fdb621f976e4230c881099c85e8cf07eaa50e2 \ + --hash=sha256:eaaaef47c2406277181d2086484c317bf7fc433e2d5d03ff94f56b0dcec87471 \ + --hash=sha256:ed5a30027cb4d8c7ca8b273d4766f3db3cf58fad9e9f3b1a68a351ffb54873d5 \ + --hash=sha256:fc1c44cd37b43fc46bae593129164f4f281e82ea116b57a85aa81bda57eafc94 + # via + # semantica (pyproject.toml) + # litellm +tinycss2==1.5.1 \ + --hash=sha256:3415ba0f5839c062696996998176c4a3751d18b7edaaeeb658c9ce21ec150661 \ + --hash=sha256:d339d2b616ba90ccce58da8495a78f46e55d4d25f9fd71dfd526f07e7d53f957 + # via bleach +tokenizers==0.22.2 \ + --hash=sha256:143b999bdc46d10febb15cbffb4207ddd1f410e2c755857b5a0797961bbdc113 \ + --hash=sha256:1a62ba2c5faa2dd175aaeed7b15abf18d20266189fb3406c5d0550dd34dd5f37 \ + --hash=sha256:1c774b1276f71e1ef716e5486f21e76333464f47bece56bbd554485982a9e03e \ + --hash=sha256:1e418a55456beedca4621dbab65a318981467a2b188e982a23e117f115ce5001 \ + --hash=sha256:1e50f8554d504f617d9e9d6e4c2c2884a12b388a97c5c77f0bc6cf4cd032feee \ + --hash=sha256:2249487018adec45d6e3554c71d46eb39fa8ea67156c640f7513eb26f318cec7 \ + --hash=sha256:25b85325d0815e86e0bac263506dd114578953b7b53d7de09a6485e4a160a7dd \ + --hash=sha256:29c30b83d8dcd061078b05ae0cb94d3c710555fbb44861139f9f83dcca3dc3e4 \ + --hash=sha256:319f659ee992222f04e58f84cbf407cfa66a65fe3a8de44e8ad2bc53e7d99012 \ + --hash=sha256:369cc9fc8cc10cb24143873a0d95438bb8ee257bb80c71989e3ee290e8d72c67 \ + --hash=sha256:37ae80a28c1d3265bb1f22464c856bd23c02a05bb211e56d0c5301a435be6c1a \ + --hash=sha256:38337540fbbddff8e999d59970f3c6f35a82de10053206a7562f1ea02d046fa5 \ + --hash=sha256:473b83b915e547aa366d1eee11806deaf419e17be16310ac0a14077f1e28f917 \ + --hash=sha256:544dd704ae7238755d790de45ba8da072e9af3eea688f698b137915ae959281c \ + --hash=sha256:64d94e84f6660764e64e7e0b22baa72f6cd942279fdbb21d46abd70d179f0195 \ + --hash=sha256:753d47ebd4542742ef9261d9da92cd545b2cacbb48349a1225466745bb866ec4 \ + --hash=sha256:791135ee325f2336f498590eb2f11dc5c295232f288e75c99a36c5dbce63088a \ + --hash=sha256:9ce725d22864a1e965217204946f830c37876eee3b2ba6fc6255e8e903d5fcbc \ + --hash=sha256:a6bf3f88c554a2b653af81f3204491c818ae2ac6fbc09e76ef4773351292bc92 \ + --hash=sha256:bfb88f22a209ff7b40a576d5324bf8286b519d7358663db21d6246fb17eea2d5 \ + --hash=sha256:c9ea31edff2968b44a88f97d784c2f16dc0729b8b143ed004699ebca91f05c48 \ + --hash=sha256:df6c4265b289083bf710dff49bc51ef252f9d5be33a45ee2bed151114a56207b \ + --hash=sha256:e10bf9113d209be7cd046d40fbabbaf3278ff6d18eb4da4c500443185dc1896c \ + --hash=sha256:f01a9c019878532f98927d2bacb79bbb404b43d3437455522a00a30718cdedb5 + # via + # semantica (pyproject.toml) + # fastembed + # litellm + # sentence-transformers + # transformers +toml==0.10.2 \ + --hash=sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b \ + --hash=sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f + # via semantica (pyproject.toml) +tomli==2.4.1 \ + --hash=sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853 \ + --hash=sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe \ + --hash=sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5 \ + --hash=sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d \ + --hash=sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd \ + --hash=sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26 \ + --hash=sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54 \ + --hash=sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6 \ + --hash=sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c \ + --hash=sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a \ + --hash=sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd \ + --hash=sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f \ + --hash=sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5 \ + --hash=sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9 \ + --hash=sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662 \ + --hash=sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9 \ + --hash=sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1 \ + --hash=sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585 \ + --hash=sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e \ + --hash=sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c \ + --hash=sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41 \ + --hash=sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f \ + --hash=sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085 \ + --hash=sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15 \ + --hash=sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7 \ + --hash=sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c \ + --hash=sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36 \ + --hash=sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076 \ + --hash=sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac \ + --hash=sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8 \ + --hash=sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232 \ + --hash=sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece \ + --hash=sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a \ + --hash=sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897 \ + --hash=sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d \ + --hash=sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4 \ + --hash=sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917 \ + --hash=sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396 \ + --hash=sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a \ + --hash=sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc \ + --hash=sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba \ + --hash=sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f \ + --hash=sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257 \ + --hash=sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30 \ + --hash=sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf \ + --hash=sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9 \ + --hash=sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049 + # via coverage +torch==2.13.0 \ + --hash=sha256:024c6cc0c1b085f2f91f20a3dc27b0471d021c31ce84b81be3afdc39f791fd9d \ + --hash=sha256:092790c696a760c729fd5722835f50b9d81fd7c8f141571f3f3cf4081a8f664c \ + --hash=sha256:0ab4b69f3ee03a62a002cfbf77b1ca5e88aceb4ea64cb4388bb28f638ddbb045 \ + --hash=sha256:1e09d6a722504957c694faceca843acde562786df1144ebcc5a74075ec7f6005 \ + --hash=sha256:2bd30b6b730d987fa386ce3898933762c5cb8cc82eb0535211d787cc3ce2dfeb \ + --hash=sha256:2fe228aba290d14b9f31b049be550dbd469c3fd3013d7a19705b30454da97027 \ + --hash=sha256:31061ff56ed8fbf26c749806905aeb749ebeb819810fd5d52508aa5afd90dddc \ + --hash=sha256:33449899ce5496c1b84b4853179d94fd102028ae1407314d9fb956bb79e70d09 \ + --hash=sha256:49b58f1e2c52440abb6f17c28f0335fe6c6d01ad1a7f55b0183b81e4b34d64e6 \ + --hash=sha256:49f1ea385c754e54919408a9bb3b5a72b0b755bbe2c916c1d6f70afbec4908a2 \ + --hash=sha256:4f8573e3ce9ebcd53fe922f01077a6085ccdfbe5f12fd215883a9d87d7a744fd \ + --hash=sha256:572df8be8ffb4599c88cbd6a0726f1f854f4da65d2e3c09f0e2c2283333cd6d4 \ + --hash=sha256:60fcdcb2f3876e21146cb4524ef06397d727ca9ad5f020818547e25075fe3cb7 \ + --hash=sha256:796633c4cdf0fe2cdced72d8f88f22e73dbcfce83132763162f6d4bff13b820b \ + --hash=sha256:94f0de129916f77b8dc2c7a8eff644cfeddfe59e39c9f55e9f6e17543410281d \ + --hash=sha256:a0d8b11f16a48d60e2015d8213aa0390744cbebb98e58b62b3514dddc656e330 \ + --hash=sha256:a3893dc2da0a972a8ca5d698c85a9f967559ac5f8ee1797b77408aa8734d073c \ + --hash=sha256:a3a9a21312872af8a26950b2c15680335a386a1f56ed03e780653d78b9607e9e \ + --hash=sha256:a7de8a313090dc5c7d7ba4bfe5c3be222528f9a4dba1acc83bddb1157360c4b8 \ + --hash=sha256:c28def70706c2f9ecc752574766e8ae4da9b810ab6676b611166761a78a9f1e1 \ + --hash=sha256:c78b7b4d04461855a764cf01bae9a462bb88bc93defcfa11235cbc8fdf3e12c4 \ + --hash=sha256:cc26eead4cf51d0b544e31e364dcf000846549c273bd148936fe9d24d29acb92 \ + --hash=sha256:d849b390e07d8d333ce8ecaf91b273c656c598379a19c9acf1318a883f6b391c \ + --hash=sha256:e76f9bcecc52b8ff711239a2f7547d5353df95878ab232f0773c1d95928b92f8 + # via + # semantica (pyproject.toml) + # accelerate + # docling-ibm-models + # docling-slim + # safetensors + # sentence-transformers + # torchvision +torchvision==0.28.0 \ + --hash=sha256:028a3d481b37d785605620d7cdad897064c5a55bae2aa1f2658766333e291940 \ + --hash=sha256:09ce8f56e81f19b9c378ae7bb109f83f6659fd8bc3cd14241a48e4af46e9ed49 \ + --hash=sha256:2a1ef4b6f4bf5828b48cfad97372c8982db906830884b2868ba5c3df937a7d81 \ + --hash=sha256:3557cc7b539f46dabcda2b6f2b14017ccbeef024de466d4fc5835fc3f287f769 \ + --hash=sha256:36beb0782976906069ca03d4c9aacaf4b6b838b06ed6c20960ea9c51cce7acdd \ + --hash=sha256:3bd9dba55224a9db4a2d77f6feaa5651770d8c8e86d3d0ddb0fa6bec54c8712b \ + --hash=sha256:46f581979c010ad6da6bd85ee602aa707e1ff44312670223b7a0ee517ad06d47 \ + --hash=sha256:546fd85345cf8652f6cd099d4f9884b0ca5c2f3fae78689a21dd2f35ea6b622f \ + --hash=sha256:5a38bc6da3d72621be003400b66f66a2b4c6d644fde05f680c2cb7ca8cf8dd6c \ + --hash=sha256:5cf78ebc401ce64ae19b8c55de866bb836797d559a4de9c25ccbe74cfa642d3a \ + --hash=sha256:62c7d110f86a039245b587e4fae60278c649f3bd42ff79cfbc1178eca4e72542 \ + --hash=sha256:6dfb0f45e2b4ceb4e76f158c3fbb5f44387099f3c466e3423a09ab665a194aba \ + --hash=sha256:7e80f543b22503d9415e126db5f0ff3917036925e38560ee6b9ae38c571a4002 \ + --hash=sha256:7e9dd6f60d6e15f8dc27d4f877fdb6002fc70d70272412135f1c2ff9cfa08d3b \ + --hash=sha256:7fad44dc9582570c7d92c4487d36ac46998f40cc39b438e8b8f5111a935ce4e8 \ + --hash=sha256:83fe6c020866a85acd7d97deccc45ff11d66daf42916d04396a4309c66c0ccb8 \ + --hash=sha256:87dc16b2df427c1318ad335f1e2be2b3b15b2cf20f7934c83b0505a48425ee5d \ + --hash=sha256:89f90e29b0966352811b12589f3a3c61943bf2bb9487b9d7bbec10efb1096bb5 \ + --hash=sha256:904cf89af220f8c6b2ed0296bb5065b474ce43b77558e48b2bf9de8b0ba17204 \ + --hash=sha256:9a45ea67235d965ef52187130d20002a4de20c54ea3d927a24286961d268dc37 \ + --hash=sha256:ad7b3a439265cc3739a4ab5b4c998c0e38ea99c0ee7ca4dea35c5d0b099ec237 \ + --hash=sha256:bb6dd6918460ed89cc7644adcc2402991474d6933cf1ce92b390641cb233fddf \ + --hash=sha256:d483b4aa3f5237569053f749cd1a2b5bb548ca456e40461a5dd087f21149d123 \ + --hash=sha256:e9f54c30cd52e3ef7fd034cc69b7bb7e0964e1c8f8743e018ab92e95b40f9eee + # via + # docling-ibm-models + # docling-slim +tornado==6.5.8 \ + --hash=sha256:11881db6b7c168494be2c2d12e65931451bdf7ee718535418ae1d8855dd5a0ee \ + --hash=sha256:547d63f450d570c14fe0e8db2cfb14c9bbd1c2503b4a6612586267955aa47b58 \ + --hash=sha256:5d242290bdf7ab3151bc1065fdd75c0dcc21cbc7b49f22a4c56329c2d6566d22 \ + --hash=sha256:67832909c4779c64942380cb5f044a5c6163d00831472d80e25e115de9917836 \ + --hash=sha256:68a7468c7e289f8514d7d664101753903217eff1bb6822c6b5994a0b5f5bcb26 \ + --hash=sha256:7b94ff0e128fe0542f3bd331fb44d06260fc4ac16881545159f34ef08aad4195 \ + --hash=sha256:7e2360a0ffbe145eca8af0b19cb7203d79b1a98dd4cccdd6b368f6f49c2e3808 \ + --hash=sha256:9452e1b208a8bd771e2cb1f2ff564985b9b214bdebbe622793e1799e0a6bd23f \ + --hash=sha256:9715b5eb79735b2bcd454ce216a9275b7c0470e64ea1bf5742f78b2f72b26eeb \ + --hash=sha256:cc6aa787d7cfab7c3d35189dc7a56fbd2399a569624c730c6b55b3d6531d0403 + # via + # ipykernel + # jupyter-client + # jupyter-server + # jupyterlab + # notebook + # terminado +tqdm==4.70.0 \ + --hash=sha256:55b0b0dbd97462d06ebee91e4dac24ed4d4702be82b24f07e6c1d27e08cea220 \ + --hash=sha256:7f585706bfddbdebf89daac705b2dfcc16890130727d3197ca62c732b4310953 + # via + # semantica (pyproject.toml) + # bertopic + # d3blocks + # docling-ibm-models + # docling-slim + # fastembed + # huggingface-hub + # mpire + # openai + # rapidocr + # semchunk + # sentence-transformers + # spacy + # transformers + # umap-learn +traitlets==5.16.1 \ + --hash=sha256:ed900c2b631aa3a112811139fa97b8d2c3bad5e989656bba4b7e52c7852c18c1 \ + --hash=sha256:f775618166caa0396c8e337099240f2bd3e5e917d203b2e6fbe21a58d3cb1f6b + # via + # ipykernel + # ipython + # ipywidgets + # jupyter-builder + # jupyter-client + # jupyter-console + # jupyter-core + # jupyter-events + # jupyter-server + # jupyterlab + # matplotlib-inline + # nbclient + # nbconvert + # nbformat +transformers==5.15.0 \ + --hash=sha256:bbf98f57b2ddd7c4ecbccfa2c0069017aa6fd01cc204bd50cbc0eeadcf2a13b8 \ + --hash=sha256:d7f007736f67749ae9490c4f8cb5d30b452ae2d68c8675e50ba8d63ea7feb107 + # via + # semantica (pyproject.toml) + # docling-core + # docling-ibm-models + # sentence-transformers +tree-sitter==0.26.0 \ + --hash=sha256:00289bfe7978f3e0dc0ce69813a20fa9f44ea4c100b3ec62043e5eb74ccfc3a2 \ + --hash=sha256:0f8793fd18ad7eec276ed4b51c097b4bf2002b357259b66b0d75db1f3f41c754 \ + --hash=sha256:10f0d4eb94aa7242dcb7f554bcd24dd7ba1c114f00d58759ba08c7a46c8ec51a \ + --hash=sha256:17a1c5cfd3a05d5c7c86bf4282b6ef8092c91dc0a98390499669c3fedb7d1814 \ + --hash=sha256:1d6fe0e8fb4df77b5ee816228e2c4475a63d8cc1d4d3a7ffd7097b2b87fc3e95 \ + --hash=sha256:253df7ab82cc0a9d311cd65f06e9f99fb3eac55996ae9fc94da22f123a861b90 \ + --hash=sha256:26c996c1edfee86e977bb3f5462e74fcec0d0b0db1e85a3c475875763caa03be \ + --hash=sha256:2f941cea06128c1f74f8937a8e2a90c7db49cf4be6647cd9e07d92a306d91517 \ + --hash=sha256:30a88be89ff1f2755297f81e8080d88b795dd98720c3f9fa2acf93873182cc95 \ + --hash=sha256:335294ce0504fcefde5245dff596778ffaf820205b98ae0b549c72e48855f1d8 \ + --hash=sha256:3f3c44339dd34fe8eb2b8d5aa7610660499a795f70376b130bbee7a437337280 \ + --hash=sha256:514a9bf8993e5210e7970736aaf6020d1759b670e195ef17b1c48f586aa30736 \ + --hash=sha256:526a165a2cb1d1f79e247d400f0e0acd8d49a817d6f312d543513af200b1f886 \ + --hash=sha256:5a3c93a352b7e6f70f73e121bbfa2d0117ba7478bd51114ed35c91b0b78814fa \ + --hash=sha256:5a6b333b0282d8bb0af741f9b018bd2523d4eecb2686bf6717066a625fecfaa4 \ + --hash=sha256:5fc2f41bf246ff2f70a9cc3690be35ec7580a4923151873d898c8bcb1a4503d3 \ + --hash=sha256:6189c6c340c7384357711e3d92645e96bfb79f7a502f86de1ebdb23eb43f7dab \ + --hash=sha256:6cb2bd20efb2544c19ac54486ab7cb8ec7b36f913bbe1ce95df84acb96743d9c \ + --hash=sha256:7075ef857ef86f327dbb72d1e2574dda78db5754b3a1fca6506acd7fe5d561a7 \ + --hash=sha256:763627db05db34f12333081bd7422cc1c675893d373cc870b3e9249e200700e4 \ + --hash=sha256:7bcbadfa614326debef581957d5c780a9d7f66065c13deea61aa21d1dd36263f \ + --hash=sha256:823251c4b6725a7c03ed497a339135ede7ae4bdde75bb8be7ef5e305aeb4ff52 \ + --hash=sha256:8ff2e0750b7daa722302838356d7b65e303829b7eb73c915df127ddba115e1d1 \ + --hash=sha256:918d89529786873f0982a0f59c2a303cd065fbfd1b903d71a8e4e1584f67b42e \ + --hash=sha256:93e220cab7e6a823efeb2046c49171427de92ef71c7c681c01820d14d8d3721f \ + --hash=sha256:94550e13b6ae576969da40246f4c4abb206380b5375ad43f26dd9151d55438e3 \ + --hash=sha256:a4033fecc8f606c7f2e8b8014d0057b74668a7f0152763606f7bc25c5f9ec64c \ + --hash=sha256:b31a8195d2f224224c530ac814632d98c1dcc123d227442c07c736e86b70d564 \ + --hash=sha256:b40c219edccc4564530c96f8f1556f6202b37cda964d1cbd7bd2b7e68b40a245 \ + --hash=sha256:b8ea92a255c91671a7ec4625aba3ab7bb5220c423630ffbf83c45d7312abe084 \ + --hash=sha256:bc6cb01d5ee75c85424aa1f1c72a82d8f07fd52539a0f3c4a6ed3e8721079b84 \ + --hash=sha256:c56581ad256c4195a21bfe449fed5d44a02fe83a4a7d6e70e6ec302c881191c7 \ + --hash=sha256:ca89e361a276dbc934b28a43dd881199e25d34ff5493ee0ce45f3c52a6124a37 \ + --hash=sha256:dea4b4e27d49e9ec5b785d4f994da000e6726882fcc6ad05ec98478500c71aef \ + --hash=sha256:e9e46b664887d8c1014f1fb33e09454bbdd9ec1fe29b7fd02dde7b46bc1bb81a \ + --hash=sha256:ed0889dbed843ce45ede9f5169c0b2dea2222f12685844a03fadb81f12705867 \ + --hash=sha256:f289be0225ba2ace8e87d6c9639b2bc9ff2b5271afb7c5d39282a4a00e248682 \ + --hash=sha256:f665510f0fcf4636fb9696f1f7853bed7a3bd764b7bb0cb8494e619c14ed5a0c \ + --hash=sha256:f9997ba61368c48ed54e715676afadf703947a1542464e39d047764fb3624b01 \ + --hash=sha256:ff527388df14cb5009f9274faf78cc69a7393ae6acf3b04784b8acca249519c5 \ + --hash=sha256:ff80d4833d330a73184a3ac5132abe93c575d2dea31975c6f15c0d21fef238aa + # via docling-core +tree-sitter-c==0.24.2 \ + --hash=sha256:1628584df0299b5a340aa63f8e67b6c97c91517f52fa7e7a4c557e40adb330a9 \ + --hash=sha256:4a2f4371cd816cc3153458f69062135ebb2ea5f275ddd90494e5c823d778204a \ + --hash=sha256:4d4579a8b54f0a442f903d88d3304cab77cd5c2031d4015baa4f2f8e15d6dcb7 \ + --hash=sha256:5041ef67eb68ce6bc8bb0b1f8ef3a5585ce523dae0c7eec109ab0627dd75aede \ + --hash=sha256:82842c5a5f2acd93f4de10038c33ac179c8979defc39376f990348d6289e933b \ + --hash=sha256:97bc80a224d48215d4e6e6376bf30d114f4c317b8145ff1b02afe785d4ba7bdd \ + --hash=sha256:abb549225091f7b25df2dd3a0143ece6e208f7055d8bcb4700b41ee79b9ef1e1 \ + --hash=sha256:c098bedcd5ac86ff93fa734d51d1dd86aed40fd5ed7d634c7af11380a0469969 \ + --hash=sha256:e2b42e8e22202c251f8629306f9321233542e07a6e01611b5fe83489272143eb + # via docling-core +tree-sitter-javascript==0.25.0 \ + --hash=sha256:199d09985190852e0912da2b8d26c932159be314bc04952cf917ed0e4c633e6b \ + --hash=sha256:1b852d3aee8a36186dbcc32c798b11b4869f9b5041743b63b65c2ef793db7a54 \ + --hash=sha256:329b5414874f0588a98f1c291f1b28138286617aa907746ffe55adfdcf963f38 \ + --hash=sha256:622a69d677aa7f6ee2931d8c77c981a33f0ebb6d275aa9d43d3397c879a9bb0b \ + --hash=sha256:8264a996b8845cfce06965152a013b5d9cbb7d199bc3503e12b5682e62bb1de1 \ + --hash=sha256:9dc04ba91fc8583344e57c1f1ed5b2c97ecaaf47480011b92fbeab8dda96db75 \ + --hash=sha256:b70f887fb269d6e58c349d683f59fa647140c410cfe2bee44a883b20ec92e3dc \ + --hash=sha256:dfcf789064c58dc13c0a4edb550acacfc6f0f280577f1e7a00de3e89fc7f8ddc \ + --hash=sha256:e5ed840f5bd4a3f0272e441d19429b26eedc257abe5574c8546da6b556865e3c + # via docling-core +tree-sitter-python==0.25.0 \ + --hash=sha256:0fbf6a3774ad7e89ee891851204c2e2c47e12b63a5edbe2e9156997731c128bb \ + --hash=sha256:14a79a47ddef72f987d5a2c122d148a812169d7484ff5c75a3db9609d419f361 \ + --hash=sha256:480c21dbd995b7fe44813e741d71fed10ba695e7caab627fb034e3828469d762 \ + --hash=sha256:71959832fc5d9642e52c11f2f7d79ae520b461e63334927e93ca46cd61cd9683 \ + --hash=sha256:86f118e5eecad616ecdb81d171a36dde9bef5a0b21ed71ea9c3e390813c3baf5 \ + --hash=sha256:9bcde33f18792de54ee579b00e1b4fe186b7926825444766f849bf7181793a76 \ + --hash=sha256:b13e090f725f5b9c86aa455a268553c65cadf325471ad5b65cd29cac8a1a68ac \ + --hash=sha256:be71650ca2b93b6e9649e5d65c6811aad87a7614c8c1003246b303f6b150f61b \ + --hash=sha256:e6d5b5799628cc0f24691ab2a172a8e676f668fe90dc60468bee14084a35c16d + # via docling-core +tree-sitter-typescript==0.23.2 \ + --hash=sha256:05db58f70b95ef0ea126db5560f3775692f609589ed6f8dd0af84b7f19f1cbb7 \ + --hash=sha256:3cd752d70d8e5371fdac6a9a4df9d8924b63b6998d268586f7d374c9fba2a478 \ + --hash=sha256:3f730b66396bc3e11811e4465c41ee45d9e9edd6de355a58bbbc49fa770da8f9 \ + --hash=sha256:4b1eed5b0b3a8134e86126b00b743d667ec27c63fc9de1b7bb23168803879e31 \ + --hash=sha256:7b167b5827c882261cb7a50dfa0fb567975f9b315e87ed87ad0a0a3aedb3834d \ + --hash=sha256:8d4f0f9bcb61ad7b7509d49a1565ff2cc363863644a234e1e0fe10960e55aea0 \ + --hash=sha256:c7cc1b0ff5d91bac863b0e38b1578d5505e718156c9db577c8baea2557f66de8 \ + --hash=sha256:e96d36b85bcacdeb8ff5c2618d75593ef12ebaf1b4eace3477e2bdb2abb1752c + # via docling-core +triton==3.7.1 \ + --hash=sha256:10ba85fa2cca4a2fbdeb36bf1cb082f2c252bda55bf9fccd74f65ec5bc647e68 \ + --hash=sha256:2020153b08280415ec0da6607834e79166442147e78e144df06b508c75b186d2 \ + --hash=sha256:3daf64305d6cea88d3334c65ebc9bcd0c64c9564a977084366aa768d57cbcf64 \ + --hash=sha256:58c0e131da05134a2a4788ccbcc0c1105cf0f54c8e98f19e34cd465396dc15eb \ + --hash=sha256:6744957e9fd610a29680ec2346057d0c86948ed3812468670719f391e94b44a5 \ + --hash=sha256:7e40869937a68206ec70d7f25bb7ec6433cb083f9135e1f36dbd318dc449a728 \ + --hash=sha256:9497f2e696ee368862a181a90b2dcc03ca978cc4f602abd67c7d81022a6988e1 \ + --hash=sha256:c58e4c61f0c73b5dba3b5d19b4a7093c32f90dc18b2a7f121a7c16ccd31107b7 \ + --hash=sha256:cdbfc09d9ec58bc5e68321525653220de7515c199e7a8097a97c85e62b52cd0a \ + --hash=sha256:d4a0e1cd4c4a76370ed74a8432a53cea28716827d19e40ffc732233e35ceb3f6 \ + --hash=sha256:ee89fbf782ec2ad50391dd1cf26cbea4f4467154c37f4773026da8fc31c0f58e \ + --hash=sha256:fe4ea396a06171f1f1f58cbd39c70b09294398f7dd7c620939bab54ad6f934fa + # via torch +typer==0.26.8 \ + --hash=sha256:3512ca79ac5c11113414b36e80281b872884477722440691c89d1112e321a49c \ + --hash=sha256:c244a6bd558886fe3f8780efb6bdd28bb9aff005a94eedebaa5cb32926fe2f7e + # via + # agnoctl + # doclang + # docling-core + # docling-slim + # instructor + # spacy + # transformers + # weasel +typing-extensions==4.16.0 \ + --hash=sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8 \ + --hash=sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5 + # via + # agno + # aiohttp + # aiosignal + # anthropic + # anyio + # azure-core + # azure-storage-blob + # beautifulsoup4 + # docling-core + # fastapi + # google-genai + # groq + # grpcio + # huggingface-hub + # ipython + # jupyter-client + # jupyterlab + # librosa + # mypy + # openai + # opentelemetry-api + # opentelemetry-sdk + # opentelemetry-semantic-conventions + # pinecone-client + # polyfactory + # psycopg + # psycopg-pool + # pydantic + # pydantic-core + # pytest-asyncio + # python-docx + # python-oxmsg + # python-pptx + # referencing + # sentence-transformers + # soundfile + # starlette + # torch + # typing-inspection +typing-inspection==0.4.4 \ + --hash=sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47 \ + --hash=sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147 + # via + # fastapi + # pydantic + # pydantic-settings +tzdata==2026.3 \ + --hash=sha256:4a1518b8993086a7982523e071643f3c0e5f213e75b21318e78bcabfff9d1415 \ + --hash=sha256:dc096730c87af6cab1b171c9d532be840741ff5d459015e7f6947bd7d7e54931 + # via + # arrow + # kombu +tzlocal==5.4.4 \ + --hash=sha256:8dbb8660838688a7b6ba4fed31d18dedf842afb4d47ca050d6d891c2c15f3be4 \ + --hash=sha256:aae09f0126a8a86fa736be266eb4a471380d26a0de3bc14844e7821fee3e2a15 + # via celery +umap-learn==0.5.12 \ + --hash=sha256:6aff02ecac5f2aad9f3c65ee518d7ae93e1a985ae38721fdcffceee4232c33c7 \ + --hash=sha256:f2a85d2a2adcb52b541bed9b27a23ca169b56bb1b23283abeebfb8dfb8a42fe5 + # via + # semantica (pyproject.toml) + # bertopic +uri-template==1.3.0 \ + --hash=sha256:0e00f8eb65e18c7de20d595a14336e9f337ead580c70934141624b6d1ffdacc7 \ + --hash=sha256:a44a133ea12d44a0c0f06d7d42a52d71282e77e2f937d8abd5655b8d56fc1363 + # via jsonschema +urllib3==2.7.0 \ + --hash=sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c \ + --hash=sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897 + # via + # botocore + # pinecone-client + # qdrant-client + # requests +uvicorn==0.52.1 \ + --hash=sha256:112ec661814189acbccd3f7b86460147cc065fc92c0821afa78918780e4354dd \ + --hash=sha256:e4403f9d93188cf9d1088e9f40e3acd12630e2df8675316704379a7fc20fff6a + # via semantica (pyproject.toml) +uvloop==0.22.1 \ + --hash=sha256:017bd46f9e7b78e81606329d07141d3da446f8798c6baeec124260e22c262772 \ + --hash=sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e \ + --hash=sha256:05e4b5f86e621cf3927631789999e697e58f0d2d32675b67d9ca9eb0bca55743 \ + --hash=sha256:0ae676de143db2b2f60a9696d7eca5bb9d0dd6cc3ac3dad59a8ae7e95f9e1b54 \ + --hash=sha256:1489cf791aa7b6e8c8be1c5a080bae3a672791fcb4e9e12249b05862a2ca9cec \ + --hash=sha256:17d4e97258b0172dfa107b89aa1eeba3016f4b1974ce85ca3ef6a66b35cbf659 \ + --hash=sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8 \ + --hash=sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad \ + --hash=sha256:286322a90bea1f9422a470d5d2ad82d38080be0a29c4dd9b3e6384320a4d11e7 \ + --hash=sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35 \ + --hash=sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289 \ + --hash=sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142 \ + --hash=sha256:3b7f102bf3cb1995cfeaee9321105e8f5da76fdb104cdad8986f85461a1b7b77 \ + --hash=sha256:40631b049d5972c6755b06d0bfe8233b1bd9a8a6392d9d1c45c10b6f9e9b2733 \ + --hash=sha256:481c990a7abe2c6f4fc3d98781cc9426ebd7f03a9aaa7eb03d3bfc68ac2a46bd \ + --hash=sha256:4a968a72422a097b09042d5fa2c5c590251ad484acf910a651b4b620acd7f193 \ + --hash=sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74 \ + --hash=sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0 \ + --hash=sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6 \ + --hash=sha256:535cc37b3a04f6cd2c1ef65fa1d370c9a35b6695df735fcff5427323f2cd5473 \ + --hash=sha256:53c85520781d84a4b8b230e24a5af5b0778efdb39142b424990ff1ef7c48ba21 \ + --hash=sha256:55502bc2c653ed2e9692e8c55cb95b397d33f9f2911e929dc97c4d6b26d04242 \ + --hash=sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705 \ + --hash=sha256:56a2d1fae65fd82197cb8c53c367310b3eabe1bbb9fb5a04d28e3e3520e4f702 \ + --hash=sha256:57df59d8b48feb0e613d9b1f5e57b7532e97cbaf0d61f7aa9aa32221e84bc4b6 \ + --hash=sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f \ + --hash=sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e \ + --hash=sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d \ + --hash=sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370 \ + --hash=sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4 \ + --hash=sha256:7cd375a12b71d33d46af85a3343b35d98e8116134ba404bd657b3b1d15988792 \ + --hash=sha256:80eee091fe128e425177fbd82f8635769e2f32ec9daf6468286ec57ec0313efa \ + --hash=sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079 \ + --hash=sha256:a592b043a47ad17911add5fbd087c76716d7c9ccc1d64ec9249ceafd735f03c2 \ + --hash=sha256:ac33ed96229b7790eb729702751c0e93ac5bc3bcf52ae9eccbff30da09194b86 \ + --hash=sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6 \ + --hash=sha256:b45649628d816c030dba3c80f8e2689bab1c89518ed10d426036cdc47874dfc4 \ + --hash=sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3 \ + --hash=sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21 \ + --hash=sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c \ + --hash=sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e \ + --hash=sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25 \ + --hash=sha256:c3e5c6727a57cb6558592a95019e504f605d1c54eb86463ee9f7a2dbd411c820 \ + --hash=sha256:c60ebcd36f7b240b30788554b6f0782454826a0ed765d8430652621b5de674b9 \ + --hash=sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88 \ + --hash=sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2 \ + --hash=sha256:ea721dd3203b809039fcc2983f14608dae82b212288b346e0bfe46ec2fab0b7c \ + --hash=sha256:ef6f0d4cc8a9fa1f6a910230cd53545d9a14479311e87e3cb225495952eb672c \ + --hash=sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42 + # via uvicorn +validators==0.35.0 \ + --hash=sha256:992d6c48a4e77c81f1b4daba10d16c3a9bb0dbb79b3a19ea847ff0928e70497a \ + --hash=sha256:e8c947097eae7892cb3d26868d637f79f47b4a0554bc6b80065dfe5aac3705dd + # via weaviate-client +vine==5.1.0 \ + --hash=sha256:40fdf3c48b2cfe1c38a49e9ae2da6fda88e4794c810050a728bd7413811fb1dc \ + --hash=sha256:8b62e981d35c41049211cf62a0a1242d8c1ee9bd15bb196ce38aefd6799e61e0 + # via + # amqp + # celery + # kombu +virtualenv==21.7.4 \ + --hash=sha256:376ec93cd6aab3044fa395d7db226db38043b7b5748948044b2a87168525e843 \ + --hash=sha256:c9d960c95fa458171e58222a5ccab7465298e4b6559977865e627c4719f1e825 + # via pre-commit +wasabi==1.1.3 \ + --hash=sha256:4bb3008f003809db0c3e28b4daf20906ea871a2bb43f9914197d540f4f2e0878 \ + --hash=sha256:f76e16e8f7e79f8c4c8be49b4024ac725713ab10cd7f19350ad18a8e3f71728c + # via + # spacy + # thinc + # weasel +watchdog==6.0.0 \ + --hash=sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a \ + --hash=sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2 \ + --hash=sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f \ + --hash=sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c \ + --hash=sha256:490ab2ef84f11129844c23fb14ecf30ef3d8a6abafd3754a6f75ca1e6654136c \ + --hash=sha256:6eb11feb5a0d452ee41f824e271ca311a09e250441c262ca2fd7ebcf2461a06c \ + --hash=sha256:6f10cb2d5902447c7d0da897e2c6768bca89174d0c6e1e30abec5421af97a5b0 \ + --hash=sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13 \ + --hash=sha256:76aae96b00ae814b181bb25b1b98076d5fc84e8a53cd8885a318b42b6d3a5134 \ + --hash=sha256:7a0e56874cfbc4b9b05c60c8a1926fedf56324bb08cfbc188969777940aef3aa \ + --hash=sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e \ + --hash=sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379 \ + --hash=sha256:90c8e78f3b94014f7aaae121e6b909674df5b46ec24d6bebc45c44c56729af2a \ + --hash=sha256:9513f27a1a582d9808cf21a07dae516f0fab1cf2d7683a742c498b93eedabb11 \ + --hash=sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282 \ + --hash=sha256:a175f755fc2279e0b7312c0035d52e27211a5bc39719dd529625b1930917345b \ + --hash=sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f \ + --hash=sha256:afd0fe1b2270917c5e23c2a65ce50c2a4abb63daafb0d419fde368e272a76b7c \ + --hash=sha256:bc64ab3bdb6a04d69d4023b29422170b74681784ffb9463ed4870cf2f3e66112 \ + --hash=sha256:bdd4e6f14b8b18c334febb9c4425a878a2ac20efd1e0b231978e7b150f92a948 \ + --hash=sha256:c7ac31a19f4545dd92fc25d200694098f42c9a8e391bc00bdd362c5736dbf881 \ + --hash=sha256:c7c15dda13c4eb00d6fb6fc508b3c0ed88b9d5d374056b239c4ad1611125c860 \ + --hash=sha256:c897ac1b55c5a1461e16dae288d22bb2e412ba9807df8397a635d88f671d36c3 \ + --hash=sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680 \ + --hash=sha256:d1cdb490583ebd691c012b3d6dae011000fe42edb7a82ece80965b42abd61f26 \ + --hash=sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26 \ + --hash=sha256:e6439e374fc012255b4ec786ae3c4bc838cd7309a540e5fe0952d03687d8804e \ + --hash=sha256:e6f0e77c9417e7cd62af82529b10563db3423625c5fce018430b249bf977f9e8 \ + --hash=sha256:e7631a77ffb1f7d2eefa4445ebbee491c720a5661ddf6df3498ebecae5ed375c \ + --hash=sha256:ef810fbf7b781a5a593894e4f439773830bdecb885e6880d957d5b9382a960d2 + # via semantica (pyproject.toml) +watchfiles==1.2.0 \ + --hash=sha256:01859b11fd9fbca670f4d5da00fbac282cfea9bd67a2125d8b2833a3b5617ea9 \ + --hash=sha256:01ea8d66f0693b9b60a6541c8d10263091ca9a9060d242f3c1f3143f9aad2c98 \ + --hash=sha256:027ae72bfdfd254862065d8b3e2a815c6ab9b1853ce41e6648ece84afd34a551 \ + --hash=sha256:03b14855c6f35539e2d95c442ae9530a75762f1e26567152b9ed05f96534a74d \ + --hash=sha256:054dc20fd2e3132b4c3883b4a00d72fd6e1f56fdaf89fccd12e8057d74cd74d7 \ + --hash=sha256:094b9b70103d4e963499bdea001ee3c2697b144cd9ae6218a62c0f89ec9e31db \ + --hash=sha256:0a105bc2283f67e8fbec74253ec2d94925de92ed72c0393f1206bf326b7b7b69 \ + --hash=sha256:0a37faaed405c67e28e6be45a1fa4f206ef5a2860f27c237db9fa30704c38242 \ + --hash=sha256:0c4997d4e4a55f0d02b6cde327322daf3a0400e5df6c6b15948994bf72497925 \ + --hash=sha256:0cb4d80e212f116474a545c21c912b445f16bb0cef9e6a73a498164223e14e2f \ + --hash=sha256:0d191c054d0715c3c95c99df9b8dbf6fd096d8c1e021e8f212e1bd8bc444ccb5 \ + --hash=sha256:0e831a271c035d89789cffc386b6aa1375f39f1cd25eb7ca0997e4970d152fc5 \ + --hash=sha256:10d86db20695afe7997ac9e1717637d6714a8d0220458c33f3d2061f54cec427 \ + --hash=sha256:11743adfa510bfffebe97659fb280182b5c9b238708f667e866f308c3430dc19 \ + --hash=sha256:1bc6195825b7dcd217968bb1f801a60fd4c16e8eeab5bedc7fe917d7d5995ab4 \ + --hash=sha256:204f299afcbd65918ab78dbc52626b0ae45e9d8cef403fdbf33ecf9e40eac66e \ + --hash=sha256:20aa0e708b920bde876a4aa82dc7dd6ebea228a63a67cda6632c2fc87b787efa \ + --hash=sha256:23282a321c8baf9b3a3c4afff673f9fe65eb7fdc2338d765ccad9d3d1916a5ba \ + --hash=sha256:24b2405c0a46738dd9e1cf7135aa5dbdb9d42d024628651b3b13d5117e99f8df \ + --hash=sha256:2581a94056e55d7d0a31a823ea92bf73749c489ca2285bfdc0fbe6b2bb49d50c \ + --hash=sha256:2995c176de7692b86a2e4c58d9ec718f753150a979cb4a754e2b4ffa38e70906 \ + --hash=sha256:2b37d10b5a63bd4d87e18472d80fa525bd670586fae62e5dd580452764879b65 \ + --hash=sha256:2cb93af48550faf1cea04c303107c8b75833de7013e57ce27d3b8d21d8d0f58c \ + --hash=sha256:2d95ddc1eb6914154253d239089900813f6a767e174b8e6a50e7fdacb7e4236c \ + --hash=sha256:3416ff151bb6b5a8d8d11664974fbef4d9305b9b2957839ab5a270468fd8df30 \ + --hash=sha256:3651aa7058595e9cfb75d35dd5ada2bf9f48a5b8a0f3562821d3e210c507e077 \ + --hash=sha256:37a6721cdf3f65dbb13aa9503510ccb4451603ac837e44d265d7992a597e1374 \ + --hash=sha256:41bc1199f7523b3f82843c88cbb979180c949caef0342cf90968f178e5d49b01 \ + --hash=sha256:43d818978d06062d9b22c4fab2ebe44cf5213d42dc8e62bda8c2760cfa2eeb33 \ + --hash=sha256:4429f3b105524a10b72c3a819b091c495d2811d419c1e1e8df773a5a5974f831 \ + --hash=sha256:4543579a9bdb0c9560039b4ffddbdb39545707659fbc430ce4c10f3f68d557f9 \ + --hash=sha256:4674d49eb94706dfe666c069fc0a1b646ffcf920473492e209f6d5f60d3f0cc2 \ + --hash=sha256:4c887eba18b7945ac73067a8b4a66f21cd46c2539b2bc68588f7be6c7eb6d26b \ + --hash=sha256:4e4ff8e37f99cf1da89e255e07c9c4b37c214038c4283707bdec308cb1b0ea1f \ + --hash=sha256:4f34e26a19f91f710c08e0183429f0d1d15df734e6bc78c31e77b9ea9c433658 \ + --hash=sha256:5327989a465505f05cfe06f04fa9d0c2fd5432bb243e10e6f012b1bdca3c8579 \ + --hash=sha256:53b2290c92e0506d102cd448fbc610d87079553f86caa39d67440856a8b8bba5 \ + --hash=sha256:56d8641cf834c2836922899105bd3ce3d0dfc69291d52edf0b4d0436829b34c0 \ + --hash=sha256:57a2d9fa4fb4c2ecae57b13dfff2c7ab53e21a2ba674fe9f05506680fcdcc0d7 \ + --hash=sha256:63ac26eefbf4af1741247d6fb68b11c49a25b2f7413fbd318a83a12aaa9cf666 \ + --hash=sha256:6543cf55d170003296d185c0af981f3e1311564907e1f4e08671fc7693a890a5 \ + --hash=sha256:704fd259e332e01f9b9c178f4bce9e49027e5587cc2600eeeaf8e76e1c846201 \ + --hash=sha256:71283b39fd17e5408eb123bd37aeecfd9d54c81fc184421943208aadb879d103 \ + --hash=sha256:71cd71740ed2c15211ebb237ced4e39a1cdf6f80566e5fe95428da1626f4fde6 \ + --hash=sha256:7571e4464cb6e434958f867f7f730b8ab0b75e3f8e5eac0499168486ab3c33a8 \ + --hash=sha256:772b80df316480d894a0e3165fdd19cf77f5d17f9a787f94029465ad0e3529d1 \ + --hash=sha256:77a0feab9af4c021c581f695258c642b3d10c5fd4c676e33a0d8606425d82631 \ + --hash=sha256:7a2cffd17d27d2ecbb310c2b1d8174f222a5495b1a721894afa88ec11e25b898 \ + --hash=sha256:7a7ce236284f002a156f70add88efe5c70879cccbb658be0822c54b1306fc09d \ + --hash=sha256:7ba0480b9a74af058f43b337e937a451e109295c420916d68ad24e3dc02f5e44 \ + --hash=sha256:8520a4ab0e37f770afc34459c4f8f7019e153f9124dc101c15538365875d1ab2 \ + --hash=sha256:86bc13c25a8d1fcd70b51d0ce7c9b65e90de5666fcbfd3e34957cc73ee19aeb5 \ + --hash=sha256:89d8c2394a065ca86f5d2910ff263ae67c127e1376ccc4f9fc35c71db879f80a \ + --hash=sha256:8c520725602756229f045b032a1ff33d7ef0f7404189d62f6c2438cb6d8ef6a1 \ + --hash=sha256:8f200104103feb097de4cab8fe4f5dd18a2026934c7dea98c55a2f5fd6d5a33b \ + --hash=sha256:8f70d8b291ef6e88d19b1f297a6905ddb978888d9272b0d05e6f53309856bcfc \ + --hash=sha256:8fa585ede612ee9f9e91b18bebf9ba11b9ae29a4e3a0d0cf6fca3e382133f0d5 \ + --hash=sha256:922c0e019fe68b3ae392965a766b02a71ba1168c932cebc3733cd52c5fe5b377 \ + --hash=sha256:9342472aff9b093c5acd4f6d8f70ae0937964ab56542502bcf5579782da69ae8 \ + --hash=sha256:9649193aa27bd9ff2e80ff29bfaa93085496c7a3a377592823cc58b77ee88add \ + --hash=sha256:9f04b092229ad2c50126dd3c922c8822e51e605993764a33058d4a791ab42281 \ + --hash=sha256:a0f27f01bee51861392bb6b7c4fdb290b27d1eb194e9e28788d68102a0e898d9 \ + --hash=sha256:a16ffe19bf5cf9f5edaa1ad1dd830c5a816e8feec430c522302ab55483a4b994 \ + --hash=sha256:a204794696ffb8f9b10fba6f7cb5216d42f3b2b71860ccac6b6e42f5f10973b0 \ + --hash=sha256:a711b51aec4370d0dcda5b6c09463206f133a5759341d7744b953a7b62e1100e \ + --hash=sha256:a88fc94e647bc4eec523f1caa540258eb71d14278b9daf72fa1e2658a98df0f0 \ + --hash=sha256:ae99b14c5f21e026e0e9d96f40e07d8570ebee6cafd9d8fc318354606daa7a28 \ + --hash=sha256:b0ef001f8c25ad0fa9529f914c1600647ecd0f542d11c19b7894768c67b6acb7 \ + --hash=sha256:b141a4891c995a039cd89e9a49e62df1dc8a559a5d1a6e4c7106d16c12777a55 \ + --hash=sha256:b4e77f6a55f858504069abd35d336a637555c09bca453dde1ee1e5ada8a6a1fb \ + --hash=sha256:b62f042afde2dde21ec1d2c1a74361e804673df86f51e418a999c9acfe671b07 \ + --hash=sha256:b718bf356bbc15e559bd8ef41782b573b8ae0e3f177ab244b440568d7ea02cfb \ + --hash=sha256:b8c8358484d5fa12ef34f05b7f4168eaf1932f408725ff6d023c33ec17bd79d4 \ + --hash=sha256:b974946a10af379d425e2eef5b62f5c6ebeaccf91d45eaad6f5b27ecd4f91aa0 \ + --hash=sha256:b9909cc2b48468b575eefa944919e1fe8a36c5849d5c7c168f80a8c1db69398e \ + --hash=sha256:b9f732dc58b2dbe69e464ccf8fff7a03b0dd0be439da4c0720d3558527d3d6b4 \ + --hash=sha256:bb68bf4df85abebe5efddc53cf2075520f243a59868d9b3973278b23e76962a9 \ + --hash=sha256:bb7e52ecf68ba46d22df23467b87cffeb2146908aa523ebfe803019618cfda06 \ + --hash=sha256:bc13eb17538be00c874699dc0abe4ee2bc8d50bb1166a6b9e175ef3fd7eb8f26 \ + --hash=sha256:c0db965c5f79aa49fe672d297cf1febc5ad149b658594944f49a54a2b96270a7 \ + --hash=sha256:c16cb06dd17d43b9d185094268459eac92c9538356f050e55b54e82cf700e1d4 \ + --hash=sha256:c525543d91961c6955b2636b308569e84a1d1c5f5f2932041ab9ef46422f43e3 \ + --hash=sha256:c5c19526f4e54a00f2666a6c0e9e40d582c09e865055ea7378bf0009aab857b3 \ + --hash=sha256:c995fba777f1ea992f090f9236e9284cf7a5d1a0130dd5a3d82c598cacd76838 \ + --hash=sha256:ca148d73dea36c9763aaa351e4d7a51780ec1584217c45276f4fe8239c768b71 \ + --hash=sha256:cee9d5efd929efdac5f7e58f72b3376f676b64050a91c5b99a7094c5b2317488 \ + --hash=sha256:d158cd89df6053823533e06fb1d73c549133bff5f0396170c0e53d9559340717 \ + --hash=sha256:d20029a60a71a052a24c4db7673bc4de39ab89adbaccbfb5d67987c5d73f424d \ + --hash=sha256:d413349d565dab74297f2a63e84a097936be69bf8f3b3801f27f380e32040f44 \ + --hash=sha256:d4a4b147f5dca2a5d325a06a832fb43f345751adfbc63204aec30e0d9ca965a2 \ + --hash=sha256:d516b3283a758e087841aedb8031549fb41ced08f3db10aa6d2bf32dc042525b \ + --hash=sha256:d73a585accffa5ae39c17264c36ec3166d2fad7000c780f5ef83b2722afb9dd2 \ + --hash=sha256:dbd6c97045dad81227c8d040173da044c1de08de64a5ea8b555da4aee1d5fa22 \ + --hash=sha256:e0618518f282c4ebff60f5e5b1247b6d91bb8b9f4476947563a1e74acc66f3c6 \ + --hash=sha256:e140ed30ebde76796b686e67c182cff10ea2fbab186fafd1560f74bb5a473a6e \ + --hash=sha256:e1cfd51e97e13ff3bd047c140764d277fc9b95b7cb5da59e46a47d167adab310 \ + --hash=sha256:e2ca07fa7d89195ec0865d3d285666286740bfa83d83e5cee204043a31ecc165 \ + --hash=sha256:e53a384f76b631c3ae5334ce6a52f0baa3a911eb94a4eac7f160079868b716d5 \ + --hash=sha256:eb283ee99e21ad6443c8cdb06ac5b34b1308c329cbdf03fa02b445363714c799 \ + --hash=sha256:eb72919d93e3a16fc451d3aa3d4b1698423daca1b382d3d959c9ac51297c12a8 \ + --hash=sha256:ecb47f183a8025b2aa18b546725c3657e542112ae9c0613a2af79b4fa8d04ad7 \ + --hash=sha256:f155b3a1b2a5fc89cdc70d47ee5d54e3b75e88efa34982028a35daef9ba00379 \ + --hash=sha256:f22943b7770483f6ea0721c6b11d022947a98eb0acae14694de034f4d0d38925 \ + --hash=sha256:f28b2725eb8cce327b9b3ab02415c853011dc55c95832fe90de6bc56f5315f72 \ + --hash=sha256:f88af53d6ddaf72179ef613ddc905e6f4785f712b49b80b3bef9f3525e6194b4 \ + --hash=sha256:faea288b6f0ab1902ef08f4ca6de005dccf856c4e0c4f21b8c5fce02d90a1b08 \ + --hash=sha256:fff610d7bb2256a317bb1e96f0d7862c7aa8076733ee5df0fd41bbe76a24a4f4 + # via uvicorn +wcwidth==0.8.2 \ + --hash=sha256:91fbef97204b96a3d4d421609b80340b760cf33e26da123ff243d76b1fda8dda \ + --hash=sha256:d63947694a0539a1d51e01eda7caf800c291020e6cdd7e28ad7b14dd33ad4f85 + # via + # prettytable + # prompt-toolkit +weasel==1.0.0 \ + --hash=sha256:7b129b44c90cc543b760532974ca1e4eb30dad2aa2026f57bdce66354ae610fc \ + --hash=sha256:89518acee027f49d743126c3502d35e6dd14f5768be5c37c9af47c171b6005cc + # via spacy +weaviate-client==4.16.2 \ + --hash=sha256:c236adca30d18667943544ad89fcd9157947af95dfc6de4a8ecf9e7619f1c979 \ + --hash=sha256:eb7107a3221a5ad68d604cafc65195bd925a9709512ea0b6fe0dd212b0678fab + # via semantica (pyproject.toml) +webcolors==25.10.0 \ + --hash=sha256:032c727334856fc0b968f63daa252a1ac93d33db2f5267756623c210e57a4f1d \ + --hash=sha256:62abae86504f66d0f6364c2a8520de4a0c47b80c03fc3a5f1815fedbef7c19bf + # via jsonschema +webencodings==0.5.1 \ + --hash=sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78 \ + --hash=sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923 + # via + # bleach + # tinycss2 +websocket-client==1.9.0 \ + --hash=sha256:9e813624b6eb619999a97dc7958469217c3176312b3a16a4bd1bc7e08a46ec98 \ + --hash=sha256:af248a825037ef591efbf6ed20cc5faa03d3b47b9e5a2230a529eeee1c1fc3ef + # via jupyter-server +websockets==16.1.1 \ + --hash=sha256:01fbdcbac298efe19360b94bc0039c8f746f0220ba570f327577bfee81059175 \ + --hash=sha256:024193f8551a2b0eafbdd160911012c4e6c228c28430c84433253299a9e42d6a \ + --hash=sha256:04fd29a0e2fe9414a95b00e92c67ae51bf900c50c0f8a4b2dafdad621f49ea1d \ + --hash=sha256:056ae37939ed7e9974f364f5864e76e49182622d8f9751ac1903c0d09b013985 \ + --hash=sha256:0f62863e8a00a6d33c3d6566ec0b89f23787b747ffe0c3bc71ec0e76b82c94b1 \ + --hash=sha256:0ffd3031ea8bda8d61762e84220186105ba3b748b3c8da2ae4f7816fac03e573 \ + --hash=sha256:1214e673c404684b9bf7154f5cf43b45025b1a6160fac3a9e438e9c1a97e22cb \ + --hash=sha256:125f22dbefaf1554fea66fc83851490edb284ce4f501d37ffed2752f418332d9 \ + --hash=sha256:130937b167a52af203c8d58e78d67705874e82759862e3b9671a452fec4abc87 \ + --hash=sha256:1427fb4cf0d72f66333e2cacc3ff5f575bf2d7008166ce991a4a470b21d51a22 \ + --hash=sha256:195c978b065fa40910582464f99d6b15c8b314c68e0546549a55ed83f4735328 \ + --hash=sha256:1d27fa8462ad6a1cb36206a3d0640b2333340def181fae11ed7f9adeaa5c0747 \ + --hash=sha256:1db4de4a0e95673f7545d393c49eeb0c2f18ac1ef93073218c79d5cdb2ee75ab \ + --hash=sha256:1f79c89b5eb034d1722938a891916582f8f7f503f58ca22518a63c3f2cd18499 \ + --hash=sha256:23253dd5bcae3f9aaee0a1d30967a8dbd52e5d3cff93a2e5b84df57b77d4750d \ + --hash=sha256:249116b4a76063d930a46391ad56e135c286e4562a18309029fc2c73f4ed4c62 \ + --hash=sha256:29dfa8114c4a620c69591c5973860f768eac29d3fd6904f37f34266cb219c512 \ + --hash=sha256:2a606d9c24035242a3e256e9d5b77ed9cd6bccfcb7cf993e5ca3c0f6f68fb6a7 \ + --hash=sha256:2a636ff1e7a5c4edf71ef0e79adae7f25dba93b4fcbe3dc958733477ffeb0eaf \ + --hash=sha256:2bb5d041a8307d2e18782e7ce777f6fdb1e8c2f5d09291484b18c294b789d9aa \ + --hash=sha256:2e28e602bb13da44fbe518c1781a88e3b9d4c3d48d02c9bad83e546164336f57 \ + --hash=sha256:30bbe120437b5648a77d3519b7024ea09530e0b5b18d3698c5a0ae536fe0cc2e \ + --hash=sha256:34420aaa64440ebd51ac72ca8a45ef4626429438c9b02e633ae412ed43f925d3 \ + --hash=sha256:38565aca3e01ea8734e578fb2118dade0ecb0250533f29e22b8d1a7a196cf4d0 \ + --hash=sha256:387e8e4aa5df2f90b198fa3cad3478822a89cf905b6a6d6c97dc3664689640cc \ + --hash=sha256:39f2a024af5c345ffe8fcf1ee18c049c024c94df393bb09b044a6917c77bde43 \ + --hash=sha256:3df13f73af9b3b38ab1195eb299ecb67a4330c911c97ae04043ff74085728abe \ + --hash=sha256:414e596c75f74e0994084694189d7dc9229fb278e33064d6784b73ffbba3ca31 \ + --hash=sha256:41c8e77f17294c0ac18008a7309b99b34ee72247ef10b6dff4c3f8b5ac29896b \ + --hash=sha256:42290eb6db4ccaca7012656738214f8514082fb6fa40cdeb61bb9a471b52e383 \ + --hash=sha256:42f599f4d48c7e1a3338fdaac3acd075be3b3cf02d4b274f3bf2767aedd3d217 \ + --hash=sha256:43e3a9fdd7cbf7ba6040c31fae0faf84ca1474fef777c4e37912f1540f854499 \ + --hash=sha256:443aefe96b7fdb132e2a70806cca1f2af49bb3f28e47abcd7c2e9dcf4d8fa1b8 \ + --hash=sha256:46dcaa042cd1de6c59e7d9269fa63ff7572b6df40510600b678f0826b3c7af51 \ + --hash=sha256:496af849a472b531f758dbd4d61338f5000538cb1a7b3d20d9d32a264517f509 \ + --hash=sha256:49ae99bdfcae803a885c926bf14f886196e84925395bb3f568fef5c0f0979d7d \ + --hash=sha256:4b57693728576d84ede0a77987ab16881b783d2cd9f1dc180a8fbbc3f79c4428 \ + --hash=sha256:4e3b680b1e0a27457e727a0d572fd81dffa87b6dbf8b228ab57da64f7d85aead \ + --hash=sha256:4e8d01cc3bcae7bbf8167f944aeafefed590fae5693552bba9794a9df68371cc \ + --hash=sha256:5283810d2646741a0d8da2aa733d6aefa0545809afccb2a5d105a26bc45125f1 \ + --hash=sha256:53260c8930da5771cec89439bff99c20c8cb03ddb9588b980697355a83cd4bd3 \ + --hash=sha256:536676848fc5961aca9d20389951f59169508f765637a172403dc5434d722fa0 \ + --hash=sha256:54509b8e92fee4453e152b7558ddef37ce9705a044922f2095a6105e3f80c96f \ + --hash=sha256:56cd5fc4f10a9ea8aa0804bddb7b42506cf9e136046f3b4c27de8fec9e2ecba5 \ + --hash=sha256:5bfd1ac19b1b9986a9c95a82d5e23a391ebb09e12c34d7be6094b86efcc35731 \ + --hash=sha256:5c31aa7e39ee3e8a358573257f1c0bb5c52430d1b637030dd9c8cc2c282926be \ + --hash=sha256:5e3b7d601f6f84156b08cc4a5e541c2b50ad7b36cfc302b657a12477c904a5df \ + --hash=sha256:61922544a0587a13fd3f53e4c0e5e606510c7b0d9d22c8444e5fae22a06b38cb \ + --hash=sha256:6456ff333092d509127d75a638cb411afae8ff17f092635015d1902efec8a293 \ + --hash=sha256:69159730a823dde3ea8d08783e8d47ef135a6d7e8d44eb127e32b321c9db8e3e \ + --hash=sha256:69e52d175a0a7d1e13b4b67ad41c560b7d98e8c6f6126eb0bda496c784faf8c7 \ + --hash=sha256:6aaface73b9c71974c6497366d8b9628357f6c9749e09c4ea3610176c63f2ae3 \ + --hash=sha256:6abbd3e82c731c8e531714466acd5d87b5e88ac3243465337ba71d68e23ae7e3 \ + --hash=sha256:6ff9417c0ada4d0f7d212f928303e5579bdf3ace4c802fa4afabb30995da58c3 \ + --hash=sha256:7421fad442de870a8cbf2287d1cad7e706ece0dbfeba5e911df132cbdc1cb56a \ + --hash=sha256:7883388947767080f094950b342b30d35a2a06b849cd967c422fa0db72b40ea9 \ + --hash=sha256:79eace538c6a97e96d0d03d4f9d314f9677f5ed85a8a984992ffd90b13cb8a56 \ + --hash=sha256:7b1b19636af86a3c7995d4d028dbe376f39b4bf31541146f9c123582a6c94562 \ + --hash=sha256:7dfcad78ea1492ee3a9ec765cb7f51bbc17d477107aaf6b22abf7b2558d1c5a0 \ + --hash=sha256:8087e82f842609734c9b5a1330464f8e94e346ba0e18c832c08bafa4b0d63c15 \ + --hash=sha256:820fb8450edddae3812fd58cbc08e2bf22812cb248ecb5f06dbb82119a56e869 \ + --hash=sha256:8483c2096363120eea8b07c06ae7304d520f686665fffd4811fad423930a65d7 \ + --hash=sha256:84a2cef8deffbd9ab8ee0ea546a2a6a7030c28f44e6cdd4547dbfeb489eb8999 \ + --hash=sha256:86d7f0f8bdb25d2c632b72527325e4776430fd5bc61b9118de4e2b8ddb5f5b01 \ + --hash=sha256:8fe0b50da2d84535fb4f7b4bfa951280f97ce3d558a0443b541166d609e67b57 \ + --hash=sha256:90001d893bc368e302ef168d82130b4e4fdd27b85fa094682df9b667c2d48838 \ + --hash=sha256:9246a0d063cfcbcc85f2359dd6876d681213f4790832272aa16641b4ed5d64d4 \ + --hash=sha256:92b820d345f7a3fc7b8163949ee92df910f290c3fc517b3d5301c78065adafe1 \ + --hash=sha256:952303a7318d4cbe1011400839bb2051c9f84fa0a35923267f5daba34b15d458 \ + --hash=sha256:97fd3a0e8b53efa41970ac1dff3d8cf0d2884cadeb4caaf95db7ad1526926ee3 \ + --hash=sha256:9c1c5705e314449e3308872fe084b8571ce078ee4fc55a98a769bdefe5917392 \ + --hash=sha256:9c9f23004a3d40e89c01a7955d186a6cc83418d93b749701944ce2de3e95a1f3 \ + --hash=sha256:9f63bcef7f4b02b06b35fc01c93b96c43b5e88e1e8868676caacf493d5a31f3a \ + --hash=sha256:a0eadbbf2c30f01efa58e1f110eb6fa293261f6b0b1aa38f7f48707107690af9 \ + --hash=sha256:a28fcbc9b6baf54a2e23f8655f308e4ccc6afdd7266f8fe7954f320dcda0f785 \ + --hash=sha256:a6a61aff018180c9c50b7b0da33bfd29d378af3497429c95006c589a23a11648 \ + --hash=sha256:aabe464bfd13bd25f4821faf111da6fefdc389f870265a53105580e45b0a2e49 \ + --hash=sha256:ab59169ace05dcb49a1d4118f0bde139557adf45091bd85747e36bf5de984dd1 \ + --hash=sha256:b436f6ec4fc3a6b4237c84d3f83170ed2b40bb584222f0ac47a0c8a5921980c7 \ + --hash=sha256:b6b9dadbef0cccd9f4c4ee96b08898afa73e26803bbe0f6aeb5bb12b0074206d \ + --hash=sha256:b852788aa51764e2d8e4cf5493d559326bcae5e38d16ba25ffa322b034df272a \ + --hash=sha256:bae954c382e013d5ea5b190d2830526bfa45ad121c326da0049b8c769f185db6 \ + --hash=sha256:bcce07e23e5769375158f5efdcdafa8d5cd014b93c6683865b840ed65b96f231 \ + --hash=sha256:cc97814dfb786a83b6e2dc2e79351e1b83e6d715647d6887fcabd83026417a00 \ + --hash=sha256:cd2ca96a082a36964aca83e992f72abeb61b7306c1a6cba4c7d06a7b93750cac \ + --hash=sha256:cfb70b4eb56cac4da0a83588f3ad50d46beb0690391082f3d4e2d488c70b68ea \ + --hash=sha256:d0fcf657e9f13ff4b177960ab2200237b12994232dfb6df16f1cfe1d4339f93c \ + --hash=sha256:d14bfb217eb4701e850f1525c9d29d79c44794cdf1c299ead25f39f8c78dea81 \ + --hash=sha256:d57685547e0060cc6fd90ee6a28405d6bd395e525545f13c8d7cd99c78afd79f \ + --hash=sha256:d6bec75c290fe484a8ba4cacdf838501e17c06ecfbbf31eede81a9e431bd7751 \ + --hash=sha256:d9531d9cbeac99af6f038fb1bc351403531f7d634a2c2e10e2f7c854c6ed5b68 \ + --hash=sha256:da4ca1a9d72f9030b3146b8d7022719a9f3d478f61efe6f7dd51d243f61c51b2 \ + --hash=sha256:dab9eb87869da2d6ed3af3f3adf28414baae6ec9d4df355ffc18889132f3436c \ + --hash=sha256:db234eda965dcce15df96bb9709f587cd87d4d52aaf0e80e2f34ec04c7670c57 \ + --hash=sha256:dc0fad4933f427acd5b1cec210f3ea6dce7089e1724e4b9ec6ef47c6c04d1b3b \ + --hash=sha256:dc385593a42e31cd6fb60c19f0ecb015b386603818fc2c6c274fb42bd2bb4165 \ + --hash=sha256:dcc04fedf83effaeb9cce98abc9469bb1b42ef85f03e01c8c1f4438ef7555737 \ + --hash=sha256:e047dc87ef7ca50f4d309bf775ad4a71711c58556d75d7bd0604b2317f43e94b \ + --hash=sha256:e09f753a169951eb4f28c2c774f71069304f66e7277e0f5a2892423599cfa854 \ + --hash=sha256:ed5bb271084b46530ee2ddc0410537a9961152c5ccba2fc98c5276d992ccba87 \ + --hash=sha256:f0aa4aad3b1b69ad3fd85a0fd0952ec64331c762bd77ec51cc814170873890b2 \ + --hash=sha256:f17dbe07eb3ea7f99e4df9b7e0efefe80fbf30d37a8cc4d561a0aed310bc8847 \ + --hash=sha256:f2769a0344a09e9ccf5b3cce538bc75a51b53eff3275d3896310c8552049195d \ + --hash=sha256:f55f0b01956a094c8587146d9558c91937e78789c333860ffaf35931a6e5dbc4 \ + --hash=sha256:f5d497865f05bb222cab7016c6034542e84e5f29f49c6fd3f4939cda7197b5b8 \ + --hash=sha256:f70541f3104339f59f830522d94ebadb1bf47426287381623443d8bb1cdbf33d \ + --hash=sha256:fb9a0a6dc3d1b3986cb88091b6899f0396651e0f74e2c9766ab8d6ffc3842e29 \ + --hash=sha256:fce6c48559c86d1ac3632ccb1bebc7d5442fbe79bd9bb0e40379ee54be2a4051 \ + --hash=sha256:fd46fff7eb62c24804d234f0051c7a8ea81285ad63e0337d3dcf33ca82aee58a + # via + # semantica (pyproject.toml) + # docling-slim + # google-genai + # uvicorn +widgetsnbextension==4.0.15 \ + --hash=sha256:8156704e4346a571d9ce73b84bee86a29906c9abfd7223b7228a28899ccf3366 \ + --hash=sha256:de8610639996f1567952d763a5a41af8af37f2575a41f9852a38f947eb82a3b9 + # via ipywidgets +wrapt==2.3.0 \ + --hash=sha256:0a45ffae742ce91a16e11cb6c7cd71e7f9994f3cbd283b962ab093f5c6dcf525 \ + --hash=sha256:0bb2797048db0956348cb3058c33bc4184614f13231389cfbccc16a5d32780a7 \ + --hash=sha256:0d3fb71e65b001adfc42684522eeccd9c21d8ba679945abc993439567b66e59f \ + --hash=sha256:0db083387d6e75ec0be8173ecbf0e811cf60bae1cc75a815feb104167ea10d4d \ + --hash=sha256:10461884b3014fbfc8eb7d09a93c5f246363e6711d9d881f95eb8c27fdef049f \ + --hash=sha256:1236fa25173ca964c97422470482e9011b9e3c7ed0d75798b40b3da3b0e0e760 \ + --hash=sha256:141ed6211286a9660d8d6702de598b43f0934b4f0eda16393f100a80f501d945 \ + --hash=sha256:1598becd30f8f2777d18564064eb4f4dbe1ab0e05a8f09786d0ef505ac782bf3 \ + --hash=sha256:195b1842b4122fb54e3cd3dd5b2b4aa49302a5a61da901df0481f5c97aedde84 \ + --hash=sha256:1d6159c9b2fefec02314e1332dbbbfaf960e369dfd26bcf7f8b258b5732065b3 \ + --hash=sha256:22cc5c0a717bd4da87018ae0bffd4c19c6fb679d3ff357216ba566ab26c76cab \ + --hash=sha256:242b60c21e30866e6a2fa606c612b47c553fa60c0eaeeeb7797fb842ac0ce609 \ + --hash=sha256:24da48596326ef8e448cfa837b454f638713d3531262375f00e5a9681682fc07 \ + --hash=sha256:261f53870cd4fb2bf38f9f972c56c728fd224cb7c65721307de59d9e7e6741ae \ + --hash=sha256:2935d5454b3f179a29b12cf390ee47246740ba2c3a7545b1b46ba31a5f2a4a0b \ + --hash=sha256:2e49885a62ec4ee854d1b9e6371fda6afd219917225752abf729a3f36d4df9a5 \ + --hash=sha256:379f670f45b7bb8993edd9f6fc36c6cc65edb81cffa0b504be34acb0303fff0a \ + --hash=sha256:3873c3c5ca9f4ef91f693602eca19d1f1e7c410338df82a4ff11d826b5896a8f \ + --hash=sha256:392158c9a7f2ab1b8699418bfc0fe6f83548788c418b27d7bf2019ad3405cebb \ + --hash=sha256:39febbee6d77301d31da6996b152ce52452da7c7ef72aba10c2fa976dff9c295 \ + --hash=sha256:3d1c2c1b808600d2ea808e6360910a60ed5f409a4011655e10f9164ba0a414a6 \ + --hash=sha256:3da470536bf9645143323dd41b32db55c6f4304ad382094c1a1da8a92061e10d \ + --hash=sha256:418f54bb09d1762db02c7009b4051149893af3153a87f92d70356703c11eea02 \ + --hash=sha256:42869085687f0aefd57c0f636c3f9354f8ffb321a8ba9cb52d19beb796e561c5 \ + --hash=sha256:45c9279b373d15649dfa2c2077cb3408ea1a6d3125afbdab9d6b809a66f68e14 \ + --hash=sha256:4fa0df3bff4e7ce45759f33fd39335fe2f60477bb9ecf7b8aa41e7d07ee36a23 \ + --hash=sha256:50f416b74d092bb9f41b424e90dd457f365f7ba4b11de62a23679769a21bd85c \ + --hash=sha256:51a7a4181c1295774812271fbcd7c909df372bc25579d4ed9eb875caaf0ae86f \ + --hash=sha256:54ca1d5573f69b5fe1d74f1f65799c68015e82f685efec9fd8cfa40a094c44d0 \ + --hash=sha256:5ab559e1b2551d23d54db2a0001c6d73bad022a254639561c5f6c382a9d6c2fe \ + --hash=sha256:5ba1e5e08ddc46130e9682b2c249f2d1dd39bda9106ed4bd401b7519f18f41bd \ + --hash=sha256:5d221a6e6ddd302b8397433184e96b59f259f50024b854db1c411a881586b6b8 \ + --hash=sha256:6208f302f110295d64b22a7ac96500c791bf492dce4366e622e4912b077c9687 \ + --hash=sha256:626b69db2021aa01671ec7bbc9740e558522bd44c18cf2ce69bf3d666a014109 \ + --hash=sha256:628f3ba8ec793a5b10a6cd8c6c6b7b55eb552abd1f3bd301336acb74c7a82dfe \ + --hash=sha256:629d73378082c00a8173031f9fb30a3ac6abbc894a5bfdfae71fabc60642d501 \ + --hash=sha256:646d20d413ffcd1b0a2f700076e2d0252d872dcb7754860a73e45a59ea883614 \ + --hash=sha256:67bfe2485f50368c3fcd2275fc1fd100e350d601e0058921a7c82678a465aeab \ + --hash=sha256:681a2d0eefd721998f90642762b8e75c2159ec531b20ad5e437245ea7b06a107 \ + --hash=sha256:69e477046f2237ef0bc6547544ee73008dc764ca26eff44f09e976d221b34d5d \ + --hash=sha256:6db604ef0c67bdb2042ecdfd7b7f037cf09733557ca42360d1018285634f7b98 \ + --hash=sha256:729126e667da34d251b8ebf8a45ef0c5ddadc21542b3d6e1abf4259ece6508df \ + --hash=sha256:73d0b10b64620a2cf4bc3d31775c4d9527e309a5549e4379e3bf71e8d2dc193e \ + --hash=sha256:7ebb274aba688b043429eb1500ff8a76ce0cb8ac0812ca3e301f06247b8722b3 \ + --hash=sha256:8159ec0b0cb7608175eb150de94c19e34f4d47ac655f5ca9baf45df6b688ffd3 \ + --hash=sha256:816877aa749253149f9ecfd2635d4d948ecfa338e1a0311d187b1acb1bb8a3eb \ + --hash=sha256:85de890ff968196e92dd1ae73a9fb8970495e7650a457b1c9ef0ac3dd550bce2 \ + --hash=sha256:8f8a1c6472675956cece9a8f403f43c3594f1681319eed2dd56f60877397c636 \ + --hash=sha256:9045917809c63fdf7abe3a2ceaed3d670b8ee4500ddd9291192d30aeb34467c5 \ + --hash=sha256:932dced0a7b2950ed58a3325536a1dcb7b58e7330af54e8552d2e566b5328b99 \ + --hash=sha256:93513bec052c6cd987f9f580c3df068c8bc4ebae6543736be3ca7ec5959cafcd \ + --hash=sha256:9790ea25190a4e0fe4cdf4eeb868e9d75f8a024a70a5b6bf9c348a3a2b72e731 \ + --hash=sha256:9f5d2aec29dfc76c37e23897dee92766a3fd4f3bff3ae7fc9c6b4bf37d8c1360 \ + --hash=sha256:a65e8db2b4e90c2e7ade931086351c98ef420bf7a94ee08c95ac8a3cbbc43579 \ + --hash=sha256:a6b5984cd65dd639546f0eb4b8eacf1c31cb2fe9fb5c27bffe240987cdb2cf84 \ + --hash=sha256:a6e19531ae33c508cea7d84a7edfda01fa86e51b8d1a93a77712c55e6e469152 \ + --hash=sha256:abc71504669d126d91f89fc0e388c6295d8fbd2439be884f175133fda8aa403c \ + --hash=sha256:ac870cc97b73bb00ac353329e9559a4bebc47c4c86792ed9b23b58c15b6ad838 \ + --hash=sha256:ad71df7a04dd3497e9302e81f4a7c91bd401ea0e15a9df9029527900f94bee43 \ + --hash=sha256:b1e5aa486e269b00ed35e64771c7d0ab8096cfd2643405ca8cd60ebedc099a51 \ + --hash=sha256:b4fc96b159af0a3e0faa72475a69d66292bea72a5bed1e1aca1bffbddc3cb2b0 \ + --hash=sha256:b767a9566f165dd14decf8f4194c6bb0ce3a8420cec213824e05a99400c9260a \ + --hash=sha256:bff9a671bc00709cab5a7f745c592b5671873449db0ee2a569af994f16b29a4d \ + --hash=sha256:c3b476ae63b4a3b4da681aafcb25ff3542d289fbda8b5da7caf76aaffafafdbb \ + --hash=sha256:c4bded758ad6f03b965830944a2f0bc5b2eb3767fe5a7310134315d1a6610e98 \ + --hash=sha256:c8388ba7faf5dbf9ee106bb70d66f257629b1bd98091123e19e8a4553a319199 \ + --hash=sha256:c8858d8ff9822a081e3cc49ae1b3b22f0f789c14001cdac8f94564010d9c9d66 \ + --hash=sha256:c88abcf53daef80e01a75c7530e727fa6e2c1888fe83e3dcdba4c96216a1f5c7 \ + --hash=sha256:cc2cea812e5cb179a796b766747e7d3b21088760d8deb95676d482b8c8e6fa7d \ + --hash=sha256:cd3a2edf0427013736b8127955cec62608c56e53ea47e82812ea32059cda407f \ + --hash=sha256:cdc021cb0b62471d6aac7f2bd92f3b4658073775f9ee7fcd325c511129e7bcc8 \ + --hash=sha256:ce9f398f868d2b3b27aa2ea4de79645ef9077aeeac8dfc2814b0d542c6a2b87f \ + --hash=sha256:d0077f3d65541925fa83002f967b22ad6550d24813ac64cb905f717194128d9c \ + --hash=sha256:d0f7284f88f4833705132d06d3b425a43095c2cbd07c58166aac3ab646ba12a4 \ + --hash=sha256:d2cc64539da63e39ffb9c7ede849b6e8ddaaf7b3876b5cfb04efd85a5f3f4eb6 \ + --hash=sha256:d8c7ed08477429752b8c44991f40ad7838b18332a160698740a6bfbc10d998a2 \ + --hash=sha256:df4ce31150bcd5d9f36f816aac3010ab4f4bf8672ac1d3b0ac7d539ec61c7c02 \ + --hash=sha256:e045ff75d7d94900fc32896ed93c45ce2d2cac28c9dead582ff9a5a49d446e35 \ + --hash=sha256:e2e692bc0d63f881cf7006730a56bd4e0c2fab5dc318466942805d692b166276 \ + --hash=sha256:e31734c5077f29f892b2565eee5106d610278151ad49fc6a9d69a647cd5730e2 \ + --hash=sha256:e3b9eaa742ae7a0aaaaad4ca4b69469d757af2d6e6663ef1dadc47adec0aeb41 \ + --hash=sha256:e3f3d7ec0a51fbfe00d3aef047641ff2c58b25565b4717fc1f90e050be01cba8 \ + --hash=sha256:e5301c35cf75655eb33498f2bd6ae8703ca19940e3167dc9cdf740c712a39c60 \ + --hash=sha256:ea52a0d0f08c584943d5764be0e84efa912c8da23c23e1e285ff2f5641c18fcc \ + --hash=sha256:ed635a9ca4f3a5a2b900c10c69e823373bc00ebc114b459383596d3487da3570 \ + --hash=sha256:fb8e2e6704a1e0b1b989546c69e2688371ef4a07fa5f61bde3eb6211186f5ac1 \ + --hash=sha256:fc648a335d7e01adb3640b25f02fd0ea05886cf04d0af7f4ee902bc7b5e466e8 \ + --hash=sha256:fc82c2ccc8e234c844f5303d9f2984b346dcdd53e94823ce8420d2c75b4b9023 \ + --hash=sha256:fd1f2f557dd3491fe75905e578f4db967393d40d1a8f468edc4d40ac7f2d5944 \ + --hash=sha256:fd85b0aa88efdb189d6ae2f35f4526943a8f091c38599c9c31478241c819e6a1 + # via + # opentelemetry-instrumentation + # smart-open +xlsxwriter==3.2.9 \ + --hash=sha256:254b1c37a368c444eac6e2f867405cc9e461b0ed97a3233b2ac1e574efb4140c \ + --hash=sha256:9a5db42bc5dff014806c58a20b9eae7322a134abb6fce3c92c181bfb275ec5b3 + # via python-pptx +yarl==1.24.5 \ + --hash=sha256:0055afc45e864b92729ac7600e2d102c17bef060647e74bca75fa84d66b9ff36 \ + --hash=sha256:0465ec8cedc2349b97a6b595ace64084a50c6e839eca40aa0626f38b8350e331 \ + --hash=sha256:0ebfaffe1a16cb72141c8e09f18cc76856dbe58639f393a4f2b26e474b96b871 \ + --hash=sha256:16a2f5010280020e90f5330257e6944bc33e73593b136cc5a241e6c1dc292498 \ + --hash=sha256:17f57620f5475b3c69109376cc87e42a7af5db13c9398e4292772a706ff10780 \ + --hash=sha256:2120b96872df4a117cde97d270bac96aea7cc52205d305cf4611df694a487027 \ + --hash=sha256:240cbec09667c1fed4c6cd0060b9ec57332427d7441289a2ed8875dc9fb2b224 \ + --hash=sha256:24e861e9630e0daddcb9191fb187f60f034e17a4426f8101279f0c475cd74144 \ + --hash=sha256:2729fcfc4f6a596fb0c50f32090400aa9367774ac296a00387e65098c0befa76 \ + --hash=sha256:2c1fe720934a16ea8e7146175cba2126f87f54912c8c5435e7f7c7a51ef808d3 \ + --hash=sha256:2cabe6546e41dabe439999a23fcb5246e0c3b595b4315b96ef755252be90caeb \ + --hash=sha256:2dbe06fc16bc91502bca713704022182e5729861ae00277c3a23354b40929740 \ + --hash=sha256:3363fcc96e665878946ad7a106b9a13eac0541766a690ef287c0232ac768b6ec \ + --hash=sha256:377fe3732edbaf78ee74efdf2c9f49f6e99f20e7f9d2649fda3eb4badd77d76e \ + --hash=sha256:3ac6aff147deb9c09461b2d4bbdf6256831198f5d8a23f5d37138213090b6d8a \ + --hash=sha256:3f45789ce415a7ec0820dc4f82925f9b5f7732070be1dec1f5f23ec381435a24 \ + --hash=sha256:4103b77b8a8225e413107d2349b65eb3c1c52627b5cc5c3c4c1c6a798b218950 \ + --hash=sha256:4377407001ca3c057773f44d8ddd6358fa5f691407c1ba92210bd3cf8d9e4c95 \ + --hash=sha256:46c2f213e23a04b93a392942d782eb9e413e6ef6bf7c8c53884e599a5c174dcb \ + --hash=sha256:47e98aab9d8d82ff682e7b0b5dded33bf138a32b817fcf7fa3b27b2d7c412928 \ + --hash=sha256:4a36f9becdd4c5c52a20c3e9484128b070b1dcfc8944c006f3a528295a359a9c \ + --hash=sha256:4af7b7e1be0a69bee8210735fe6dcfc38879adfac6d62e789d53ba432d1ffa41 \ + --hash=sha256:4d97a951a81039050e45f04e96689b58b8243fa5e62aa14fe67cb6075300885e \ + --hash=sha256:4db9aecb141cb7a5447171b57aa1ed3a8fee06af40b992ffc31206c0b0121550 \ + --hash=sha256:53e549287ef628fecba270045c9701b0c564563a9b0577d24a4ec75b8ab8040f \ + --hash=sha256:56b149b22de33b23b0c6077ab9518c6dcb538ad462e1830e68d06591ccf6e38b \ + --hash=sha256:570fec8fbd22b032733625f03f10b7ff023bc399213db15e72a7acaef28c2f4e \ + --hash=sha256:5b8ee53be440a0cffc991a27be3057e0530122548dbe7c0892df08822fce5ede \ + --hash=sha256:5ba4f78df2bcc19f764a4b26a8a4f5049c110090ad5825993aacb052bf8003ad \ + --hash=sha256:5c55256dee8f4b27bfbf636c8363383c7c8db7890c7cba5217d7bd5f5f21dab6 \ + --hash=sha256:5c88e5815a49d289e599f3513aa7fde0bc2092ff188f99c940f007f90f53d104 \ + --hash=sha256:5fede79c6f73ff2c3ef822864cb1ada23196e62756df53bc6231d351a49516a2 \ + --hash=sha256:65be18ec59496c13908f02a2472751d9ef840b4f3fb5726f129306bf6a2a7bba \ + --hash=sha256:66410eb6345d467151934b49bfa70fb32f5b35a6140baa40ad97d6436abea2e9 \ + --hash=sha256:665b0a2c463cc9423dd647e0bfd9f4ccc9b50f768c55304d5e9f80b177c1de12 \ + --hash=sha256:6b8536851f9f65e7f00c7a1d49ba7f2be0ffe2c11555367fc9f50d9f842410a1 \ + --hash=sha256:6c95b17fe34ed802f17e205112e6e10db92275c34fee290aa9bdc55a9c724027 \ + --hash=sha256:6e73e7fe93f17a7b191f52ec9da9dd8c06a8fe735a1ecbd13b97d1c723bff385 \ + --hash=sha256:6efbccc3d7f75d5b03105172a8dc86d82ba4da86817952529dd93185f4a88be2 \ + --hash=sha256:709f1efed56c4a145793c046cd4939f9959bcd818979a787b77d8e09c57a0840 \ + --hash=sha256:79af890482fc94648e8cde4c68620378f7fef60932710fa17a66abc039244da2 \ + --hash=sha256:7bcbe0fcf850eae67b6b01749815a4f7161c560a844c769ad7b48fcd99f791c4 \ + --hash=sha256:7c0494a31a1ac5461a226e7947a9c9b78c44e1dc7185164fa7e9651557a5d9bc \ + --hash=sha256:7ce27823052e2013b597e0c738b13e7e36b8ccb9400df8959417b052ab0fd92c \ + --hash=sha256:7f72c74aa99359e27a2ee8d6613fefa28b5f76a983c083074dfc2aaa4ab46213 \ + --hash=sha256:7fa5e51397466ea7e98de493fa2ff1b8193cfef8a7b0f9b4842f92d342df0dba \ + --hash=sha256:82632daed195dcc8ea664e8556dc9bdbd671960fb3776bd92806ce05792c2448 \ + --hash=sha256:82f75e05912e84b7a0fe57075d9c59de3cb352b928330f2eb69b2e1f54c3e1f0 \ + --hash=sha256:841f0852f48fefea3b12c9dfec00704dfa3aef5215d0e3ce564bb3d7cd8d57c6 \ + --hash=sha256:874019bd513008b009f58657134e5d0c5e030b3559bd0553976837adf52fe966 \ + --hash=sha256:88f50c94e21a0a7f14042c015b0eba1881af78562e7bf007e0033e624da59750 \ + --hash=sha256:89a1bbb58e0e3f7a283653d854b1e95d65e5cfd4af224dac5f02629ec1a3e621 \ + --hash=sha256:8a6987eaad834cb32dd57d9d582225f0054a5d1af706ccfbbdba735af4927e13 \ + --hash=sha256:8ac73abdc7ab75610f95a8fd994c6457e87752b02a63987e188f937a1fc180f0 \ + --hash=sha256:8ccf9aca873b767977c73df497a85dbedee4ee086ae9ae49dc461333b9b79f58 \ + --hash=sha256:90333fd89b43c0d08ac85f3f1447593fc2c66de18c3d6378d7125ea118dc7a54 \ + --hash=sha256:92ab3e11448f2ff7bf53c5a26eff0edc086898ec8b21fb154b85839ce1d88075 \ + --hash=sha256:9335a099ad87287c37fe5d1a982ff392fa5efe5d14b40a730b1ec1d6a41382b4 \ + --hash=sha256:96d30286dd02679e32a39aa8f0b7498fc847fcda46cfc09df5513e82ce252440 \ + --hash=sha256:9baafc71b04f8f4bb0703b21d6fc9f0c30b346c636a532ff16ec8491a5ea4b1f \ + --hash=sha256:9d1216a7f6f77836617dba35687c5b78a4170afc3c3f18fc788f785ba26565c4 \ + --hash=sha256:9d399bdcfb4a0f659b9b3788bbc89babe63d9a6a65aacdf4d4e7065ff2e6316c \ + --hash=sha256:9e4e16c73d717c5cf27626c524d0a2e261ad20e46932b2670f64ad5dde23e26f \ + --hash=sha256:9f4d8cf085a4c6a40fb97ea0f46938a8df43c85d31f9d45e2a8867ea9293790d \ + --hash=sha256:a33700d13d9b7d84fd10947b09ff69fb9a792e519c8cb9764a3ca70baa6c23a7 \ + --hash=sha256:a3732e66413163e72508da9eff9ce9d2846fde51fae45d3605393d3e6cd303e9 \ + --hash=sha256:a4582acf7ef76482f6f511ebaf1946dae7f2e85ec4728b81a678c01df63bd723 \ + --hash=sha256:a61834fb15d81322d872eaafd333838ae7c9cea84067f232656f75965933d047 \ + --hash=sha256:a7cff474ab7cd149765bb784cf6d78b32e18e20473fb7bda860bce98ab58e9da \ + --hash=sha256:a8fe66b8f300da93798025a785a5b90b42f3810dc2b72283ff84a41aaaebc293 \ + --hash=sha256:a929d878fec099030c292803b31e5d5540a7b6a31e6a3cc76cb4685fc2a2f51b \ + --hash=sha256:ad5d8201d310b031e6cd839d9bac2d4e5a01533ce5d3d5b50b7de1ef3af1de61 \ + --hash=sha256:af3aefa655adb5869491fa907e652290386800ae99cc50095cba71e2c6aefdca \ + --hash=sha256:c0ebc836c47a6477e182169c6a476fc691d12b518894bf7dd2572f0d59f1c7ed \ + --hash=sha256:c687ed078e145f5fd53a14854beff320e1d2ab76df03e2009c98f39a0f68f39a \ + --hash=sha256:cbb833ccacdb5519eff9b8b71ee618cc2801c878e77e288775d77c3a2ced858a \ + --hash=sha256:cf139c02f5f23ef6532040a30ff662c00a318c952334f211046b8e60b7f17688 \ + --hash=sha256:d46b86567dd4e248c6c159fcbcdcce01e0a5c8a7cd2334a0fff759d0fa075b16 \ + --hash=sha256:d693396e5aea78db03decd60aec9ece16c9b40ba00a587f089615ff4e718a81d \ + --hash=sha256:d897129df1a22b12aeed2c2c98df0785a2e8e6e0bde87b389491d0025c187077 \ + --hash=sha256:daba5e594f06114e37db186efd2dd916609071e59daca901a0a2e71f02b142ce \ + --hash=sha256:dd625535328fd9882374356269227670189adfcc6a2d90284f323c05862eecbd \ + --hash=sha256:e006d3a974c4ee19512e5f058abedb6eef36a5e553c14812bdeba1758d812e6d \ + --hash=sha256:e1ae548a9d901adca07899a4147a7c826bbcc06239d3ce9a59f57886a28a4c88 \ + --hash=sha256:e2935f8c39e3b03e83519292d78f075189978f3f4adc15a78144c7c8e2a1cba5 \ + --hash=sha256:e42d75862735da90e7fc5a7b23db0c976f737113a54b3c9777a9b665e9cbff75 \ + --hash=sha256:e7d42c531243450ef0d4d9c172e7ed6ef052640f195629065041b5add4e058d1 \ + --hash=sha256:e81b83143bee16329c23db3c1b2d82b29892fcbcb849186d2f6e98a5abe9a57f \ + --hash=sha256:e8ffa78582120024f476a611d7befc123cee59e47e8309d470cf667d806e613b \ + --hash=sha256:ebb0ec7f17803063d5aeb982f3b1bd2b2f4e4fae6751226cbd6ba1fcfe9e63ff \ + --hash=sha256:f08c7513ecef5aad65687bfdf6bc601ae9fccd04a42904501f8f7141abad9eb9 \ + --hash=sha256:f0a658a6d3fafee5c6f63c58f3e785c8c43c93fbc02bf9f2b6663f8185e0971f \ + --hash=sha256:f0e466ed7511fe9d459a819edbc6c2585c0b6eabde9fa8a8947552468a7a6ef0 \ + --hash=sha256:f141474e85b7e54998ec5180530a7cda99ab29e282fa50e0756d89981a9b43c5 \ + --hash=sha256:f4239bbec5a3577ddb49e4b50aeb32d8e5792098262ae2f63723f916a29b1a25 \ + --hash=sha256:f540c013589084679a6c7fac07096b10159737918174f5dfc5e11bf5bca4dfe6 \ + --hash=sha256:f9f3e9c8a9ecffa57bef8fb4fa19e5fa4d2d8307cf6bac5b1fca5e5860f4ba00 \ + --hash=sha256:fa139875ff98ab97da323cfadfaff08900d1ad42f1b5087b0b812a55c5a06373 \ + --hash=sha256:fcd3b77e2f17bbe4ca56ec7bcb07992647d19d0b9c05d84886dcd6f9eb810afd \ + --hash=sha256:fd8c81f346b58f45818d09ea11db69a8d5fd34a224b79871f6d44f12cd7977b1 \ + --hash=sha256:fe7b7bb170daccbba19ad33012d2b15f1e7942296fd4d45fc1b79013da8cc0f2 \ + --hash=sha256:ff330d3c30db4eb6b01d79e29d2d0b407a7ecad39cfd9ec993ece57396a2ec0d \ + --hash=sha256:ff405d91509d88e8d44129cd87b18d70acd1f0c1aeabd7bc3c46792b1fe2acba \ + --hash=sha256:ffcd54362564dc1a30fb74d8b8a6e5a6b11ebd5e27266adc3b7427a21a6c9104 + # via aiohttp +zipp==4.1.0 \ + --hash=sha256:25ad4e16390cd314347dd8f1de67a2ac538ae658ed4ab9db16029c07c188e97f \ + --hash=sha256:4cb57381f544315db7688e976e922a2b18cdb513d21cc194eb42232ba2a3e602 + # via importlib-metadata From 42afc06003a60c2b3c7972ed9ecaa0b89e54ca6d Mon Sep 17 00:00:00 2001 From: Zohaib Hassnain <109234410+ZohaibHassan16@users.noreply.github.com> Date: Fri, 14 Aug 2026 22:18:46 +0500 Subject: [PATCH 041/105] ci: refresh github/codeql-action pin to current v4 (#986) The pin was 5595ccaf..., but upstream has since moved the v4 tag to ff2f1c62.... The Verify Action Pins workflow flags this drift on every PR that touches any workflow file, regardless of whether that PR changed codeql.yml or defender-for-devops.yml. Verified the new SHA against the GitHub API directly (not just the CI error text) and confirmed .github/scripts/verify-action-pins.sh passes clean locally (40/40 action references OK, exit 0). --- .github/workflows/codeql.yml | 12 ++++++------ .github/workflows/defender-for-devops.yml | 4 ++-- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 9aedaf40..0c15e84c 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -32,7 +32,7 @@ jobs: # meaningful state carried over from a failed attempt. - name: Initialize CodeQL (attempt 1) id: codeql-init-1 - uses: github/codeql-action/init@5595ccaf912efad79be6eef63a5619ff05969be3 # v4 + uses: github/codeql-action/init@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4 continue-on-error: true with: languages: python @@ -42,7 +42,7 @@ jobs: - name: Initialize CodeQL (attempt 2) id: codeql-init-2 if: steps.codeql-init-1.outcome == 'failure' - uses: github/codeql-action/init@5595ccaf912efad79be6eef63a5619ff05969be3 # v4 + uses: github/codeql-action/init@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4 continue-on-error: true with: languages: python @@ -52,17 +52,17 @@ jobs: - name: Initialize CodeQL (attempt 3) id: codeql-init-3 if: steps.codeql-init-2.outcome == 'failure' - uses: github/codeql-action/init@5595ccaf912efad79be6eef63a5619ff05969be3 # v4 + uses: github/codeql-action/init@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4 with: languages: python queries: security-and-quality config-file: .github/codeql/codeql-config.yml - name: Autobuild - uses: github/codeql-action/autobuild@5595ccaf912efad79be6eef63a5619ff05969be3 # v4 + uses: github/codeql-action/autobuild@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@5595ccaf912efad79be6eef63a5619ff05969be3 # v4 + uses: github/codeql-action/analyze@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4 with: category: "/language:python" upload: false @@ -72,7 +72,7 @@ jobs: # Uploads results only when Default Setup is not active. # If Default Setup is still enabled, this step skips gracefully # instead of failing the workflow with HTTP 409. - uses: github/codeql-action/upload-sarif@5595ccaf912efad79be6eef63a5619ff05969be3 # v4 + uses: github/codeql-action/upload-sarif@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4 with: sarif_file: ${{ steps.codeql.outputs.sarif-output }} category: "/language:python" diff --git a/.github/workflows/defender-for-devops.yml b/.github/workflows/defender-for-devops.yml index c561dc17..becb7d64 100644 --- a/.github/workflows/defender-for-devops.yml +++ b/.github/workflows/defender-for-devops.yml @@ -57,7 +57,7 @@ jobs: # avoiding the guardian.cmd/checkov exit-code bug in the MSDO wrapper. tools: eslint,templateanalyzer,terrascan - name: Upload results to Security tab - uses: github/codeql-action/upload-sarif@5595ccaf912efad79be6eef63a5619ff05969be3 # v4 + uses: github/codeql-action/upload-sarif@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4 with: sarif_file: ${{ steps.msdo.outputs.sarifFile }} @@ -82,7 +82,7 @@ jobs: } - name: Upload Checkov results to Security tab - uses: github/codeql-action/upload-sarif@5595ccaf912efad79be6eef63a5619ff05969be3 # v4 + uses: github/codeql-action/upload-sarif@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4 if: always() with: sarif_file: reports/checkov.sarif From c1be6dd7dc0fa06d5dc1e1212fc62a2e91d7c6af Mon Sep 17 00:00:00 2001 From: yzxcj797 <54314860+yzxcj797@users.noreply.github.com> Date: Sat, 15 Aug 2026 03:38:13 +0800 Subject: [PATCH 042/105] docs: fix dead allcontributors emoji-key link (#987) --- CONTRIBUTORS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index 014f0e53..336ece42 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -101,7 +101,7 @@ When using the all-contributors bot, use these codes: - `infra` - Infrastructure - `maintenance` - Maintenance -See [all-contributors specification](https://allcontributors.org/docs/en/emoji-key) for complete list. +See [all-contributors specification](https://github.com/all-contributors/all-contributors#emoji-key) for complete list. --- From 557e29ee14b4716de25c868a9a020fd8c1808328 Mon Sep 17 00:00:00 2001 From: joseedson Date: Sat, 15 Aug 2026 05:18:26 -0300 Subject: [PATCH 043/105] fix(explorer): repair /api/enrich/extract (always 503) and the /api/decisions routes (always 500) (#886) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(explorer): repair /api/enrich/extract and the /api/decisions routes Two Explorer API endpoints fail on every install. /api/enrich/extract imported extract_entities and extract_relations from semantic_extract.methods, where neither name is defined — that module ships only the per-strategy variants (extract_entities_ml, extract_relations_regex, ...), and nothing re-exports a plain facade. The resulting ImportError was caught and reported as "semantic_extract module not available. Ensure spacy and transformers are installed.", so a wiring bug looked like a missing dependency. The route now calls NamedEntityRecognizer and RelationExtractor directly, the classes the README documents, and feeds the extracted entities into relation extraction rather than re-deriving them. The 503 branch stays for a genuinely absent module. Every /api/decisions* route returned 500 once the graph held a decision: record_decision() stores timestamp as datetime.now().timestamp(), a float, while DecisionResponse types the field as str, so pydantic rejected the value the library itself wrote. A before-mode field validator on DecisionResponse normalizes float, int and datetime inputs to ISO-8601, covering every route that builds the model instead of only the list endpoint. The existing tests missed both: test_extract accepted 503 as a pass, and the decision fixtures are hand-built nodes carrying no timestamp at all. Both are tightened, and a TestRecordedDecisions class exercises the routes against decisions created through record_decision(). Co-Authored-By: Claude Opus 5 * perf(semantic_extract): cache spaCy models instead of loading one per call extract_entities_ml(), extract_relations_similarity() and extract_relations_dependency() called spacy.load() on every invocation, so the model was re-read from disk and re-initialized per call. On a short sentence that is ~120 ms of loading around ~2 ms of work, and successive calls never got cheaper. The path is reachable from the CLI, the MCP extract_entities tool, the pipeline ner_extract step and POST /api/enrich/extract, and process_batch() multiplies it by the number of documents. The module already had a cached loader for one code path — get_nlp_model() and its _nlp_cache global — but the extraction functions bypassed it. Adds load_spacy_model(), a process-level cache keyed by model name behind a lock so concurrent callers do not each start a load, and routes the five call sites through it. Errors are left uncached and propagate unchanged, so the existing OSError fallbacks to pattern extraction still fire. get_nlp_model() keeps its own entry: it loads with disable=["parser", "ner", "lemmatizer"] for similarity work, so its model is not interchangeable with the NER one. Cache entries record the spacy module object they came from. Several tests patch methods.spacy with a mock and assert on load calls; without that guard a name-keyed cache would hand a previous test's mock to a later one. Measured on the same sentence, Python 3.12.13 / spacy 3.8.15 / en_core_web_sm: extract_entities_ml() median 132 ms before, 2.1 ms after, identical entities. Co-Authored-By: Claude Opus 5 * fix(explorer): harden extraction and timestamp handling * fix(explorer): catch OverflowError/OSError in decision timestamp validator DecisionResponse._normalize_timestamp only guarded against NaN/inf via math.isfinite(), but datetime.fromtimestamp() raises OverflowError or OSError for finite epoch values outside the platform's representable range (e.g. milliseconds stored where seconds were expected). Those exceptions escaped the pydantic validator unhandled, reintroducing an unhandled 500 on /api/decisions* for exactly the bug class this PR closes. Also exclude bool from the numeric branch, since bool is an int subclass and was being silently coerced to epoch 0/1. * docs: add changelog entry for PR #886 (explorer extract/decisions fixes) Documents the extraction 503, decisions timestamp 500, and folded-in spaCy caching fixes, plus the review-round hardening from Sameer6305 and the timestamp overflow/bool fix from this follow-up commit. --------- Co-authored-by: joseedson18jc Co-authored-by: Claude Opus 5 Co-authored-by: Sameer Kadam Co-authored-by: KaifAhmad1 Co-authored-by: Mohd Kaif <98801504+KaifAhmad1@users.noreply.github.com> --- CHANGELOG.md | 9 + semantica/explorer/routes/enrich.py | 34 +-- semantica/explorer/schemas.py | 39 ++- semantica/semantic_extract/methods.py | 44 +++- tests/explorer/test_explorer_api.py | 225 +++++++++++++++++- .../test_spacy_model_cache.py | 114 +++++++++ 6 files changed, 441 insertions(+), 24 deletions(-) create mode 100644 tests/semantic_extract/test_spacy_model_cache.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 11f89028..bcace0fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,6 +52,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **`POST /api/enrich/extract` returned 503 on every request; the whole `/api/decisions*` family returned 500 as soon as a decision existed** (#886, closes #883, closes #884, closes #889) by @joseedson18jc, reviewed by @Sameer6305 + - `semantica/explorer/routes/enrich.py` imported `extract_entities`/`extract_relations` from `semantica.semantic_extract.methods`, names that module never defined (only per-strategy variants like `extract_entities_ml` exist) — the `except ImportError` handler reported this as `"semantic_extract module not available"`, masking a wiring bug as a missing dependency. The route now calls `NamedEntityRecognizer`/`RelationExtractor` directly and forwards extracted entities into relation extraction instead of re-deriving them + - `ContextGraph.record_decision()` stores `timestamp` as `datetime.now().timestamp()` (a float), while `DecisionResponse.timestamp` was typed `Optional[str]`; passing the value through unconverted failed pydantic validation on every decision route (`/api/decisions`, `/{id}`, `/{id}/chain`, `/{id}/precedents`, `/{id}/compliance`). Added a `field_validator(mode="before")` on `DecisionResponse` normalizing float/int/datetime inputs to ISO-8601 + - Folds in the fix for #889: `extract_entities_ml`/`extract_relations_similarity`/`extract_relations_dependency` called `spacy.load()` on every invocation (~120ms of a ~132ms call, ~60x the actual extraction work). Added a process-level, lock-guarded `load_spacy_model()` cache in `semantic_extract/methods.py`, keyed by model name; failed loads are not cached, and the separate `get_nlp_model()` cache (different `disable=` pipeline config for similarity work) is kept independent to avoid handing one caller's spaCy pipeline to another + - **Fixed during review** (@Sameer6305): capped previously-unbounded input text on `/api/enrich/extract`; tightened the route's exception handling + - **Fixed during review** (@KaifAhmad1): the timestamp validator's `math.isfinite()` guard only rejected NaN/inf — a finite-but-out-of-range epoch (e.g. milliseconds mistakenly stored instead of seconds, such as `1723600000000`) still raised an uncaught `OverflowError`/`OSError` from `datetime.fromtimestamp()`, reintroducing an unhandled 500 on `/api/decisions*` for exactly the class of bug this PR closes. Now caught and re-raised as a `ValueError`. Also excluded `bool` from the numeric branch (`isinstance(True, int)` is `True` in Python, so `timestamp=True` was silently coerced to epoch 1 instead of being rejected) + - New/updated tests: `tests/explorer/test_explorer_api.py` (`TestRecordedDecisions`, extraction coverage, 4 new `TestDecisionResponseTimestampValidator` cases for the range/bool fixes), `tests/semantic_extract/test_spacy_model_cache.py` (6 tests) + - `pytest tests/explorer tests/semantic_extract/test_spacy_model_cache.py`: 266 passed + - **Explorer UI hid backend failures: graph load hung forever, landing page always showed "System Online"** (#980, closes #977) by @ZohaibHassan16, reviewed by @Sameer6305 - `GraphWorkspace.tsx` only destructured `{ data, isLoading, isFetching }` from `useLoadGraph()`, ignoring the `isError`/`error`/`refetch` that `useQuery` (`retry: 0`) already returned. Combined with `GraphLoadingOverlay` having no error prop and `showLoadingOverlay` staying true whenever `loadingProgress` held a stale frame, a backend-down or failed fetch left the graph workspace stuck on the last progress frame indefinitely, with no error message and no way to recover short of a full page reload - `GraphLoadingOverlay` now accepts `error`/`onRetry` and renders an error card with the real fetch error message and a Retry button (`refetch()`) instead of the stuck progress UI diff --git a/semantica/explorer/routes/enrich.py b/semantica/explorer/routes/enrich.py index 699875fa..8e935e75 100644 --- a/semantica/explorer/routes/enrich.py +++ b/semantica/explorer/routes/enrich.py @@ -176,26 +176,30 @@ async def extract_entities( session: GraphSession = Depends(get_session), ): try: - from ...semantic_extract.methods import extract_entities as _extract_entities - from ...semantic_extract.methods import extract_relations as _extract_relations - - entities = await asyncio.to_thread(_extract_entities, body.text) - relations = await asyncio.to_thread(_extract_relations, body.text) - - ent_list = entities if isinstance(entities, list) else getattr(entities, "entities", []) - rel_list = relations if isinstance(relations, list) else getattr(relations, "relations", []) - - return EnrichExtractResponse( - entities=[_safe_dict(entity) for entity in ent_list], - relations=[_safe_dict(relation) for relation in rel_list], - ) + from ...semantic_extract import NamedEntityRecognizer, RelationExtractor except ImportError: raise HTTPException( status_code=503, detail="semantic_extract module not available. Ensure spacy and transformers are installed.", ) - except Exception as exc: - raise HTTPException(status_code=422, detail=f"Extraction failed: {exc}") + + recognizer = NamedEntityRecognizer(confidence_threshold=0.7) + extractor = RelationExtractor(confidence_threshold=0.6) + + entities = await asyncio.to_thread(recognizer.extract_entities, body.text) + + ent_list = entities if isinstance(entities, list) else getattr(entities, "entities", []) + + relations = await asyncio.to_thread( + extractor.extract_relations, body.text, ent_list + ) + + rel_list = relations if isinstance(relations, list) else getattr(relations, "relations", []) + + return EnrichExtractResponse( + entities=[_safe_dict(entity) for entity in ent_list], + relations=[_safe_dict(relation) for relation in rel_list], + ) @router.post("/api/enrich/links", response_model=LinkPredictionResponse) diff --git a/semantica/explorer/schemas.py b/semantica/explorer/schemas.py index 638fa82e..13f2ac3f 100644 --- a/semantica/explorer/schemas.py +++ b/semantica/explorer/schemas.py @@ -2,10 +2,10 @@ Shared Pydantic schemas for the Semantica Knowledge Explorer API. """ -from datetime import datetime +from datetime import datetime, timezone from typing import Any, Dict, List, Literal, Optional, Tuple -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, field_validator class ErrorResponse(BaseModel): @@ -144,6 +144,37 @@ class DecisionResponse(BaseModel): timestamp: Optional[str] = None metadata: Dict[str, Any] = Field(default_factory=dict) + @field_validator("timestamp", mode="before") + @classmethod + def _normalize_timestamp(cls, value: Any) -> Optional[str]: + """Accept the epoch floats ContextGraph.record_decision() writes. + + Decision nodes store ``timestamp`` as ``datetime.now().timestamp()``, a + float, so passing the stored value through unconverted fails validation + and turns every decision route into a 500. Normalize to ISO-8601 here so + the wire format stays a single string type whatever the producer wrote. + """ + if value is None or isinstance(value, str): + return value + if isinstance(value, datetime): + return value.isoformat() + if isinstance(value, (int, float)) and not isinstance(value, bool): + import math + if not math.isfinite(value): + raise ValueError( + f"timestamp must be a finite number, got {value!r}" + ) + try: + return datetime.fromtimestamp(value, tz=timezone.utc).isoformat() + except (OverflowError, OSError) as exc: + raise ValueError( + f"timestamp {value!r} is out of the representable epoch range" + ) from exc + raise ValueError( + f"timestamp must be None, a string, a datetime, or a numeric epoch; " + f"got {type(value).__name__!r}" + ) + class CausalChainResponse(BaseModel): decision_id: str @@ -174,7 +205,9 @@ class TemporalPatternResponse(BaseModel): class EnrichExtractRequest(BaseModel): - text: str + # 10 000 characters is sufficient for a substantial document paragraph while + # preventing unbounded spaCy NLP processing on arbitrarily large payloads. + text: str = Field(..., max_length=10_000) class EnrichExtractResponse(BaseModel): diff --git a/semantica/semantic_extract/methods.py b/semantica/semantic_extract/methods.py index 72898deb..f0559dec 100644 --- a/semantica/semantic_extract/methods.py +++ b/semantica/semantic_extract/methods.py @@ -108,6 +108,7 @@ License: MIT import re import difflib +import threading from concurrent.futures import ThreadPoolExecutor, as_completed from typing import Any, Dict, List, Optional, Tuple, Union @@ -153,6 +154,39 @@ spacy, SPACY_AVAILABLE = safe_import("spacy") _nlp_cache = None _embedder_cache = None +# Cache for models loaded by name, so extraction functions do not pay +# spacy.load() on every call. Entries record the spacy module they were loaded +# from: tests patch `methods.spacy` with a mock, and an entry produced by a +# different module object must not be handed back to a later caller. +_spacy_model_cache: Dict[str, Tuple[Any, Any]] = {} +_spacy_model_cache_lock = threading.Lock() + + +def load_spacy_model(name: str): + """Load a spaCy model once per process, keyed by model name. + + Raises whatever ``spacy.load`` raises (``OSError`` for a missing model), so + callers keep their existing fallback behavior. + """ + cached = _spacy_model_cache.get(name) + if cached is not None and cached[0] is spacy: + return cached[1] + + with _spacy_model_cache_lock: + cached = _spacy_model_cache.get(name) + if cached is not None and cached[0] is spacy: + return cached[1] + nlp = spacy.load(name) + _spacy_model_cache[name] = (spacy, nlp) + return nlp + + +def clear_spacy_model_cache() -> None: + """Drop every cached spaCy model. Intended for tests.""" + with _spacy_model_cache_lock: + _spacy_model_cache.clear() + + def get_text_embedder(): """ Get or load the TextEmbedder model for high-accuracy semantic similarity. @@ -676,11 +710,11 @@ def extract_entities_ml( return extract_entities_pattern(text, **kwargs) try: - nlp = spacy.load(model) + nlp = load_spacy_model(model) except OSError: logger.warning(f"spaCy model {model} not found, using en_core_web_sm") try: - nlp = spacy.load("en_core_web_sm") + nlp = load_spacy_model("en_core_web_sm") except OSError: logger.warning( "spaCy model not available, falling back to pattern extraction" @@ -1400,12 +1434,12 @@ def extract_relations_similarity( # Prefer larger models for vectors for model_name in ["en_core_web_lg", "en_core_web_md", "en_core_web_sm"]: if spacy.util.is_package(model_name): - nlp = spacy.load(model_name) + nlp = load_spacy_model(model_name) break if not nlp: # Try loading what we have try: - nlp = spacy.load("en_core_web_sm") + nlp = load_spacy_model("en_core_web_sm") except: pass except Exception: @@ -1505,7 +1539,7 @@ def extract_relations_dependency( return extract_relations_pattern(text, entities, **kwargs) try: - nlp = spacy.load(model) + nlp = load_spacy_model(model) except OSError: logger.warning(f"spaCy model {model} not found") return extract_relations_pattern(text, entities, **kwargs) diff --git a/tests/explorer/test_explorer_api.py b/tests/explorer/test_explorer_api.py index 4f16d13e..18d7fcaf 100644 --- a/tests/explorer/test_explorer_api.py +++ b/tests/explorer/test_explorer_api.py @@ -1,5 +1,6 @@ """Integration tests for the explorer API.""" +from datetime import datetime import json from pathlib import Path import uuid @@ -444,6 +445,63 @@ class TestDecisions: assert violation_response.json()["compliant"] is False +@pytest.fixture(scope="module") +def recorded_client(): + """Client over a graph whose decisions were written by record_decision().""" + graph = ContextGraph(advanced_analytics=False) + entities = ["applicant_A7291"] + graph.record_decision( + category="credit_application", + scenario="Personal loan, $85k income, 31% DTI", + reasoning="Income meets threshold; employment stable", + outcome="proceed_to_underwriting", + confidence=0.88, + entities=entities, + ) + graph.record_decision( + category="loan_underwriting", + scenario="Underwriting review for A-7291", + reasoning="DTI within policy; clean 36-month credit history", + outcome="approved", + confidence=0.94, + entities=entities, + ) + with TestClient(create_app(session=GraphSession(graph))) as test_client: + yield test_client + + +class TestRecordedDecisions: + """Decisions written by record_decision(), not hand-built decision nodes. + + record_decision() stores ``timestamp`` as a float epoch. The fixtures above + set no timestamp at all, so these routes were only ever exercised against + decision nodes that could not trigger the float/str mismatch. + """ + + def test_list_decisions_serializes_float_timestamp(self, recorded_client): + response = recorded_client.get("/api/decisions") + assert response.status_code == 200 + payload = response.json() + assert len(payload) == 2 + for item in payload: + assert isinstance(item["timestamp"], str) + datetime.fromisoformat(item["timestamp"]) + + def test_get_decision(self, recorded_client): + listed = recorded_client.get("/api/decisions").json() + decision_id = listed[0]["decision_id"] + response = recorded_client.get(f"/api/decisions/{decision_id}") + assert response.status_code == 200 + assert response.json()["decision_id"] == decision_id + + def test_filter_by_category(self, recorded_client): + response = recorded_client.get("/api/decisions?category=loan_underwriting") + assert response.status_code == 200 + payload = response.json() + assert len(payload) == 1 + assert payload[0]["outcome"] == "approved" + + class TestTemporal: def test_snapshot_now(self, client): response = client.get("/api/temporal/snapshot") @@ -602,7 +660,20 @@ class TestEnrichment: def test_extract(self, client): response = client.post("/api/enrich/extract", json={"text": "Alice works at Acme Corp."}) - assert response.status_code in (200, 422, 503) + # 503 is reserved for a genuinely absent semantic_extract module; it must + # not be reachable on an install where the module imports cleanly. + # Runtime errors from the extraction stack surface as 500, not 422. + assert response.status_code in (200, 422, 500) + + def test_extract_returns_entities(self, client): + response = client.post( + "/api/enrich/extract", + json={"text": "Apple CEO Tim Cook announced record earnings in Cupertino."}, + ) + assert response.status_code == 200 + payload = response.json() + assert payload["entities"], "extraction returned no entities" + assert any("Tim Cook" in str(entity) for entity in payload["entities"]) def test_link_prediction(self, client): response = client.post("/api/enrich/links", json={"node_id": "python", "top_n": 5}) @@ -1157,3 +1228,155 @@ class TestClassifyDistance: def test_large_hop_count_is_distant(self): assert classify_path_distance(20) == "distant" + + +# --------------------------------------------------------------------------- +# Timestamp validator unit tests (no HTTP server needed) +# --------------------------------------------------------------------------- + +class TestDecisionResponseTimestampValidator: + """Unit tests for DecisionResponse._normalize_timestamp. + + These run directly against the Pydantic model, not through the HTTP stack, + so they are fast and isolated from the rest of the Explorer infrastructure. + """ + + def _make(self, ts): + from semantica.explorer.schemas import DecisionResponse + import pytest as _pytest + return DecisionResponse(decision_id="x", timestamp=ts) + + def test_none_passes_through(self): + from semantica.explorer.schemas import DecisionResponse + dr = DecisionResponse(decision_id="x", timestamp=None) + assert dr.timestamp is None + + def test_string_passes_through_unchanged(self): + from semantica.explorer.schemas import DecisionResponse + iso = "2024-08-14T10:23:45+00:00" + dr = DecisionResponse(decision_id="x", timestamp=iso) + assert dr.timestamp == iso + + def test_float_epoch_becomes_iso_string(self): + from datetime import datetime, timezone + from semantica.explorer.schemas import DecisionResponse + epoch = 1723600000.5 + dr = DecisionResponse(decision_id="x", timestamp=epoch) + assert isinstance(dr.timestamp, str) + parsed = datetime.fromisoformat(dr.timestamp) + assert abs(parsed.timestamp() - epoch) < 1.0 + + def test_int_epoch_becomes_iso_string(self): + from datetime import datetime + from semantica.explorer.schemas import DecisionResponse + epoch = 1723600000 + dr = DecisionResponse(decision_id="x", timestamp=epoch) + assert isinstance(dr.timestamp, str) + datetime.fromisoformat(dr.timestamp) + + def test_nan_raises_validation_error(self): + import math + import pytest + from pydantic import ValidationError + from semantica.explorer.schemas import DecisionResponse + with pytest.raises(ValidationError): + DecisionResponse(decision_id="x", timestamp=math.nan) + + def test_positive_inf_raises_validation_error(self): + import math + import pytest + from pydantic import ValidationError + from semantica.explorer.schemas import DecisionResponse + with pytest.raises(ValidationError): + DecisionResponse(decision_id="x", timestamp=math.inf) + + def test_negative_inf_raises_validation_error(self): + import math + import pytest + from pydantic import ValidationError + from semantica.explorer.schemas import DecisionResponse + with pytest.raises(ValidationError): + DecisionResponse(decision_id="x", timestamp=-math.inf) + + def test_dict_raises_validation_error(self): + import pytest + from pydantic import ValidationError + from semantica.explorer.schemas import DecisionResponse + with pytest.raises(ValidationError): + DecisionResponse(decision_id="x", timestamp={"$date": 1723600000}) + + def test_list_raises_validation_error(self): + import pytest + from pydantic import ValidationError + from semantica.explorer.schemas import DecisionResponse + with pytest.raises(ValidationError): + DecisionResponse(decision_id="x", timestamp=[1723600000]) + + def test_bool_raises_validation_error(self): + import pytest + from pydantic import ValidationError + from semantica.explorer.schemas import DecisionResponse + with pytest.raises(ValidationError): + DecisionResponse(decision_id="x", timestamp=True) + + def test_oserror_range_epoch_raises_validation_error(self): + import pytest + from pydantic import ValidationError + from semantica.explorer.schemas import DecisionResponse + # Milliseconds mistakenly stored where seconds were expected. + with pytest.raises(ValidationError): + DecisionResponse(decision_id="x", timestamp=1723600000000) + + def test_overflow_range_epoch_raises_validation_error(self): + import pytest + from pydantic import ValidationError + from semantica.explorer.schemas import DecisionResponse + with pytest.raises(ValidationError): + DecisionResponse(decision_id="x", timestamp=1e20) + + +# --------------------------------------------------------------------------- +# /api/enrich/extract input-size and import-boundary tests +# --------------------------------------------------------------------------- + +class TestEnrichExtractValidation: + """Tests for the input constraints and exception handling added to + POST /api/enrich/extract.""" + + def test_oversized_input_rejected_before_nlp(self, client): + """A payload exceeding the 10 000-character limit must be rejected with + 422 before any NLP work is attempted.""" + oversized = "a " * 5_001 # 10 002 characters + response = client.post("/api/enrich/extract", json={"text": oversized}) + assert response.status_code == 422 + + def test_input_at_limit_is_accepted(self, client): + """A payload at exactly the maximum length must not be rejected by the + schema validator (NLP may still fail, but the schema must accept it).""" + at_limit = "a" * 10_000 + response = client.post("/api/enrich/extract", json={"text": at_limit}) + # 503 = module missing, 500 = runtime error from the extraction stack, + # 200 = success. What must NOT happen is a schema rejection (422 from + # Pydantic due to max_length), since this input is exactly at the limit. + assert response.status_code in (200, 500, 503) + + def test_import_failure_returns_503_not_422(self, client, monkeypatch): + """A genuine ImportError on the semantic_extract import must produce 503 + (dependency unavailable), NOT 422 (extraction failed).""" + import semantica.explorer.routes.enrich as enrich_module + + def _failing_import(name, *args, **kwargs): + if "semantic_extract" in name: + raise ImportError("semantic_extract not installed") + return original_import(name, *args, **kwargs) + + import builtins + original_import = builtins.__import__ + + monkeypatch.setattr(builtins, "__import__", _failing_import) + response = client.post( + "/api/enrich/extract", + json={"text": "Apple was founded by Steve Jobs."}, + ) + assert response.status_code == 503 + assert "semantic_extract" in response.json()["detail"].lower() diff --git a/tests/semantic_extract/test_spacy_model_cache.py b/tests/semantic_extract/test_spacy_model_cache.py new file mode 100644 index 00000000..11d5313b --- /dev/null +++ b/tests/semantic_extract/test_spacy_model_cache.py @@ -0,0 +1,114 @@ +"""Tests for the process-level spaCy model cache in semantic_extract.methods. + +Before this cache existed, extract_entities_ml(), extract_relations_similarity() +and extract_relations_dependency() called spacy.load() on every invocation, so a +short sentence cost ~120 ms of model loading on top of ~2 ms of actual work. +""" + +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest + +from semantica.semantic_extract import methods + + +@pytest.fixture(autouse=True) +def clear_cache(): + methods.clear_spacy_model_cache() + yield + methods.clear_spacy_model_cache() + + +def _fake_spacy(load): + return SimpleNamespace(load=load, util=SimpleNamespace(is_package=lambda name: True)) + + +def test_model_loaded_once_across_calls(monkeypatch): + calls = [] + + def fake_load(name, **kwargs): + calls.append(name) + return MagicMock() + + monkeypatch.setattr(methods, "spacy", _fake_spacy(fake_load)) + + methods.load_spacy_model("en_core_web_sm") + methods.load_spacy_model("en_core_web_sm") + methods.load_spacy_model("en_core_web_sm") + + assert calls == ["en_core_web_sm"], "spacy.load should run once per model name" + + +def test_same_object_returned(monkeypatch): + sentinel = MagicMock() + monkeypatch.setattr(methods, "spacy", _fake_spacy(lambda name, **kw: sentinel)) + + assert methods.load_spacy_model("en_core_web_sm") is sentinel + assert methods.load_spacy_model("en_core_web_sm") is sentinel + + +def test_distinct_models_cached_separately(monkeypatch): + calls = [] + monkeypatch.setattr( + methods, + "spacy", + _fake_spacy(lambda name, **kw: (calls.append(name), MagicMock())[1]), + ) + + methods.load_spacy_model("en_core_web_sm") + methods.load_spacy_model("en_core_web_lg") + methods.load_spacy_model("en_core_web_sm") + + assert calls == ["en_core_web_sm", "en_core_web_lg"] + + +def test_load_errors_propagate_and_are_not_cached(monkeypatch): + """Callers rely on OSError to trigger their fallback path.""" + attempts = [] + + def failing_load(name, **kwargs): + attempts.append(name) + raise OSError(f"Can't find model '{name}'") + + monkeypatch.setattr(methods, "spacy", _fake_spacy(failing_load)) + + with pytest.raises(OSError): + methods.load_spacy_model("en_core_web_missing") + with pytest.raises(OSError): + methods.load_spacy_model("en_core_web_missing") + + assert len(attempts) == 2, "a failed load must not populate the cache" + + +def test_cache_ignores_entries_from_a_replaced_spacy_module(monkeypatch): + """Patching methods.spacy must not hand back a model from the old module. + + Existing tests patch this attribute with a mock and assert on load calls, so + a cache keyed on model name alone would leak objects across those tests. + """ + first = MagicMock() + monkeypatch.setattr(methods, "spacy", _fake_spacy(lambda name, **kw: first)) + assert methods.load_spacy_model("en_core_web_sm") is first + + second = MagicMock() + monkeypatch.setattr(methods, "spacy", _fake_spacy(lambda name, **kw: second)) + assert methods.load_spacy_model("en_core_web_sm") is second + + +def test_extract_entities_ml_reuses_the_cached_model(monkeypatch): + calls = [] + + def fake_load(name, **kwargs): + calls.append(name) + nlp = MagicMock() + nlp.return_value = SimpleNamespace(ents=[]) + return nlp + + monkeypatch.setattr(methods, "spacy", _fake_spacy(fake_load)) + monkeypatch.setattr(methods, "SPACY_AVAILABLE", True) + + methods.extract_entities_ml("Alice works at Acme Corp.") + methods.extract_entities_ml("Bob works at Globex.") + + assert len(calls) == 1, "the model should be loaded once, not once per call" From b8175ea8019f42004d8cd2d4604f12947d3c2122 Mon Sep 17 00:00:00 2001 From: "Guofang.Tang" <136770748@qq.com> Date: Sat, 15 Aug 2026 18:34:26 +0800 Subject: [PATCH 044/105] fix(kg): make k-shortest path search side-effect free (#1000) * fix(kg): make k-shortest path search side-effect free * fix(kg): respect traversal direction for edge exclusion --- semantica/kg/path_finder.py | 291 +++++++++++++++++++---------------- tests/kg/test_path_finder.py | 46 +++++- 2 files changed, 207 insertions(+), 130 deletions(-) diff --git a/semantica/kg/path_finder.py b/semantica/kg/path_finder.py index 86269bfb..c7a89da9 100644 --- a/semantica/kg/path_finder.py +++ b/semantica/kg/path_finder.py @@ -127,66 +127,100 @@ class PathFinder: try: self.logger.info(f"Finding Dijkstra shortest path from {source} to {target}") - # Validate nodes exist - if not self._node_exists(graph, source): - raise ValueError(f"Source node {source} not found") - if not self._node_exists(graph, target): - raise ValueError(f"Target node {target} not found") - - traversal_graph = graph if directed else self._make_undirected_view(graph) - - # Dijkstra's algorithm - distances = {source: 0.0} - previous = {} - priority_queue = [(0.0, source)] - visited = set() - - while priority_queue: - current_distance, current_node = heapq.heappop(priority_queue) - - if current_node in visited: - continue - - visited.add(current_node) - - if current_node == target: - break - - # Explore neighbors - for neighbor, edge_data in self._get_neighbors(traversal_graph, current_node): - if neighbor in visited: - continue - - # Get edge weight - weight = self._get_edge_weight(edge_data, weight_attribute, default_weight) - distance = current_distance + weight - - if neighbor not in distances or distance < distances[neighbor]: - distances[neighbor] = distance - previous[neighbor] = current_node - heapq.heappush(priority_queue, (distance, neighbor)) - - # Reconstruct path - if target not in previous and source != target: - return [] # No path found - - path = [] - current = target - while current is not None: - path.append(current) - current = previous.get(current) - - path.reverse() - + path = self._dijkstra_shortest_path( + graph, + source, + target, + weight_attribute, + default_weight, + directed, + ) self.logger.info(f"Found path of length {len(path)}") return path - + except ValueError: # Re-raise ValueError for invalid nodes raise except Exception as e: self.logger.error(f"Dijkstra path finding failed: {str(e)}") raise RuntimeError(f"Path finding failed: {str(e)}") + + def _dijkstra_shortest_path( + self, + graph: Any, + source: str, + target: str, + weight_attribute: str = "weight", + default_weight: float = 1.0, + directed: bool = True, + excluded_nodes: Optional[Set[str]] = None, + excluded_edges: Optional[Set[Tuple[str, str]]] = None, + ) -> List[str]: + """Find a shortest path without mutating the graph. + + ``excluded_nodes`` and ``excluded_edges`` are used internally by + Yen's algorithm to model its temporary graph modifications. + """ + excluded_nodes = excluded_nodes or set() + excluded_edges = excluded_edges or set() + + # Validate nodes exist before applying the temporary exclusions. + if not self._node_exists(graph, source): + raise ValueError(f"Source node {source} not found") + if not self._node_exists(graph, target): + raise ValueError(f"Target node {target} not found") + if source in excluded_nodes or target in excluded_nodes: + return [] + + traversal_graph = graph if directed else self._make_undirected_view(graph) + + # Dijkstra's algorithm + distances = {source: 0.0} + previous = {} + priority_queue = [(0.0, source)] + visited = set() + + while priority_queue: + current_distance, current_node = heapq.heappop(priority_queue) + + if current_node in visited or current_node in excluded_nodes: + continue + + visited.add(current_node) + + if current_node == target: + break + + # Explore neighbors + for neighbor, edge_data in self._get_neighbors(traversal_graph, current_node): + if neighbor in visited or neighbor in excluded_nodes: + continue + if self._edge_is_excluded( + traversal_graph, current_node, neighbor, excluded_edges + ): + continue + + # Get edge weight + weight = self._get_edge_weight(edge_data, weight_attribute, default_weight) + distance = current_distance + weight + + if neighbor not in distances or distance < distances[neighbor]: + distances[neighbor] = distance + previous[neighbor] = current_node + heapq.heappush(priority_queue, (distance, neighbor)) + + # Reconstruct path + if target not in previous and source != target: + return [] # No path found + + path = [] + current = target + while current is not None: + path.append(current) + current = previous.get(current) + + path.reverse() + return path def a_star_search( self, @@ -493,69 +527,89 @@ class PathFinder: raise ValueError("k must be positive") # Find first shortest path - first_path = self.dijkstra_shortest_path(graph, source, target, weight_attribute, default_weight) + first_path = self.dijkstra_shortest_path( + graph, source, target, weight_attribute, default_weight + ) if not first_path: return [] paths = [first_path] candidates = [] - - for i in range(1, k): - # Generate candidate paths - for j in range(len(paths[-1]) - 1): - spur_node = paths[-1][j] - root_path = paths[-1][:j + 1] - - # Temporarily remove edges - removed_edges = [] + candidate_paths = {tuple(first_path)} + candidate_order = 0 + + while len(paths) < k: + previous_path = paths[-1] + + # Generate candidate paths from every spur node in the last path. + for j in range(len(previous_path) - 1): + spur_node = previous_path[j] + root_path = previous_path[:j + 1] + + # Block the next edge of every accepted path sharing this root. + excluded_edges = set() for path in paths: - if len(path) > j and path[:j + 1] == root_path: - if j + 1 < len(path): - edge_data = self._get_edge_data(graph, path[j], path[j + 1]) - if edge_data is not None: - removed_edges.append((path[j], path[j + 1], edge_data)) - self._remove_edge(graph, path[j], path[j + 1]) - - # Temporarily remove nodes (except spur node and nodes that don't exist) - removed_nodes = [] - for node in root_path[:-1]: - if node != spur_node and node != source and self._node_exists(graph, node): - removed_nodes.append(node) - self._remove_node(graph, node) - - # Find spur path - spur_path = self.dijkstra_shortest_path(graph, spur_node, target, weight_attribute, default_weight) - - # Restore graph - for node in removed_nodes: - self._restore_node(graph, node) - for u, v, data in removed_edges: - self._restore_edge(graph, u, v, data) - - # Combine root and spur paths - if spur_path: - candidate_path = root_path[:-1] + spur_path - if candidate_path not in candidates and candidate_path not in paths: - candidates.append(candidate_path) - - # Calculate path lengths and sort - candidates_with_lengths = [] - for path in candidates: - try: - length = self.path_length(graph, path, weight_attribute, default_weight) - candidates_with_lengths.append((path, length)) - except ValueError: - # Skip invalid paths - continue - - candidates_with_lengths.sort(key=lambda x: x[1]) - - # Add shortest unique paths - for path, length in candidates_with_lengths: - if len(paths) < k and path not in paths: - paths.append(path) - + if len(path) > j + 1 and path[:j + 1] == root_path: + excluded_edges.add((path[j], path[j + 1])) + + # Block root nodes so the combined path remains loopless. + excluded_nodes = set(root_path[:-1]) + spur_path = self._dijkstra_shortest_path( + graph, + spur_node, + target, + weight_attribute, + default_weight, + excluded_nodes=excluded_nodes, + excluded_edges=excluded_edges, + ) + + if not spur_path: + continue + + candidate_path = root_path[:-1] + spur_path + if len(candidate_path) != len(set(candidate_path)): + continue + + candidate_key = tuple(candidate_path) + if candidate_key in candidate_paths: + continue + + try: + length = self.path_length( + graph, candidate_path, weight_attribute, default_weight + ) + except ValueError: + continue + + candidate_paths.add(candidate_key) + heapq.heappush(candidates, (length, candidate_order, candidate_path)) + candidate_order += 1 + + if not candidates: + break + + _, _, next_path = heapq.heappop(candidates) + paths.append(next_path) + return paths + + def _edge_is_excluded( + self, + graph: Any, + source: str, + target: str, + excluded_edges: Set[Tuple[str, str]], + ) -> bool: + """Check whether an edge is excluded for the current traversal.""" + if (source, target) in excluded_edges: + return True + + is_directed = getattr(graph, "is_directed", None) + if callable(is_directed) and not is_directed(): + return (target, source) in excluded_edges + + return False def _node_exists(self, graph: Any, node: str) -> bool: """Check if node exists in graph.""" @@ -614,27 +668,6 @@ class PathFinder: return edge_data.get(weight_attribute, default_weight) return default_weight - def _remove_edge(self, graph: Any, u: str, v: str) -> None: - """Remove edge from graph.""" - if hasattr(graph, 'remove_edge'): - graph.remove_edge(u, v) - - def _restore_edge(self, graph: Any, u: str, v: str, data: Any) -> None: - """Restore edge to graph.""" - if hasattr(graph, 'add_edge'): - graph.add_edge(u, v, **data) - - def _remove_node(self, graph: Any, node: str) -> None: - """Remove node from graph.""" - if hasattr(graph, 'remove_node'): - graph.remove_node(node) - - def _restore_node(self, graph: Any, node: str) -> None: - """Restore node to graph (implementation depends on graph type).""" - # This is a simplified implementation - # In practice, you'd need to restore the node and its connections - pass - def _reconstruct_all_paths( self, previous: Dict[str, List[str]], diff --git a/tests/kg/test_path_finder.py b/tests/kg/test_path_finder.py index a4fab78a..76e5b0b4 100644 --- a/tests/kg/test_path_finder.py +++ b/tests/kg/test_path_finder.py @@ -241,7 +241,51 @@ class TestPathFinder: if len(paths) > 1: lengths = [self.finder.path_length(multi_path_graph, path) for path in paths] assert all(lengths[i] <= lengths[i+1] for i in range(len(lengths)-1)) - + + def test_find_k_shortest_paths_preserves_graph(self): + """Test k-shortest path search does not mutate the input graph.""" + graph = nx.Graph() + graph.add_edges_from([ + ("A", "X"), ("X", "Y"), ("Y", "E"), + ("A", "B"), ("B", "C"), ("C", "E"), + ]) + original_nodes = set(graph.nodes) + original_edges = set(graph.edges) + + paths = self.finder.find_k_shortest_paths(graph, "A", "E", k=5) + + assert len(paths) == 2 + assert set(graph.nodes) == original_nodes + assert set(graph.edges) == original_edges + + def test_find_k_shortest_paths_returns_loopless_paths(self): + """Test k-shortest paths do not repeat nodes.""" + graph = nx.Graph() + graph.add_edges_from([ + ("A", "D"), ("A", "E"), ("A", "C"), + ("B", "D"), ("B", "C"), + ]) + + paths = self.finder.find_k_shortest_paths(graph, "A", "B", k=5) + + assert paths == [["A", "C", "B"], ["A", "D", "B"]] + assert all(len(path) == len(set(path)) for path in paths) + + def test_dijkstra_exclusion_respects_undirected_traversal(self): + """Test exclusions apply in both directions for undirected traversal.""" + graph = nx.DiGraph() + graph.add_edge("A", "B") + + path = self.finder._dijkstra_shortest_path( + graph, + "B", + "A", + directed=False, + excluded_edges={("A", "B")}, + ) + + assert path == [] + def test_find_k_shortest_paths_no_path(self): """Test finding k shortest paths with no path available.""" paths = self.finder.find_k_shortest_paths(self.disconnected_graph, "A", "D", k=3) From 84ce3c515535de631162b4a782a5bb6b874fff48 Mon Sep 17 00:00:00 2001 From: pravit-amp <43916793+pravit-amp@users.noreply.github.com> Date: Sat, 15 Aug 2026 03:39:33 -0700 Subject: [PATCH 045/105] fix(context): make ContextGraph.add_edge idempotent by deduping on edge_id (#926) * fix(context): make ContextGraph.add_edge idempotent by deduping on edge_id (#922) * docs(changelog): document add_edge dedupe fix Adds an Unreleased/Fixed entry for #922/#926 so the ContextGraph edge-dedupe bug and its fix are recorded per Keep a Changelog format. --------- Co-authored-by: Pravit Ampapathini Co-authored-by: Mohd Kaif <98801504+KaifAhmad1@users.noreply.github.com> Co-authored-by: KaifAhmad1 --- CHANGELOG.md | 8 +++++ semantica/context/context_graph.py | 9 +++++ tests/context/test_context.py | 55 ++++++++++++++++++++++++++++++ 3 files changed, 72 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index bcace0fb..ae6ccf95 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,6 +52,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **`ContextGraph.add_edge` had no dedupe — identical edges were stored repeatedly under one shared edge ID, and re-ingest doubled the edge set** (#926, closes #922) by @pravit-amp + - `_add_internal_edge` appended to `self.edges`, `edge_type_index`, and `_adjacency` unconditionally, with no check for an edge already present. Edge identity is content-derived (`_resolve_edge_identity` builds `edge_id` from `source_id`/`target_id`/`edge_type`/`weight`/`metadata`/`valid_from`/`valid_until`), so two identical `add_edge` calls produced two edge objects sharing one `edge_id` — the graph already considered them the same edge, it just kept both copies. `self.nodes` already deduped by ID; edges did not, so `stats()["edge_count"]` inflated, `density()` could exceed its mathematical maximum of `1.0`, and a refresh/restore job calling `build_from_entities_and_relationships()` (or reloading a saved graph) doubled the edge set on every cycle + - Added an `edge_id -> ContextEdge` index (`_edge_index`), mirroring how `self.nodes` dedupes by node ID. `_add_internal_edge` now returns `False` when the `edge_id` already exists, checked before touching `edges`/`edge_type_index`/`_adjacency` and before firing the mutation callback, so a repeat `add_edge` is a silent no-op with no phantom `ADD_EDGE` audit event + - Genuinely parallel edges are unaffected: differing type/weight/metadata/validity still produce distinct content-derived `edge_id`s, so multigraph semantics are preserved + - Both state-reset paths (`load_from_file()` and `clear()`) also clear `_edge_index` + - New tests: repeat `add_edge` is a no-op, parallel edges with distinct attributes are preserved, re-ingest via `build_from_entities_and_relationships()` stays at one edge, and `clear()` resets the dedupe index + - `pytest tests/context/test_context.py`: 31 passed + - **`POST /api/enrich/extract` returned 503 on every request; the whole `/api/decisions*` family returned 500 as soon as a decision existed** (#886, closes #883, closes #884, closes #889) by @joseedson18jc, reviewed by @Sameer6305 - `semantica/explorer/routes/enrich.py` imported `extract_entities`/`extract_relations` from `semantica.semantic_extract.methods`, names that module never defined (only per-strategy variants like `extract_entities_ml` exist) — the `except ImportError` handler reported this as `"semantic_extract module not available"`, masking a wiring bug as a missing dependency. The route now calls `NamedEntityRecognizer`/`RelationExtractor` directly and forwards extracted entities into relation extraction instead of re-deriving them - `ContextGraph.record_decision()` stores `timestamp` as `datetime.now().timestamp()` (a float), while `DecisionResponse.timestamp` was typed `Optional[str]`; passing the value through unconverted failed pydantic validation on every decision route (`/api/decisions`, `/{id}`, `/{id}/chain`, `/{id}/precedents`, `/{id}/compliance`). Added a `field_validator(mode="before")` on `DecisionResponse` normalizing float/int/datetime inputs to ISO-8601 diff --git a/semantica/context/context_graph.py b/semantica/context/context_graph.py index df7c71be..4cc66e70 100644 --- a/semantica/context/context_graph.py +++ b/semantica/context/context_graph.py @@ -464,6 +464,7 @@ class ContextGraph: self.nodes: Dict[str, ContextNode] = {} self.edges: List[ContextEdge] = [] + self._edge_index: Dict[str, ContextEdge] = {} self._adjacency: Dict[str, List[ContextEdge]] = defaultdict(list) @@ -1120,6 +1121,7 @@ class ContextGraph: # Clear existing self.nodes.clear() self.edges.clear() + self._edge_index.clear() self._adjacency.clear() self.node_type_index.clear() self.edge_type_index.clear() @@ -1523,6 +1525,7 @@ class ContextGraph: with self._lock: self.nodes.clear() self.edges.clear() + self._edge_index.clear() self._adjacency.clear() self.node_type_index.clear() self.edge_type_index.clear() @@ -1595,6 +1598,11 @@ class ContextGraph: self.logger.warning("Skipping internal edge with invalid endpoints: %r", edge) return False with self._lock: + # Edge identity is content-derived, so an existing edge_id means this + # exact edge is already stored; re-adding it is a no-op (issue #922). + if edge.edge_id in self._edge_index: + return False + # Ensure nodes exist if edge.source_id not in self.nodes: self._add_internal_node( @@ -1605,6 +1613,7 @@ class ContextGraph: ContextNode(edge.target_id, "entity", edge.target_id) ) + self._edge_index[edge.edge_id] = edge self.edges.append(edge) self.edge_type_index[edge.edge_type].append(edge) self._adjacency[edge.source_id].append(edge) diff --git a/tests/context/test_context.py b/tests/context/test_context.py index dd9a706f..45000912 100644 --- a/tests/context/test_context.py +++ b/tests/context/test_context.py @@ -109,6 +109,61 @@ class TestContextModule(unittest.TestCase): self.assertEqual(neighbors[0]["id"], "n2") self.assertEqual(neighbors[0]["relationship"], "knows") + def test_add_edge_is_idempotent(self): + graph = ContextGraph() + graph.add_node("a", "t") + graph.add_node("b", "t") + + self.assertTrue(graph.add_edge("a", "b", "rel")) + self.assertFalse(graph.add_edge("a", "b", "rel")) + self.assertFalse(graph.add_edge("a", "b", "rel")) + + self.assertEqual(len(graph.edges), 1) + self.assertEqual(len(graph.edge_type_index["rel"]), 1) + self.assertEqual(len(graph._adjacency["a"]), 1) + self.assertEqual(graph.stats()["edge_count"], 1) + self.assertLessEqual(graph.density(), 1.0) + + def test_parallel_edges_with_distinct_attributes_are_kept(self): + graph = ContextGraph() + graph.add_node("a", "t") + graph.add_node("b", "t") + + graph.add_edge("a", "b", "rel", confidence=0.9) + graph.add_edge("a", "b", "rel", confidence=0.5) + graph.add_edge("a", "b", "other") + + self.assertEqual(len(graph.edges), 3) + self.assertEqual(len({e.edge_id for e in graph.edges}), 3) + + def test_reingest_does_not_duplicate_edges(self): + graph = ContextGraph() + entities = [ + {"id": "alice", "type": "person"}, + {"id": "acme", "type": "org"}, + ] + relationships = [ + {"source_id": "alice", "target_id": "acme", "type": "works_at"} + ] + + for _ in range(3): + graph.build_from_entities_and_relationships(entities, relationships) + + self.assertEqual(len(graph.edges), 1) + + def test_clear_resets_edge_dedupe_index(self): + graph = ContextGraph() + graph.add_node("a", "t") + graph.add_node("b", "t") + graph.add_edge("a", "b", "rel") + + graph.clear() + + graph.add_node("a", "t") + graph.add_node("b", "t") + self.assertTrue(graph.add_edge("a", "b", "rel")) + self.assertEqual(len(graph.edges), 1) + def test_get_nodes_by_label_returns_metadata_copy(self): graph = ContextGraph() graph.add_node("n1", "person", "Alice", role="engineer") From 6df97cf0a0e687a31f03399cd906081e6ca9e442 Mon Sep 17 00:00:00 2001 From: pravit-amp <43916793+pravit-amp@users.noreply.github.com> Date: Sat, 15 Aug 2026 04:20:49 -0700 Subject: [PATCH 046/105] fix(triplet_store): stop CONSTRUCT detection matching inside a leading comment (#951) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CONSTRUCT_QUERY_RE skipped comments with a bare \#[^\n]*, whose trailing * backtracks. For '# CONSTRUCT ...\nSELECT ...' the engine gave back everything after the '#', so the CONSTRUCT inside the comment satisfied the query-form keyword and a SELECT/ASK was reported as a CONSTRUCT. All four SPARQL backends delegate to this regex, so such a query took the CONSTRUCT branch of execute_sparql, which sends Accept: text/turtle and parses the body as Turtle — failing with a misleading 'Failed to parse CONSTRUCT response as Turtle'. Require a comment to reach a line terminator. Both LF and CR are accepted because the SPARQL grammar ends a comment at either; matching only LF would regress CR-terminated comments into false negatives. Add regression tests covering both directions across all four backends.> --- semantica/triplet_store/sparql_escaping.py | 13 +- .../test_construct_query_detection.py | 210 ++++++++++++++++++ 2 files changed, 222 insertions(+), 1 deletion(-) create mode 100644 tests/triplet_store/test_construct_query_detection.py diff --git a/semantica/triplet_store/sparql_escaping.py b/semantica/triplet_store/sparql_escaping.py index 311fdb41..ea56fd41 100644 --- a/semantica/triplet_store/sparql_escaping.py +++ b/semantica/triplet_store/sparql_escaping.py @@ -50,12 +50,23 @@ _DISALLOWED_URI_CHARS_RE = re.compile(r"[\s<>\"{}|\\^`]") # # Shared by BlazegraphStore and RDF4JStore so the detection logic has one # canonical implementation rather than being duplicated per-backend. +# +# The comment alternative must consume the whole comment up to a line +# terminator. Written as a bare `\#[^\n]*`, the trailing `*` backtracks: for +# "# CONSTRUCT ...\nSELECT ...", the engine gives back everything after the +# '#', letting the CONSTRUCT *inside the comment* satisfy the query-form +# keyword and misreporting a SELECT as a CONSTRUCT. Requiring a terminator +# ([\n\r], or end of input for a trailing comment) makes that backtracking +# impossible: if the character class gives a character back, the next +# character is by definition not a terminator, so the group cannot match. +# Both LF and CR are treated as terminators because the SPARQL grammar ends +# a comment at either. CONSTRUCT_QUERY_RE = re.compile( r""" \A # anchor to start of string (?: # skip zero or more of: \s+ # whitespace - | \#[^\n]* # comments (until newline) + | \#[^\n\r]*(?:[\n\r]|\Z) # comment, to end of line or end of input | PREFIX\s+[\w\-]*:\s*<[^>]*> # PREFIX declaration | BASE\s+<[^>]*> # BASE declaration )* diff --git a/tests/triplet_store/test_construct_query_detection.py b/tests/triplet_store/test_construct_query_detection.py new file mode 100644 index 00000000..58bbe8db --- /dev/null +++ b/tests/triplet_store/test_construct_query_detection.py @@ -0,0 +1,210 @@ +"""Regression tests for CONSTRUCT query-form detection (issue #931). + +``CONSTRUCT_QUERY_RE``'s comment alternative used to be written ``\\#[^\\n]*``. +The trailing ``*`` backtracks, so for a query like:: + + # CONSTRUCT in a comment + SELECT * WHERE { } + +the engine consumed the ``#``, gave back everything after it, and let the +CONSTRUCT *inside the comment* satisfy the query-form keyword. Every SPARQL +backend delegates to this one regex, so a SELECT/ASK carrying such a leading +comment was routed down the CONSTRUCT path of ``execute_sparql`` — which sends +``Accept: text/turtle`` and parses the body as Turtle, failing with a +misleading "Failed to parse CONSTRUCT response as Turtle". + +Both directions are pinned here: the false positives that motivated the fix, +and the queries that were already detected correctly, so a future tightening +cannot silently start dropping real CONSTRUCT queries instead. +""" + +import unittest +from unittest.mock import patch + +from semantica.triplet_store import sparql_escaping +from semantica.triplet_store.anzo_store import AnzoStore +from semantica.triplet_store.blazegraph_store import BlazegraphStore +from semantica.triplet_store.jena_store import JenaStore +from semantica.triplet_store.rdf4j_store import RDF4JStore + +# Queries whose *form* is CONSTRUCT. Each must be detected. +CONSTRUCT_CASES = { + "bare": "CONSTRUCT { ?s ?p ?o } WHERE { ?s ?p ?o }", + "lowercase": "construct { ?s ?p ?o } where { ?s ?p ?o }", + "mixed_case": "Construct { ?s ?p ?o } Where { ?s ?p ?o }", + "leading_whitespace": " \n\t CONSTRUCT { ?s ?p ?o } WHERE { ?s ?p ?o }", + "prefix_preamble": ( + "PREFIX e: \nCONSTRUCT { ?s ?p ?o } WHERE { ?s ?p ?o }" + ), + "base_preamble": "BASE CONSTRUCT { ?s ?p ?o } WHERE { ?s ?p ?o }", + "comment_then_construct": "# a comment\nCONSTRUCT { ?s ?p ?o } WHERE { ?s ?p ?o }", + "two_comments_then_construct": "#\n#\nCONSTRUCT { ?s ?p ?o } WHERE { ?s ?p ?o }", + "comment_crlf": "# a comment\r\nCONSTRUCT { ?s ?p ?o } WHERE { ?s ?p ?o }", + "comment_cr_only": "# a comment\rCONSTRUCT { ?s ?p ?o } WHERE { ?s ?p ?o }", + "empty_comment": "#\nCONSTRUCT { ?s ?p ?o } WHERE { ?s ?p ?o }", + "mixed_preamble": ( + " \n # header \n PREFIX e: \n # note \n " + "CONSTRUCT { ?s ?p ?o } WHERE { ?s ?p ?o }" + ), +} + +# Queries whose form is NOT CONSTRUCT. None may be detected. +NON_CONSTRUCT_CASES = { + "plain_select": "SELECT ?s WHERE { ?s ?p ?o }", + "plain_ask": "ASK { ?s ?p ?o }", + "describe": "DESCRIBE ", + "keyword_in_literal": 'SELECT * WHERE { ?s ?p "please CONSTRUCT this" }', + "keyword_in_trailing_comment": "SELECT * WHERE { } # CONSTRUCT", + "keyword_as_substring": 'SELECT ?s WHERE { ?s "CONSTRUCTOR" }', + # The issue #931 payloads: CONSTRUCT inside a *leading* comment. + "leading_comment_lf": "# CONSTRUCT in a comment\nSELECT * WHERE { }", + "leading_comment_crlf": "# CONSTRUCT in a comment\r\nSELECT * WHERE { }", + "leading_comment_cr_only": "# CONSTRUCT in a comment\rSELECT * WHERE { }", + "leading_comment_no_space": "#CONSTRUCT\nSELECT * WHERE { }", + "leading_comment_mid_sentence": "# we will CONSTRUCT later\nSELECT * WHERE { }", + "leading_comment_second_line": "#\n# CONSTRUCT\nSELECT * WHERE { }", + "leading_comment_before_ask": "# TODO: CONSTRUCT\nASK { ?s ?p ?o }", + "leading_comment_word_construction": "# CONSTRUCTION notes\nSELECT * WHERE { }", + "comment_only_no_newline": "# CONSTRUCT", +} + + +def _blazegraph_store() -> BlazegraphStore: + with patch.object(BlazegraphStore, "_connect", autospec=True): + store = BlazegraphStore(endpoint="http://localhost:9999/blazegraph") + store.connected = True + return store + + +def _rdf4j_store() -> RDF4JStore: + with patch.object(RDF4JStore, "_connect", autospec=True): + store = RDF4JStore( + endpoint="http://localhost:8080/rdf4j-server", repository_id="repo1" + ) + store.connected = True + return store + + +def _anzo_store() -> AnzoStore: + with patch.object(AnzoStore, "_connect", autospec=True): + store = AnzoStore( + endpoint="http://localhost:8080", + dataset_uri="http://cambridgesemantics.com/Graphmart/abc123", + ) + store.connected = True + return store + + +def _jena_store() -> JenaStore: + return JenaStore() + + +# Every backend that delegates to CONSTRUCT_QUERY_RE. Detection is shared, so +# a per-backend regression would otherwise only surface in whichever backend +# happened to be covered. +BACKENDS = { + "blazegraph": _blazegraph_store, + "rdf4j": _rdf4j_store, + "anzo": _anzo_store, + "jena": _jena_store, +} + + +class TestConstructQueryRegex(unittest.TestCase): + """Direct tests of the shared regex.""" + + def test_case_tables_are_populated(self): + """Guard against a vacuous suite. + + Every test below iterates a table; if a table were emptied or renamed + away, those loops would pass without asserting anything. + """ + self.assertGreaterEqual(len(CONSTRUCT_CASES), 12) + self.assertGreaterEqual(len(NON_CONSTRUCT_CASES), 15) + self.assertEqual(len(BACKENDS), 4) + + def test_detects_construct_query_forms(self): + for label, query in CONSTRUCT_CASES.items(): + with self.subTest(case=label): + self.assertIsNotNone( + sparql_escaping.CONSTRUCT_QUERY_RE.search(query), + f"{label}: real CONSTRUCT query was not detected", + ) + + def test_rejects_non_construct_query_forms(self): + for label, query in NON_CONSTRUCT_CASES.items(): + with self.subTest(case=label): + self.assertIsNone( + sparql_escaping.CONSTRUCT_QUERY_RE.search(query), + f"{label}: non-CONSTRUCT query was misdetected as CONSTRUCT", + ) + + def test_comment_alternative_does_not_backtrack(self): + """The specific mechanism behind #931. + + A comment must be consumed up to its terminator. If the character + class backtracks, the match ends *inside* the comment instead of + failing, which is what let CONSTRUCT-in-a-comment win. + """ + query = "# CONSTRUCT in a comment\nSELECT * WHERE { }" + self.assertIsNone(sparql_escaping.CONSTRUCT_QUERY_RE.search(query)) + + def test_carriage_return_terminates_a_comment(self): + """CR alone ends a comment, so CONSTRUCT after it is a real CONSTRUCT. + + Pins the difference between `[^\\n]*(?:\\n|\\Z)` and the shipped + `[^\\n\\r]*(?:[\\n\\r]|\\Z)`: the former treats a CR-terminated comment + as running to end of input, swallowing the query form after it. + """ + self.assertIsNotNone( + sparql_escaping.CONSTRUCT_QUERY_RE.search( + "# a comment\rCONSTRUCT { ?s ?p ?o } WHERE { ?s ?p ?o }" + ) + ) + self.assertIsNone( + sparql_escaping.CONSTRUCT_QUERY_RE.search( + "# CONSTRUCT in a comment\rSELECT * WHERE { }" + ) + ) + + +class TestConstructDetectionAcrossBackends(unittest.TestCase): + """The regex is shared, so assert every backend's public detector agrees.""" + + def test_all_backends_detect_construct_query_forms(self): + for backend, factory in BACKENDS.items(): + store = factory() + for label, query in CONSTRUCT_CASES.items(): + with self.subTest(backend=backend, case=label): + self.assertTrue( + store._is_construct_query(query), + f"{backend}/{label}: real CONSTRUCT query was not detected", + ) + + def test_all_backends_reject_non_construct_query_forms(self): + for backend, factory in BACKENDS.items(): + store = factory() + for label, query in NON_CONSTRUCT_CASES.items(): + with self.subTest(backend=backend, case=label): + self.assertFalse( + store._is_construct_query(query), + f"{backend}/{label}: non-CONSTRUCT query was misdetected", + ) + + def test_every_backend_exposes_the_detector(self): + """Fail loudly if a backend stops delegating to the shared regex. + + Without this, a backend that dropped `_is_construct_query` would make + the loops above error rather than report a meaningful failure. + """ + for backend, factory in BACKENDS.items(): + with self.subTest(backend=backend): + store = factory() + self.assertTrue( + callable(getattr(store, "_is_construct_query", None)), + f"{backend}: no callable _is_construct_query", + ) + + +if __name__ == "__main__": + unittest.main() From f1e7e64ad14ff6063cb4527a92cfd69bf9a99d9a Mon Sep 17 00:00:00 2001 From: pravit-amp <43916793+pravit-amp@users.noreply.github.com> Date: Sat, 15 Aug 2026 04:42:10 -0700 Subject: [PATCH 047/105] feat(context): add retraction and purge to ContextGraph (#957) * feat(context): add retraction and purge to ContextGraph ContextGraph had 56 public methods and none that removed anything: the only option was clear(), which discards the whole graph. Removing one entity meant exporting to a dict, filtering by hand and rebuilding, losing provenance. Add two operations with deliberately different contracts. retract_node/retract_edge close the entity's validity window. The entity stops being active going forward, but state_at() before the retraction still returns it, so decisions recorded against it remain explainable. This reuses the valid_from/valid_until machinery already present rather than adding a new subsystem. purge_node/purge_edge remove the entity outright, from history as well as from the active view, leaving a tombstone that records that a purge happened and why but never the purged content. Scope is this graph only; copies in AgentMemory or a bound vector store are not reached, so it is one step of an erasure workflow rather than the whole of it. Both record themselves through the existing mutation_callback path. MutationRecord already documented REMOVE_NODE/REMOVE_EDGE in its operation vocabulary, so retraction emits UPDATE_NODE and purge emits REMOVE_NODE with no changes required to change_management. Incident-edge lookup scans self.edges rather than _adjacency, which is keyed by source only and would otherwise leave inbound edges pointing at a removed node. Purge updates edges, edge_type_index and _adjacency together so the indexes cannot drift, and clear() now resets the retraction and tombstone records. * fix(context): address review findings on retraction and purge * fix(context): close every duplicate when retracting/purging by edge_id edge_id is content-derived and not yet guaranteed unique (#922, fix pending in #926): two identical add_edge() calls produce two edge objects sharing one id. retract_edge()/purge_edge() resolved "the edge" via the first matching object only, so a duplicate was silently left untouched (still live, still active) while the call returned True and recorded a tombstone/retraction claiming it was fully handled. Repeat purge_edge() calls also silently overwrote the tombstone's reason/purged_at on each partial attempt instead of no-op'ing once nothing remained to purge. retract_node()'s cascade had the same root cause from the other direction: it checked the live _retractions dict mid-loop, so the first duplicate's just-written record made the second look already handled and it was skipped outright, left permanently active. retract_edge()/purge_edge() now act on every edge matching the id under a single record; the cascade's dedup check is snapshotted before the loop starts so within-call duplicates are still closed rather than skipped. Adds TestDuplicateEdgeId (5 tests) reproducing all three paths. * docs(changelog): document retraction/purge feature Adds an Unreleased/Added entry for #955/#957 covering retract_node, retract_edge, purge_node, purge_edge and the get/list accessors, plus the duplicate-edge_id fix caught and applied during review. --------- Co-authored-by: Pravit Ampapathini Co-authored-by: KaifAhmad1 --- CHANGELOG.md | 10 + semantica/context/context_graph.py | 541 ++++++++++++++++ .../context/test_context_graph_retraction.py | 595 ++++++++++++++++++ 3 files changed, 1146 insertions(+) create mode 100644 tests/context/test_context_graph_retraction.py diff --git a/CHANGELOG.md b/CHANGELOG.md index ae6ccf95..758fa407 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **`ContextGraph` gains retraction and purge — the graph previously had no way to remove a node or edge without discarding everything via `clear()`** (#957, closes #955) by @pravit-amp, reviewed by @KaifAhmad1 + - `retract_node()`/`retract_edge()` close an entity's validity window rather than deleting it, reusing the existing `valid_from`/`valid_until`/`state_at()` machinery: the entity drops out of `find_active_nodes()` and future `state_at()` queries going forward, but `state_at()` calls before the retraction time still return it, so decisions recorded against it stay explainable. A `("kind", id)`-keyed retraction record captures who/why/when, retrievable via `get_retraction()`/`list_retractions()` + - `purge_node()`/`purge_edge()` are the destructive counterpart: the entity is removed outright, from history as well as the active view, for erasure obligations retraction alone cannot satisfy (e.g. GDPR Article 17). Only a tombstone remains — that a purge happened, when, and why — deliberately never the purged content, via `get_tombstone()`/`list_tombstones()`. Purge is graph-scope only: `AgentMemory` and any bound vector store are not reached, so it is one step of an erasure workflow rather than the whole of it + - Both operations default to `cascade=True` (also touching every incident edge, and for `purge_node`, the marker node of any cross-graph link the node exits through) since leaving edges active around an inactive/removed node produces an inconsistent active view or dangling endpoints; both accept `cascade=False` for callers that want to handle edges themselves + - Both are idempotent: retracting/purging an already-retracted/purged entity returns `False` rather than raising, and a repeat retraction preserves the original record's reason rather than overwriting it + - Retraction/purge closing a validity window never widens an existing one — a node or edge added with `valid_until` already in the past keeps that earlier bound rather than being pushed later by a subsequent retraction time + - Reuses the existing audit-trail path with no changes to `change_management`: `MutationRecord` already documented `REMOVE_NODE`/`REMOVE_EDGE` in its operation vocabulary; retraction now emits `UPDATE_NODE`/`UPDATE_EDGE`, purge emits `REMOVE_NODE`/`REMOVE_EDGE`, matching the documented contract. Mutation payloads are snapshotted inside the lock and the callback fires after it is released, so a callback that itself mutates the graph (e.g. `clear()`) can't observe or lose in-flight records + - **Fixed during review** (@KaifAhmad1): `retract_edge()`/`purge_edge()` resolved "the edge" for a given `edge_id` via the first matching object only. `edge_id` is content-derived and, prior to #926, was not guaranteed unique — a graph holding two identical `add_edge()` calls had two edge objects sharing one id. A direct `retract_edge()`/`purge_edge()` call would silently leave the second duplicate untouched (still live, still active) while returning `True` and recording a tombstone/retraction that claimed the edge was fully handled; repeat `purge_edge()` calls also silently overwrote the tombstone's `reason`/`purged_at` on each partial attempt instead of no-op'ing. The same gap let `retract_node()`'s cascade skip a duplicate outright, since it checked the live `_retractions` dict mid-loop and treated the first duplicate's just-written record as proof the second was already handled. `#926` (merged) stops *new* duplicates from being created, but any graph already holding one — loaded from a save made before that fix, or built during the window before it landed — could still trigger this. Now `retract_edge()`/`purge_edge()` act on every edge matching the id under one record, and the cascade's dedup check is snapshotted before the loop starts so within-call duplicates are still closed rather than skipped. 5 new regression tests in `TestDuplicateEdgeId` + - New `tests/context/test_context_graph_retraction.py`: 49 tests, covering retraction/purge semantics, cascade, idempotency, validity-window narrowing, id-keyspace collisions between node and edge ids, cross-graph link teardown, `clear()`/`load_from_file()` resetting retraction/tombstone state, audit-trail integration against a real `TemporalVersionManager`, mutation-emission ordering under a concurrent `clear()`, and concurrent purges + - Full `tests/context/` suite: 533 passed - **`DistanceExporter.compute_pairs()` gains an opt-in `metric_errors` column to distinguish legitimate `None` results from computation failures** (#960, follow-up to #879) by @Karunasagar12 - Previously, a `None` in `hop_count`/`weighted_distance`/`semantic_similarity`/betweenness could mean either "no path exists" or "the underlying computation raised" — logged as a warning per #879, but not otherwise surfaced, so the two cases were indistinguishable in exported CSV/JSONL/DataFrame data. `include=["metric_errors"]` now adds a `metric_errors` field per row: `""` when all requested metrics succeeded, or a comma-separated list of metric names that raised (e.g. `"hop_count,weighted_distance"`) - Opt-in only — default `compute_pairs()`/`to_csv()`/`to_dataframe()`/`to_jsonl()` schema is unchanged unless `"metric_errors"` is explicitly requested diff --git a/semantica/context/context_graph.py b/semantica/context/context_graph.py index 4cc66e70..28b431ef 100644 --- a/semantica/context/context_graph.py +++ b/semantica/context/context_graph.py @@ -188,6 +188,26 @@ def _normalize_temporal_input(value: Optional[Union[str, int, float, datetime]]) raise ValueError("Temporal values must be datetime, epoch seconds, ISO strings, or None") +def _closing_valid_until(current: Optional[str], at_iso: str) -> str: + """Return the earlier of an existing end bound and a retraction time. + + Retraction closes a validity window and must never widen one: an entity + added with ``valid_until`` already in the past would otherwise be reported + active by ``is_active``/``state_at`` for the span between its original end + and the retraction. An unparseable ``current`` imposes no end bound at all + (see :func:`_parse_iso_dt`), so ``at_iso`` still closes it. + """ + if current is None: + return at_iso + existing = _parse_iso_dt(current) + if existing is None: + return at_iso + requested = _parse_iso_dt(at_iso) + if requested is None or existing <= requested: + return current + return at_iso + + def _pick_first(*values: Any) -> Any: for value in values: if value is None: @@ -476,6 +496,15 @@ class ContextGraph: self._unresolved_links: Dict[str, Dict[str, str]] = {} + # Retraction closes an entity's validity window but keeps it in the + # graph; a tombstone records that an entity was purged outright, + # without retaining the purged content. Keyed by + # ``(entity_kind, entity_id)`` -- node ids are caller-supplied strings + # and edge ids are UUID strings, so a single id keyspace would let a + # node record mask an edge of the same id, and vice versa. + self._retractions: Dict[Tuple[str, str], Dict[str, Any]] = {} + self._tombstones: Dict[Tuple[str, str], Dict[str, Any]] = {} + self.progress_tracker = get_progress_tracker() @@ -1127,6 +1156,10 @@ class ContextGraph: self.edge_type_index.clear() self._linked_graphs.clear() self._unresolved_links.clear() + # Deletion metadata belongs to the graph being replaced; keeping it + # would make entities in the loaded graph read as already retracted. + self._retractions.clear() + self._tombstones.clear() if "graph_id" in data: self.graph_id = data["graph_id"] @@ -1520,6 +1553,371 @@ class ContextGraph: max_edges = n * (n - 1) return len(self.edges) / max_edges + def retract_node( + self, + node_id: str, + reason: Optional[str] = None, + at: Optional[Union[str, datetime]] = None, + cascade: bool = True, + ) -> bool: + """Retract a node: no longer active, but still visible in history. + + Closes the node's validity window rather than deleting it, so + :meth:`state_at` before ``at`` still returns the node and any decision + recorded against it remains explainable. Use :meth:`purge_node` when + the data itself has to be gone. + + Args: + node_id: Node to retract. + reason: Why it was retracted, stored on the retraction record. + at: When the retraction takes effect (ISO string or datetime). + Defaults to now, UTC. + cascade: Also retract every edge touching the node. Leaving edges + active around an inactive node means :meth:`find_active_nodes` + drops the node while its relationships still read as current, + so the default keeps the active view self-consistent. + + Retraction is expressed through the temporal window, so it is visible + to the activity-aware views -- :meth:`find_active_nodes`, + :meth:`state_at`, ``ContextNode.is_active`` -- and not to membership + checks like :meth:`has_node` or :meth:`stats`, which continue to count + the retained record. That matches how ``valid_until`` already behaved + before retraction existed. + + A node whose ``valid_until`` is already earlier than ``at`` keeps that + earlier bound: retraction only ever closes a validity window, never + widens one. + + Returns: + True if the node was retracted; False if it does not exist or was + already retracted. + + Note: + Emits ``UPDATE_NODE`` to the audit-trail callback, since retraction + changes the validity window rather than removing the record. + """ + at_iso = _normalize_temporal_input(at) or datetime.now(timezone.utc).isoformat() + with self._lock: + node = self.nodes.get(node_id) + if node is None: + self.logger.warning("Cannot retract unknown node: %r", node_id) + return False + if ("node", node_id) in self._retractions: + return False + + node.valid_until = _closing_valid_until(node.valid_until, at_iso) + record = { + "entity_id": node_id, + "entity_kind": "node", + "retracted_at": at_iso, + "reason": reason, + } + self._retractions[("node", node_id)] = record + node_payload = {**node.to_dict(), "retraction": dict(record)} + + cascaded: List[Tuple[str, Dict[str, Any]]] = [] + if cascade: + # Snapshotted once, before the loop: edge_id is content-derived + # and not guaranteed unique (#922), so two distinct edge objects + # can share one id. Checking the live _retractions dict inside + # the loop would let the first duplicate's record block the + # second from ever being closed, leaving it active indefinitely + # while its retraction record claimed otherwise. + already_retracted_edge_ids = { + key[1] for key in self._retractions if key[0] == "edge" + } + for edge in self._incident_edges(node_id): + if edge.edge_id in already_retracted_edge_ids: + continue + edge.valid_until = _closing_valid_until(edge.valid_until, at_iso) + edge_record = { + "entity_id": edge.edge_id, + "entity_kind": "edge", + "retracted_at": at_iso, + "reason": reason, + "cascaded_from": node_id, + } + self._retractions[("edge", edge.edge_id)] = edge_record + # Payloads are snapshotted here, not read back after the + # lock is released: a concurrent clear() would otherwise + # wipe the record out from under the emission below. + cascaded.append( + ( + edge.edge_id, + {**edge.to_dict(), "retraction": dict(edge_record)}, + ) + ) + + self._emit_mutation("UPDATE_NODE", node_id, node_payload) + for edge_id, edge_payload in cascaded: + self._emit_mutation("UPDATE_EDGE", edge_id, edge_payload) + self.logger.info( + "Retracted node %r at %s (cascaded %d edge(s))", + node_id, + at_iso, + len(cascaded), + ) + return True + + def retract_edge( + self, + edge_id: str, + reason: Optional[str] = None, + at: Optional[Union[str, datetime]] = None, + ) -> bool: + """Retract a single edge, leaving its endpoints untouched. + + An edge whose ``valid_until`` is already earlier than ``at`` keeps that + earlier bound; retraction never widens a validity window. + + Args: + edge_id: Edge to retract. + reason: Why it was retracted. + at: When the retraction takes effect. Defaults to now, UTC. + + Returns: + True if the edge was retracted; False if it does not exist or was + already retracted. + + Note: + ``edge_id`` is content-derived and not guaranteed unique (#922): + two distinct edge objects can share one id. Every edge matching + ``edge_id`` is closed under a single retraction record, so a + duplicate can never be left silently active while the record + claims it was retracted. + """ + at_iso = _normalize_temporal_input(at) or datetime.now(timezone.utc).isoformat() + with self._lock: + edges = [e for e in self.edges if e.edge_id == edge_id] + if not edges: + self.logger.warning("Cannot retract unknown edge: %r", edge_id) + return False + if ("edge", edge_id) in self._retractions: + return False + + record = { + "entity_id": edge_id, + "entity_kind": "edge", + "retracted_at": at_iso, + "reason": reason, + } + self._retractions[("edge", edge_id)] = record + for edge in edges: + edge.valid_until = _closing_valid_until(edge.valid_until, at_iso) + payload = {**edges[0].to_dict(), "retraction": dict(record)} + + self._emit_mutation("UPDATE_EDGE", edge_id, payload) + self.logger.info( + "Retracted edge %r at %s (%d underlying record(s))", + edge_id, + at_iso, + len(edges), + ) + return True + + def purge_node( + self, + node_id: str, + reason: Optional[str] = None, + at: Optional[Union[str, datetime]] = None, + cascade: bool = True, + ) -> bool: + """Permanently remove a node; history no longer contains it. + + Unlike :meth:`retract_node` this is destructive: the node disappears + from :meth:`state_at` as well as from the active view. Only a tombstone + remains, recording that a purge happened and why -- deliberately + without the purged content, since retaining it would defeat the point. + + Scope is this graph only. Copies held elsewhere (``AgentMemory``, a + bound vector store, an exported file) are not reached, so this is one + step of an erasure workflow, not the whole of it. + + Args: + node_id: Node to purge. + reason: Why it was purged, e.g. an erasure-request reference. + at: When the purge takes effect, recorded as the tombstone's + ``purged_at`` (ISO string or datetime). Defaults to now, UTC. + cascade: Also purge every edge touching the node, and the marker + node of any cross-graph link it exits through. Defaults to True + because leaving edges pointing at a removed node produces + dangling endpoints. + + Cross-graph links registered by :meth:`link_graph` out of this node are + deregistered either way -- a link whose source no longer exists would + still resolve through :meth:`navigate_to` and still be serialized by + :meth:`save_to_file`. + + Returns: + True if the node was purged; False if it does not exist. + + Note: + Emits ``REMOVE_NODE``/``REMOVE_EDGE`` to the audit-trail callback. + """ + purged_at = ( + _normalize_temporal_input(at) or datetime.now(timezone.utc).isoformat() + ) + with self._lock: + if node_id not in self.nodes: + self.logger.warning("Cannot purge unknown node: %r", node_id) + return False + + # The link marker node is scaffolding reachable only from the node + # being purged, so it goes with the cascade rather than surviving as + # an orphan. Resolve the markers before deregistering the links they + # are derived from. + targets = [node_id] + if cascade: + targets.extend(self._cross_graph_marker_nodes(node_id)) + for link_id in self._cross_graph_links_for(node_id): + self._linked_graphs.pop(link_id, None) + self._unresolved_links.pop(link_id, None) + + # Tombstones are snapshotted into locals before the lock is + # released; reading them back afterwards would race a clear(). + purged_edges: List[Tuple[str, Dict[str, Any]]] = [] + purged_nodes: List[Tuple[str, Dict[str, Any]]] = [] + for target in targets: + cascaded_from = None if target == node_id else node_id + if cascade: + for edge in self._incident_edges(target): + self._drop_edge_from_indexes(edge) + edge_record = { + "entity_id": edge.edge_id, + "entity_kind": "edge", + "purged_at": purged_at, + "reason": reason, + "cascaded_from": node_id, + } + self._tombstones[("edge", edge.edge_id)] = edge_record + self._retractions.pop(("edge", edge.edge_id), None) + purged_edges.append((edge.edge_id, dict(edge_record))) + + self._drop_node_from_indexes(target) + node_record = { + "entity_id": target, + "entity_kind": "node", + "purged_at": purged_at, + "reason": reason, + } + if cascaded_from is not None: + node_record["cascaded_from"] = cascaded_from + self._tombstones[("node", target)] = node_record + self._retractions.pop(("node", target), None) + purged_nodes.append((target, dict(node_record))) + + for edge_id, payload in purged_edges: + self._emit_mutation("REMOVE_EDGE", edge_id, payload) + for purged_id, payload in purged_nodes: + self._emit_mutation("REMOVE_NODE", purged_id, payload) + self.logger.info( + "Purged node %r (cascaded %d edge(s), %d node(s))", + node_id, + len(purged_edges), + len(purged_nodes) - 1, + ) + return True + + def purge_edge( + self, + edge_id: str, + reason: Optional[str] = None, + at: Optional[Union[str, datetime]] = None, + ) -> bool: + """Permanently remove a single edge, leaving its endpoints in place. + + If the edge is the bridge of a cross-graph link, the link is also + deregistered -- :meth:`navigate_to` should not keep resolving a link + whose bridge is gone. The marker node itself is an endpoint and is left + in place; purge it directly, or purge the link's source node, to remove + it too. + + Args: + edge_id: Edge to purge. + reason: Why it was purged. + at: When the purge takes effect, recorded as the tombstone's + ``purged_at``. Defaults to now, UTC. + + Returns: + True if the edge was purged; False if it does not exist. + + Note: + ``edge_id`` is content-derived and not guaranteed unique (#922): + two distinct edge objects can share one id. Every edge matching + ``edge_id`` is dropped under a single tombstone, so a duplicate + can never be left live in the graph while the tombstone claims + the edge is gone. + """ + purged_at = ( + _normalize_temporal_input(at) or datetime.now(timezone.utc).isoformat() + ) + with self._lock: + edges = [e for e in self.edges if e.edge_id == edge_id] + if not edges: + self.logger.warning("Cannot purge unknown edge: %r", edge_id) + return False + for edge in edges: + self._drop_edge_from_indexes(edge) + link_id = (edge.metadata or {}).get("link_id") + if (edge.metadata or {}).get("cross_graph") and link_id: + self._linked_graphs.pop(link_id, None) + self._unresolved_links.pop(link_id, None) + self._retractions.pop(("edge", edge_id), None) + record = { + "entity_id": edge_id, + "entity_kind": "edge", + "purged_at": purged_at, + "reason": reason, + } + self._tombstones[("edge", edge_id)] = record + payload = dict(record) + + self._emit_mutation("REMOVE_EDGE", edge_id, payload) + self.logger.info( + "Purged edge %r (%d underlying record(s))", edge_id, len(edges) + ) + return True + + def get_retraction( + self, entity_id: str, entity_kind: Optional[str] = None + ) -> Optional[Dict[str, Any]]: + """Return the retraction record for a node or edge, or None. + + Args: + entity_id: Node id or edge id. + entity_kind: ``"node"`` or ``"edge"``. Records are keyed by kind as + well as id, so pass this when a node id and an edge id could + collide; without it a node record is preferred over an edge one. + """ + with self._lock: + return self._find_removal_record(self._retractions, entity_id, entity_kind) + + def get_tombstone( + self, entity_id: str, entity_kind: Optional[str] = None + ) -> Optional[Dict[str, Any]]: + """Return the purge tombstone for a node or edge, or None. + + The tombstone records that a purge happened, when, and why. It never + contains the purged content. + + Args: + entity_id: Node id or edge id. + entity_kind: ``"node"`` or ``"edge"``; disambiguates a node id that + collides with an edge id, as for :meth:`get_retraction`. + """ + with self._lock: + return self._find_removal_record(self._tombstones, entity_id, entity_kind) + + def list_retractions(self) -> List[Dict[str, Any]]: + """Return every retraction record.""" + with self._lock: + return [dict(record) for record in self._retractions.values()] + + def list_tombstones(self) -> List[Dict[str, Any]]: + """Return every purge tombstone.""" + with self._lock: + return [dict(record) for record in self._tombstones.values()] + def clear(self) -> None: """Fully reset the graph state and indexes.""" with self._lock: @@ -1531,6 +1929,8 @@ class ContextGraph: self.edge_type_index.clear() self._linked_graphs.clear() self._unresolved_links.clear() + self._retractions.clear() + self._tombstones.clear() self.logger.debug("Graph state fully cleared.") # --- Internal Helpers --- @@ -1629,6 +2029,147 @@ class ContextGraph: ) return True + def _emit_mutation( + self, operation: str, entity_id: str, payload: Dict[str, Any] + ) -> None: + """Fire the audit-trail callback, mirroring the add paths. + + Kept in one place so retraction and purge record themselves the same + way ``_add_internal_node``/``_add_internal_edge`` already do, including + the ``_suspend_mutation_callback`` guard used during restores. + """ + if not getattr(self, "mutation_callback", None): + return + if getattr(self, "_suspend_mutation_callback", False): + return + try: + self.mutation_callback(operation, entity_id, payload) + except Exception as e: + self.logger.warning( + f"Audit trail callback failed for {operation} {entity_id}: {e}" + ) + + def _incident_edges(self, node_id: str) -> List[ContextEdge]: + """Every edge touching ``node_id``, in either direction. + + ``_adjacency`` is keyed by source only, so incoming edges have to come + from a scan of ``self.edges``; relying on ``_adjacency`` alone would + silently leave inbound edges pointing at a removed node. + """ + return [ + edge + for edge in self.edges + if edge.source_id == node_id or edge.target_id == node_id + ] + + @staticmethod + def _find_removal_record( + store: Dict[Tuple[str, str], Dict[str, Any]], + entity_id: str, + entity_kind: Optional[str], + ) -> Optional[Dict[str, Any]]: + """Look a retraction/tombstone up by id, optionally narrowed by kind. + + The caller must hold ``self._lock``. Records are keyed by + ``(entity_kind, entity_id)``; with no kind given, both keyspaces are + tried so callers that know an id is unambiguous can pass it alone. + """ + if entity_kind is not None: + if entity_kind not in ("node", "edge"): + raise ValueError( + f"entity_kind must be 'node', 'edge' or None, got {entity_kind!r}" + ) + kinds: Tuple[str, ...] = (entity_kind,) + else: + kinds = ("node", "edge") + for kind in kinds: + record = store.get((kind, entity_id)) + if record is not None: + return dict(record) + return None + + def _cross_graph_links_for(self, node_id: str) -> List[str]: + """Link ids that ``node_id`` participates in, as exit point or marker. + + The caller must hold ``self._lock``. :meth:`link_graph` registers a link + in three places -- ``_linked_graphs``, a marker node and the bridge edge + -- so removing only the node would leave :meth:`navigate_to` resolving a + link whose source is gone. + """ + link_ids = [ + link_id + for link_id, (_, source_node_id, _) in self._linked_graphs.items() + if source_node_id == node_id + ] + link_ids.extend( + link_id + for link_id, meta in self._unresolved_links.items() + if meta.get("source_node_id") == node_id + ) + node = self.nodes.get(node_id) + metadata = getattr(node, "metadata", None) or {} + if metadata.get("cross_graph") and metadata.get("link_id"): + link_ids.append(metadata["link_id"]) + return list(dict.fromkeys(link_ids)) + + def _cross_graph_marker_nodes(self, node_id: str) -> List[str]: + """Marker nodes of the cross-graph links ``node_id`` exits through. + + The caller must hold ``self._lock``. + """ + return [ + marker_id + for marker_id in ( + f"__cross_graph_{link_id}" + for link_id in self._cross_graph_links_for(node_id) + ) + if marker_id != node_id and marker_id in self.nodes + ] + + def _drop_node_from_indexes(self, node_id: str) -> None: + """Remove one node from ``nodes``, ``node_type_index`` and ``_adjacency``. + + The caller must hold ``self._lock``. Incident edges are not touched -- + see :meth:`_drop_edge_from_indexes`. + """ + node = self.nodes.pop(node_id, None) + if node is None: + return + bucket = self.node_type_index.get(node.node_type) + if bucket is not None: + bucket.discard(node_id) + if not bucket: + del self.node_type_index[node.node_type] + self._adjacency.pop(node_id, None) + + def _drop_edge_from_indexes(self, edge: ContextEdge) -> None: + """Remove one edge from every structure that references it. + + The caller must hold ``self._lock``. ``edges``, ``edge_type_index`` and + ``_adjacency`` must be updated together or the indexes drift out of + step with the edge list. + """ + try: + self.edges.remove(edge) + except ValueError: + pass + bucket = self.edge_type_index.get(edge.edge_type) + if bucket is not None: + try: + bucket.remove(edge) + except ValueError: + pass + if not bucket: + del self.edge_type_index[edge.edge_type] + adjacent = self._adjacency.get(edge.source_id) + if adjacent is not None: + try: + adjacent.remove(edge) + except ValueError: + pass + if not adjacent: + del self._adjacency[edge.source_id] + # --- Builder Methods (Legacy/Utility) --- def build_from_conversations( diff --git a/tests/context/test_context_graph_retraction.py b/tests/context/test_context_graph_retraction.py new file mode 100644 index 00000000..fa0fc158 --- /dev/null +++ b/tests/context/test_context_graph_retraction.py @@ -0,0 +1,595 @@ +"""Tests for ContextGraph retraction and purge (issue #955). + +``ContextGraph`` had 56 public methods and none that removed anything: the only +option was ``clear()``, which discards the whole graph. Two operations are +added, with deliberately different contracts. + +Retraction closes an entity's validity window. The entity stops being active +going forward, but ``state_at()`` before the retraction still returns it, so +decisions recorded against it remain explainable. Purge is destructive: the +entity is gone from history too, leaving only a tombstone recording that a +purge happened and why -- never the purged content. + +The audit-trail assertions run against a real ``TemporalVersionManager`` rather +than a mock callback, since the behaviour under test is precisely that these +operations reach the existing mutation-recording path. +""" + +import json +import os +import tempfile +import threading +import unittest +from datetime import datetime + +from semantica.change_management import TemporalVersionManager +from semantica.context import ContextEdge, ContextGraph + +BEFORE = "2025-06-01T00:00:00Z" +BETWEEN = "2025-09-01T00:00:00Z" +CUTOFF = "2026-01-01T00:00:00Z" +AFTER = "2026-06-01T00:00:00Z" + + +def _graph(): + """alice --works_at--> acme, plus an unrelated bob.""" + graph = ContextGraph(advanced_analytics=False) + graph.add_node("alice", "person") + graph.add_node("acme", "org") + graph.add_node("bob", "person") + graph.add_edge("alice", "acme", "works_at") + return graph + + +def _ids_at(graph, when): + return {node.get("id") for node in graph.state_at(when).get("nodes", [])} + + +def _index_totals(graph): + return { + "nodes": len(graph.nodes), + "node_index": sum(len(v) for v in graph.node_type_index.values()), + "edges": len(graph.edges), + "edge_index": sum(len(v) for v in graph.edge_type_index.values()), + "adjacency": sum(len(v) for v in graph._adjacency.values()), + } + + +class TestRetractNode(unittest.TestCase): + def test_retracted_node_leaves_the_active_view(self): + graph = _graph() + self.assertTrue(graph.retract_node("alice", at=CUTOFF)) + active = {node["id"] for node in graph.find_active_nodes()} + self.assertNotIn("alice", active) + self.assertIn("bob", active) + + def test_history_before_the_retraction_is_preserved(self): + graph = _graph() + graph.retract_node("alice", at=CUTOFF) + self.assertIn("alice", _ids_at(graph, BEFORE)) + self.assertNotIn("alice", _ids_at(graph, AFTER)) + + def test_retraction_record_captures_reason_and_time(self): + graph = _graph() + graph.retract_node("alice", reason="employment ended", at=CUTOFF) + record = graph.get_retraction("alice") + self.assertEqual(record["entity_id"], "alice") + self.assertEqual(record["entity_kind"], "node") + self.assertEqual(record["reason"], "employment ended") + self.assertIn("2026-01-01", record["retracted_at"]) + + def test_retracting_twice_is_a_no_op(self): + graph = _graph() + self.assertTrue(graph.retract_node("alice", reason="first", at=CUTOFF)) + self.assertFalse(graph.retract_node("alice", reason="second")) + self.assertEqual(graph.get_retraction("alice")["reason"], "first") + + def test_retracting_an_unknown_node_returns_false(self): + graph = _graph() + self.assertFalse(graph.retract_node("nobody")) + self.assertIsNone(graph.get_retraction("nobody")) + + def test_cascade_retracts_incident_edges_in_both_directions(self): + graph = _graph() + graph.add_edge("bob", "alice", "knows") # inbound, not in _adjacency['alice'] + graph.retract_node("alice", at=CUTOFF) + for edge in graph.edges: + self.assertIsNotNone( + graph.get_retraction(edge.edge_id), + f"edge {edge.edge_type} touching alice was not retracted", + ) + + def test_cascade_can_be_disabled(self): + graph = _graph() + graph.retract_node("alice", at=CUTOFF, cascade=False) + edge = graph.edges[0] + self.assertIsNone(graph.get_retraction(edge.edge_id)) + + def test_retraction_does_not_remove_the_record(self): + """Retraction is a temporal change, not a deletion.""" + graph = _graph() + graph.retract_node("alice", at=CUTOFF) + self.assertTrue(graph.has_node("alice")) + self.assertIsNotNone(graph.find_node("alice")) + + +class TestRetractEdge(unittest.TestCase): + def test_edge_is_retracted_without_touching_endpoints(self): + graph = _graph() + edge_id = graph.edges[0].edge_id + self.assertTrue(graph.retract_edge(edge_id, reason="wrong extraction")) + self.assertIsNotNone(graph.get_retraction(edge_id)) + active = {node["id"] for node in graph.find_active_nodes()} + self.assertIn("alice", active) + self.assertIn("acme", active) + + def test_retracting_an_unknown_edge_returns_false(self): + self.assertFalse(_graph().retract_edge("no-such-edge")) + + def test_retracting_an_edge_twice_is_a_no_op(self): + graph = _graph() + edge_id = graph.edges[0].edge_id + self.assertTrue(graph.retract_edge(edge_id)) + self.assertFalse(graph.retract_edge(edge_id)) + + +class TestPurge(unittest.TestCase): + def test_purged_node_is_absent_from_history(self): + graph = _graph() + self.assertTrue(graph.purge_node("alice", reason="erasure request #1")) + self.assertNotIn("alice", _ids_at(graph, BEFORE)) + self.assertFalse(graph.has_node("alice")) + + def test_tombstone_records_the_purge_without_the_content(self): + graph = ContextGraph(advanced_analytics=False) + graph.add_node("alice", "person", email="alice@example.com") + graph.purge_node("alice", reason="erasure request #1") + + tombstone = graph.get_tombstone("alice") + self.assertEqual(tombstone["entity_id"], "alice") + self.assertEqual(tombstone["reason"], "erasure request #1") + self.assertIn("purged_at", tombstone) + self.assertNotIn( + "alice@example.com", + str(tombstone), + "tombstone retained purged content, defeating the purpose of a purge", + ) + + def test_purge_cascades_to_incident_edges(self): + graph = _graph() + graph.add_edge("bob", "alice", "knows") + graph.purge_node("alice") + remaining = {(e.source_id, e.target_id) for e in graph.edges} + self.assertEqual(remaining, set()) + + def test_purge_keeps_every_index_consistent(self): + """The invariant clear() already upholds must hold here too.""" + graph = ContextGraph(advanced_analytics=False) + for i in range(5): + graph.add_node(f"n{i}", f"t{i % 2}") + graph.add_edge("n0", "n1", "a") + graph.add_edge("n1", "n2", "b") + graph.add_edge("n2", "n0", "a") + graph.add_edge("n3", "n0", "b") + + graph.purge_node("n0") + + totals = _index_totals(graph) + self.assertEqual(totals["node_index"], totals["nodes"]) + self.assertEqual(totals["edge_index"], totals["edges"]) + self.assertEqual(totals["adjacency"], totals["edges"]) + self.assertEqual(totals["edges"], 1) # only n1->n2 survives + + def test_purge_edge_leaves_endpoints_in_place(self): + graph = _graph() + edge_id = graph.edges[0].edge_id + self.assertTrue(graph.purge_edge(edge_id)) + self.assertEqual(len(graph.edges), 0) + self.assertTrue(graph.has_node("alice")) + self.assertTrue(graph.has_node("acme")) + totals = _index_totals(graph) + self.assertEqual(totals["edge_index"], 0) + self.assertEqual(totals["adjacency"], 0) + + def test_purging_unknown_entities_returns_false(self): + graph = _graph() + self.assertFalse(graph.purge_node("nobody")) + self.assertFalse(graph.purge_edge("no-such-edge")) + + def test_purge_supersedes_an_earlier_retraction(self): + graph = _graph() + graph.retract_node("alice", reason="left", at=CUTOFF) + graph.purge_node("alice", reason="erasure request #2") + self.assertIsNone(graph.get_retraction("alice")) + self.assertIsNotNone(graph.get_tombstone("alice")) + + +class TestRetractionNeverWidensTheWindow(unittest.TestCase): + """Retraction closes a validity window; it must never extend one. + + An entity added with ``valid_until`` already in the past was inactive from + that point on. Overwriting the bound with a later retraction time would + make ``state_at`` report it active over a span it previously was not. + """ + + def test_a_node_keeps_an_earlier_valid_until(self): + graph = ContextGraph(advanced_analytics=False) + graph.add_node("alice", "person", valid_until=BEFORE) + self.assertTrue(graph.retract_node("alice", at=AFTER)) + self.assertEqual(graph.nodes["alice"].valid_until, BEFORE) + self.assertNotIn("alice", _ids_at(graph, BETWEEN)) + + def test_an_edge_keeps_an_earlier_valid_until(self): + graph = ContextGraph(advanced_analytics=False) + graph.add_node("alice", "person") + graph.add_node("acme", "org") + graph.add_edge("alice", "acme", "works_at", valid_until=BEFORE) + edge = graph.edges[0] + self.assertTrue(graph.retract_edge(edge.edge_id, at=AFTER)) + self.assertEqual(edge.valid_until, BEFORE) + self.assertFalse(edge.is_active(datetime(2025, 9, 1))) + + def test_cascade_keeps_an_earlier_edge_bound(self): + graph = ContextGraph(advanced_analytics=False) + graph.add_node("alice", "person") + graph.add_node("acme", "org") + graph.add_edge("alice", "acme", "works_at", valid_until=BEFORE) + graph.retract_node("alice", at=AFTER) + self.assertEqual(graph.edges[0].valid_until, BEFORE) + + def test_an_open_window_is_still_closed_at_the_retraction_time(self): + graph = _graph() + graph.retract_node("alice", at=CUTOFF) + self.assertEqual(graph.nodes["alice"].valid_until, "2026-01-01T00:00:00") + + +class TestPurgeTimestamp(unittest.TestCase): + """Purge accepts an explicit effective time, as retraction does.""" + + def test_node_tombstone_records_the_supplied_time(self): + graph = _graph() + graph.purge_node("alice", reason="erasure request #4", at=CUTOFF) + self.assertEqual( + graph.get_tombstone("alice")["purged_at"], "2026-01-01T00:00:00" + ) + + def test_edge_tombstone_records_the_supplied_time(self): + graph = _graph() + edge_id = graph.edges[0].edge_id + graph.purge_edge(edge_id, at=CUTOFF) + self.assertEqual( + graph.get_tombstone(edge_id)["purged_at"], "2026-01-01T00:00:00" + ) + + def test_cascaded_edge_tombstones_share_the_supplied_time(self): + graph = _graph() + edge_id = graph.edges[0].edge_id + graph.purge_node("alice", at=CUTOFF) + self.assertEqual( + graph.get_tombstone(edge_id)["purged_at"], "2026-01-01T00:00:00" + ) + + def test_purge_time_defaults_to_now(self): + graph = _graph() + graph.purge_node("alice") + self.assertIn("purged_at", graph.get_tombstone("alice")) + + +class TestIdKeyspaces(unittest.TestCase): + """Node ids are caller-supplied and edge ids are UUIDs, so they can collide.""" + + def _colliding(self): + graph = _graph() + edge_id = graph.edges[0].edge_id + graph.add_node(edge_id, "person") + return graph, edge_id + + def test_an_edge_retraction_does_not_block_a_colliding_node(self): + graph, edge_id = self._colliding() + self.assertTrue(graph.retract_edge(edge_id, reason="edge")) + self.assertTrue(graph.retract_node(edge_id, reason="node")) + self.assertEqual(graph.get_retraction(edge_id, "edge")["reason"], "edge") + self.assertEqual(graph.get_retraction(edge_id, "node")["reason"], "node") + + def test_purging_a_node_leaves_a_colliding_edge_alone(self): + graph, edge_id = self._colliding() + self.assertTrue(graph.purge_node(edge_id)) + self.assertEqual(len(graph.edges), 1) + self.assertIsNone(graph.get_tombstone(edge_id, "edge")) + self.assertIsNotNone(graph.get_tombstone(edge_id, "node")) + + def test_an_unknown_entity_kind_is_rejected(self): + with self.assertRaises(ValueError): + _graph().get_retraction("alice", "vertex") + + +class TestDuplicateEdgeId(unittest.TestCase): + """``edge_id`` is content-derived; before #926, two identical ``add_edge`` + calls produced two edge objects sharing one id. #926 stops *new* + duplicates through ``add_edge``/``add_edges``, but a graph can still carry + one from a save made before that fix, or from any other path that builds + a ``ContextEdge`` directly -- so retraction/purge must still handle it. + Every duplicate must be reached, or a retraction/tombstone record can + claim an edge is gone/inactive while a live copy remains in the graph. + """ + + def _duplicated(self): + """A graph with two distinct ``ContextEdge`` objects sharing one + edge_id, reproducing pre-#926 (or any hand-built) duplicate state + without going through the now-deduping ``add_edge``. + """ + graph = _graph() + original = graph.edges[0] + duplicate = ContextEdge( + source_id=original.source_id, + target_id=original.target_id, + edge_type=original.edge_type, + weight=original.weight, + ) + self.assertEqual(duplicate.edge_id, original.edge_id) + graph.edges.append(duplicate) + graph.edge_type_index[duplicate.edge_type].append(duplicate) + graph._adjacency[duplicate.source_id].append(duplicate) + edge_id = original.edge_id + self.assertEqual({e.edge_id for e in graph.edges}, {edge_id}) + self.assertEqual(len(graph.edges), 2) + return graph, edge_id + + def test_retract_edge_closes_every_duplicate(self): + graph, edge_id = self._duplicated() + self.assertTrue(graph.retract_edge(edge_id, reason="dup", at=CUTOFF)) + for edge in graph.edges: + self.assertEqual(edge.valid_until, "2026-01-01T00:00:00") + self.assertFalse(edge.is_active(datetime(2026, 6, 1))) + + def test_retract_node_cascade_closes_every_duplicate(self): + graph, edge_id = self._duplicated() + self.assertTrue(graph.retract_node("alice", at=CUTOFF)) + for edge in graph.edges: + self.assertEqual(edge.valid_until, "2026-01-01T00:00:00") + + def test_purge_edge_removes_every_duplicate(self): + graph, edge_id = self._duplicated() + self.assertTrue(graph.purge_edge(edge_id, reason="dup")) + self.assertFalse(any(e.edge_id == edge_id for e in graph.edges)) + + def test_purge_node_cascade_removes_every_duplicate(self): + graph, edge_id = self._duplicated() + self.assertTrue(graph.purge_node("alice")) + self.assertFalse(any(e.edge_id == edge_id for e in graph.edges)) + + def test_repeat_purge_edge_does_not_overwrite_the_tombstone(self): + """Once every duplicate is gone, a second call must no-op, not + silently 'complete' the purge again and clobber the original record.""" + graph, edge_id = self._duplicated() + self.assertTrue(graph.purge_edge(edge_id, reason="first")) + self.assertFalse(graph.purge_edge(edge_id, reason="second")) + self.assertEqual(graph.get_tombstone(edge_id)["reason"], "first") + + +class TestPurgeCrossGraphLinks(unittest.TestCase): + """link_graph() registers a link, a marker node and a bridge edge.""" + + def _linked(self): + graph = _graph() + other = ContextGraph(advanced_analytics=False) + other.add_node("target", "topic") + return graph, other, graph.link_graph(other, "alice", "target") + + def test_purging_the_source_removes_link_marker_and_registration(self): + graph, _, link_id = self._linked() + graph.purge_node("alice", reason="erasure request #5") + self.assertFalse(graph.has_node(f"__cross_graph_{link_id}")) + with self.assertRaises(KeyError): + graph.navigate_to(link_id) + totals = _index_totals(graph) + self.assertEqual(totals["node_index"], totals["nodes"]) + self.assertEqual(totals["edge_index"], totals["edges"]) + self.assertEqual(totals["adjacency"], totals["edges"]) + + def test_the_marker_purge_is_recorded_as_cascaded(self): + graph, _, link_id = self._linked() + graph.purge_node("alice") + tombstone = graph.get_tombstone(f"__cross_graph_{link_id}") + self.assertEqual(tombstone["cascaded_from"], "alice") + + def test_a_purged_link_is_not_serialized(self): + graph, _, _ = self._linked() + graph.purge_node("alice") + with tempfile.TemporaryDirectory() as directory: + path = os.path.join(directory, "graph.json") + graph.save_to_file(path) + with open(path, encoding="utf-8") as handle: + data = json.load(handle) + self.assertEqual(data["links"], []) + + def test_cascade_disabled_still_deregisters_the_link(self): + """The source node is gone either way, so the link cannot resolve.""" + graph, _, link_id = self._linked() + graph.purge_node("alice", cascade=False) + with self.assertRaises(KeyError): + graph.navigate_to(link_id) + self.assertTrue(graph.has_node(f"__cross_graph_{link_id}")) + + def test_purging_the_bridge_edge_deregisters_the_link(self): + graph, _, link_id = self._linked() + bridge = next( + edge for edge in graph.edges if edge.metadata.get("link_id") == link_id + ) + self.assertTrue(graph.purge_edge(bridge.edge_id)) + with self.assertRaises(KeyError): + graph.navigate_to(link_id) + + def test_purging_the_marker_node_deregisters_the_link(self): + graph, _, link_id = self._linked() + self.assertTrue(graph.purge_node(f"__cross_graph_{link_id}")) + with self.assertRaises(KeyError): + graph.navigate_to(link_id) + self.assertTrue(graph.has_node("alice")) + + def test_an_unrelated_link_survives(self): + graph, other, link_id = self._linked() + graph.purge_node("bob") + self.assertEqual(graph.navigate_to(link_id), (other, "target")) + + +class TestClearResetsRecords(unittest.TestCase): + def test_clear_drops_retractions_and_tombstones(self): + graph = _graph() + graph.retract_node("alice", at=CUTOFF) + graph.purge_node("bob") + graph.clear() + self.assertEqual(graph.list_retractions(), []) + self.assertEqual(graph.list_tombstones(), []) + + def test_load_from_file_drops_records_from_the_previous_graph(self): + source = _graph() + graph = ContextGraph(advanced_analytics=False) + graph.add_node("alice", "person") + graph.add_node("carol", "person") + graph.retract_node("alice", at=CUTOFF) + graph.purge_node("carol") + + with tempfile.TemporaryDirectory() as directory: + path = os.path.join(directory, "graph.json") + source.save_to_file(path) + graph.load_from_file(path) + + self.assertEqual(graph.list_retractions(), []) + self.assertEqual(graph.list_tombstones(), []) + # The reloaded alice is a fresh record, not one already retracted. + self.assertTrue(graph.retract_node("alice", at=CUTOFF)) + + +class TestAuditTrailIntegration(unittest.TestCase): + """Against the real TemporalVersionManager, not a mock callback.""" + + def _attached(self): + manager = TemporalVersionManager() + graph = _graph() + manager.attach_to_graph(graph) + return manager, graph + + def _ops(self, manager, entity_id): + history = manager.storage.get_entity_history(entity_id) or [] + return [entry.get("operation") for entry in history] + + def test_retraction_is_recorded_as_an_update(self): + manager, graph = self._attached() + graph.retract_node("alice", reason="left", at=CUTOFF) + self.assertIn("UPDATE_NODE", self._ops(manager, "alice")) + + def test_purge_is_recorded_as_a_removal(self): + manager, graph = self._attached() + graph.purge_node("acme", reason="erasure request #3") + self.assertIn("REMOVE_NODE", self._ops(manager, "acme")) + + def test_operations_use_the_documented_mutation_vocabulary(self): + """MutationRecord documents ADD/UPDATE/REMOVE for nodes and edges.""" + manager, graph = self._attached() + graph.retract_node("alice", at=CUTOFF) + graph.purge_node("bob") + allowed = { + "ADD_NODE", + "UPDATE_NODE", + "REMOVE_NODE", + "ADD_EDGE", + "UPDATE_EDGE", + "REMOVE_EDGE", + } + seen = set() + for entity_id in ("alice", "acme", "bob"): + seen.update(self._ops(manager, entity_id)) + self.assertTrue(seen) + self.assertTrue( + seen <= allowed, f"undocumented mutation operation(s): {seen - allowed}" + ) + + +class TestMutationEmissionIsSelfContained(unittest.TestCase): + """Audit payloads must be snapshotted before the lock is released. + + The callback fires outside the lock, so anything read from + ``_retractions``/``_tombstones`` at emission time can already have been + wiped by a concurrent ``clear()``. A callback that clears the graph on its + first call stands in for that interleaving deterministically. + """ + + def _clearing_callback(self, graph, seen): + def callback(operation, entity_id, payload): + seen.append((operation, entity_id, payload)) + if len(seen) == 1: + graph.clear() + + return callback + + def test_purge_emits_every_mutation_after_a_concurrent_clear(self): + graph = _graph() + graph.add_edge("bob", "alice", "knows") + seen = [] + graph.mutation_callback = self._clearing_callback(graph, seen) + + self.assertTrue(graph.purge_node("alice", reason="erasure request #6")) + + self.assertEqual( + [operation for operation, _, _ in seen], + ["REMOVE_EDGE", "REMOVE_EDGE", "REMOVE_NODE"], + ) + for _, entity_id, payload in seen: + self.assertEqual(payload["entity_id"], entity_id) + self.assertEqual(payload["reason"], "erasure request #6") + + def test_retraction_emits_every_mutation_after_a_concurrent_clear(self): + graph = _graph() + graph.add_edge("bob", "alice", "knows") + seen = [] + graph.mutation_callback = self._clearing_callback(graph, seen) + + self.assertTrue(graph.retract_node("alice", reason="left", at=CUTOFF)) + + self.assertEqual( + [operation for operation, _, _ in seen], + ["UPDATE_NODE", "UPDATE_EDGE", "UPDATE_EDGE"], + ) + for _, _, payload in seen: + self.assertEqual(payload["retraction"]["reason"], "left") + + +class TestConcurrency(unittest.TestCase): + def test_concurrent_purges_keep_indexes_consistent(self): + """Post-condition, not timing: threads must finish and indexes agree.""" + graph = ContextGraph(advanced_analytics=False) + for i in range(60): + graph.add_node(f"n{i}", "t") + for i in range(59): + graph.add_edge(f"n{i}", f"n{i + 1}", "rel") + + errors = [] + + def purge(start): + try: + for i in range(start, 60, 4): + graph.purge_node(f"n{i}") + except Exception as exc: # surfaced below, never swallowed + errors.append(f"{type(exc).__name__}: {exc}") + + threads = [ + threading.Thread(target=purge, args=(offset,)) for offset in range(4) + ] + for thread in threads: + thread.start() + for thread in threads: + thread.join(timeout=30) + + self.assertEqual([t.name for t in threads if t.is_alive()], []) + self.assertEqual(errors, []) + self.assertEqual(len(graph.nodes), 0) + totals = _index_totals(graph) + self.assertEqual(totals["node_index"], 0) + self.assertEqual(totals["edge_index"], 0) + self.assertEqual(totals["adjacency"], 0) + self.assertEqual(totals["edges"], 0) + + +if __name__ == "__main__": + unittest.main() From 8639cb9f162e36c4addaa8cc5f7371d6ad32d3e3 Mon Sep 17 00:00:00 2001 From: yzxcj797 <54314860+yzxcj797@users.noreply.github.com> Date: Sat, 15 Aug 2026 20:18:36 +0800 Subject: [PATCH 048/105] fix(seed): pass connection string to DBIngestor and stop mislabeling OSError in load_from_database (#995) Fix DBIngestor calls in load_from_database , it was never actually reaching the db. execute_query/export_table need the connection string as their first arg, but we were only passing it to the constructor's config dict, which those methods don't read. Every call blew up with a TypeError before connecting. Also split the ImportError/OSError handling , they were caught together so a real connection failure got reported as "module not available", which sent people looking in the wrong place. OSError now surfaces as an actual failure with the original exception chained via `from e`. Fixes #973. --- semantica/seed/seed_manager.py | 21 ++++++++++++++------- tests/test_seed_manager.py | 23 ++++++++++++++++++++++- 2 files changed, 36 insertions(+), 8 deletions(-) diff --git a/semantica/seed/seed_manager.py b/semantica/seed/seed_manager.py index 5051697d..16f21ea1 100644 --- a/semantica/seed/seed_manager.py +++ b/semantica/seed/seed_manager.py @@ -401,18 +401,25 @@ class SeedDataManager: """ try: from ..ingest.db_ingestor import DBIngestor + except ImportError as e: + raise ProcessingError( + "Database ingestion module not available. Install required dependencies." + ) from e + try: # Initialize DB ingestor db_ingestor = DBIngestor(config={"connection_string": connection_string}) - # Execute query or export table + # Execute query or export table. Both ingestor methods take the + # connection string as their first argument — the constructor's + # config is not a substitute for it (#973). if query: # Execute custom query - result = db_ingestor.execute_query(query) + result = db_ingestor.execute_query(connection_string, query) records = result if isinstance(result, list) else [result] elif table_name: # Export table - table_data = db_ingestor.export_table(table_name) + table_data = db_ingestor.export_table(connection_string, table_name) records = table_data.rows if hasattr(table_data, "rows") else [] else: raise ProcessingError("Either 'query' or 'table_name' must be provided") @@ -429,11 +436,11 @@ class SeedDataManager: self.logger.info(f"Loaded {len(records)} records from database") return records - except (ImportError, OSError): - raise ProcessingError( - "Database ingestion module not available. Install required dependencies." - ) + except ProcessingError: + raise except Exception as e: + # OSError here is a real connection/driver failure, not a missing + # module — report the actual cause and keep the chain (#973). raise ProcessingError(f"Failed to load from database: {e}") from e def load_from_api( diff --git a/tests/test_seed_manager.py b/tests/test_seed_manager.py index 31f966f1..c66149cc 100644 --- a/tests/test_seed_manager.py +++ b/tests/test_seed_manager.py @@ -126,7 +126,11 @@ def test_load_from_database(mock_db_ingestor_cls, seed_manager): assert len(records) == 1 assert records[0]["id"] == 1 assert records[0]["entity_type"] == "User" - mock_db_ingestor.execute_query.assert_called_once_with("SELECT * FROM users") + # Regression for #973: the ingestor methods receive the connection + # string as their first argument — the constructor config is not enough. + mock_db_ingestor.execute_query.assert_called_once_with( + "sqlite:///:memory:", "SELECT * FROM users" + ) # Mock export_table result mock_table_data = MagicMock() @@ -139,6 +143,23 @@ def test_load_from_database(mock_db_ingestor_cls, seed_manager): ) assert len(records) == 1 assert records[0]["id"] == 2 + mock_db_ingestor.export_table.assert_called_once_with("sqlite:///:memory:", "users") + +def test_load_from_database_os_error_not_misreported(seed_manager): + # Regression for #973: a real OSError from the ingestor must surface as a + # database failure with the cause chained, not as a missing module. + import semantica.ingest.db_ingestor as dbi + + with patch.object( + dbi.DBIngestor, "execute_query", side_effect=OSError(111, "Connection refused") + ): + with pytest.raises(ProcessingError) as excinfo: + seed_manager.load_from_database( + "postgresql://u:p@10.0.0.9/db", query="SELECT 1" + ) + assert "Failed to load from database" in str(excinfo.value) + assert "module not available" not in str(excinfo.value) + assert isinstance(excinfo.value.__cause__, OSError) def test_load_from_database_import_error(seed_manager): with patch.dict("sys.modules", {"semantica.ingest.db_ingestor": None}): From 115e7965cd6d5be120ac1c0ac6b1d438696e71ce Mon Sep 17 00:00:00 2001 From: Lakshay Saini <76612216+lakshayxi@users.noreply.github.com> Date: Sat, 15 Aug 2026 18:17:35 +0530 Subject: [PATCH 049/105] fix(explorer): gate temporal requests on graph load (#1003) Explorer was firing temporal requests before the graph even loaded. When the backend is down, /api/graph/nodes fails but the temporal bounds and snapshot effects didn't care , they fired anyway, off in their own corner, ignoring whether the graph actually came up. Every page load with no backend meant three failed requests instead of one, and a scrubber that had nothing to scrub. Added two small predicate functions and gated the temporal effects on them. Basically: don't ask for time-based data until you know the graph itself loaded. An empty graph still counts as loaded, so that case isn't broken. Confirmed with the backend down, before and after: three failing requests down to one. Fixes #982. --- explorer/package.json | 2 +- .../GraphWorkspace/GraphWorkspace.tsx | 32 ++++- .../temporalLifecyclePredicates.ts | 31 +++++ explorer/tests/temporalLifecycle.test.ts | 114 ++++++++++++++++++ 4 files changed, 175 insertions(+), 4 deletions(-) create mode 100644 explorer/src/workspaces/GraphWorkspace/temporalLifecyclePredicates.ts create mode 100644 explorer/tests/temporalLifecycle.test.ts diff --git a/explorer/package.json b/explorer/package.json index 72e36f73..162f2bc0 100644 --- a/explorer/package.json +++ b/explorer/package.json @@ -9,7 +9,7 @@ "lint": "eslint .", "preview": "vite preview", "test:graph-store": "node --test tests/graphStore.multi-edge.test.mjs", - "test:graph-workspace": "node --import tsx --test tests/graphSceneState.display.test.ts", + "test:graph-workspace": "node --import tsx --test tests/graphSceneState.display.test.ts tests/temporalLifecycle.test.ts", "test:plugin-registry": "node --import tsx --test tests/pluginRegistry.temporal.test.mjs" }, "dependencies": { diff --git a/explorer/src/workspaces/GraphWorkspace/GraphWorkspace.tsx b/explorer/src/workspaces/GraphWorkspace/GraphWorkspace.tsx index d38d21ad..e077980b 100644 --- a/explorer/src/workspaces/GraphWorkspace/GraphWorkspace.tsx +++ b/explorer/src/workspaces/GraphWorkspace/GraphWorkspace.tsx @@ -40,6 +40,7 @@ import { type GraphPluginToolbarItem, } from "./plugins"; import { explorationEffectsShouldLoad, neighborhoodPanelShouldLoad, temporalOverlayShouldLoad } from "./pluginRegistryPredicates"; +import { shouldFetchTemporalBounds, shouldFetchTemporalSnapshot } from "./temporalLifecyclePredicates"; import type { LinkPrediction, PathResponse } from "./GraphInspectorPanel"; import type { GraphSceneHandle, GraphSceneRuntime } from "./scene"; import type { @@ -1440,7 +1441,18 @@ export function GraphWorkspace({ externalFocusNodeId, externalFocusToken }: Grap applyGraphReadySummary(summary); }, [applyGraphReadySummary, graphReady, summary]); + const canFetchTemporalBounds = shouldFetchTemporalBounds(summary); + const canFetchTemporalSnapshot = shouldFetchTemporalSnapshot({ + debouncedTime, + isLoading, + summary, + }); + useEffect(() => { + if (!canFetchTemporalBounds) { + return; + } + let cancelled = false; const loadBounds = async () => { try { @@ -1460,10 +1472,21 @@ export function GraphWorkspace({ externalFocusNodeId, externalFocusToken }: Grap return () => { cancelled = true; }; - }, [summary?.nodeCount, summary?.edgeCount]); + }, [ + canFetchTemporalBounds, + summary?.nodeCount, + summary?.edgeCount, + ]); useEffect(() => { - if (!debouncedTime || isLoading) return; + if (!canFetchTemporalSnapshot) { + return; + } + + if (!debouncedTime) { + return; + } + let cancelled = false; const applySnapshot = async () => { @@ -1505,7 +1528,10 @@ export function GraphWorkspace({ externalFocusNodeId, externalFocusToken }: Grap return () => { cancelled = true; }; - }, [debouncedTime, isLoading]); + }, [ + canFetchTemporalSnapshot, + debouncedTime, + ]); const resolveNodeIdForFocusedMode = useCallback(( nodeId: string, diff --git a/explorer/src/workspaces/GraphWorkspace/temporalLifecyclePredicates.ts b/explorer/src/workspaces/GraphWorkspace/temporalLifecyclePredicates.ts new file mode 100644 index 00000000..bea2575b --- /dev/null +++ b/explorer/src/workspaces/GraphWorkspace/temporalLifecyclePredicates.ts @@ -0,0 +1,31 @@ +import type { GraphLoadSummary } from "./types"; + +/** + * Predicates for gating GraphWorkspace temporal API requests. + * + * Temporal bounds and snapshot requests must strictly not execute until the + * initial graph load has succeeded (summary !== undefined). An empty graph + * (nodeCount: 0) is still a successful load and must not be rejected. + */ + +export function shouldFetchTemporalBounds( + summary: GraphLoadSummary | undefined, +): boolean { + return summary !== undefined; +} + +export function shouldFetchTemporalSnapshot({ + debouncedTime, + isLoading, + summary, +}: { + debouncedTime: Date | null; + isLoading: boolean; + summary: GraphLoadSummary | undefined; +}): boolean { + return ( + summary !== undefined && + debouncedTime !== null && + !isLoading + ); +} diff --git a/explorer/tests/temporalLifecycle.test.ts b/explorer/tests/temporalLifecycle.test.ts new file mode 100644 index 00000000..c9aa3191 --- /dev/null +++ b/explorer/tests/temporalLifecycle.test.ts @@ -0,0 +1,114 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + shouldFetchTemporalBounds, + shouldFetchTemporalSnapshot, +} from "../src/workspaces/GraphWorkspace/temporalLifecyclePredicates.ts"; +import type { GraphLoadSummary } from "../src/workspaces/GraphWorkspace/types.ts"; + +const sampleSummary: GraphLoadSummary = { + nodeCount: 42, + edgeCount: 78, + loadTimeMs: 120, + hasCoordinates: true, + layoutSource: "provided", + layoutReady: true, +}; + +const emptyGraphSummary: GraphLoadSummary = { + nodeCount: 0, + edgeCount: 0, + loadTimeMs: 15, + hasCoordinates: false, + layoutSource: "runtime", + layoutReady: false, +}; + +// ── shouldFetchTemporalBounds ──────────────────────────────────────────────── + +test("temporal bounds: false when summary is undefined (initial mount or failed load)", () => { + assert.equal( + shouldFetchTemporalBounds(undefined), + false, + "bounds request must not run before graph load succeeds", + ); +}); + +test("temporal bounds: true when non-empty summary is present", () => { + assert.equal( + shouldFetchTemporalBounds(sampleSummary), + true, + "bounds request should run when successful graph summary exists", + ); +}); + +test("temporal bounds: true when successful summary has nodeCount of 0", () => { + assert.equal( + shouldFetchTemporalBounds(emptyGraphSummary), + true, + "an empty graph is still a successful load and must allow bounds fetching", + ); +}); + +// ── shouldFetchTemporalSnapshot ────────────────────────────────────────────── + +test("temporal snapshot: false when summary is undefined even if scrubber time is set and isLoading is false", () => { + assert.equal( + shouldFetchTemporalSnapshot({ + debouncedTime: new Date("2024-01-01T00:00:00Z"), + isLoading: false, + summary: undefined, + }), + false, + "snapshot request must not run when graph load failed", + ); +}); + +test("temporal snapshot: false when graph is currently loading", () => { + assert.equal( + shouldFetchTemporalSnapshot({ + debouncedTime: new Date("2024-01-01T00:00:00Z"), + isLoading: true, + summary: sampleSummary, + }), + false, + "snapshot request must not run while graph is loading", + ); +}); + +test("temporal snapshot: false when debouncedTime is null", () => { + assert.equal( + shouldFetchTemporalSnapshot({ + debouncedTime: null, + isLoading: false, + summary: sampleSummary, + }), + false, + "snapshot request must not run without a scrubber timestamp", + ); +}); + +test("temporal snapshot: true when summary exists, isLoading is false, and time is set", () => { + assert.equal( + shouldFetchTemporalSnapshot({ + debouncedTime: new Date("2024-01-01T00:00:00Z"), + isLoading: false, + summary: sampleSummary, + }), + true, + "snapshot request should run after graph load succeeds and time is set", + ); +}); + +test("temporal snapshot: true when successful summary has 0 nodes, isLoading is false, and time is set", () => { + assert.equal( + shouldFetchTemporalSnapshot({ + debouncedTime: new Date("2024-01-01T00:00:00Z"), + isLoading: false, + summary: emptyGraphSummary, + }), + true, + "empty successful graph must allow snapshot requests once ready", + ); +}); From eaf51b3383216c7e3c24b15bc25b958de4e0d7f7 Mon Sep 17 00:00:00 2001 From: yzxcj797 <1784931579@qq.com> Date: Sat, 15 Aug 2026 23:50:47 +0800 Subject: [PATCH 050/105] fix(explorer): enable edge label rendering on the graph canvas --- explorer/src/workspaces/GraphWorkspace/GraphCanvas.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/explorer/src/workspaces/GraphWorkspace/GraphCanvas.tsx b/explorer/src/workspaces/GraphWorkspace/GraphCanvas.tsx index 998be21a..328bfd6c 100644 --- a/explorer/src/workspaces/GraphWorkspace/GraphCanvas.tsx +++ b/explorer/src/workspaces/GraphWorkspace/GraphCanvas.tsx @@ -162,7 +162,11 @@ const SIGMA_SETTINGS = { hideLabelsOnMove: true, hideEdgesOnMove: true, enableEdgeEvents: true, - renderEdgeLabels: false, + // #1009: edge labels (the edge `type` — "works_for", "leads", ...) were + // hardcoded off, so edge text never rendered regardless of data. The + // labelDensity / labelGridCellSize / labelRenderedSizeThreshold settings + // below already throttle label density for both nodes and edges. + renderEdgeLabels: true, labelDensity: 0.7, labelGridCellSize: 140, zIndex: true, From 5579851208ae5adbc813c787be8c8581d8bd2aed Mon Sep 17 00:00:00 2001 From: pravit-amp <43916793+pravit-amp@users.noreply.github.com> Date: Sat, 15 Aug 2026 09:40:04 -0700 Subject: [PATCH 051/105] fix(export): harden YAML export input handling (#958) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(export): centralize graph-payload key normalization Graph payloads circulate under two vocabularies, entities/relationships and nodes/edges, and consumers each reconciled them locally with competing idioms. The same payload could be exported, silently dropped, or rejected depending on which consumer read it. Add normalize_graph_payload() to utils.helpers as the single place that decision is made. Both spellings present with one empty resolves to the populated one, which is the shape JSONExporter emits; both non-empty and different is refused, since there is no basis to prefer either and picking one would silently discard the other; a non-empty mapping with no recognized key raises rather than returning empty collections, with require_recognized=False for callers that should degrade. Adopt it in the three exporters that genuinely alias. LPGExporter read nodes with entities as the default, so it dropped every entity when nodes was present but empty, losing everything on a JSON round-trip. ArangoAQLExporter had the same idiom plus a manual fallback. Neo4jCSVExporter routes its mapping branch through the shared resolver so the reference implementation cannot drift; its attribute branch stays local, since objects are not mappings. Also feed LPGExporter._generate_indexes the resolved entities. It read entities directly, so a nodes/edges payload produced no indexes even once node generation was fixed. CSVExporter and JSONExporter are deliberately excluded: they write entities, relationships, nodes and edges as separate outputs by design rather than reconciling two spellings of one collection, so normalizing there would rename output files. * fix(export): reject non-mapping input to the YAML exporters export_yaml declared Union[Dict[str, Any], List[Dict[str, Any]]], but both YAML exporters read their payload by key, so a list reached .get() and surfaced as a bare AttributeError from inside the exporter, naming neither the offending argument nor the shape expected. Reject rather than wrap. These formats distinguish entities from relationships from triplets, so inferring which collection a bare list represents would silently mislabel the records, and wrapping it under an unrecognised key would write a structurally valid file with every collection empty - trading a loud failure for silent data loss. Validate in the exporters, matching the existing precedent in Neo4jCSVExporter._normalize_graph, so direct users of the classes get the same contract as callers of the convenience wrapper. Narrow the wrapper type hint to Dict[str, Any] to match. * fix(export): address YAML exporter review findings - semantica/export/yaml_exporter.py — import Sequence from typing instead of collections.abc. `Sequence[str]` in _require_mapping's annotation is evaluated at function-definition time; collections.abc.Sequence only became subscriptable in Python 3.9, so on the 3.8 this project declares support for, importing this module raised TypeError. typing.Sequence has supported subscripting since 3.5.3. Mapping stays imported from collections.abc since it's only used for isinstance. - tests/export/test_yaml_exporter_input_validation.py — clean up each test's tempfile.mkdtemp() dir via addCleanup instead of leaking it, and read exported YAML through a context manager instead of an unclosed yaml.safe_load(open(...)). * fix(export): reject YAML export payloads with no recognized key Both YAML exporters built their output from a fixed set of `.get(key, [])` lookups, so a mapping keyed by anything else serialized to a structurally valid file with every collection empty. Nothing signalled the loss: no exception, no warning, and the progress log reported a completed export. The only way to notice was to open the file. The realistic trigger is re-exporting an `export_json` payload, whose `{"data", "count", "metadata"}` envelope drops every record. - SemanticNetworkYAMLExporter.export_semantic_network now resolves its collections through normalize_graph_payload(), which raises rather than returning empty collections for an unrecognized mapping. Adopting the shared resolver rather than repeating the check locally also brings the 'nodes'/'edges' aliases, so ContextGraph.to_dict() — the most direct path from this library's own graph type to YAML, used in examples/capability_gap_context_graphs_example.py — exports its records instead of an empty file. - export_for_pipeline built its nested semantic network from the same defaulted lookups and had the same defect; it goes through the resolver too. - YAMLSchemaExporter.export_ontology_schema gets the equivalent check over its own key set. Schemas are a separate vocabulary with no aliasing, so _require_recognized_keys lives in this module rather than in the shared graph resolver. - 'metadata' is deliberately not sufficient to make a payload recognized. An export_json envelope carries one, so accepting it would readmit the case this fix is most likely to be needed for. - An empty mapping is still exported: an empty graph is legitimate and has no records to lose. - SemanticNetworkYAMLExporter.export() serializes before creating the output directory, so a rejected export leaves nothing behind. The two rejections keep distinct exception types, following what the codebase already does: a payload of the wrong *type* cannot be exported at all and raises ProcessingError, matching Neo4jCSVExporter._normalize_graph; a mapping whose *contents* are unusable raises ValidationError, matching normalize_graph_payload. _require_mapping therefore runs first at every entry point, so a non-mapping never reaches the resolver. Docstring Raises sections, export_usage.md and docs/reference/export.md record the accepted input shapes and both failures. Closes #953. * fix(export): reject payloads whose records resolve to nothing Addresses the Qodo findings on #958. Presence-only recognition (finding 1): checking that a recognized key is present answered "did the caller use our vocabulary" when the question that matters is "did anything the caller supplied survive". A payload like {"entities": [], "data": [...records...]} cleared the check, resolved to empty, and dropped every record under 'data' -- the silent-empty export by a narrower route. - utils/helpers.py — split the check in two. _require_recognized_keys keeps the presence rule; _require_nothing_dropped runs after resolution and refuses a payload that resolved to nothing while an unread key still holds records. Only a non-empty list counts as evidence: ContextGraph.to_dict() always carries a populated 'statistics' dict, and an empty graph must stay exportable, so 'metadata', 'statistics' and 'count' are named as context rather than records. - export/yaml_exporter.py — the schema path had the same hole and now runs both checks through the shared helpers rather than its own copy, so the two vocabularies cannot drift apart in what counts as a silent-empty export. Progress reported success on a failed write (finding 3): export_semantic_ network stops its tracking as completed once serialization returns, but export() then creates the directory and writes the file. A failure there left the tracker showing a completed export with no output. - export/yaml_exporter.py — the serialization span now says it serialized, not that it exported, and export() opens its own span around the filesystem work that stops as failed on error. Nothing reports a completed export until the bytes are on disk. Finding 2 (export_yaml no longer accepts List[Dict]) is the intended resolution of #952 rather than a regression: wrapping a bare list under a guessed key is what would mislabel the records. The signature, docstring and PR description already record the narrowed contract. Tests cover both directions of each fix, including that an empty ContextGraph still exports and that a failing write is not reported as completed. * fix(export): validate collection values and make Neo4j mappings strict Two gaps at the boundary the shared normalizer is supposed to own. _resolve_collection() resolved on truthiness alone, so a recognized key could still hold something that is not a collection of records: {"entities": "abc"} normalized to three single-character "records", and {"entities": 42} surfaced as a raw TypeError from list() inside whichever exporter happened to read it, naming the exporter rather than the payload key at fault. Collection values are now validated before conversion -- strings, bytes, mappings, and non-iterable scalars are rejected by key name, and each element must be a mapping or an attribute-carrying object, the two record shapes the exporters actually read. None stays legal as an absent collection, the spelling a JSON round-trip produces for []; it cannot hide dropped records, since _require_nothing_dropped() still runs. Every spelling present is validated, not just the one that wins, so a malformed alias is not excused by a well-formed canonical key. Neo4jCSVExporter._normalize_graph() opted out of the recognized-key check for mappings, which left it able to turn {"data": [...]} into header-only CSVs indistinguishable from a genuinely empty graph -- the exact failure the rest of the change exists to prevent. Mapping payloads now go through normalize_graph_payload() on its default terms. The attribute path for graph objects is untouched. With no caller left opting out, the require_recognized flag is removed rather than kept as a way back into the silent-empty export. Regression tests cover the malformed values end to end through every export path that reads the normalizer, and assert the rejected Neo4j export writes no CSV files. * fix(export): close YAML schema and record validation gaps Fix 1 -- _require_usable_schema silent data loss (P1): _require_usable_schema() passed all values from _SCHEMA_KEYS into _require_nothing_dropped() as evidence that records survived. Scalar metadata fields such as version='1.0' and uri='http://...' are truthy strings, so any one of them caused _require_nothing_dropped() to return early and silently discard records stored under an unread key alongside them (e.g. {'version': '1.0', 'nodes': [{'id': 'c1'}]}). Fixed by building the resolved list from only non-empty list/tuple values of recognised schema keys. Fix 2 -- _is_record accepts modules and type objects (P2): _is_record() accepted any object with __dict__, which includes Python modules and class objects. Elements that passed _coerce_records then reached exporters and raised AttributeError (e.g. module 'math' has no attribute 'get') rather than a ValidationError at the validation boundary. Fixed by excluding types.ModuleType and type from the __dict__ branch while preserving support for all user-defined attribute-bearing record objects. Tests: 101 tests pass across tests/utils/test_normalize_graph_payload.py tests/export/test_yaml_exporter_key_recognition.py tests/export/test_yaml_exporter_input_validation.py tests/export/test_neo4j_csv_exporter.py * fix(export): close exception-type and record-shape gaps in normalize_graph_payload LPGExporter and ArangoAQLExporter called normalize_graph_payload() with no type guard, so non-mapping input raised ValidationError from inside the resolver while the YAML and Neo4j exporters raised ProcessingError for the identical mistake -- inconsistent with the exception-type contract this PR establishes. Both now use the shared _require_mapping() guard (moved from yaml_exporter.py into utils/helpers.py so all three can use it). Neo4jCSVExporter._normalize_graph checked isinstance(graph, dict), so a non-dict Mapping (MappingProxyType, ChainMap) fell through to the object-attribute branch and was rejected, even though the identical payload exported fine via the other three exporters. Now checks isinstance(graph, Mapping). normalize_graph_payload() accepts dataclass/attribute-bearing object records, but LPGExporter/ArangoAQLExporter call .get(...) directly on resolved entities -- an object-shaped record passed validation only to crash with a raw AttributeError once used, the exact failure this boundary exists to prevent. Records are now converted to plain dicts at the boundary (_coerce_records -> new _record_to_dict), so every consumer gets a uniform shape regardless of which reading the caller used. Two non-empty spellings of the same collection holding identical records in a different order were rejected as conflicting, since the check used plain list equality. Comparison is now an order-independent multiset of each record's canonical JSON form. * docs(changelog): add entry for #958 YAML export input hardening Documents the full arc of #958 -- the normalize_graph_payload() centralization, YAML input validation, both review rounds from @Sameer6305, and the exception-type/record-shape follow-up fixes -- plus closes #956, #952, #953. --------- Co-authored-by: Pravit Ampapathini Co-authored-by: Sameer Kadam Co-authored-by: KaifAhmad1 --- CHANGELOG.md | 16 + docs/reference/export.md | 13 + semantica/export/arango_aql_exporter.py | 24 +- semantica/export/export_usage.md | 68 +++ semantica/export/lpg_exporter.py | 40 +- semantica/export/methods.py | 22 +- semantica/export/neo4j_csv_exporter.py | 32 +- semantica/export/yaml_exporter.py | 195 ++++++- semantica/utils/__init__.py | 2 + semantica/utils/helpers.py | 373 +++++++++++++- tests/export/test_neo4j_csv_exporter.py | 57 ++ .../test_yaml_exporter_input_validation.py | 181 +++++++ .../test_yaml_exporter_key_recognition.py | 434 ++++++++++++++++ tests/utils/test_normalize_graph_payload.py | 487 ++++++++++++++++++ 14 files changed, 1897 insertions(+), 47 deletions(-) create mode 100644 tests/export/test_yaml_exporter_input_validation.py create mode 100644 tests/export/test_yaml_exporter_key_recognition.py create mode 100644 tests/utils/test_normalize_graph_payload.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 758fa407..a94fd882 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -62,6 +62,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **`export_yaml` raised a raw `AttributeError` on list input, silently wrote empty exports for unrecognized dict keys, and graph payloads were reconciled differently by every exporter** (#958, closes #956, #952, #953) by @pravit-amp, reviewed by @Sameer6305 + - Graph payloads circulate under two vocabularies, `entities`/`relationships` and `nodes`/`edges`, and each exporter reconciled them locally with a different idiom — `LPGExporter` in particular dropped every entity whenever `nodes` was present but empty, the exact shape `JSONExporter` emits. A new `normalize_graph_payload()` in `utils/helpers.py` centralizes that decision once, adopted by `LPGExporter`, `ArangoAQLExporter`, `Neo4jCSVExporter`, and both YAML exporters; `ContextGraph.to_dict()` now round-trips through YAML correctly as a result + - `export_yaml(records, path)` on a bare list previously failed with `AttributeError` from inside the exporter; it and the other YAML methods now reject non-mapping input with an actionable `ProcessingError` naming the expected keys, since these formats distinguish entities/relationships/triplets and guessing which one a list represents would mislabel the records + - `export_yaml({"data": [...]}, path)` previously wrote a structurally valid file with every collection empty, no exception, no warning, and the progress log reporting a completed export. `export_semantic_network`, `export_for_pipeline`, and `export_ontology_schema` now raise `ValidationError` when the payload shares no recognized key with what the method reads, or resolves to nothing while an unread key still holds records — an empty mapping is still accepted, since a genuinely empty graph has no records to lose + - **Breaking**: the two cases above, plus a bare list, now raise instead of returning cleanly with data silently dropped or a raw `AttributeError` from exporter internals. Migration: pass records under a recognized key (`{"entities": [...]}` / `{"nodes": [...]}` for `semantic_network`, `{"classes": [...]}` for `schema`) + - **Fixed during review** (Qodo): progress tracking could report a completed export before the output directory existed or the file was written; `export()` now creates the directory and serializes before starting tracking, so a rejected export leaves nothing behind + - **Fixed during review** (@Sameer6305, round 1): `normalize_graph_payload()`'s collection resolver treated any truthy value as a collection — `{"entities": "abc"}` silently became three single-character records, `{"entities": 42}` leaked a raw `TypeError` from inside `list()`. Collection values are now validated before conversion, rejecting strings/bytes/mappings/non-iterable scalars by name. Separately, `Neo4jCSVExporter._normalize_graph` called the shared resolver with `require_recognized=False`, so it alone kept accepting an unrecognized mapping as a silent empty export; the opt-out (introduced earlier in this same PR, with no other caller) was removed + - **Fixed during review** (@Sameer6305, round 2): `YAMLSchemaExporter`'s usable-schema check could treat scalar schema metadata (`version`, `uri`, `title`, `description`) as evidence records had been exported, letting records under an unread key drop silently; and `_is_record()` accepted modules and class/type objects through the generic `__dict__` path, which would have reached exporter internals instead of failing at the boundary. Both closed, with regression coverage + - **Fixed during final maintainer review** (before merge): four more gaps in the shared boundary that the earlier rounds didn't reach + - `LPGExporter`/`ArangoAQLExporter` called `normalize_graph_payload()` with no type guard, so non-mapping input raised `ValidationError` from inside the resolver — while YAML and `Neo4jCSVExporter` raised `ProcessingError` for the identical mistake, per this PR's own stated contract. The `_require_mapping()` guard that already existed in `yaml_exporter.py` is now shared from `utils/helpers.py` and used by all three + - `Neo4jCSVExporter._normalize_graph` checked `isinstance(graph, dict)`, so a non-dict `Mapping` (`MappingProxyType`, `ChainMap`) fell through to the object-attribute branch and was rejected, even though the identical payload exported fine via `LPGExporter`/`ArangoAQLExporter`/YAML. Now checks `isinstance(graph, Mapping)` + - `normalize_graph_payload()` accepts dataclass and attribute-bearing object records (`Neo4jCSVExporter._record_to_dict` reads them), but `LPGExporter`/`ArangoAQLExporter` call `.get(...)` directly on resolved entities — an object-shaped record passed validation only to crash with a raw `AttributeError` once used, the exact failure this PR's boundary exists to prevent. Records are now converted to plain dicts at the boundary (`_coerce_records` → new `_record_to_dict`), so every consumer gets a uniform shape regardless of which reading the caller used + - Two non-empty spellings of the same collection (e.g. `entities` and `nodes`) holding identical records in a different order were rejected as conflicting, since the check used plain list equality; a caller round-tripping through a dict-keyed cache or a set has no reason to preserve order. Comparison is now an order-independent multiset of each record's canonical JSON form + - New regression coverage in `tests/utils/test_normalize_graph_payload.py`: exception-type parity for non-mapping input across `export_lpg`/`export_arango`/`export_neo4j_csv`, dataclass-record conversion verified end-to-end through the same three exporters, `Neo4jCSVExporter` accepting a `MappingProxyType` payload, and reordered-alias equality (plus a duplicate-count case confirming the multiset check still catches real conflicts); 4 existing tests updated to assert the corrected dict-conversion behavior instead of the previous object passthrough + - `pytest tests/export tests/utils tests/context tests/test_export_module.py tests/test_export_methods_wrapper.py tests/test_notebooks_simulation.py`: 718 passed, 4 skipped (up from 641 passed, 62 subtests at PR submission); `black`/`isort`/`flake8 --max-line-length=88` clean on every line this PR touches; `python -m build`: succeeds + - **`ContextGraph.add_edge` had no dedupe — identical edges were stored repeatedly under one shared edge ID, and re-ingest doubled the edge set** (#926, closes #922) by @pravit-amp - `_add_internal_edge` appended to `self.edges`, `edge_type_index`, and `_adjacency` unconditionally, with no check for an edge already present. Edge identity is content-derived (`_resolve_edge_identity` builds `edge_id` from `source_id`/`target_id`/`edge_type`/`weight`/`metadata`/`valid_from`/`valid_until`), so two identical `add_edge` calls produced two edge objects sharing one `edge_id` — the graph already considered them the same edge, it just kept both copies. `self.nodes` already deduped by ID; edges did not, so `stats()["edge_count"]` inflated, `density()` could exceed its mathematical maximum of `1.0`, and a refresh/restore job calling `build_from_entities_and_relationships()` (or reloading a saved graph) doubled the edge set on every cycle - Added an `edge_id -> ContextEdge` index (`_edge_index`), mirroring how `self.nodes` dedupes by node ID. `_add_internal_edge` now returns `False` when the `edge_id` already exists, checked before touching `edges`/`edge_type_index`/`_adjacency` and before firing the mutation callback, so a repeat `add_edge` is a silent no-op with no phantom `ADD_EDGE` audit event diff --git a/docs/reference/export.md b/docs/reference/export.md index 49df715e..eef19e94 100644 --- a/docs/reference/export.md +++ b/docs/reference/export.md @@ -203,6 +203,13 @@ export_lpg(graph, "import.cypher", method="cypher") exporter = SemanticNetworkYAMLExporter() exporter.export(graph, "graph.yaml") ``` + + The YAML exporters read `entities`/`relationships`/`triplets` (with + `nodes`/`edges` accepted as aliases, so `ContextGraph.to_dict()` exports + directly). A non-empty mapping supplying none of them raises + `ValidationError` rather than writing a file with every collection empty, + as does one whose collection value is not a list of records + (`{"entities": "abc"}`). **LPGExporter** writes Cypher `CREATE` statements for Neo4j and Memgraph: @@ -236,6 +243,12 @@ export_lpg(graph, "import.cypher", method="cypher") Both exporters write to a file and return `None`. + `LPGExporter`, `ArangoAQLExporter`, and `Neo4jCSVExporter` resolve mapping + payloads on the same terms as the YAML exporters above, so an unrecognized + or malformed mapping is rejected instead of exported as an empty graph. + `Neo4jCSVExporter` still reads graph *objects* off their + `nodes`/`entities` and `edges`/`relationships` attributes. + **`ArangoAQLExporter.export()` and `LPGExporter.export()` write to a file and return `None`.** They do not return the AQL/Cypher string. Write to a file and read it back if you need the string. diff --git a/semantica/export/arango_aql_exporter.py b/semantica/export/arango_aql_exporter.py index 69140481..d5f9a571 100644 --- a/semantica/export/arango_aql_exporter.py +++ b/semantica/export/arango_aql_exporter.py @@ -28,7 +28,7 @@ import json from pathlib import Path from typing import Any, Dict, List, Optional, Union -from ..utils.helpers import ensure_directory +from ..utils.helpers import _require_mapping, ensure_directory, normalize_graph_payload from ..utils.logging import get_logger from ..utils.progress_tracker import get_progress_tracker @@ -204,17 +204,19 @@ class ArangoAQLExporter: self._generate_collection_creation(vertex_collection, edge_collection) ) - # Extract entities and relationships - entities = knowledge_graph.get("entities", []) - relationships = knowledge_graph.get("relationships", []) - nodes = knowledge_graph.get("nodes", entities) - edges = knowledge_graph.get("edges", relationships) + # A non-mapping payload cannot reach normalize_graph_payload(): it + # raises ValidationError for that case, which would leave this + # exporter alone in raising a different exception type than the YAML + # and Neo4j exporters raise for the identical mistake. + _require_mapping( + knowledge_graph, ("entities", "relationships", "nodes", "edges") + ) - # Use nodes/edges if entities/relationships are empty - if not entities and nodes: - entities = nodes - if not relationships and edges: - relationships = edges + # Accept either vocabulary; resolution is centralized so every + # exporter agrees on what a given payload means. + normalized = normalize_graph_payload(knowledge_graph) + entities = normalized["entities"] + relationships = normalized["relationships"] # Generate vertex INSERT statements vertex_statements = self._generate_vertex_inserts(entities, vertex_collection) diff --git a/semantica/export/export_usage.md b/semantica/export/export_usage.md index 140c5a01..7c8881e2 100644 --- a/semantica/export/export_usage.md +++ b/semantica/export/export_usage.md @@ -297,6 +297,57 @@ export_yaml(semantic_network, "network.yaml", method="semantic_network") export_yaml(schema, "schema.yaml", method="schema") ``` +### Accepted Input + +Both YAML exporters read their payload by key, so the input must be a mapping; +anything else raises `ProcessingError`. A bare list is rejected rather than +wrapped, since these formats distinguish entities from relationships from +triplets and guessing which one a list holds would mislabel the records. + +Each exporter then reads a fixed set of keys, and raises `ValidationError` on a +non-empty mapping that supplies none of them — such a payload would otherwise +serialize to a valid file with every collection empty. Naming a recognized key +is not enough on its own: `{"entities": [], "data": [...]}` also raises, since +nothing resolves while the records sit under a key the exporter never reads. + +| Method | Recognized keys | +| :--- | :--- | +| `"semantic_network"` | `entities` (alias `nodes`), `relationships` (alias `edges`), `triplets` | +| `"schema"` | `classes`, `properties`, `namespaces`, `uri`, `title`, `description`, `version` | + +`metadata` is carried through on both, but does not by itself make a payload +recognized — an `export_json` envelope (`{"data": [...], "count": N, +"metadata": {...}}`) carries one and is rejected. + +```python +# ContextGraph.to_dict() exports directly via the nodes/edges aliases +export_yaml(context_graph.to_dict(), "graph.yaml") + +# A bare list has no unambiguous meaning here +export_yaml(records, "out.yaml") # ProcessingError + +# An export_json payload is refused rather than written out empty +export_yaml({"data": records}, "out.yaml") # ValidationError + +# ...and so is one that names a recognized key but leaves it empty +export_yaml({"entities": [], "data": records}, "out.yaml") # ValidationError +``` + +The value under a recognized key must be a collection of records — a list or +tuple of mappings or objects. A string, a bare mapping, or a scalar raises +`ValidationError` naming the key, rather than being iterated into +character-sized "records" or surfacing as a `TypeError` from inside the +exporter. `None` is read as an absent collection, the same as `[]`. + +```python +export_yaml({"entities": "abc"}, "out.yaml") # ValidationError +export_yaml({"entities": 42}, "out.yaml") # ValidationError +export_yaml({"nodes": {"id": "n1"}}, "out.yaml") # ValidationError — wrap it in a list +``` + +An empty mapping is still accepted: an empty graph is a legitimate export and +has no records to lose. + ## OWL Export ### OWL/XML Format @@ -479,6 +530,23 @@ Pass `validate=True` to run a post-export integrity check before returning: export_neo4j_csv(kg, "neo4j_import/", validate=True) ``` +#### Accepted Input + +Mapping payloads are read on the same terms as the YAML exporters (see [Accepted +Input](#accepted-input) above): `entities`/`relationships`, with `nodes`/`edges` +accepted as aliases. A non-empty mapping that supplies neither — or that supplies +a malformed collection value — raises `ValidationError` rather than writing +header-only CSVs indistinguishable from a genuinely exported empty graph. The +payload is normalized before any file is opened, so a rejected export writes +nothing. + +Graph *objects* are unaffected: they are still read off `nodes`/`entities` and +`edges`/`relationships` attributes. + +```python +export_neo4j_csv({"data": [{"id": "e1"}]}, "neo4j_import/") # ValidationError +``` + #### Importing into Neo4j Once the CSV files are generated, they can be imported into a new Neo4j database using the `neo4j-admin database import` command: diff --git a/semantica/export/lpg_exporter.py b/semantica/export/lpg_exporter.py index 40f688a5..06ac4554 100644 --- a/semantica/export/lpg_exporter.py +++ b/semantica/export/lpg_exporter.py @@ -24,7 +24,7 @@ from pathlib import Path from typing import Any, Dict, List, Optional, Union from ..utils.exceptions import ProcessingError, ValidationError -from ..utils.helpers import ensure_directory +from ..utils.helpers import _require_mapping, ensure_directory, normalize_graph_payload from ..utils.logging import get_logger from ..utils.progress_tracker import get_progress_tracker @@ -154,15 +154,26 @@ class LPGExporter: """ queries = [] - # Generate indexes if requested - if self.include_indexes: - queries.extend(self._generate_indexes(knowledge_graph)) + # A non-mapping payload cannot reach normalize_graph_payload(): it + # raises ValidationError for that case, which would leave this + # exporter alone in raising a different exception type than the YAML + # and Neo4j exporters raise for the identical mistake. + _require_mapping( + knowledge_graph, ("entities", "relationships", "nodes", "edges") + ) - # Extract entities and relationships - entities = knowledge_graph.get("entities", []) - relationships = knowledge_graph.get("relationships", []) - nodes = knowledge_graph.get("nodes", entities) - edges = knowledge_graph.get("edges", relationships) + # Accept either vocabulary. Reading 'nodes' with 'entities' as the + # default dropped every entity when 'nodes' was present but empty -- + # the shape JSONExporter emits -- so resolution is centralized. + normalized = normalize_graph_payload(knowledge_graph) + nodes = normalized["entities"] + edges = normalized["relationships"] + + # Generate indexes if requested. Fed the normalized entities so index + # generation sees the same records as node generation; reading + # 'entities' directly here skipped indexes for nodes/edges payloads. + if self.include_indexes: + queries.extend(self._generate_indexes(nodes)) # Generate node creation queries node_queries = self._generate_node_queries(nodes) @@ -174,13 +185,18 @@ class LPGExporter: return queries - def _generate_indexes(self, knowledge_graph: Dict[str, Any]) -> List[str]: - """Generate Cypher index and constraint creation queries.""" + def _generate_indexes(self, entities: List[Dict[str, Any]]) -> List[str]: + """Generate Cypher index and constraint creation queries. + + Args: + entities: Entity records, already resolved from whichever + vocabulary the caller supplied. + """ indexes = [] # Get unique entity types for labels entity_types = set() - for entity in knowledge_graph.get("entities", []): + for entity in entities: entity_type = entity.get("type") or entity.get("entity_type") if entity_type: entity_types.add(entity_type) diff --git a/semantica/export/methods.py b/semantica/export/methods.py index 6cec22c4..d2af2581 100644 --- a/semantica/export/methods.py +++ b/semantica/export/methods.py @@ -494,7 +494,7 @@ def export_graph( def export_yaml( - data: Union[Dict[str, Any], List[Dict[str, Any]]], + data: Dict[str, Any], file_path: Union[str, Path], method: str = "semantic_network", **kwargs, @@ -504,14 +504,32 @@ def export_yaml( This is a user-friendly wrapper that exports data to YAML format. + Unlike :func:`export_json` and :func:`export_csv`, which treat a list as + opaque records, both YAML methods are keyed formats: they distinguish + entities from relationships from triplets (and classes from properties + for ``method="schema"``). A bare list is therefore rejected rather than + guessed at, since inferring which collection it represents would silently + mislabel the records. + Args: - data: Data to export (semantic network, entities, relationships) + data: Data to export, as a mapping. For ``method="semantic_network"``, + keyed by 'entities'/'relationships'/'triplets'; for + ``method="schema"``, by 'classes'/'properties'. file_path: Output YAML file path method: Export method (default: "semantic_network") - "semantic_network": Semantic network YAML export - "schema": Schema YAML export **kwargs: Additional options passed to YAML exporters + Raises: + ProcessingError: if ``data`` is not a mapping, or if ``method`` is not + a known YAML export method. + ValidationError: if ``data`` is a mapping whose keys the selected + exporter does not read -- an ``export_json`` envelope + (``{"data": [...], "count": N, "metadata": {...}}``) is the + common case. Such a payload used to be written out as a valid + YAML file with every collection empty. + Examples: >>> from semantica.export.methods import export_yaml >>> export_yaml(semantic_network, "network.yaml", method="semantic_network") diff --git a/semantica/export/neo4j_csv_exporter.py b/semantica/export/neo4j_csv_exporter.py index 7a3461d1..8dbbd31b 100644 --- a/semantica/export/neo4j_csv_exporter.py +++ b/semantica/export/neo4j_csv_exporter.py @@ -32,12 +32,13 @@ from __future__ import annotations import csv import hashlib import json +from collections.abc import Mapping from dataclasses import asdict, is_dataclass from pathlib import Path from typing import Any, Dict, Iterable, List, Optional, Sequence, Union from ..utils.exceptions import ProcessingError, ValidationError -from ..utils.helpers import ensure_directory +from ..utils.helpers import ensure_directory, normalize_graph_payload from ..utils.logging import get_logger from ..utils.progress_tracker import get_progress_tracker @@ -173,6 +174,19 @@ class Neo4jCSVExporter: Returns: Mapping with ``"nodes"`` and ``"relationships"`` output paths. + + Raises: + ValidationError: if a mapping payload carries no recognized graph + key, resolves to nothing while an unread key still holds + records, or holds something other than records under one -- + see + :func:`~semantica.utils.helpers.normalize_graph_payload`. + Each would otherwise be written out as header-only CSVs + indistinguishable from a genuinely empty graph. The payload is + normalized before any file is opened, so a rejected export + writes nothing. + ProcessingError: if a non-mapping payload exposes none of the + graph attributes. """ output_dir = Path(output_dir) ensure_directory(output_dir) @@ -494,9 +508,19 @@ class Neo4jCSVExporter: return prepared def _normalize_graph(self, graph: Any) -> Dict[str, List[Dict[str, Any]]]: - if isinstance(graph, dict): - nodes = graph.get("nodes") or graph.get("entities") or [] - relationships = graph.get("edges") or graph.get("relationships") or [] + if isinstance(graph, Mapping): + # Mapping payloads go through the shared resolver on its default + # terms, so this backend cannot drift from the others: an + # unrecognized mapping raises here rather than writing header-only + # CSVs that read as a successful export of an empty graph. Checked + # against Mapping rather than dict, so a non-dict Mapping (a + # MappingProxyType, a ChainMap) takes this path too, instead of + # falling through to the attribute branch below and being rejected + # as an unrecognized object -- the LPG, Arango, and YAML exporters + # already accept such payloads via the same resolver. + resolved = normalize_graph_payload(graph) + nodes = resolved["entities"] + relationships = resolved["relationships"] else: nodes = getattr(graph, "nodes", None) if nodes is None: diff --git a/semantica/export/yaml_exporter.py b/semantica/export/yaml_exporter.py index 0249ec08..ecbd55fa 100644 --- a/semantica/export/yaml_exporter.py +++ b/semantica/export/yaml_exporter.py @@ -21,15 +21,83 @@ Author: Semantica Contributors License: MIT """ +from collections.abc import Mapping from datetime import datetime from pathlib import Path from typing import Any, Dict, List, Optional, Union -from ..utils.exceptions import ProcessingError, ValidationError -from ..utils.helpers import ensure_directory +from ..utils.exceptions import ValidationError +from ..utils.helpers import ( + _require_mapping, + _require_nothing_dropped, + _require_recognized_keys, + ensure_directory, + normalize_graph_payload, +) from ..utils.logging import get_logger from ..utils.progress_tracker import get_progress_tracker +# Keys YAMLSchemaExporter.export_ontology_schema reads. Graph payloads use the +# recognized set owned by normalize_graph_payload() instead; schemas are a +# separate vocabulary with no aliasing, so the set lives here. +_SCHEMA_KEYS = ( + "classes", + "properties", + "namespaces", + "uri", + "title", + "description", + "version", +) + + +def _require_usable_schema(ontology: Mapping) -> None: + """Reject a schema mapping this exporter cannot read. + + Two ways an ontology mapping produces an empty file: it shares no key with + the recognized set at all, or it names a recognized key that is empty + while the real records sit under a key this exporter does not read + (``{"classes": [], "nodes": [...]}``). Both are refused, using the same + checks the graph payloads go through, so the two vocabularies cannot drift + apart in what they consider a silent-empty export. + + An empty mapping is allowed through: it carries nothing that could be + lost, and an empty export is a legitimate result. + + Note the deliberate split in exception types, which the codebase already + makes: a wrong *type* cannot be exported at all and raises + ProcessingError, matching ``Neo4jCSVExporter._normalize_graph``; a mapping + whose *contents* are unusable raises ValidationError, matching + ``normalize_graph_payload``. + + Args: + ontology: Mapping already checked by :func:`_require_mapping`. + + Raises: + ValidationError: if the mapping shares no key with ``_SCHEMA_KEYS``, + or resolves to nothing while an unread key still holds records. + """ + _require_recognized_keys(ontology, _SCHEMA_KEYS, what="Ontology schema") + # Only non-empty list/tuple values from recognized schema keys count as + # evidence that records survived export. Scalar metadata fields such as + # 'uri', 'title', 'description', and 'version' are truthy strings, but + # their presence does not mean the caller's record collections were + # exported -- passing them as ``resolved`` would let any scalar value + # short-circuit the dropped-records check and silently discard a list + # under an unread key alongside e.g. {"version": "1.0", "nodes": [...]}. + resolved = [ + v + for key in _SCHEMA_KEYS + for v in (ontology.get(key),) + if isinstance(v, (list, tuple)) and v + ] + _require_nothing_dropped( + ontology, + _SCHEMA_KEYS, + resolved, + what="Ontology schema", + ) + class SemanticNetworkYAMLExporter: """ @@ -90,15 +158,39 @@ class SemanticNetworkYAMLExporter: Args: semantic_network: Semantic network dictionary containing: - - entities: List of entity dictionaries + - entities: List of entity dictionaries (alias: 'nodes') - relationships: List of relationship dictionaries + (alias: 'edges') - triplets: List of triplet dictionaries (optional) - metadata: Metadata dictionary (optional) + + Key resolution is delegated to + :func:`~semantica.utils.helpers.normalize_graph_payload`, so + ``ContextGraph.to_dict()`` output ('nodes'/'edges') exports + directly. **options: Additional export options (unused) Returns: String containing YAML representation of semantic network + Raises: + ProcessingError: if ``semantic_network`` is not a mapping. A bare + list of records cannot be exported here because this format + distinguishes entities, relationships, and triplets, and + guessing which one a list represents would silently mislabel + it. + ValidationError: if the mapping carries both spellings of a + collection with different contents; if it is non-empty and + shares no key with the recognized set; or if it resolves to + nothing while an unread key still holds records + (``{"entities": [], "data": [...]}``). Each previously + serialized to a file with every collection empty while the log + reported success. An empty mapping is still accepted -- it has + no records to lose. Note that 'metadata' alone is not a + recognized key: an ``export_json`` envelope carries one, and + accepting it would readmit the silent-empty export it is the + most likely source of. + Example: >>> network = { ... "entities": [...], @@ -107,6 +199,8 @@ class SemanticNetworkYAMLExporter: ... } >>> yaml_str = exporter.export_semantic_network(network) """ + _require_mapping(semantic_network, ("entities", "relationships", "triplets")) + # Track YAML export tracking_id = self.progress_tracker.start_tracking( file=None, @@ -119,15 +213,14 @@ class SemanticNetworkYAMLExporter: self.progress_tracker.update_tracking( tracking_id, message="Preparing YAML data..." ) + records = normalize_graph_payload(semantic_network) yaml_data = { "metadata": { "exported_at": datetime.now().isoformat(), "version": "1.0", **semantic_network.get("metadata", {}), }, - "entities": semantic_network.get("entities", []), - "relationships": semantic_network.get("relationships", []), - "triplets": semantic_network.get("triplets", []), + **records, } self.progress_tracker.update_tracking( @@ -140,7 +233,7 @@ class SemanticNetworkYAMLExporter: self.progress_tracker.stop_tracking( tracking_id, status="completed", - message="Exported semantic network to YAML", + message="Serialized semantic network to YAML", ) return result @@ -160,16 +253,46 @@ class SemanticNetworkYAMLExporter: data: Data to export file_path: Output file path **options: Additional options + + Raises: + ProcessingError: if ``data`` is not a mapping. + ValidationError: on the mappings :meth:`export_semantic_network` + rejects. Serialization runs before the output directory is + created, so a rejected export leaves nothing behind. + OSError: if the file cannot be written. The write is tracked + separately from serialization, so no progress entry reports a + completed export until the bytes are on disk. """ file_path = Path(file_path) - ensure_directory(file_path.parent) - yaml_content = self.export_semantic_network(data, **options) - with open(file_path, "w", encoding="utf-8") as f: - f.write(yaml_content) + # Serialization reports its own completion, but it says nothing about + # the file: without this second span, a failing write would leave the + # tracker showing a completed export and no output. + tracking_id = self.progress_tracker.start_tracking( + file=str(file_path), + module="export", + submodule="SemanticNetworkYAMLExporter", + message=f"Writing YAML to {file_path}", + ) - self.logger.info(f"Exported YAML to: {file_path}") + try: + ensure_directory(file_path.parent) + with open(file_path, "w", encoding="utf-8") as f: + f.write(yaml_content) + + self.logger.info(f"Exported YAML to: {file_path}") + self.progress_tracker.stop_tracking( + tracking_id, + status="completed", + message=f"Exported YAML to: {file_path}", + ) + + except Exception as e: + self.progress_tracker.stop_tracking( + tracking_id, status="failed", message=str(e) + ) + raise def export_entities( self, entities: List[Dict[str, Any]], include_metadata: bool = True, **options @@ -263,18 +386,34 @@ class SemanticNetworkYAMLExporter: • Structure for definition generation • Include extraction metadata • Return pipeline-ready YAML + + Args: + extracted_data: Semantic network mapping, read through + :func:`~semantica.utils.helpers.normalize_graph_payload` on + the same terms as :meth:`export_semantic_network`. + pipeline_stage: Stage number recorded in the output. + **options: Additional export options (unused) + + Returns: + Pipeline-ready YAML string. + + Raises: + ProcessingError: if ``extracted_data`` is not a mapping. + ValidationError: on the same mappings as + :meth:`export_semantic_network` -- this method built its + nested semantic network from the same defaulted lookups and + so had the same silent-empty failure. """ + _require_mapping(extracted_data, ("entities", "relationships", "triplets")) + + semantic_network = normalize_graph_payload(extracted_data) yaml_data = { "pipeline_stage": pipeline_stage, "metadata": { "extracted_at": datetime.now().isoformat(), **extracted_data.get("metadata", {}), }, - "semantic_network": { - "entities": extracted_data.get("entities", []), - "relationships": extracted_data.get("relationships", []), - "triplets": extracted_data.get("triplets", []), - }, + "semantic_network": semantic_network, } return self.yaml.dump(yaml_data, default_flow_style=False, sort_keys=False) @@ -308,7 +447,29 @@ class YAMLSchemaExporter: • Include hierarchies and constraints • Structure for easy editing • Return YAML schema + + Args: + ontology: Ontology mapping keyed by any of 'classes', + 'properties', 'namespaces', 'uri', 'title', 'description', + 'version'. + **options: Additional export options (unused) + + Returns: + YAML schema string. + + Raises: + ProcessingError: if ``ontology`` is not a mapping. + ValidationError: if ``ontology`` is a non-empty mapping sharing + no key with the recognized set, or resolves to nothing while + an unread key still holds records + (``{"classes": [], "nodes": [...]}``) -- each previously + produced a file with empty 'classes', 'properties' and + 'namespaces' and no indication anything was dropped. An empty + mapping is still accepted. """ + _require_mapping(ontology, ("classes", "properties")) + _require_usable_schema(ontology) + yaml_data = { "ontology": { "uri": ontology.get("uri", ""), diff --git a/semantica/utils/__init__.py b/semantica/utils/__init__.py index bd0e7e2d..002a139d 100644 --- a/semantica/utils/__init__.py +++ b/semantica/utils/__init__.py @@ -80,6 +80,7 @@ from .helpers import ( hash_data, merge_dicts, normalize_entities, + normalize_graph_payload, parse_timestamp, read_json_file, retry_on_error, @@ -183,6 +184,7 @@ __all__ = [ "format_data", "clean_text", "normalize_entities", + "normalize_graph_payload", "hash_data", "safe_filename", "ensure_directory", diff --git a/semantica/utils/helpers.py b/semantica/utils/helpers.py index 21f6cfc2..7462f6db 100644 --- a/semantica/utils/helpers.py +++ b/semantica/utils/helpers.py @@ -63,9 +63,16 @@ import importlib import json import os import re +import types +from collections import Counter +from collections.abc import Iterable as IterableABC +from collections.abc import Mapping +from dataclasses import asdict, is_dataclass from datetime import datetime, timezone from pathlib import Path -from typing import Any, Dict, List, Optional, Tuple, Type, Union +from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple, Type, Union + +from .exceptions import ProcessingError, ValidationError def format_data(data: Any, format_type: str = "json") -> str: @@ -584,3 +591,367 @@ def classify_path_distance(hop_count: int) -> str: if hop_count <= 6: return "mid-range" return "distant" + + +# Graph payloads circulate under two vocabularies: 'entities'/'relationships' +# (kg builders, most exporters) and 'nodes'/'edges' (ContextGraph.to_dict, +# Neo4jCSVExporter, the Explorer routes). Consumers each reconciled them +# locally, with at least three competing idioms, so the same payload could be +# exported, silently dropped, or rejected depending on which consumer read it. +# This is the single place that decision is made. +_ENTITY_KEYS = ("entities", "nodes") +_RELATIONSHIP_KEYS = ("relationships", "edges") +_TRIPLET_KEYS = ("triplets",) + +# Keys that legitimately travel alongside the collections without being +# records themselves, so their presence is never evidence that records were +# dropped: ContextGraph.to_dict() carries 'statistics', JSON envelopes carry +# 'metadata' and 'count'. +_CONTEXT_KEYS = ("metadata", "statistics", "count") + + +def _require_recognized_keys( + payload: Mapping, recognized_keys: Sequence[str], *, what: str +) -> None: + """Reject a mapping that shares no key with the recognized set. + + A consumer that reads a fixed set of keys turns an unrecognized mapping + into an empty result that looks like a legitimate one. An empty mapping is + allowed through -- it carries nothing that could be lost. + + Args: + payload: Mapping to check. + recognized_keys: Keys the consumer reads. + what: Noun for the error message, e.g. ``"Graph payload"``. + + Raises: + ValidationError: if ``payload`` is non-empty and shares no key with + ``recognized_keys``. + """ + if not payload or any(key in payload for key in recognized_keys): + return + + supplied = ", ".join(f"'{key}'" for key in sorted(map(str, payload))) + expected = ", ".join(f"'{key}'" for key in recognized_keys) + raise ValidationError( + f"{what} has no recognized key. Supplied: {supplied}. " + f"Expected at least one of: {expected}." + ) + + +def _require_nothing_dropped( + payload: Mapping, + recognized_keys: Sequence[str], + resolved: Iterable[Any], + *, + what: str, +) -> None: + """Reject a mapping that resolved to nothing while still holding records. + + Checking that a recognized key is *present* is not enough: + ``{"entities": [], "data": [...]}`` clears that bar and still resolves to + empty, dropping every record under 'data'. Presence answers "did the + caller use our vocabulary"; this answers the question that actually + matters, "did anything the caller supplied survive". + + Only non-empty lists count as evidence of dropped records. A payload can + carry scalars and dicts that are not collections -- ContextGraph.to_dict() + always includes 'statistics' -- and an empty graph must stay exportable. + + Args: + payload: Mapping to check. + recognized_keys: Keys the consumer reads. + resolved: The collections the consumer resolved from ``payload``. + what: Noun for the error message, e.g. ``"Graph payload"``. + + Raises: + ValidationError: if nothing resolved and an unread key holds a + non-empty list. + """ + if any(resolved): + return + + dropped = sorted( + str(key) + for key, value in payload.items() + if key not in recognized_keys + and key not in _CONTEXT_KEYS + and isinstance(value, (list, tuple)) + and value + ) + if not dropped: + return + + named = ", ".join(f"'{key}'" for key in dropped) + expected = ", ".join(f"'{key}'" for key in recognized_keys) + raise ValidationError( + f"{what} resolved to nothing, but {named} still holds records. " + f"Exporting it would drop them silently. Supply the records under " + f"one of: {expected}." + ) + + +def _is_record(value: Any) -> bool: + """Report whether a value can stand in for a graph record. + + Consumers read records either as mappings (``entity.get("type")`` in the + LPG and Arango exporters) or as objects with attributes + (``Neo4jCSVExporter._record_to_dict`` accepts dataclasses and anything + carrying a ``__dict__``). Both are legitimate, so both are accepted here; + strings, numbers, and nested sequences are not records under either + reading. + + Modules and class/type objects are excluded even though they carry + ``__dict__``: they are not graph records under any supported reading, and + passing them through the boundary would produce ``AttributeError`` inside + exporters rather than a ``ValidationError`` at the boundary where the + problem is visible. + """ + return isinstance(value, Mapping) or is_dataclass(value) or ( + hasattr(value, "__dict__") + and not isinstance(value, (types.ModuleType, type)) + ) + + +def _record_to_dict(record: Any) -> Dict[str, Any]: + """Convert an accepted record to a plain dict. + + :func:`_is_record` accepts mappings, dataclasses, and objects carrying + ``__dict__`` as legitimate record shapes, but consumers of + :func:`normalize_graph_payload` -- YAML serialization, ``entity.get(...)`` + in the LPG and Arango exporters -- read records as dicts. Converting here, + at the boundary, means every exporter gets the same shape regardless of + which reading the caller used; previously only ``Neo4jCSVExporter`` + converted object-shaped records locally, so a dataclass record passed + validation for the other exporters only to crash with a raw + ``AttributeError`` once used. + """ + if isinstance(record, Mapping): + return dict(record) + if is_dataclass(record): + return asdict(record) + return { + key: value for key, value in vars(record).items() if not key.startswith("_") + } + + +def _coerce_records(key: str, value: Any) -> List[Any]: + """Validate one collection value and materialize it as a list of records. + + This runs before any truthiness or ``list()`` call, because both mislead + on malformed input: ``list("abc")`` quietly turns a string into three + single-character "records", and ``list(42)`` raises a bare ``TypeError`` + from deep inside the exporter that named the exporter rather than the + offending payload key. Neither reaches the caller as an actionable + message, so the shapes that produce them are rejected by name instead. + + ``None`` is deliberately not rejected: JSON round-trips an absent + collection to null, and treating that as "no records under this key" is + the same answer an explicit ``[]`` gets. It is not silent data loss -- + a null collection alongside records under an unread key is still caught + by :func:`_require_nothing_dropped`. + + Args: + key: Payload key the value came from, for the error message. + value: The raw value stored under ``key``. + + Returns: + The records as a new list, so the result never aliases the input. + + Raises: + ValidationError: if ``value`` is a string, bytes, a mapping, or any + non-iterable scalar; or if any element is not a record. + """ + if value is None: + return [] + + if isinstance(value, (str, bytes, bytearray)): + raise ValidationError( + f"Graph payload key '{key}' holds a {type(value).__name__}, not a " + f"collection of records. Iterating it would yield characters, not " + f"records. Supply a list of records." + ) + + if isinstance(value, Mapping): + raise ValidationError( + f"Graph payload key '{key}' holds a mapping, not a collection of " + f"records. If it is a single record, wrap it in a list; if it is " + f"keyed by ID, supply its values as a list." + ) + + if not isinstance(value, IterableABC): + raise ValidationError( + f"Graph payload key '{key}' holds a " + f"{type(value).__name__}, not a collection of records. Supply a " + f"list of records." + ) + + records = list(value) + for index, record in enumerate(records): + if not _is_record(record): + raise ValidationError( + f"Graph payload key '{key}' holds a " + f"{type(record).__name__} at index {index}, not a record. " + f"Records must be mappings or objects with attributes." + ) + return [_record_to_dict(record) for record in records] + + +def _canonical_record_multiset(records: List[Dict[str, Any]]) -> "Counter[str]": + """Represent records as an order-independent multiset for equality checks. + + Two spellings of the same collection (``entities`` and ``nodes``) can + legitimately list identical records in a different order -- a caller + round-tripping through a dict-keyed cache or a set has no reason to + preserve list order. Comparing with plain list equality would treat that + as a conflict and reject a payload that carries no real data loss, so + records are compared as a multiset of their canonical JSON form instead. + """ + return Counter( + json.dumps(record, sort_keys=True, default=str) for record in records + ) + + +def _resolve_collection( + payload: Dict[str, Any], keys: Tuple[str, ...] +) -> List[Dict[str, Any]]: + """Pick one collection from a payload that may use either vocabulary. + + Both spellings may legitimately be present: ``JSONExporter`` writes + 'entities' and 'nodes' side by side, so a round-trip of its output carries + both, one of them empty. Where only one holds records, that one wins. + + Two non-empty, unequal spellings are a different matter -- there is no + basis for preferring either, and picking one would silently discard the + other -- so that is refused rather than guessed at. + + Every spelling present is validated, not just the one that wins: a + malformed 'nodes' alongside a well-formed 'entities' is a payload the + caller should hear about, and validating only the winner would let it + through on the strength of the other key. + + Args: + payload: Mapping to read from. + keys: Accepted spellings, most canonical first. + + Returns: + The resolved collection, or an empty list if no spelling is present. + + Raises: + ValidationError: if a spelling holds something other than a collection + of records; or if two spellings are both present, both non-empty, + and hold different records, order ignored. + """ + present = { + key: _coerce_records(key, payload[key]) for key in keys if key in payload + } + populated = {key: value for key, value in present.items() if value} + + if len(populated) > 1: + values = list(populated.values()) + canonical = [_canonical_record_multiset(value) for value in values] + if any(entry != canonical[0] for entry in canonical[1:]): + named = " and ".join(f"'{key}'" for key in populated) + raise ValidationError( + f"Graph payload carries {named} with different contents; " + f"cannot determine which to export. Supply one, or make them " + f"identical." + ) + + for key in keys: + value = present.get(key) + if value: + # Already a fresh list from _coerce_records, so the result cannot + # alias the caller's collection. + return value + + # Every spelling present is empty (or none is): an explicit empty + # collection is a legitimate answer, distinct from "unrecognized". + return [] + + +def _require_mapping(data: Any, expected_keys: Sequence[str]) -> None: + """Reject non-mapping export input with an actionable error. + + Shared by every consumer of :func:`normalize_graph_payload` so that a + wrong *type* fails the same way everywhere. Handed a sequence (or any + other non-mapping), every downstream key lookup would fail with a bare + ``AttributeError: 'list' object has no attribute 'get'``, which tells the + caller nothing about the shape expected -- and ``normalize_graph_payload`` + itself raises ``ValidationError`` for this case, which would leave + exporters that skip this guard raising a different exception type than + the ones that call it, for the identical mistake. + + A list is rejected rather than wrapped: these formats distinguish + entities from relationships from triplets (or nodes/edges), so inferring + which one a bare list represents would silently mislabel the records. + + Args: + data: Candidate export payload. + expected_keys: Key names the caller reads, named in the error so the + caller learns the expected shape. + + Raises: + ProcessingError: if ``data`` is not a mapping. + """ + if not isinstance(data, Mapping): + keys = "/".join(f"'{key}'" for key in expected_keys) + raise ProcessingError( + f"Cannot export object of type '{type(data).__name__}': " + f"expected a dict with {keys}." + ) + + +def normalize_graph_payload( + payload: Dict[str, Any], +) -> Dict[str, List[Dict[str, Any]]]: + """Reduce a graph payload to one canonical vocabulary. + + Accepts either 'entities'/'relationships' or 'nodes'/'edges' (or a mix) + and returns the canonical spelling, so consumers read one shape instead of + reimplementing the reconciliation. + + This is the validation boundary for graph payloads: it either returns + collections of records or raises. Nothing that reaches an exporter through + it needs re-checking, and nothing malformed passes through it as a + valid-looking empty graph. + + Args: + payload: Graph payload mapping. + + Returns: + ``{"entities": [...], "relationships": [...], "triplets": [...]}``. + + Raises: + ValidationError: if ``payload`` is not a mapping; if a recognized key + holds something other than a collection of records; if two + spellings of the same collection are both non-empty and differ; if + a non-empty mapping contains no recognized key; or if it resolves + to nothing while an unread key still holds records. The last two + would otherwise hand the caller a valid-looking result with their + records silently dropped. + + Example: + >>> normalize_graph_payload({"nodes": [{"id": "n1"}], "edges": []}) + {'entities': [{'id': 'n1'}], 'relationships': [], 'triplets': []} + """ + if not isinstance(payload, Mapping): + raise ValidationError( + f"Cannot normalize graph payload of type " + f"'{type(payload).__name__}': expected a mapping." + ) + + recognized = _ENTITY_KEYS + _RELATIONSHIP_KEYS + _TRIPLET_KEYS + _require_recognized_keys(payload, recognized, what="Graph payload") + + resolved = { + "entities": _resolve_collection(payload, _ENTITY_KEYS), + "relationships": _resolve_collection(payload, _RELATIONSHIP_KEYS), + "triplets": _resolve_collection(payload, _TRIPLET_KEYS), + } + + _require_nothing_dropped( + payload, recognized, resolved.values(), what="Graph payload" + ) + + return resolved diff --git a/tests/export/test_neo4j_csv_exporter.py b/tests/export/test_neo4j_csv_exporter.py index 529f7bd4..e7f4d0c9 100644 --- a/tests/export/test_neo4j_csv_exporter.py +++ b/tests/export/test_neo4j_csv_exporter.py @@ -301,3 +301,60 @@ def test_nested_properties_are_json_serialized(tmp_path): by_id = {row[0]: row for row in rows[1:]} assert by_id["node1"][2] == '{"k":"v"}' assert by_id["node1"][3] == "[1,2,3]" + + +def test_unrecognized_mapping_is_refused_rather_than_exported_empty(tmp_path): + """The Neo4j path reads mappings on the shared normalizer's default terms. + + An ``export_json`` envelope names no graph key, so it resolves to nothing. + Written out, that is a pair of header-only CSVs indistinguishable from a + genuinely empty graph -- the silent-empty export the shared contract + exists to prevent. + """ + exporter = Neo4jCSVExporter() + + with pytest.raises(ValidationError) as excinfo: + exporter.export({"data": [{"id": "e1"}]}, tmp_path) + + message = str(excinfo.value) + assert "data" in message + assert "entities" in message + + assert not (tmp_path / "nodes.csv").exists() + assert not (tmp_path / "relationships.csv").exists() + + +def test_records_under_an_unread_key_are_not_dropped_silently(tmp_path): + """Naming a recognized key is not enough if nothing resolves from it.""" + exporter = Neo4jCSVExporter() + + with pytest.raises(ValidationError): + exporter.export({"nodes": [], "data": [{"id": "e1"}]}, tmp_path) + + assert not (tmp_path / "nodes.csv").exists() + + +def test_malformed_collection_value_is_refused(tmp_path): + """``list("abc")`` would otherwise export one node per character.""" + exporter = Neo4jCSVExporter() + + for value in ("abc", 42, {"id": "n1"}): + with pytest.raises(ValidationError) as excinfo: + exporter.export({"nodes": value}, tmp_path) + assert "nodes" in str(excinfo.value) + + assert not (tmp_path / "nodes.csv").exists() + + +def test_graph_objects_still_use_the_attribute_path(tmp_path): + """Only mappings changed; objects are not mappings and are unaffected.""" + + class Graph: + def __init__(self): + self.nodes = [{"id": "e1", "type": "Person", "name": "Acme"}] + self.edges = [] + + exporter = Neo4jCSVExporter() + exporter.export(Graph(), tmp_path) + + assert "Acme" in (tmp_path / "nodes.csv").read_text(encoding="utf-8") diff --git a/tests/export/test_yaml_exporter_input_validation.py b/tests/export/test_yaml_exporter_input_validation.py new file mode 100644 index 00000000..13a375d7 --- /dev/null +++ b/tests/export/test_yaml_exporter_input_validation.py @@ -0,0 +1,181 @@ +"""Regression tests for YAML export input validation (issue #952). + +``export_yaml`` declared ``Union[Dict[str, Any], List[Dict[str, Any]]]`` but +both YAML exporters read their payload by key, so a list reached +``semantic_network.get(...)`` and surfaced as a bare +``AttributeError: 'list' object has no attribute 'get'`` from inside the +exporter — an error that names neither the offending argument nor the shape +expected. + +A list is rejected rather than wrapped. These formats distinguish entities +from relationships from triplets, so inferring which collection a bare list +represents would silently mislabel the records; and wrapping it under an +unrecognised key would write a structurally valid file with every collection +empty, trading a loud failure for silent data loss. + +Both directions are pinned: non-mappings raise ``ProcessingError`` with an +actionable message, and every mapping that worked before still exports. +""" + +import os +import shutil +import tempfile +import unittest +from collections import OrderedDict, defaultdict + +import yaml + +from semantica.export.methods import export_yaml +from semantica.export.yaml_exporter import ( + SemanticNetworkYAMLExporter, + YAMLSchemaExporter, +) +from semantica.utils.exceptions import ProcessingError + +# Non-mapping payloads that must be rejected. A list of dicts is the shape +# from #952; the rest guard the same path against other sequence/scalar types. +NON_MAPPINGS = { + "list_of_dicts": [{"id": "1", "name": "Acme"}], + "empty_list": [], + "tuple_of_dicts": ({"id": "1"},), + "list_of_scalars": ["a", "b"], + "string": "entities", + "bytes": b"entities", + "int": 42, + "none": None, + "set": {"a"}, +} + +# Both YAML methods, with a minimal valid payload and the key names the +# corresponding error message must mention. +METHODS = { + "semantic_network": { + "valid": { + "entities": [{"id": "1", "name": "Acme"}], + "relationships": [], + "triplets": [], + }, + "expected_key": "entities", + "top_level_key": "entities", + }, + "schema": { + "valid": {"classes": [{"name": "Thing"}], "properties": []}, + "expected_key": "classes", + "top_level_key": "classes", + }, +} + + +class TestExportYamlRejectsNonMappings(unittest.TestCase): + """Non-mapping input fails loudly, through the public wrapper.""" + + def setUp(self): + self.tmpdir = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, self.tmpdir, ignore_errors=True) + + def _path(self, name="out.yaml"): + return os.path.join(self.tmpdir, name) + + def test_fixture_tables_are_populated(self): + """Guard against a vacuous suite. + + Every test below iterates a table; emptying or renaming one would let + those loops pass without asserting anything. + """ + self.assertGreaterEqual(len(NON_MAPPINGS), 9) + self.assertEqual(set(METHODS), {"semantic_network", "schema"}) + + def test_non_mapping_raises_processing_error(self): + for method in METHODS: + for label, payload in NON_MAPPINGS.items(): + with self.subTest(method=method, case=label): + with self.assertRaises(ProcessingError): + export_yaml(payload, self._path(), method=method) + + def test_error_names_the_offending_type_and_expected_keys(self): + """The message must be actionable, not just the right exception type.""" + for method, spec in METHODS.items(): + with self.subTest(method=method): + with self.assertRaises(ProcessingError) as ctx: + export_yaml([{"id": "1"}], self._path(), method=method) + message = str(ctx.exception) + self.assertIn("list", message) + self.assertIn(spec["expected_key"], message) + + def test_no_file_is_written_when_input_is_rejected(self): + """A rejected export must not leave a partial or empty artefact.""" + for method in METHODS: + with self.subTest(method=method): + path = self._path(f"{method}_rejected.yaml") + with self.assertRaises(ProcessingError): + export_yaml([{"id": "1"}], path, method=method) + self.assertFalse(os.path.exists(path)) + + def test_exporter_classes_reject_non_mappings_directly(self): + """Validation lives in the exporters, not only the convenience wrapper. + + Callers using the classes directly get the same contract. + """ + for label, payload in NON_MAPPINGS.items(): + with self.subTest(exporter="SemanticNetworkYAMLExporter", case=label): + with self.assertRaises(ProcessingError): + SemanticNetworkYAMLExporter().export_semantic_network(payload) + with self.subTest(exporter="YAMLSchemaExporter", case=label): + with self.assertRaises(ProcessingError): + YAMLSchemaExporter().export_ontology_schema(payload) + + +class TestExportYamlStillAcceptsMappings(unittest.TestCase): + """Everything that exported before must still export.""" + + def setUp(self): + self.tmpdir = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, self.tmpdir, ignore_errors=True) + + def _path(self, name="out.yaml"): + return os.path.join(self.tmpdir, name) + + def _load(self, path): + with open(path, encoding="utf-8") as handle: + return yaml.safe_load(handle) + + def test_valid_mapping_exports_for_each_method(self): + for method, spec in METHODS.items(): + with self.subTest(method=method): + path = self._path(f"{method}.yaml") + export_yaml(spec["valid"], path, method=method) + self.assertTrue(os.path.exists(path)) + loaded = self._load(path) + self.assertIn(spec["top_level_key"], loaded) + + def test_semantic_network_records_survive_the_round_trip(self): + path = self._path("network.yaml") + export_yaml(METHODS["semantic_network"]["valid"], path) + loaded = self._load(path) + self.assertEqual(loaded["entities"], [{"id": "1", "name": "Acme"}]) + + def test_empty_mapping_is_still_accepted(self): + """An empty dict is a mapping; rejecting it would be a behaviour change.""" + for method in METHODS: + with self.subTest(method=method): + path = self._path(f"{method}_empty.yaml") + export_yaml({}, path, method=method) + self.assertTrue(os.path.exists(path)) + + def test_mapping_subclasses_are_accepted(self): + """Validation is by Mapping, not dict, so these must keep working.""" + valid = METHODS["semantic_network"]["valid"] + subclasses = { + "OrderedDict": OrderedDict(valid), + "defaultdict": defaultdict(list, valid), + } + for label, payload in subclasses.items(): + with self.subTest(case=label): + path = self._path(f"{label}.yaml") + export_yaml(payload, path) + loaded = self._load(path) + self.assertEqual(loaded["entities"], valid["entities"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/export/test_yaml_exporter_key_recognition.py b/tests/export/test_yaml_exporter_key_recognition.py new file mode 100644 index 00000000..d7dc2839 --- /dev/null +++ b/tests/export/test_yaml_exporter_key_recognition.py @@ -0,0 +1,434 @@ +"""Tests for YAML export key recognition (issue #953). + +``SemanticNetworkYAMLExporter`` built its output from ``.get(key, [])`` +lookups, so a mapping keyed by anything it did not read -- an ``export_json`` +envelope, a typo'd 'entitys', ``ContextGraph.to_dict()``'s 'nodes'/'edges' -- +serialized to a structurally valid file with every collection empty. Nothing +signalled the loss: no exception, no warning, and the progress log reported a +completed export. ``YAMLSchemaExporter`` had the same defect over a different +key set. + +The exporters are run for real rather than mocked, and the written files are +parsed back, since the behaviour under test is what actually lands on disk. +""" + +from pathlib import Path + +import pytest +import yaml + +from semantica.context.context_graph import ContextGraph +from semantica.export.methods import export_json, export_yaml +from semantica.export.yaml_exporter import ( + SemanticNetworkYAMLExporter, + YAMLSchemaExporter, +) +from semantica.utils.exceptions import ProcessingError, ValidationError + +ENTITIES = [{"id": "e1", "name": "Acme"}, {"id": "e2", "name": "Beta"}] +RELATIONSHIPS = [{"id": "r1", "source": "e1", "target": "e2", "type": "PARTNER"}] +TRIPLETS = [{"subject": "e1", "predicate": "partner_of", "object": "e2"}] + + +def _load(path): + with open(path, "r", encoding="utf-8") as handle: + return yaml.safe_load(handle) + + +class TestSemanticNetworkKeyRecognition: + """An unrecognized mapping is refused instead of silently emptied.""" + + def test_export_json_envelope_is_rejected(self, tmp_path): + """The realistic trigger: re-exporting an export_json payload. + + ``export_json`` wraps records as ``{"data": [...], "count": N, + "metadata": {...}}``. Feeding that straight to ``export_yaml`` used to + write a file with every record gone. Note the envelope's 'metadata' + key is deliberately not enough to make the payload recognized -- + treating it as sufficient would readmit exactly this case. + """ + json_path = tmp_path / "records.json" + export_json(ENTITIES, json_path) + envelope = yaml.safe_load(json_path.read_text(encoding="utf-8")) + assert "data" in envelope and "metadata" in envelope + + yaml_path = tmp_path / "records.yaml" + with pytest.raises(ValidationError) as excinfo: + export_yaml(envelope, yaml_path) + + message = str(excinfo.value) + assert "'data'" in message, "error should name the supplied keys" + assert "'entities'" in message, "error should name the expected keys" + assert not yaml_path.exists(), "a rejected export must write nothing" + + @pytest.mark.parametrize( + "payload", + [ + {"records": ENTITIES}, + {"entitys": ENTITIES}, + {"data": ENTITIES}, + {"metadata": {"source": "test"}}, + ], + ids=["records", "typo", "data", "metadata-only"], + ) + def test_unrecognized_mappings_are_rejected(self, payload): + exporter = SemanticNetworkYAMLExporter() + with pytest.raises(ValidationError): + exporter.export_semantic_network(payload) + + @pytest.mark.parametrize( + "payload", + [ + {"entities": [], "data": ENTITIES}, + {"nodes": [], "edges": [], "records": ENTITIES}, + {"triplets": [], "data": ENTITIES, "metadata": {"source": "test"}}, + ], + ids=["entities-empty", "nodes-edges-empty", "triplets-empty"], + ) + def test_recognized_but_empty_with_records_elsewhere_is_rejected(self, payload): + """Presence of a recognized key is not proof the records survived. + + ``{"entities": [], "data": [...]}`` clears a presence-only check and + still resolves to empty, dropping everything under 'data' -- the same + silent-empty export by a narrower route. + """ + exporter = SemanticNetworkYAMLExporter() + with pytest.raises(ValidationError) as excinfo: + exporter.export_semantic_network(payload) + + message = str(excinfo.value) + assert "holds records" in message + assert "'entities'" in message, "error should name where records belong" + + def test_empty_graph_with_non_record_keys_still_exports(self, tmp_path): + """The rejection must key on dropped *records*, not on unread keys. + + ``ContextGraph.to_dict()`` always carries a populated 'statistics' + dict, so an empty graph would be refused if any unread key counted. + """ + graph = ContextGraph() + path = tmp_path / "empty_graph.yaml" + export_yaml(graph.to_dict(), path) + + written = _load(path) + assert written["entities"] == [] + assert written["relationships"] == [] + + def test_empty_mapping_still_exports(self, tmp_path): + """An empty graph is legitimate and carries nothing that could be lost.""" + path = tmp_path / "empty.yaml" + export_yaml({}, path) + + written = _load(path) + assert written["entities"] == [] + assert written["relationships"] == [] + assert written["triplets"] == [] + + def test_recognized_keys_still_export(self, tmp_path): + path = tmp_path / "network.yaml" + export_yaml( + { + "entities": ENTITIES, + "relationships": RELATIONSHIPS, + "triplets": TRIPLETS, + "metadata": {"source": "test"}, + }, + path, + ) + + written = _load(path) + assert written["entities"] == ENTITIES + assert written["relationships"] == RELATIONSHIPS + assert written["triplets"] == TRIPLETS + assert written["metadata"]["source"] == "test" + + def test_nodes_edges_alias_exports_records(self, tmp_path): + path = tmp_path / "aliased.yaml" + export_yaml({"nodes": ENTITIES, "edges": RELATIONSHIPS}, path) + + written = _load(path) + assert written["entities"] == ENTITIES + assert written["relationships"] == RELATIONSHIPS + + def test_context_graph_to_dict_round_trips(self, tmp_path): + """The most direct path from this library's own graph type to YAML. + + Built from a real ``ContextGraph`` rather than a hand-written + 'nodes'/'edges' dict, so the test breaks if ``to_dict()`` changes + vocabulary. + """ + graph = ContextGraph() + graph.add_node("n1", node_type="Person", content="Alice") + graph.add_node("n2", node_type="Org", content="Acme") + graph.add_edge("n1", "n2", "WORKS_FOR") + + path = tmp_path / "context.yaml" + export_yaml(graph.to_dict(), path) + + written = _load(path) + assert len(written["entities"]) == 2 + assert len(written["relationships"]) == 1 + + def test_conflicting_spellings_are_refused(self): + """Two populated spellings of one collection: no basis to pick either.""" + exporter = SemanticNetworkYAMLExporter() + with pytest.raises(ValidationError): + exporter.export_semantic_network( + {"entities": ENTITIES, "nodes": [{"id": "other"}]} + ) + + def test_non_mapping_raises_processing_error(self): + """A wrong type is a different failure from a wrong-keyed mapping. + + ProcessingError says the object cannot be exported at all; + ValidationError says the mapping's contents are unusable. Pinned here + so the two do not quietly converge. + """ + exporter = SemanticNetworkYAMLExporter() + with pytest.raises(ProcessingError): + exporter.export_semantic_network(ENTITIES) + + def test_rejected_export_creates_no_output_directory(self, tmp_path): + """Validation runs before the output directory is created.""" + target = tmp_path / "nested" / "out.yaml" + exporter = SemanticNetworkYAMLExporter() + + with pytest.raises(ValidationError): + exporter.export({"data": ENTITIES}, target) + + assert not target.parent.exists() + + +class TestPipelineExportKeyRecognition: + """export_for_pipeline read the same defaulted lookups, so it had the bug too.""" + + def test_unrecognized_mapping_is_rejected(self): + exporter = SemanticNetworkYAMLExporter() + with pytest.raises(ValidationError): + exporter.export_for_pipeline({"data": ENTITIES}) + + def test_non_mapping_raises_processing_error(self): + exporter = SemanticNetworkYAMLExporter() + with pytest.raises(ProcessingError): + exporter.export_for_pipeline(ENTITIES) + + def test_aliases_resolve_into_the_semantic_network(self): + exporter = SemanticNetworkYAMLExporter() + written = yaml.safe_load( + exporter.export_for_pipeline({"nodes": ENTITIES, "edges": RELATIONSHIPS}) + ) + + assert written["semantic_network"]["entities"] == ENTITIES + assert written["semantic_network"]["relationships"] == RELATIONSHIPS + + def test_metadata_is_preserved(self): + exporter = SemanticNetworkYAMLExporter() + written = yaml.safe_load( + exporter.export_for_pipeline( + {"entities": ENTITIES, "metadata": {"source": "test"}} + ) + ) + + assert written["metadata"]["source"] == "test" + assert written["semantic_network"]["entities"] == ENTITIES + + +class TestSchemaKeyRecognition: + """method="schema" emitted empty classes/properties/namespaces the same way.""" + + def test_unrecognized_mapping_is_rejected(self, tmp_path): + path = tmp_path / "schema.yaml" + with pytest.raises(ValidationError) as excinfo: + export_yaml({"nodes": [{"id": "1"}]}, path, method="schema") + + message = str(excinfo.value) + assert "'nodes'" in message + assert "'classes'" in message + assert not path.exists() + + def test_non_mapping_raises_processing_error(self): + exporter = YAMLSchemaExporter() + with pytest.raises(ProcessingError): + exporter.export_ontology_schema([{"id": "1"}]) + + def test_recognized_but_empty_with_records_elsewhere_is_rejected(self): + """The schema path had the same presence-only hole.""" + exporter = YAMLSchemaExporter() + with pytest.raises(ValidationError) as excinfo: + exporter.export_ontology_schema({"classes": [], "nodes": [{"id": "1"}]}) + + assert "holds records" in str(excinfo.value) + + def test_ontology_metadata_without_records_still_exports(self): + """A schema described only by its identity is not a dropped export.""" + exporter = YAMLSchemaExporter() + written = yaml.safe_load( + exporter.export_ontology_schema( + {"uri": "http://example.org/o", "classes": []} + ) + ) + + assert written["ontology"]["uri"] == "http://example.org/o" + assert written["classes"] == [] + + def test_empty_mapping_still_exports(self, tmp_path): + path = tmp_path / "schema.yaml" + export_yaml({}, path, method="schema") + + written = _load(path) + assert written["classes"] == [] + assert written["properties"] == [] + assert written["namespaces"] == {} + + @pytest.mark.parametrize( + "payload", + [ + {"classes": ["Person"], "properties": ["WORKS_FOR"]}, + {"namespaces": {"ex": "http://example.org/"}}, + {"uri": "http://example.org/ontology"}, + ], + ids=["classes-properties", "namespaces-only", "uri-only"], + ) + def test_recognized_keys_still_export(self, payload, tmp_path): + path = tmp_path / "schema.yaml" + export_yaml(payload, path, method="schema") + + written = _load(path) + assert written["classes"] == payload.get("classes", []) + assert written["properties"] == payload.get("properties", []) + assert written["ontology"]["uri"] == payload.get("uri", "") + + # ── Fix regression: scalar recognized keys must not short-circuit the ── + # ── dropped-records check (version, uri, title, description). ────────── + + @pytest.mark.parametrize( + "scalar_key, scalar_value", + [ + ("version", "1.0"), + ("uri", "http://example.org/ontology"), + ("title", "My Ontology"), + ("description", "A test ontology"), + ], + ids=["version", "uri", "title", "description"], + ) + def test_scalar_recognized_key_does_not_excuse_records_under_unread_key( + self, scalar_key, scalar_value + ): + """A truthy scalar such as version='1.0' must not silence the dropped- + records check. Before the fix, any truthy value from _SCHEMA_KEYS + would make _require_nothing_dropped believe something resolved and + return early, silently discarding a list under an unread key. + """ + exporter = YAMLSchemaExporter() + with pytest.raises(ValidationError) as excinfo: + exporter.export_ontology_schema( + {scalar_key: scalar_value, "nodes": [{"id": "c1"}]} + ) + assert "holds records" in str(excinfo.value), str(excinfo.value) + + def test_valid_classes_with_scalar_metadata_is_accepted(self): + """classes/properties populated alongside version/uri must still work.""" + exporter = YAMLSchemaExporter() + written = yaml.safe_load( + exporter.export_ontology_schema( + { + "classes": [{"id": "Person"}], + "properties": [{"id": "name"}], + "version": "2.0", + "uri": "http://example.org/o", + } + ) + ) + assert written["classes"] == [{"id": "Person"}] + assert written["properties"] == [{"id": "name"}] + assert written["ontology"]["version"] == "2.0" + assert written["ontology"]["uri"] == "http://example.org/o" + + +class TestFailureIsObservable: + """The complaint in #953 was that the logs affirmatively reported success.""" + + def test_no_success_is_logged_for_a_rejected_export(self, tmp_path, caplog): + path = tmp_path / "out.yaml" + + with caplog.at_level("DEBUG"): + with pytest.raises(ValidationError): + export_yaml({"data": ENTITIES}, path) + + assert "Exported YAML to" not in caplog.text + assert any( + record.levelname in ("WARNING", "ERROR", "CRITICAL") + for record in caplog.records + ), "a rejected export should leave something at warning or above" + + +class _RecordingTracker: + """Records the exporter's own progress calls, which are what is under test.""" + + def __init__(self): + self.stopped = [] + self._next_id = 0 + + def start_tracking(self, **kwargs): + self._next_id += 1 + return str(self._next_id) + + def update_tracking(self, tracking_id, **kwargs): + pass + + def stop_tracking(self, tracking_id, status=None, message=None): + self.stopped.append((status, message)) + + +class TestProgressReflectsTheWrite: + """Serialization completing is not the same as the file landing on disk.""" + + def test_failed_write_is_not_reported_as_completed(self, tmp_path): + """A write failure after serialization must not leave a clean tracker. + + The path's parent is an existing *file*, so directory creation fails + after `export_semantic_network` has already reported its own + completion. + """ + blocker = tmp_path / "blocker" + blocker.write_text("not a directory", encoding="utf-8") + target = blocker / "nested" / "out.yaml" + + exporter = SemanticNetworkYAMLExporter() + tracker = _RecordingTracker() + exporter.progress_tracker = tracker + + with pytest.raises(OSError): + exporter.export({"entities": ENTITIES}, target) + + assert not target.exists() + statuses = [status for status, _ in tracker.stopped] + assert "failed" in statuses, f"write failure went unreported: {tracker.stopped}" + assert not any( + status == "completed" and "Exported YAML" in (message or "") + for status, message in tracker.stopped + ), "no span may claim a completed export when nothing was written" + + def test_successful_write_is_reported_as_completed(self, tmp_path): + target = tmp_path / "out.yaml" + exporter = SemanticNetworkYAMLExporter() + tracker = _RecordingTracker() + exporter.progress_tracker = tracker + + exporter.export({"entities": ENTITIES}, target) + + assert target.exists() + assert all(status == "completed" for status, _ in tracker.stopped) + assert any( + "Exported YAML" in (message or "") for _, message in tracker.stopped + ), "the write should report its own completion, not just serialization" + + +class TestUnaffectedExporters: + """export_json's own behaviour is untouched -- only the YAML path changed.""" + + def test_export_json_still_accepts_a_bare_list(self, tmp_path): + path = tmp_path / "records.json" + export_json(ENTITIES, path) + + assert Path(path).exists() diff --git a/tests/utils/test_normalize_graph_payload.py b/tests/utils/test_normalize_graph_payload.py new file mode 100644 index 00000000..02b53219 --- /dev/null +++ b/tests/utils/test_normalize_graph_payload.py @@ -0,0 +1,487 @@ +"""Tests for the shared graph-payload normalizer (issue #956). + +Graph payloads circulate under two vocabularies -- 'entities'/'relationships' +and 'nodes'/'edges' -- and consumers each reconciled them locally with at +least three competing idioms. The same payload could therefore be exported, +silently dropped, or rejected depending on which consumer read it: +``export_lpg`` dropped every entity when 'nodes' was present but empty, which +is precisely the shape ``JSONExporter`` emits. + +The end-to-end assertions run the real exporters rather than mocking them, +since the behaviour under test is that the exporters now agree. +""" + +import os +import shutil +import tempfile +import unittest +from dataclasses import dataclass + +from semantica.export import methods as export_methods +from semantica.utils import normalize_graph_payload +from semantica.utils.exceptions import ValidationError + +ENTITY = {"id": "e1", "name": "Acme"} +RELATIONSHIP = {"id": "r1", "source": "e1", "target": "e2"} + + +class TestVocabularyResolution(unittest.TestCase): + def test_canonical_keys_pass_through(self): + result = normalize_graph_payload( + {"entities": [ENTITY], "relationships": [RELATIONSHIP]} + ) + self.assertEqual(result["entities"], [ENTITY]) + self.assertEqual(result["relationships"], [RELATIONSHIP]) + self.assertEqual(result["triplets"], []) + + def test_aliases_are_mapped_to_canonical_keys(self): + result = normalize_graph_payload({"nodes": [ENTITY], "edges": [RELATIONSHIP]}) + self.assertEqual(result["entities"], [ENTITY]) + self.assertEqual(result["relationships"], [RELATIONSHIP]) + + def test_empty_alias_does_not_mask_a_populated_canonical_key(self): + """The JSONExporter round-trip shape, and the #956 data-loss case.""" + result = normalize_graph_payload( + {"entities": [ENTITY], "nodes": [], "relationships": [], "edges": []} + ) + self.assertEqual(result["entities"], [ENTITY]) + + def test_empty_canonical_key_does_not_mask_a_populated_alias(self): + result = normalize_graph_payload({"entities": [], "nodes": [ENTITY]}) + self.assertEqual(result["entities"], [ENTITY]) + + def test_identical_spellings_are_accepted(self): + result = normalize_graph_payload({"entities": [ENTITY], "nodes": [ENTITY]}) + self.assertEqual(result["entities"], [ENTITY]) + + def test_conflicting_spellings_are_refused(self): + """No basis to prefer either, and picking one would lose the other.""" + with self.assertRaises(ValidationError) as ctx: + normalize_graph_payload( + {"entities": [ENTITY], "nodes": [{"id": "different"}]} + ) + message = str(ctx.exception) + self.assertIn("entities", message) + self.assertIn("nodes", message) + + def test_reordered_identical_spellings_are_accepted(self): + """Same records, different order, is not a conflict. + + A caller round-tripping through a dict-keyed cache or a set has no + reason to preserve list order; comparing spellings with plain list + equality rejected this as if the records differed. + """ + other = {"id": "e2", "name": "Beta"} + result = normalize_graph_payload( + {"entities": [ENTITY, other], "nodes": [other, ENTITY]} + ) + self.assertCountEqual(result["entities"], [ENTITY, other]) + + def test_reordered_spellings_with_duplicate_records_still_conflict(self): + """Multiset comparison must still catch a real count mismatch.""" + with self.assertRaises(ValidationError): + normalize_graph_payload({"entities": [ENTITY, ENTITY], "nodes": [ENTITY]}) + + def test_triplets_are_carried_through(self): + result = normalize_graph_payload({"triplets": [{"s": "a", "p": "b", "o": "c"}]}) + self.assertEqual(result["triplets"], [{"s": "a", "p": "b", "o": "c"}]) + + def test_missing_collections_default_to_empty_lists(self): + result = normalize_graph_payload({"entities": [ENTITY]}) + self.assertEqual(result["relationships"], []) + self.assertEqual(result["triplets"], []) + + def test_result_does_not_alias_the_input_collections(self): + payload = {"entities": [ENTITY]} + result = normalize_graph_payload(payload) + result["entities"].append({"id": "e2"}) + self.assertEqual(len(payload["entities"]), 1) + + +class TestUnrecognizedInput(unittest.TestCase): + def test_unrecognized_keys_raise_by_default(self): + for payload in ({"data": [ENTITY]}, {"records": [ENTITY]}, {"foo": "bar"}): + with self.subTest(payload=payload): + with self.assertRaises(ValidationError): + normalize_graph_payload(payload) + + def test_error_names_supplied_and_expected_keys(self): + with self.assertRaises(ValidationError) as ctx: + normalize_graph_payload({"data": [ENTITY]}) + message = str(ctx.exception) + self.assertIn("data", message) + self.assertIn("entities", message) + self.assertIn("nodes", message) + + def test_empty_mapping_is_accepted(self): + """An empty graph is legitimate and carries nothing that could be lost.""" + result = normalize_graph_payload({}) + self.assertEqual(result, {"entities": [], "relationships": [], "triplets": []}) + + def test_non_mapping_input_raises(self): + for payload in ([ENTITY], (ENTITY,), "entities", 42, None): + with self.subTest(payload=repr(payload)): + with self.assertRaises(ValidationError): + normalize_graph_payload(payload) + + +class TestExportersAgree(unittest.TestCase): + """The divergence from #956, run against the real exporters.""" + + # export_csv is excluded: it writes entities/relationships/nodes/edges to + # four separate files by design, so it is not resolving two spellings of + # one collection and is out of scope for this change. + EXPORTERS = ("export_json", "export_arango", "export_neo4j_csv", "export_lpg") + + def setUp(self): + self.tmpdir = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, self.tmpdir, ignore_errors=True) + + def _export_and_read(self, name, payload): + outdir = os.path.join(self.tmpdir, name) + os.makedirs(outdir, exist_ok=True) + getattr(export_methods, name)(payload, os.path.join(outdir, "out")) + blob = "" + for root, _, files in os.walk(outdir): + for filename in files: + with open(os.path.join(root, filename), errors="ignore") as handle: + blob += handle.read() + return blob + + def test_exporter_list_is_populated(self): + """Guard against a vacuous suite if the list is emptied.""" + self.assertGreaterEqual(len(self.EXPORTERS), 4) + + def test_every_exporter_keeps_records_when_an_alias_is_empty(self): + payload = { + "entities": [ENTITY], + "nodes": [], + "relationships": [], + "edges": [], + } + for name in self.EXPORTERS: + with self.subTest(exporter=name): + self.assertIn( + "Acme", + self._export_and_read(name, payload), + f"{name} dropped the entity when 'nodes' was present but empty", + ) + + def test_every_exporter_accepts_the_alias_vocabulary(self): + payload = {"nodes": [ENTITY], "edges": []} + for name in self.EXPORTERS: + with self.subTest(exporter=name): + self.assertIn( + "Acme", + self._export_and_read(name, payload), + f"{name} dropped the entity supplied as 'nodes'", + ) + + def test_every_exporter_raises_processing_error_for_non_mapping_input(self): + """A wrong-type payload is rejected the same way everywhere. + + export_yaml and export_neo4j_csv raised ProcessingError for a bare + list; export_lpg and export_arango called normalize_graph_payload() + directly with no type guard, so they alone raised ValidationError + (from inside the resolver) for the identical mistake. + """ + from semantica.utils.exceptions import ProcessingError + + for name in ("export_arango", "export_neo4j_csv", "export_lpg"): + with self.subTest(exporter=name): + outdir = os.path.join(self.tmpdir, name + "_bad_type") + os.makedirs(outdir, exist_ok=True) + with self.assertRaises(ProcessingError): + getattr(export_methods, name)([ENTITY], os.path.join(outdir, "out")) + + def test_every_exporter_converts_object_shaped_records(self): + """A dataclass record must not merely pass validation. + + normalize_graph_payload() accepts dataclass/attribute-bearing + records (Neo4jCSVExporter reads them off attributes), but + export_lpg and export_arango read records with ``.get(...)``. A + record that passed validation unconverted crashed with a raw + AttributeError once used -- the exact failure the boundary exists + to prevent. + """ + + @dataclass + class Node: + id: str + name: str + + payload = {"entities": [Node(id="e1", name="Acme")], "relationships": []} + for name in ("export_arango", "export_neo4j_csv", "export_lpg"): + with self.subTest(exporter=name): + self.assertIn("Acme", self._export_and_read(name, payload)) + + def test_neo4j_accepts_non_dict_mappings(self): + """Neo4jCSVExporter's mapping path must not be narrower than the rest. + + _normalize_graph checked isinstance(graph, dict), so a non-dict + Mapping (a MappingProxyType, a ChainMap) fell into the + object-attribute branch and was rejected as an unrecognized object, + even though the identical payload exports fine via LPG/Arango/YAML. + """ + import types + + payload = types.MappingProxyType({"entities": [ENTITY], "relationships": []}) + self.assertIn("Acme", self._export_and_read("export_neo4j_csv", payload)) + + +class TestRecordsCannotBeDroppedSilently(unittest.TestCase): + """Presence of a recognized key is not proof the records survived. + + ``{"entities": [], "data": [...]}`` clears a presence-only check and still + resolves to empty, so the records under 'data' would be dropped with no + signal -- the same failure the recognition check exists to prevent. + """ + + def test_empty_recognized_key_does_not_excuse_records_elsewhere(self): + with self.assertRaises(ValidationError) as ctx: + normalize_graph_payload({"entities": [], "data": [ENTITY]}) + + message = str(ctx.exception) + self.assertIn("'data'", message) + self.assertIn("holds records", message) + + def test_check_applies_to_every_recognized_spelling(self): + for key in ("entities", "nodes", "relationships", "edges", "triplets"): + with self.subTest(key=key): + with self.assertRaises(ValidationError): + normalize_graph_payload({key: [], "records": [ENTITY]}) + + def test_non_record_keys_are_not_mistaken_for_dropped_records(self): + """ContextGraph.to_dict() always carries 'statistics'. + + An empty graph must stay exportable, so only a non-empty list counts + as evidence that records were dropped. + """ + result = normalize_graph_payload( + {"nodes": [], "edges": [], "statistics": {"node_count": 0}} + ) + + self.assertEqual(result["entities"], []) + self.assertEqual(result["relationships"], []) + + def test_records_alongside_a_populated_collection_are_not_refused(self): + """Something resolved, so the export is not silently empty.""" + result = normalize_graph_payload({"entities": [ENTITY], "statistics": {"n": 1}}) + + self.assertEqual(result["entities"], [ENTITY]) + + +class TestCollectionValuesAreValidated(unittest.TestCase): + """A recognized key is not proof its value is a collection of records. + + Resolving on truthiness alone let ``{"entities": "abc"}`` through as three + single-character "records" and let ``{"entities": 42}`` surface as a raw + ``TypeError`` from ``list()`` inside an exporter, naming the exporter + rather than the payload key at fault. Both are rejected here, at the + boundary that owns the question. + """ + + COLLECTION_KEYS = ("entities", "nodes", "relationships", "edges", "triplets") + + # Every public export path that reads its payload through the normalizer. + # export_json is excluded: it treats the payload as opaque records rather + # than resolving graph collections, so it never calls the normalizer. + NORMALIZING_EXPORTERS = ( + "export_arango", + "export_neo4j_csv", + "export_lpg", + "export_yaml", + ) + + def test_string_value_is_not_treated_as_a_collection(self): + for key in self.COLLECTION_KEYS: + with self.subTest(key=key): + with self.assertRaises(ValidationError) as ctx: + normalize_graph_payload({key: "abc"}) + message = str(ctx.exception) + self.assertIn(f"'{key}'", message) + self.assertIn("str", message) + + def test_bytes_value_is_not_treated_as_a_collection(self): + for value in (b"abc", bytearray(b"abc")): + with self.subTest(value=repr(value)): + with self.assertRaises(ValidationError): + normalize_graph_payload({"entities": value}) + + def test_scalar_value_raises_validation_error_not_type_error(self): + for key in self.COLLECTION_KEYS: + for value in (42, 3.5, True, object()): + with self.subTest(key=key, value=repr(value)): + with self.assertRaises(ValidationError) as ctx: + normalize_graph_payload({key: value}) + self.assertIn(f"'{key}'", str(ctx.exception)) + + def test_mapping_value_is_not_treated_as_a_collection(self): + """``{"nodes": {"id": "n1"}}`` -- a single record, or an ID index.""" + for payload in ( + {"nodes": {"id": "n1"}}, + {"entities": {"e1": ENTITY}}, + {"edges": {"id": "r1"}}, + ): + with self.subTest(payload=payload): + with self.assertRaises(ValidationError) as ctx: + normalize_graph_payload(payload) + self.assertIn("mapping", str(ctx.exception)) + + def test_non_record_elements_are_rejected(self): + for value in (["Acme"], [ENTITY, "Acme"], [42], [None], [[ENTITY]]): + with self.subTest(value=repr(value)): + with self.assertRaises(ValidationError) as ctx: + normalize_graph_payload({"entities": value}) + self.assertIn("'entities'", str(ctx.exception)) + + def test_error_names_the_offending_index(self): + with self.assertRaises(ValidationError) as ctx: + normalize_graph_payload({"entities": [ENTITY, ENTITY, "Acme"]}) + self.assertIn("index 2", str(ctx.exception)) + + def test_object_records_are_accepted(self): + """Attribute-bearing objects are accepted and converted to dicts. + + LPGExporter and ArangoAQLExporter read records with ``.get(...)``, so + an object record that merely passed validation unconverted would + still crash with AttributeError once used; the boundary converts it. + """ + + class Node: + def __init__(self): + self.id = "e1" + self.name = "Acme" + + node = Node() + result = normalize_graph_payload({"entities": [node]}) + self.assertEqual(result["entities"], [{"id": "e1", "name": "Acme"}]) + + def test_dataclass_records_are_accepted(self): + @dataclass + class Node: + id: str + + node = Node(id="e1") + result = normalize_graph_payload({"entities": [node]}) + self.assertEqual(result["entities"], [{"id": "e1"}]) + + def test_tuple_collections_are_accepted_and_materialized(self): + result = normalize_graph_payload({"entities": (ENTITY,)}) + self.assertEqual(result["entities"], [ENTITY]) + + def test_none_is_read_as_an_absent_collection(self): + """JSON round-trips an absent collection to null.""" + result = normalize_graph_payload( + {"entities": None, "relationships": [RELATIONSHIP]} + ) + self.assertEqual(result["entities"], []) + self.assertEqual(result["relationships"], [RELATIONSHIP]) + + def test_null_collection_still_cannot_hide_dropped_records(self): + with self.assertRaises(ValidationError): + normalize_graph_payload({"entities": None, "data": [ENTITY]}) + + def test_every_spelling_is_validated_not_just_the_winner(self): + """A malformed alias is a defect even when the canonical key resolves.""" + with self.assertRaises(ValidationError) as ctx: + normalize_graph_payload({"entities": [ENTITY], "nodes": "abc"}) + self.assertIn("'nodes'", str(ctx.exception)) + + def test_malformed_value_reaches_no_exporter(self): + """The end-to-end half: no exporter sees a TypeError from list().""" + tmpdir = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, tmpdir, ignore_errors=True) + + for name in self.NORMALIZING_EXPORTERS: + for value in ("abc", 42, {"id": "n1"}): + with self.subTest(exporter=name, value=repr(value)): + outdir = os.path.join(tmpdir, f"{name}_{type(value).__name__}") + os.makedirs(outdir, exist_ok=True) + with self.assertRaises(ValidationError): + getattr(export_methods, name)( + {"entities": value}, os.path.join(outdir, "out") + ) + + +class TestIsRecordBoundary(unittest.TestCase): + """_is_record gates the validation boundary introduced by this PR. + + Modules and class/type objects carry ``__dict__`` but are not graph + records. Passing them through previously produced ``AttributeError`` + inside exporters rather than a ``ValidationError`` at the boundary. + """ + + def test_python_module_in_entities_raises_validation_error(self): + """import math; {"entities": [math]} must be rejected at the boundary.""" + import math + + with self.assertRaises(ValidationError) as ctx: + normalize_graph_payload({"entities": [math]}) + self.assertIn("'entities'", str(ctx.exception)) + + def test_class_object_in_entities_raises_validation_error(self): + """A class (type object) is not a graph record.""" + + class MyNode: + pass + + with self.assertRaises(ValidationError) as ctx: + normalize_graph_payload({"entities": [MyNode]}) + self.assertIn("'entities'", str(ctx.exception)) + + def test_user_defined_instance_with_attributes_is_accepted(self): + """Attribute-bearing instances are the legitimate use-case, converted + to a dict so every exporter -- not just Neo4jCSVExporter -- can read + it with ``.get(...)``.""" + + class Node: + def __init__(self): + self.id = "n1" + self.name = "Alice" + + node = Node() + result = normalize_graph_payload({"entities": [node]}) + self.assertEqual(result["entities"], [{"id": "n1", "name": "Alice"}]) + + def test_dataclass_instance_is_accepted(self): + """Dataclasses are a common record type used by Neo4jCSVExporter, + converted to a dict at the boundary so LPGExporter and + ArangoAQLExporter can read it too.""" + node = dataclass_node() + result = normalize_graph_payload({"entities": [node]}) + self.assertEqual(result["entities"], [{"id": "dc1"}]) + + def test_mapping_record_is_accepted(self): + """Plain dicts are the canonical record shape.""" + result = normalize_graph_payload({"entities": [ENTITY]}) + self.assertEqual(result["entities"], [ENTITY]) + + def test_module_rejected_through_normalizing_exporter(self): + """End-to-end: a module element must not reach an exporter's internals.""" + import math + + tmpdir = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, tmpdir, ignore_errors=True) + + for name in ("export_arango", "export_neo4j_csv", "export_lpg"): + with self.subTest(exporter=name): + outdir = os.path.join(tmpdir, name) + os.makedirs(outdir, exist_ok=True) + with self.assertRaises(ValidationError): + getattr(export_methods, name)( + {"entities": [math]}, os.path.join(outdir, "out") + ) + + +@dataclass +class _DataclassNode: + id: str + + +def dataclass_node(): + return _DataclassNode(id="dc1") + + +if __name__ == "__main__": + unittest.main() From 2f04bc01a32552a2310363cc03edc66651572ba8 Mon Sep 17 00:00:00 2001 From: Accute9 Date: Sat, 15 Aug 2026 20:37:56 -0400 Subject: [PATCH 052/105] route spaCy model loads through process cache --- semantica/split/methods.py | 3 ++- semantica/split/semantic_chunker.py | 5 ++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/semantica/split/methods.py b/semantica/split/methods.py index 8c338dc6..f2b3f525 100644 --- a/semantica/split/methods.py +++ b/semantica/split/methods.py @@ -93,6 +93,7 @@ from ..utils.exceptions import ProcessingError from ..utils.helpers import safe_import from ..utils.logging import get_logger from .semantic_chunker import Chunk +from ..semantic_extract.methods import load_spacy_model logger = get_logger("split_methods") @@ -336,7 +337,7 @@ def split_by_sentences( # Try spaCy first if SPACY_AVAILABLE and kwargs.get("use_spacy", True): try: - nlp = spacy.load("en_core_web_sm") + nlp = load_spacy_model("en_core_web_sm") doc = nlp(text) sentences = [sent.text for sent in doc.sents] except Exception: diff --git a/semantica/split/semantic_chunker.py b/semantica/split/semantic_chunker.py index 079ba976..5d712627 100644 --- a/semantica/split/semantic_chunker.py +++ b/semantica/split/semantic_chunker.py @@ -35,6 +35,8 @@ from ..utils.exceptions import ProcessingError from ..utils.helpers import safe_import from ..utils.logging import get_logger from ..utils.progress_tracker import get_progress_tracker +from ..semantic_extract.methods import load_spacy_model + spacy, SPACY_AVAILABLE = safe_import("spacy") @@ -79,7 +81,8 @@ class SemanticChunker: if SPACY_AVAILABLE: model_name = config.get("model", "en_core_web_sm") try: - self.nlp = spacy.load(model_name) + # self.nlp = spacy.load(model_name) + self.nlp = load_spacy_model(model_name) except OSError: self.logger.warning( f"spaCy model {model_name} not found. Using fallback chunking." From 83649f68219ef069f1a12f58424a99abb4f2639d Mon Sep 17 00:00:00 2001 From: Accute9 Date: Sat, 15 Aug 2026 20:56:39 -0400 Subject: [PATCH 053/105] forgot to remove comment --- semantica/split/semantic_chunker.py | 1 - 1 file changed, 1 deletion(-) diff --git a/semantica/split/semantic_chunker.py b/semantica/split/semantic_chunker.py index 5d712627..d7f72726 100644 --- a/semantica/split/semantic_chunker.py +++ b/semantica/split/semantic_chunker.py @@ -81,7 +81,6 @@ class SemanticChunker: if SPACY_AVAILABLE: model_name = config.get("model", "en_core_web_sm") try: - # self.nlp = spacy.load(model_name) self.nlp = load_spacy_model(model_name) except OSError: self.logger.warning( From d94d8f6ab83cbfd6efb2782224bda0574a3d9433 Mon Sep 17 00:00:00 2001 From: Shinde vinayak rao patil <119512435+Shindevrp@users.noreply.github.com> Date: Sun, 16 Aug 2026 11:45:43 +0530 Subject: [PATCH 054/105] Feat/crewai integration (#988) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(crewai): add first-class CrewAI integration (#962) Add native CrewAI support so Crew agents can share a ContextGraph and AgentContext via BaseTool subclasses and a BaseKnowledgeSource, matching the existing agno integration pattern. - SemanticaKGTool: 5 KG actions (extract_entities, extract_relations, add_to_graph, query_graph, find_related) with sync run()/async arun() - SemanticaDecisionTool: 5 decision-intelligence actions (record_decision, find_precedents, trace_causal_chain, analyze_impact, check_policy) over AgentContext - SemanticaKnowledgeSource: serializes a ContextGraph into crew knowledge storage; bridges legacy load_content() and current validate_content()/aadd() contracts for crewai>=0.80.0 - All classes degrade gracefully when crewai is absent - New pip extra crewai=... included in the all bundle - 70 new tests (stub-based present-case + subprocess degradation path) - Docs: integrations/crewai.md, docs.json nav, README matrix updates * fix(crewai): harden tools against real Semantica dataclass shapes (#962) Bugs found during live testing with crewai 1.15.16: - SemanticaKGTool.add_to_graph crashed on real Entity/Relation dataclasses ('str' object has no attribute 'end_char'): string names were passed to extract_relations(entities=...), which requires Entity objects, and the tool read .name/.source/.target instead of Entity's .text/.label and Relation's .subject/.object. Add shape-agnostic field helpers. - SemanticaDecisionTool() created an AgentContext without a knowledge_graph, so _decision_backend was never set and record_decision raised 'Decision tracking is not enabled'. Wire in a ContextGraph. - record_decision hard-failed when the agent omitted optional fields; fall back to category='general', reasoning='agent decision', outcome='recorded'. Add tests covering real Entity/Relation dataclass shapes and the live auto-created AgentContext path (now 77 crewai tests, 212 total). * fix(crewai): make find_related traverse edges undirected (#962) ContextGraph.get_neighbors only follows outgoing edges, so a node whose only edge is incoming (A -> B) reported no related concepts. Rebuild a bidirectional adjacency from find_edges() in SemanticaKGTool._find_related so 'related' honors both directions. * fix(crewai): harden tools for checkpoint serialization and correct action semantics (#962) - Exclude live graph/context/extractor state from JSON serialization (model_dump(mode="json")) so CrewAI checkpointing no longer raises PydanticSerializationError; model_post_init self-heals defaults on restore - query_graph now searches node content via graph.query() plus id/type - trace_causal_chain returns an explicit error when causal tracing is unavailable instead of substituting similarity precedents; call trace_decision_causality(..., max_depth=...) with the correct kwarg name - find_precedents propagates max_precedents/limit to the backend instead of being silently capped at 10 - Serialize add_to_graph batches under a module lock to prevent concurrent double-counting; skip nameless entities instead of creating repr()-junk nodes - aadd() runs CPU-bound serialization in a thread executor - Mirror crewai args_schema serialize/restore in the conftest stub and add serialization regression tests (crewai: 92 tests) * fix(crewai): correct check_policy coercion, guard causal tracing, and harden concurrency (#962) - _eval_rule now coerces rule values type-aware: bool("false") was truthy, so 'enabled == false' reported a violation for enabled=false, and string datums like "0.90" were compared lexicographically instead of numerically - _trace_causal_chain no longer raises AttributeError (which escaped _run) when the decision context lacks knowledge_graph; returns honest error JSON - SemanticaKnowledgeSource storage failures log an actionable ERROR; without a configured crew embedder agents previously retrieved nothing silently - add_to_graph uses a per-graph re-entrant lock (WeakKeyDictionary) instead of a process-global one: independent graphs no longer serialize each other and re-entrant extractor callbacks cannot deadlock - entity/relation confidence=None normalizes to 1.0 instead of failing the whole extraction with float(None) - add subprocess integration test against real crewai covering Crew-level serialization round-trip and checkpoint restore (stub tests cannot see it) - docs: embedder requirement for SemanticaKnowledgeSource; resume contract note * fix(crewai): surface knowledge-source save failures at ERROR when storage is wired (#962) Re-verification against real crewai showed the embedder-missing failure raises ValueError even though storage IS wired, so the old except-ValueError branch mislabeled it as 'storage not wired' and logged DEBUG — hiding the failure. Distinguish by storage presence instead of exception type: storage is None -> DEBUG keep-in-memory (legitimate standalone use); storage wired but save() raises -> actionable ERROR. Add regression test mirroring real crewai's ValueError-on-missing-embedder behavior. * fix(crewai): expose run()/arun() entry points in degraded mode (#962) The public crewai contract is run()/arun(); without crewai installed they were missing (only the private _run existed), so the documented 'usable without crewai' path raised AttributeError at the entry point. Define them in degraded mode only, leaving crewai's BaseTool implementations untouched when present. Extend the degradation subprocess test to exercise run() and arun(). * fix(crewai): standardize query shape, field-name rules, and restore-state flag - _query_graph: id/type matches now return the same schema as content matches (id/type/label/content/score) instead of a bare list - _eval_rule: non-greedy field capture so hyphen/dot/space JSON keys (e.g. "risk-score >= 0.9") are addressable in policy rules - add had_live_state/reconstructed_state so checkpoint-restored tools and knowledge sources signal that their live graph/context was lost and an empty one reconstructed; knowledge source no longer hides the loss by eagerly rebuilding its graph inside __init__ (pydantic calls __init__ during model_validate) * fix(crewai): address Qodo review — confidence errors, string trim, holistic availability - record_decision: stop calling float() in _run, so malformed confidence values surface as JSON errors (via _record_decision's handling) instead of crashing the tool - _coerce_value: return the stripped string for non-numeric literals so whitespace-padded decision_data fields match policy rules - centralize crewai availability in _availability.py so the exported CREWAI_AVAILABLE flag is holistic across tools and knowledge source (previously each module probed crewai independently and the package flag came from decision_tool only) * ci: regenerate requirements-ci.txt for the crewai extra The crewai extra in pyproject.toml brings in crewai, crewai-tools and transitive deps (chromadb, lancedb, ...). Recompile with uv==0.12.1 per CONTRIBUTING.md so the CI staleness check passes. * ci: keep crewai out of the locked CI dependency set crewai (all versions) hard-requires chromadb~=1.1.0, which carries a pre-authentication code-injection advisory (CVE-2026-45829 / GHSA-f4j7-r4q5-qw2c) with NO fixed release — even the latest 1.5.9 is affected. Keeping crewai in the 'all' extra failed pip-audit and the safety check on requirements-ci.txt. - drop crewai from the 'all' aggregate (standalone semantica[crewai] extra is unchanged and still installs crewai) - stop listing crewai-tools in the extra: the integration only uses crewai core (BaseTool, BaseKnowledgeSource) and crewai-tools pulled extra transitive deps - regenerate requirements-ci.txt: OSV/pip-audit 0 vulnerabilities, safety 0 vulnerabilities, staleness check matches * docs(crewai): document crewai extra scope and chromadb CVE-2026-45829 - CHANGELOG: extra is crewai>=0.80.0 only (no crewai-tools) and is not part of the 'all' bundle, with the chromadb CVE-2026-45829 reason - integrations/crewai/README.md: add a security warning that installing the extra pulls chromadb~=1.1.0, which is affected by the unpatched pre-auth code-injection CVE-2026-45829 --------- --- CHANGELOG.md | 11 + README.md | 20 +- docs/docs.json | 1 + docs/integrations/crewai.md | 147 +++++ integrations/crewai/README.md | 108 ++++ integrations/crewai/__init__.py | 44 ++ integrations/crewai/_availability.py | 24 + integrations/crewai/decision_tool.py | 555 +++++++++++++++++ integrations/crewai/kg_tool.py | 573 ++++++++++++++++++ integrations/crewai/knowledge_source.py | 331 ++++++++++ pyproject.toml | 8 + requirements-ci.txt | 8 +- tests/integrations/crewai/conftest.py | 151 +++++ .../integrations/crewai/test_decision_tool.py | 562 +++++++++++++++++ tests/integrations/crewai/test_degradation.py | 103 ++++ tests/integrations/crewai/test_kg_tool.py | 453 ++++++++++++++ .../crewai/test_knowledge_source.py | 228 +++++++ .../crewai/test_real_crewai_integration.py | 123 ++++ 18 files changed, 3434 insertions(+), 16 deletions(-) create mode 100644 docs/integrations/crewai.md create mode 100644 integrations/crewai/README.md create mode 100644 integrations/crewai/__init__.py create mode 100644 integrations/crewai/_availability.py create mode 100644 integrations/crewai/decision_tool.py create mode 100644 integrations/crewai/kg_tool.py create mode 100644 integrations/crewai/knowledge_source.py create mode 100644 tests/integrations/crewai/conftest.py create mode 100644 tests/integrations/crewai/test_decision_tool.py create mode 100644 tests/integrations/crewai/test_degradation.py create mode 100644 tests/integrations/crewai/test_kg_tool.py create mode 100644 tests/integrations/crewai/test_knowledge_source.py create mode 100644 tests/integrations/crewai/test_real_crewai_integration.py diff --git a/CHANGELOG.md b/CHANGELOG.md index a94fd882..78212679 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **First-class CrewAI integration** (#962) + - New `pip install semantica[crewai]` extra (`crewai>=0.80.0`) — crewai core provides `BaseTool`/`BaseKnowledgeSource`, so `crewai-tools` is intentionally not included, and the extra is intentionally **not** part of the `all` bundle: crewai hard-requires `chromadb~=1.1.0`, which is affected by the unpatched pre-auth code-injection CVE-2026-45829 (see `integrations/crewai/README.md`) + - `integrations/crewai/SemanticaKGTool` — a CrewAI `BaseTool` exposing 5 KG actions (`extract_entities`, `extract_relations`, `add_to_graph`, `query_graph`, `find_related`) backed by `NERExtractor` / `RelationExtractor` / `ContextGraph`; supports both sync `run()` and async `arun()` + - `integrations/crewai/SemanticaDecisionTool` — a CrewAI `BaseTool` wrapping `AgentContext` with 5 decision-intelligence actions (`record_decision`, `find_precedents`, `trace_causal_chain`, `analyze_impact`, `check_policy`) + - `integrations/crewai/SemanticaKnowledgeSource` — a CrewAI `BaseKnowledgeSource` that serializes a `ContextGraph` into crew knowledge storage; implements both the legacy `load_content()` and current `validate_content()`/`aadd()` contracts so it works across `crewai>=0.80.0` + - All three classes degrade gracefully when `crewai` is not installed (still importable, full Semantica API available) + - New `tests/integrations/crewai/`: 70 tests covering stub-based present-case behavior (Pydantic/BaseTool subclassing, every action, knowledge-source chunking/storage) plus a subprocess isolation test for the crewai-absent degradation path + - Docs: `docs/integrations/crewai.md` page, `docs.json` Integrations nav entry, and README integration-matrix/install updates + - **Hardened during code review**: live `graph`/`context`/extractor state is excluded from CrewAI JSON serialization (`model_dump(mode="json")`) with `model_post_init` self-healing defaults, so checkpoint/resume no longer raises `PydanticSerializationError`; `query_graph` now searches node content (not just ids/types); `trace_causal_chain` returns an explicit error instead of substituting similarity precedents when causal tracing is unavailable, and calls `trace_decision_causality(..., max_depth=...)` with the correct argument name; `find_precedents` propagates `max_precedents` as the backend `limit`; `add_to_graph` writes are serialized under a module lock so concurrent agents can't double-count duplicate adds; nameless entities are skipped instead of creating `repr()`-junk nodes + - **Hardened during second code review**: `check_policy` rules are now coerced type-aware — `bool("false")` was truthy, so `enabled == false` reported a violation for `enabled: false`, and string datums like `"0.90"` were compared lexicographically instead of numerically; `trace_causal_chain` no longer raises `AttributeError` (which escaped the tool) when the decision context has no `knowledge_graph`, returning honest error JSON instead; knowledge-source storage failures log an actionable ERROR (a missing crew embedder otherwise silently left agents with empty retrieval); `add_to_graph` uses a per-graph re-entrant lock instead of a process-global one (independent graphs no longer serialize each other, and re-entrant extractors can't deadlock); entity/relation `confidence=None` normalizes to `1.0` instead of failing the whole extraction; added a subprocess integration test against the real `crewai` package covering `Crew`-level serialization round-trip and restore + - **`ContextGraph` gains retraction and purge — the graph previously had no way to remove a node or edge without discarding everything via `clear()`** (#957, closes #955) by @pravit-amp, reviewed by @KaifAhmad1 - `retract_node()`/`retract_edge()` close an entity's validity window rather than deleting it, reusing the existing `valid_from`/`valid_until`/`state_at()` machinery: the entity drops out of `find_active_nodes()` and future `state_at()` queries going forward, but `state_at()` calls before the retraction time still return it, so decisions recorded against it stay explainable. A `("kind", id)`-keyed retraction record captures who/why/when, retrievable via `get_retraction()`/`list_retractions()` - `purge_node()`/`purge_edge()` are the destructive counterpart: the entity is removed outright, from history as well as the active view, for erasure obligations retraction alone cannot satisfy (e.g. GDPR Article 17). Only a tombstone remains — that a purge happened, when, and why — deliberately never the purged content, via `get_tombstone()`/`list_tombstones()`. Purge is graph-scope only: `AgentMemory` and any bound vector store are not reached, so it is one step of an erasure workflow rather than the whole of it diff --git a/README.md b/README.md index fbc20781..fe3272ea 100644 --- a/README.md +++ b/README.md @@ -77,7 +77,7 @@ Semantica sits underneath your LLM, vector store, and agent framework as a deter - **Graph Analytics:** Centrality, community detection, link prediction, and shortest-path queries over the graph you just built - **Polyglot Graph Storage:** Native RDF (embedded Oxigraph, Blazegraph, Apache Jena, Eclipse RDF4J via SPARQL) and Labeled Property Graphs (Neo4j, FalkorDB, Apache AGE, AWS Neptune via Cypher), plus vector stores, all swappable without touching your code - **Visualization:** Explore any graph, ontology, or timeline in an interactive browser workbench -- **Drop-in Integrations:** Native Agno support, a full-featured MCP server, a comprehensive CLI, a REST API, and plugins across major editors +- **Drop-in Integrations:** Native Agno and CrewAI support, a full-featured MCP server, a comprehensive CLI, a REST API, and plugins across major editors --- @@ -1189,7 +1189,7 @@ Start with `semantica`, verify with `doctor`, build a graph, and explore the com ## Integrations -Native plugin bundles for Claude Code, Cursor, Codex, Windsurf, Cline, Continue, VS Code, and OpenClaw; a full-featured MCP server for any MCP-compatible client; a comprehensive REST API; and first-class Agno support for multi-agent shared context. Every major LLM provider is already supported via `semantica.llms` and LiteLLM: OpenAI, Anthropic, Gemini, Mistral, Llama, Groq, Cohere, Azure, Bedrock, Ollama, DeepSeek, HuggingFace, and more. +Native plugin bundles for Claude Code, Cursor, Codex, Windsurf, Cline, Continue, VS Code, and OpenClaw; a full-featured MCP server for any MCP-compatible client; a comprehensive REST API; and first-class Agno and CrewAI support for agentic frameworks. Every major LLM provider is already supported via `semantica.llms` and LiteLLM: OpenAI, Anthropic, Gemini, Mistral, Llama, Groq, Cohere, Azure, Bedrock, Ollama, DeepSeek, HuggingFace, and more. MCP setup takes 30 seconds — see [MCP Server](#mcp-server) below. @@ -1303,6 +1303,11 @@ MCP setup takes 30 seconds — see [MCP Server](#mcp-server) below. Agno
First-class · pip install semantica[agno] +
@@ -1319,11 +1324,6 @@ MCP setup takes 30 seconds — see [MCP Server](#mcp-server) below. REST API · MCP - -{children}, + tbody: ({ children }) => {children}, + tr: ({ children }) => {children}, + th: ({ children }) => , + td: ({ children }) => , + pre: ({ children }) =>
{children}
, + code: ({ className: codeClass, children, ...props }) => { + const isInline = !codeClass && typeof children === "string" && !children.includes("\n"); + return ( + + {children} + + ); + }, + }} + > + {rawContent} + + + )} + + + ); +} + +/* ─── Styles ──────────────────────────────────────────────────────── */ + +const viewerContainerStyle: CSSProperties = { + display: "flex", + flexDirection: "column", + background: "rgba(255, 255, 255, 0.025)", + border: `1px solid ${GRAPH_THEME.ui.surface.panelBorder}`, + borderRadius: 12, + overflow: "hidden", +}; + +const viewerHeaderStyle: CSSProperties = { + display: "flex", + alignItems: "center", + justifyContent: "space-between", + padding: "6px 10px", + background: "rgba(0, 0, 0, 0.2)", + borderBottom: `1px solid ${GRAPH_THEME.ui.surface.panelBorder}`, +}; + +const tabBtnStyle: CSSProperties = { + display: "inline-flex", + alignItems: "center", + padding: "4px 9px", + borderRadius: 6, + border: "1px solid transparent", + background: "transparent", + color: GRAPH_THEME.ui.text.muted, + fontSize: 12, + fontWeight: 600, + cursor: "pointer", + transition: "all 150ms ease", +}; + +const activeTabBtnStyle: CSSProperties = { + background: GRAPH_THEME.ui.timeline.playheadSoft, + border: `1px solid ${GRAPH_THEME.ui.control.activeBorder}`, + color: GRAPH_THEME.ui.timeline.playhead, +}; + +const copyBtnStyle: CSSProperties = { + display: "inline-flex", + alignItems: "center", + padding: "3px 8px", + borderRadius: 6, + border: `1px solid ${GRAPH_THEME.ui.surface.panelBorder}`, + background: "rgba(255, 255, 255, 0.04)", + color: GRAPH_THEME.ui.text.subtle, + fontSize: 11, + cursor: "pointer", +}; + +const viewerBodyStyle: CSSProperties = { + padding: 12, + maxHeight: 380, + overflowY: "auto", +}; + +const emptyTextStyle: CSSProperties = { + color: GRAPH_THEME.ui.text.muted, + fontSize: 12, + lineHeight: 1.5, + fontStyle: "italic", +}; + +const sourcePreStyle: CSSProperties = { + margin: 0, + padding: 10, + borderRadius: 8, + background: "rgba(0, 0, 0, 0.3)", + border: "1px solid rgba(255, 255, 255, 0.05)", + overflowX: "auto", +}; + +const sourceCodeStyle: CSSProperties = { + fontFamily: "'JetBrains Mono', 'Fira Code', monospace", + fontSize: 12, + lineHeight: 1.6, + color: GRAPH_THEME.ui.text.strong, + whiteSpace: "pre-wrap", + wordBreak: "break-word", + userSelect: "text", +}; + +const previewStyle: CSSProperties = { + color: GRAPH_THEME.ui.text.body, + fontSize: 13, + lineHeight: 1.6, + wordBreak: "break-word", +}; + +const h1Style: CSSProperties = { + fontSize: 16, + fontWeight: 700, + color: GRAPH_THEME.ui.text.strong, + marginTop: 8, + marginBottom: 6, + paddingBottom: 3, + borderBottom: `1px solid ${GRAPH_THEME.ui.surface.panelBorder}`, +}; + +const h2Style: CSSProperties = { + fontSize: 14, + fontWeight: 700, + color: GRAPH_THEME.ui.text.strong, + marginTop: 8, + marginBottom: 4, +}; + +const h3Style: CSSProperties = { + fontSize: 13, + fontWeight: 600, + color: GRAPH_THEME.ui.text.strong, + marginTop: 6, + marginBottom: 4, +}; + +const h4Style: CSSProperties = { + fontSize: 12, + fontWeight: 600, + color: GRAPH_THEME.ui.text.strong, + marginTop: 4, + marginBottom: 2, +}; + +const blockquoteStyle: CSSProperties = { + margin: "8px 0", + padding: "6px 12px", + borderLeft: `3px solid ${GRAPH_THEME.ui.timeline.playhead}`, + background: "rgba(98, 226, 205, 0.05)", + borderRadius: "0 6px 6px 0", + color: GRAPH_THEME.ui.text.body, + fontStyle: "italic", +}; + +const linkStyle: CSSProperties = { + color: "#79c0ff", + textDecoration: "underline", + textUnderlineOffset: "3px", + wordBreak: "break-all", +}; + +const imageBadgeStyle: CSSProperties = { + display: "inline-flex", + alignItems: "center", + padding: "3px 7px", + background: "rgba(255, 255, 255, 0.04)", + border: `1px solid ${GRAPH_THEME.ui.surface.panelBorder}`, + borderRadius: 6, + color: GRAPH_THEME.ui.text.muted, + fontSize: 11, + margin: "3px 0", +}; + +const inlineCodeStyle: CSSProperties = { + fontFamily: "'JetBrains Mono', monospace", + fontSize: 12, + padding: "2px 5px", + borderRadius: 4, + background: "rgba(255, 255, 255, 0.07)", + color: "#e6edf3", + border: "1px solid rgba(255, 255, 255, 0.08)", +}; + +const preBlockStyle: CSSProperties = { + margin: "8px 0", + padding: 10, + borderRadius: 8, + background: "rgba(0, 0, 0, 0.35)", + border: "1px solid rgba(255, 255, 255, 0.08)", + overflowX: "auto", +}; + +const blockCodeStyle: CSSProperties = { + fontFamily: "'JetBrains Mono', monospace", + fontSize: 12, + lineHeight: 1.5, + color: "#e6edf3", +}; diff --git a/explorer/tests/markdownContentViewer.test.ts b/explorer/tests/markdownContentViewer.test.ts new file mode 100644 index 00000000..d0c608f1 --- /dev/null +++ b/explorer/tests/markdownContentViewer.test.ts @@ -0,0 +1,70 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { isSafeUrl } from "../src/workspaces/GraphWorkspace/MarkdownContentViewer.tsx"; + +test("isSafeUrl permits safe http, https, and mailto URLs", () => { + assert.equal(isSafeUrl("https://example.com"), true); + assert.equal(isSafeUrl("http://localhost:8000"), true); + assert.equal(isSafeUrl("mailto:user@example.com"), true); + assert.equal(isSafeUrl("#section-1"), true); + assert.equal(isSafeUrl("/relative/path"), true); +}); + +test("isSafeUrl rejects dangerous schemes like javascript:, data:, and vbscript:", () => { + assert.equal(isSafeUrl("javascript:alert('xss')"), false); + assert.equal(isSafeUrl("JAVASCRIPT:alert(1)"), false); + assert.equal(isSafeUrl("data:text/html;base64,PHNjcmlwdD4="), false); + assert.equal(isSafeUrl("vbscript:MsgBox(1)"), false); + assert.equal(isSafeUrl(""), false); + assert.equal(isSafeUrl(undefined), false); +}); + +test("preserves exact unmodified content, whitespace, and Unicode in source format", () => { + const sampleMarkdown = `# Title with Unicode 🚀\n\n * Indented item 1\n * Indented item 2\n\n\`\`\`python\ndef test():\n return "α + β = γ"\n\`\`\``; + + // Exact characters, newlines, and whitespace must remain unmodified + assert.equal(sampleMarkdown.includes(" * Indented item 1"), true); + assert.equal(sampleMarkdown.includes("🚀"), true); + assert.equal(sampleMarkdown.includes("α + β = γ"), true); + assert.equal(sampleMarkdown.includes(" return"), true); +}); + +test("handles empty, null, and whitespace content gracefully without errors", () => { + const emptyValues = ["", " \n\t ", null, undefined]; + for (const val of emptyValues) { + const raw = typeof val === "string" ? val : ""; + const hasContent = raw.trim().length > 0; + assert.equal(hasContent, false); + } +}); + +test("handles plain text without requiring Markdown syntax", () => { + const plainText = "Simple plain text summary of graph entity without any formatting."; + const raw = typeof plainText === "string" ? plainText : ""; + const hasContent = raw.trim().length > 0; + assert.equal(hasContent, true); + assert.equal(raw, plainText); +}); + +test("handles very long content without truncation or performance failure", () => { + const longParagraph = "Semantica knowledge graph node content with structured facts. ".repeat(500); + const longMarkdown = `# Big Document\n\n${longParagraph}\n\n## Section 2\n\n${longParagraph}`; + assert.equal(longMarkdown.length > 50000, true); + const raw = typeof longMarkdown === "string" ? longMarkdown : ""; + assert.equal(raw.length, longMarkdown.length); +}); + +test("handles raw HTML content safely as text", () => { + const dangerousHtml = ``; + // In source mode, content is preserved literally without execution + assert.equal(dangerousHtml.includes("`; + const html = renderToString(React.createElement(MarkdownContentViewer, { content: dangerousHtml, defaultMode: "preview" })); + + // Script and iframe tags must NOT be rendered as active DOM tags + assert.equal(html.includes("`; - // In source mode, content is preserved literally without execution - assert.equal(dangerousHtml.includes("
+CrewAI
+CrewAI
+First-class · pip install semantica[crewai] +
Already Supported via REST API & MCP -CrewAI
-CrewAI
-REST API · MCP -
LlamaIndex
LlamaIndex
REST API · MCP @@ -1354,11 +1354,6 @@ MCP setup takes 30 seconds — see [MCP Server](#mcp-server) below. Dedicated toolkit
-CrewAI
-CrewAI
-Dedicated toolkit -
LlamaIndex
LlamaIndex
Dedicated toolkit @@ -1514,6 +1509,7 @@ pip install semantica[all] # everything ```bash pip install semantica[agno] # Agno multi-agent integration +pip install semantica[crewai] # CrewAI integration pip install semantica[llm-litellm] # OpenAI, Anthropic, Gemini, Mistral, Llama, Groq, Cohere, Bedrock, Ollama, DeepSeek, and more pip install semantica[graph-neo4j] # Neo4j graph store (LPG) pip install semantica[graph-falkordb] # FalkorDB graph store (LPG) diff --git a/docs/docs.json b/docs/docs.json index f52cdbd6..d2ad5da2 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -102,6 +102,7 @@ "group": "Integrations", "pages": [ "integrations/agno", + "integrations/crewai", "integrations/docling", "integrations/snowflake", "integrations/databricks" diff --git a/docs/integrations/crewai.md b/docs/integrations/crewai.md new file mode 100644 index 00000000..fb555cda --- /dev/null +++ b/docs/integrations/crewai.md @@ -0,0 +1,147 @@ +--- +title: "CrewAI Integration" +description: "Give CrewAI crews a shared semantic knowledge graph, decision intelligence, and graph-based retrieval via three drop-in components." +icon: "users" +--- + +> Three drop-in components that bring Semantica's knowledge graph and decision intelligence into any CrewAI crew. + +## Installation + +```bash +pip install "semantica[crewai]" +``` + +Requires `crewai >= 0.80.0`. If `crewai` is not installed, the integration still imports — every class carries the full Semantica API and degrades gracefully, but cannot be passed to a `Crew`. + +## Components at a Glance + +- **SemanticaKGTool** — `Agent(tools=[…])`: 5 KG construction/query actions: extract entities, extract relations, add to graph, query graph, find related. +- **SemanticaDecisionTool** — `Agent(tools=[…])`: 5 decision intelligence actions: record decisions, find precedents, trace causal chains, analyze impact, check policies. +- **SemanticaKnowledgeSource** — `Crew(knowledge_sources=[…])`: Serializes a `ContextGraph` into CrewAI knowledge storage so every agent gets retrieval access to the graph. + +## Component Details + + + + Lets agents actively **build and query** a shared `ContextGraph` mid-reasoning. + + ```python + from crewai import Agent, Crew, Task + from semantica.context import ContextGraph + from integrations.crewai import SemanticaKGTool + + graph = ContextGraph() + + analyst = Agent( + role="Knowledge Analyst", + goal="Build and explore a knowledge graph from documents", + backstory="You map entities and relationships into a shared graph.", + tools=[SemanticaKGTool(graph=graph)], + ) + + crew = Crew( + agents=[analyst], + tasks=[Task( + description="Extract and link key entities from the brief", + expected_output="JSON", + agent=analyst, + )], + ) + crew.kickoff() + ``` + + | Tool | Description | + | :------ | :------------- | + | `extract_entities` | Extract named entities from `text` | + | `extract_relations` | Extract relationships between entities in `text` | + | `add_to_graph` | Extract entities/relations from `text` and add them to the shared graph | + | `query_graph` | Keyword-search the graph by node id, type, and content using `query` | + | `find_related` | Find concepts related to `entity` within `hops` hops | + + All actions return JSON so agents get parseable results. + + **Sharing a graph:** the tool reads/writes whatever `graph` you pass in. When no `graph` is given, a fresh in-memory `ContextGraph()` is created (and a warning is logged) — two tool instances that each auto-create their own graph do **not** share knowledge. Pass the same `ContextGraph` to every agent that must share state. + + + Exposes Semantica's decision intelligence as a native CrewAI tool, backed by `AgentContext`. + + ```python + from crewai import Agent, Crew, Task + from integrations.crewai import SemanticaDecisionTool + + planner = Agent( + role="Decision Planner", + goal="Make grounded, precedented decisions", + backstory="You record decisions and validate them against policy.", + tools=[SemanticaDecisionTool()], + ) + + crew = Crew(agents=[planner], tasks=[...]) + ``` + + When no `AgentContext` is passed, one is created in-memory with `decision_tracking=True` and its own `ContextGraph`, so decision actions work out of the box (a warning is logged — pass the same `AgentContext` to every agent that must share decision state). Missing optional fields in `record_decision` fall back to `category="general"`, `reasoning="agent decision"`, and `outcome="recorded"`. `find_precedents` returns up to `max_precedents` results. If a knowledge graph cannot trace causality, `trace_causal_chain` returns an explicit error rather than substituting similarity-based results. + + | Tool | Description | + | :------ | :------------- | + | `record_decision` | Record a decision with reasoning, outcome, and confidence | + | `find_precedents` | Search for similar past decisions | + | `trace_causal_chain` | Trace the causal chain from a decision | + | `analyze_impact` | Assess downstream influence of a decision | + | `check_policy` | Validate a proposed decision against policy rules | + + + Gives **every agent in the crew** retrieval access to a `ContextGraph`. + + ```python + from crewai import Agent, Crew, Task + from semantica.context import ContextGraph + from integrations.crewai import SemanticaKnowledgeSource + + graph = ContextGraph() + graph.add_node(node_id="privacy", node_type="policy", content="...") + + researcher = Agent( + role="Policy Researcher", + goal="Answer questions from the knowledge base", + backstory="You retrieve from graph knowledge to answer accurately.", + ) + + crew = Crew( + agents=[researcher], + tasks=[...], + knowledge_sources=[SemanticaKnowledgeSource(graph=graph)], + ) + ``` + + On kickoff the graph's nodes and edges are serialized, chunked, and stored through CrewAI's knowledge pipeline. + + > **Embedder required:** storing chunks goes through CrewAI's knowledge pipeline, which needs an embedder to be configured. Set `Crew(embedder=...)` (or provide the default credentials CrewAI falls back to, e.g. `OPENAI_API_KEY`). If no working embedder is configured, storage fails, an ERROR is logged, and agents will retrieve **nothing** — the crew still runs, but its knowledge queries return empty. + + **Compatibility:** CrewAI's `BaseKnowledgeSource` contract changed between `0.80.x` and current releases (`load_content()` → `validate_content()`/`aadd()`). `SemanticaKnowledgeSource` implements both legacy and current methods, so it works across `crewai>=0.80.0`. + + + +## Checkpoints & Serialization + +CrewAI serializes tools and knowledge sources to JSON for checkpointing/resume. Live Semantica state (`ContextGraph`, `AgentContext`, extractors) is **excluded from that serialization** — a restored tool/source comes back with a fresh in-memory `ContextGraph` and logs a warning. Until you re-attach the live graph/context, the restored objects answer queries against an **empty** graph, so re-wire them after resuming (e.g. `restored_tool.graph = live_graph`) before agents continue. + +## API Reference + +```python +from integrations.crewai import ( + SemanticaKGTool, # BaseTool: KG construction/query actions + SemanticaDecisionTool, # BaseTool: decision intelligence actions + SemanticaKnowledgeSource, # BaseKnowledgeSource: graph → crew knowledge + CREWAI_AVAILABLE, # bool: True if crewai is installed +) +``` + +All three classes are usable without `crewai` installed: they carry the full Semantica API and degrade gracefully. + +## See Also + +- [Context Module](../reference/context) — AgentContext and ContextGraph backing the integration. +- [Semantic Extraction](../reference/semantic_extract) — NERExtractor / RelationExtractor used by SemanticaKGTool. +- [LLMs](../reference/llms) — Configure LLM providers for your crew's agents. +- [Vector Store](../reference/vector_store) — Vector backend used by SemanticaDecisionTool. diff --git a/integrations/crewai/README.md b/integrations/crewai/README.md new file mode 100644 index 00000000..e083dd7e --- /dev/null +++ b/integrations/crewai/README.md @@ -0,0 +1,108 @@ +# Semantica × CrewAI + +First-class integration between Semantica and [CrewAI](https://github.com/crewAIInc/crewAI) — give your crews a shared semantic knowledge graph, decision intelligence, and graph-based retrieval. + +## Installation + +```bash +pip install semantica[crewai] +``` + +Requires `crewai >= 0.80.0`. If `crewai` is not installed, the integration still imports (classes degrade gracefully), but you can't pass the objects to a `Crew`. + +> **⚠️ Security note:** crewai hard-requires `chromadb~=1.1.0`, which is currently affected by the unpatched pre-authentication code-injection advisory **CVE-2026-45829** (no fixed release — even the latest chromadb 1.5.9 is affected). Installing `semantica[crewai]` pulls that dependency into your environment. The `crewai` extra is intentionally **not** part of `semantica[all]` for this reason — only install it where you actually use CrewAI, and follow chromadb for a patched release. + +## 1. SemanticaKGTool + +A `BaseTool` that lets agents **build and query** a shared `ContextGraph` mid-reasoning: + +- `extract_entities` — extract named entities from `text` +- `extract_relations` — extract relationships from `text` +- `add_to_graph` — extract entities/relations from `text` and add them to the shared graph +- `query_graph` — keyword-search the graph using `query` +- `find_related` — find concepts related to `entity` within `hops` + +```python +from crewai import Agent, Crew, Task +from semantica.context import ContextGraph +from integrations.crewai import SemanticaKGTool + +graph = ContextGraph() + +analyst = Agent( + role="Knowledge Analyst", + goal="Build and explore a knowledge graph from documents", + backstory="You map entities and relationships into a shared graph.", + tools=[SemanticaKGTool(graph=graph)], +) + +crew = Crew( + agents=[analyst], + tasks=[Task(description="Extract and link key entities from the brief", expected_output="JSON", agent=analyst)], +) +result = crew.kickoff() +``` + +All actions return JSON, so agents get parseable results. + +## 2. SemanticaDecisionTool + +A `BaseTool` that wraps `AgentContext` and exposes decision intelligence: + +- `record_decision` — record a decision with reasoning and outcome +- `find_precedents` — retrieve past decisions similar to a scenario +- `trace_causal_chain` — trace the causal chain from a decision +- `analyze_impact` — assess downstream influence using graph centrality +- `check_policy` — validate a proposed decision against rule-based policies + +```python +from crewai import Agent, Crew, Task +from integrations.crewai import SemanticaDecisionTool + +planner = Agent( + role="Decision Planner", + goal="Make grounded, precedented decisions", + backstory="You record decisions and validate them against policy.", + tools=[SemanticaDecisionTool()], +) + +crew = Crew(agents=[planner], tasks=[...]) +``` + +When no `AgentContext` is passed, one is created in-memory with `decision_tracking=True`. + +## 3. SemanticaKnowledgeSource + +A `BaseKnowledgeSource` that serializes the current state of a `ContextGraph` (nodes, edges, metadata) into CrewAI's knowledge storage, giving **every agent in the crew** retrieval access to the graph: + +```python +from crewai import Agent, Crew, Task +from semantica.context import ContextGraph +from integrations.crewai import SemanticaKnowledgeSource + +graph = ContextGraph() +graph.add_node(node_id="privacy", node_type="policy", content="...") + +researcher = Agent( + role="Policy Researcher", + goal="Answer questions from the knowledge base", + backstory="You retrieve from graph knowledge to answer accurately.", +) + +crew = Crew( + agents=[researcher], + tasks=[...], + knowledge_sources=[SemanticaKnowledgeSource(graph=graph)], +) +``` + +> **Embedder required:** storing chunks goes through CrewAI's knowledge pipeline, which needs an embedder. Set `Crew(embedder=...)` (or provide CrewAI's default credentials, e.g. `OPENAI_API_KEY`). Without a working embedder, storage fails, an ERROR is logged, and agents retrieve nothing — the crew still runs with empty knowledge queries. + +### Compatibility note + +CrewAI's `BaseKnowledgeSource` contract changed between `0.80.x` and current releases (`load_content()` → `validate_content()`/`aadd()`). `SemanticaKnowledgeSource` implements both the legacy and current methods, so it works across `crewai>=0.80.0`. + +### Sharing state & checkpoints + +- Each tool/source holds whatever `graph`/`context` you pass it. When omitted, a fresh in-memory object is created and a warning is logged — instances that auto-create their own state do **not** share knowledge, so pass the same object to every agent that must share. +- Live state (`ContextGraph`, `AgentContext`, extractors) is excluded from CrewAI's JSON serialization. After restoring from a checkpoint, re-attach the live graph/context to the restored objects. diff --git a/integrations/crewai/__init__.py b/integrations/crewai/__init__.py new file mode 100644 index 00000000..96c027c7 --- /dev/null +++ b/integrations/crewai/__init__.py @@ -0,0 +1,44 @@ +""" +Semantica × CrewAI Integration +============================== + +First-class integration between the Semantica semantic intelligence stack and +the `CrewAI `_ agentic framework. + +Public surface +-------------- +SemanticaKGTool — CrewAI ``BaseTool`` exposing KG construction/query actions +SemanticaDecisionTool — CrewAI ``BaseTool`` exposing decision-intelligence actions +SemanticaKnowledgeSource— CrewAI ``BaseKnowledgeSource`` giving crews graph knowledge + +Quick start +----------- + pip install semantica[crewai] + + >>> from integrations.crewai import ( + ... SemanticaKGTool, + ... SemanticaDecisionTool, + ... SemanticaKnowledgeSource, + ... ) + +Compatibility +------------- +Requires ``crewai >= 0.80.0``. All three classes degrade gracefully when +``crewai`` is not installed — they are still importable and carry the full +Semantica API, but cannot be passed to ``Crew`` / ``Agent`` constructors. +""" + +from ._availability import CREWAI_AVAILABLE, CREWAI_IMPORT_ERROR +from .decision_tool import SemanticaDecisionTool +from .kg_tool import SemanticaKGTool +from .knowledge_source import SemanticaKnowledgeSource + +__all__ = [ + "SemanticaKGTool", + "SemanticaDecisionTool", + "SemanticaKnowledgeSource", + "CREWAI_AVAILABLE", + "CREWAI_IMPORT_ERROR", +] + +__version__ = "0.1.0" diff --git a/integrations/crewai/_availability.py b/integrations/crewai/_availability.py new file mode 100644 index 00000000..6c871628 --- /dev/null +++ b/integrations/crewai/_availability.py @@ -0,0 +1,24 @@ +""" +Shared CrewAI availability probe. + +Every integration module needs to know whether the real ``crewai`` package is +installed. Probing once here (instead of once per module) guarantees the +exported ``CREWAI_AVAILABLE`` flag means the *whole* integration is ready — a +caller gating on it will never see tools using CrewAI while a knowledge source +silently degrades (or vice versa). +""" + +from typing import Optional + +CREWAI_AVAILABLE = False +CREWAI_IMPORT_ERROR: Optional[str] = None + +try: + from crewai.knowledge.source.base_knowledge_source import ( # noqa: F401 + BaseKnowledgeSource, + ) + from crewai.tools import BaseTool # noqa: F401 + + CREWAI_AVAILABLE = True +except ImportError as exc: + CREWAI_IMPORT_ERROR = str(exc) diff --git a/integrations/crewai/decision_tool.py b/integrations/crewai/decision_tool.py new file mode 100644 index 00000000..bf3552bf --- /dev/null +++ b/integrations/crewai/decision_tool.py @@ -0,0 +1,555 @@ +""" +SemanticaDecisionTool — a CrewAI ``BaseTool`` exposing Semantica's decision +intelligence (``AgentContext``) to agents. + +Lets agents record decisions with reasoning, retrieve past precedents, trace +causal chains, analyse downstream impact, and validate proposed decisions +against policy rules. + +Install +------- + pip install semantica[crewai] + +Example +------- + >>> from integrations.crewai import SemanticaDecisionTool + >>> from crewai import Agent, Crew, Task + >>> tool = SemanticaDecisionTool() + >>> crew = Crew( + ... agents=[Agent(role="...", goal="...", backstory="...", tools=[tool])], + ... tasks=[...], + ... ) + +Tools exposed +------------- +record_decision — Record a decision with reasoning and outcome +find_precedents — Search past decisions similar to a scenario +trace_causal_chain— Trace the causal chain from a decision node +analyze_impact — Assess downstream influence of a decision +check_policy — Validate a proposed decision against policy rules +""" + +from __future__ import annotations + +import json +import re +from typing import Any, Dict, List, Literal, Optional, Type + +from pydantic import BaseModel, Field + +from semantica.utils.logging import get_logger + +from ._availability import CREWAI_AVAILABLE + +logger = get_logger(__name__) + +# --------------------------------------------------------------------------- +# Optional: CrewAI BaseTool base class +# --------------------------------------------------------------------------- +_BaseTool: Any = object + +if CREWAI_AVAILABLE: + from crewai.tools import BaseTool as _BaseTool # type: ignore + + +# --------------------------------------------------------------------------- +# Input schema +# --------------------------------------------------------------------------- +class SemanticaDecisionToolInput(BaseModel): + """ + Input schema for ``SemanticaDecisionTool``. + + Exactly one action is dispatched per call; the remaining fields are only + used by the actions that need them. + """ + + action: Literal[ + "record_decision", + "find_precedents", + "trace_causal_chain", + "analyze_impact", + "check_policy", + ] = Field( + ..., + description=( + "Which decision-intelligence operation to run. One of: " + "'record_decision', 'find_precedents', 'trace_causal_chain', " + "'analyze_impact', 'check_policy'." + ), + ) + category: Optional[str] = Field( + None, + description="Domain category, e.g. 'loan_approval'. Used by 'record_decision'.", + ) + scenario: Optional[str] = Field( + None, + description=( + "Short description of the situation. Used by 'record_decision' and " + "'find_precedents'." + ), + ) + reasoning: Optional[str] = Field( + None, description="Why this outcome was chosen. Used by 'record_decision'." + ) + outcome: Optional[str] = Field( + None, description="The decision result. Used by 'record_decision'." + ) + confidence: float = Field( + 0.8, + ge=0.0, + le=1.0, + description="Confidence score in [0, 1]. Used by 'record_decision'.", + ) + entities: Optional[str] = Field( + None, + description="Comma-separated entity names. Used by 'record_decision'.", + ) + decision_id: Optional[str] = Field( + None, + description=( + "Identifier of a decision. Used by 'trace_causal_chain' and " + "'analyze_impact'." + ), + ) + depth: int = Field( + 3, + ge=1, + le=20, + description="Maximum chain depth. Used by 'trace_causal_chain'.", + ) + decision_data: Optional[str] = Field( + None, + description=( + "JSON object describing a proposed decision. Used by 'check_policy'." + ), + ) + policy_rules: Optional[str] = Field( + None, + description=( + "JSON list of rule strings like 'confidence >= 0.7'. Used by " + "'check_policy'." + ), + ) + + +# --------------------------------------------------------------------------- +# SemanticaDecisionTool +# --------------------------------------------------------------------------- +class SemanticaDecisionTool(_BaseTool): # type: ignore[misc] + """ + CrewAI tool that surfaces Semantica's decision intelligence as agent actions. + + Parameters + ---------- + context: + A ``semantica.context.AgentContext`` (or compatible object exposing + ``record_decision``, ``find_precedents_advanced``, + ``analyze_decision_influence``). A fresh in-memory context is created + when ``None``. + max_precedents: + Default number of precedents returned by ``find_precedents``. + causal_depth: + Default chain depth used by ``trace_causal_chain``. + """ + + name: str = "semantica_decision" + description: str = ( + "Decision intelligence toolkit. Actions: 'record_decision' (record a " + "decision with category, scenario, reasoning, outcome, confidence), " + "'find_precedents' (search past decisions similar to 'scenario'), " + "'trace_causal_chain' (trace the causal chain from 'decision_id'), " + "'analyze_impact' (assess downstream influence of 'decision_id'), " + "'check_policy' (validate 'decision_data' JSON against 'policy_rules' " + "rules like 'confidence >= 0.7'). Returns JSON." + ) + args_schema: Type[BaseModel] = SemanticaDecisionToolInput + context: Any = Field(default=None, exclude=True) + max_precedents: int = 5 + causal_depth: int = 3 + had_live_state: bool = False + reconstructed_state: bool = Field(default=False, exclude=True) + + def __init__( + self, + context: Any = None, + max_precedents: int = 5, + causal_depth: int = 3, + **kwargs: Any, + ) -> None: + if CREWAI_AVAILABLE: + super().__init__( + context=context, + max_precedents=max_precedents, + causal_depth=causal_depth, + **kwargs, + ) + else: + super().__init__() + self.context = context + self.max_precedents = max_precedents + self.causal_depth = causal_depth + # Degraded mode is a plain class — no model_post_init lifecycle. + self._ensure_defaults() + + logger.info("SemanticaDecisionTool initialised (crewai=%s)", CREWAI_AVAILABLE) + + def model_post_init(self, __context: Any) -> None: + """Re-create default state after validation/deserialisation. + + ``context`` is excluded from JSON serialisation (CrewAI checkpoints + serialise every tool via ``model_dump(mode="json")``), so a tool + restored from a checkpoint has ``None`` state until this runs. + """ + self._ensure_defaults() + super().model_post_init(__context) + + def _ensure_defaults(self) -> None: + """Lazy-import and build a real AgentContext when none is wired.""" + if self.context is None: + from semantica.context import AgentContext, ContextGraph + from semantica.vector_store import VectorStore + + self.context = AgentContext( + vector_store=VectorStore(backend="faiss"), + decision_tracking=True, + knowledge_graph=ContextGraph(), + ) + if self.had_live_state: + self.reconstructed_state = True + logger.warning( + "SemanticaDecisionTool: the live decision context was lost " + "during serialization/checkpoint restore — an EMPTY " + "context was reconstructed; re-attach the original context " + "before continuing" + ) + else: + logger.warning( + "SemanticaDecisionTool created a fresh in-memory " + "AgentContext — agents sharing decision state must be " + "wired to the same context" + ) + self.had_live_state = True + + # ------------------------------------------------------------------ + # CrewAI entry points + # ------------------------------------------------------------------ + + def _run( + self, + action: str, + category: Optional[str] = None, + scenario: Optional[str] = None, + reasoning: Optional[str] = None, + outcome: Optional[str] = None, + confidence: float = 0.8, + entities: Optional[str] = None, + decision_id: Optional[str] = None, + depth: int = 3, + decision_data: Optional[str] = None, + policy_rules: Optional[str] = None, + **kwargs: Any, + ) -> str: + valid = { + "record_decision", + "find_precedents", + "trace_causal_chain", + "analyze_impact", + "check_policy", + } + if action not in valid: + return json.dumps( + { + "error": f"Unknown action '{action}'. Valid actions: " + + ", ".join(sorted(valid)) + } + ) + + if action == "record_decision": + return self._record_decision( + category=category or "general", + scenario=scenario or "decision recorded", + reasoning=reasoning or "agent decision", + outcome=outcome or "recorded", + confidence=confidence, + entities=entities, + ) + if action == "find_precedents": + return self._find_precedents(scenario=scenario or "", category=category) + if action == "trace_causal_chain": + return self._trace_causal_chain(decision_id or "", depth=depth) + if action == "analyze_impact": + return self._analyze_impact(decision_id or "") + return self._check_policy(decision_data or "", policy_rules) + + async def _arun(self, action: str, **kwargs: Any) -> str: + """Async variant of ``_run`` for CrewAI's async tool path.""" + return self._run(action=action, **kwargs) + + # ------------------------------------------------------------------ + # Actions + # ------------------------------------------------------------------ + + def _record_decision( + self, + category: str, + scenario: str, + reasoning: str, + outcome: str, + confidence: float = 0.8, + entities: Optional[str] = None, + ) -> str: + entity_list: Optional[List[str]] = None + if entities: + entity_list = [e.strip() for e in entities.split(",") if e.strip()] + + try: + decision_id = self.context.record_decision( + category=category, + scenario=scenario, + reasoning=reasoning, + outcome=outcome, + confidence=float(confidence), + entities=entity_list, + ) + result = {"decision_id": str(decision_id), "status": "recorded"} + logger.info("record_decision → %s", decision_id) + except Exception as exc: + result = {"error": str(exc), "status": "failed"} + logger.warning("record_decision failed: %s", exc) + + return json.dumps(result) + + def _find_precedents( + self, + scenario: str, + category: Optional[str] = None, + limit: Optional[int] = None, + ) -> str: + k = limit if limit is not None else self.max_precedents + try: + precedents = self.context.find_precedents_advanced( + scenario=scenario, + category=category, + limit=k, + ) + out: List[Dict[str, Any]] = [] + for p in (precedents or [])[:k]: + if isinstance(p, dict): + out.append(p) + else: + out.append( + { + "scenario": getattr(p, "scenario", str(p)), + "outcome": getattr(p, "outcome", ""), + "confidence": getattr(p, "confidence", 0.0), + "category": getattr(p, "category", ""), + } + ) + logger.info("find_precedents('%s') → %d results", scenario, len(out)) + return json.dumps({"precedents": out, "count": len(out)}) + except Exception as exc: + logger.warning("find_precedents failed: %s", exc) + return json.dumps({"precedents": [], "count": 0, "error": str(exc)}) + + def _trace_causal_chain(self, decision_id: str, depth: Optional[int] = None) -> str: + if not decision_id: + return json.dumps( + { + "error": "decision_id is required for trace_causal_chain", + "causal_chain": [], + "decision_id": "", + } + ) + max_depth = depth or self.causal_depth + try: + graph = getattr(self.context, "knowledge_graph", None) + if graph is None: + return json.dumps( + { + "error": ( + "causal tracing is not available on this knowledge " + "graph (the decision context has no knowledge_graph)" + ), + "causal_chain": [], + "decision_id": decision_id, + } + ) + trace = getattr(graph, "trace_decision_causality", None) + if trace is None: + return json.dumps( + { + "error": ( + "causal tracing is not available on this knowledge graph " + "(graph.trace_decision_causality is not implemented)" + ), + "causal_chain": [], + "decision_id": decision_id, + } + ) + chain = trace(decision_id, max_depth=max_depth) + return json.dumps({"causal_chain": chain, "decision_id": decision_id}) + except Exception as exc: + logger.warning("trace_causal_chain failed: %s", exc) + return json.dumps( + {"error": str(exc), "causal_chain": [], "decision_id": decision_id} + ) + + def _analyze_impact(self, decision_id: str) -> str: + try: + influence = self.context.analyze_decision_influence(decision_id) + if not isinstance(influence, dict): + influence = {"influence": str(influence)} + influence["decision_id"] = decision_id + return json.dumps(influence) + except Exception as exc: + logger.warning("analyze_impact failed: %s", exc) + return json.dumps({"error": str(exc), "decision_id": decision_id}) + + def _check_policy( + self, + decision_data: str, + policy_rules: Optional[str] = None, + ) -> str: + try: + data = ( + json.loads(decision_data) + if isinstance(decision_data, str) + else decision_data + ) + except json.JSONDecodeError as exc: + return json.dumps( + { + "compliant": False, + "violations": [f"Invalid decision_data JSON: {exc}"], + "warnings": [], + } + ) + + if not isinstance(data, dict): + return json.dumps( + { + "compliant": False, + "violations": [ + f"decision_data must decode to a JSON object, " + f"got {type(data).__name__}: {data!r}" + ], + "warnings": [], + } + ) + + violations: List[str] = [] + warnings: List[str] = [] + + rules: List[str] = [] + if policy_rules: + try: + parsed_rules = json.loads(policy_rules) + except json.JSONDecodeError: + rules = [r.strip() for r in policy_rules.split(",") if r.strip()] + else: + if isinstance(parsed_rules, str): + rules = [parsed_rules] + elif isinstance(parsed_rules, list): + for item in parsed_rules: + if isinstance(item, str): + rules.append(item) + else: + warnings.append( + f"Ignoring non-string policy rule entry: {item!r}" + ) + else: + warnings.append( + f"policy_rules must decode to a JSON list of rule strings, " + f"got {type(parsed_rules).__name__}: {parsed_rules!r}" + ) + + for rule in rules: + try: + if not self._eval_rule(rule, data): + violations.append(f"Rule violated: {rule}") + except Exception as exc: + warnings.append(f"Could not evaluate rule '{rule}': {exc}") + + compliant = len(violations) == 0 + logger.debug( + "check_policy: compliant=%s, violations=%d", compliant, len(violations) + ) + return json.dumps( + { + "compliant": compliant, + "violations": violations, + "warnings": warnings, + } + ) + + def _eval_rule(self, rule: str, data: Dict[str, Any]) -> bool: + """Evaluate a simple comparison rule (``field op value``) against data. + + This is a small standalone evaluator for the tool's ``check_policy`` + action — it is intentionally independent of Semantica's policy engine + so agents get a bounded, side-effect-free rule check. Rules are + `` `` comparisons only; there is no expression + evaluation (no ``eval``), so untrusted rule strings are safe to pass. + + Values are coerced type-aware: ``true``/``false`` (and ``1``/``0``) + become booleans, numeric literals become numbers, and string values + that parse as numbers are compared numerically, so ``score == 0.9`` + holds for ``score: "0.90"`` and ``enabled == false`` holds for + ``enabled: false``. Field names may contain hyphens, dots and spaces + (e.g. ``risk-score >= 0.9``); they are matched against ``data`` keys + as-is. + """ + m = re.match(r"(.+?)\s*(>=|<=|!=|==|>|<)\s*(.+)$", rule.strip()) + if not m: + raise ValueError(f"unrecognised rule format: {rule!r}") + field, op, val_str = m.group(1), m.group(2), m.group(3).strip().strip("\"'") + if field not in data: + raise ValueError(f"rule references undefined field {field!r}") + actual = data[field] + if actual is None: + raise ValueError(f"field {field!r} is null — cannot evaluate rule") + val = self._coerce_value(val_str) + if isinstance(actual, str): + actual = self._coerce_value(actual) + ops = { + ">=": lambda a, b: a >= b, + "<=": lambda a, b: a <= b, + "!=": lambda a, b: a != b, + "==": lambda a, b: a == b, + ">": lambda a, b: a > b, + "<": lambda a, b: a < b, + } + return ops[op](actual, val) + + @staticmethod + def _coerce_value(value: str) -> Any: + """Parse a rule literal into its most specific Python type.""" + text = value.strip() + lowered = text.lower() + if lowered in ("true", "1"): + return True + if lowered in ("false", "0"): + return False + try: + return int(text) + except ValueError: + pass + try: + return float(text) + except ValueError: + pass + return text + + # When crewai is absent there is no BaseTool to provide the public + # ``run``/``arun`` entry points, so expose them directly. With crewai + # installed these are left untouched so crewai's own implementations + # (usage tracking, ``result_as_answer``) win. + if not CREWAI_AVAILABLE: + + def run(self, *args: Any, **kwargs: Any) -> str: + """Run the tool synchronously (degraded mode, no crewai).""" + return self._run(*args, **kwargs) + + async def arun(self, *args: Any, **kwargs: Any) -> str: + """Run the tool asynchronously (degraded mode, no crewai).""" + return self._run(*args, **kwargs) diff --git a/integrations/crewai/kg_tool.py b/integrations/crewai/kg_tool.py new file mode 100644 index 00000000..7740e3fa --- /dev/null +++ b/integrations/crewai/kg_tool.py @@ -0,0 +1,573 @@ +""" +SemanticaKGTool — a CrewAI ``BaseTool`` exposing Semantica's knowledge-graph +pipeline (``NERExtractor``, ``RelationExtractor``, ``ContextGraph``) to agents. + +Lets agents build and query a shared ``ContextGraph`` as part of their +reasoning loop. + +Install +------- + pip install semantica[crewai] + +Example +------- + >>> from integrations.crewai import SemanticaKGTool + >>> from semantica.context import ContextGraph + >>> from crewai import Agent, Crew, Task + >>> graph = ContextGraph() + >>> tool = SemanticaKGTool(graph=graph) + >>> crew = Crew( + ... agents=[Agent(role="...", goal="...", backstory="...", tools=[tool])], + ... tasks=[...], + ... ) + +Tools exposed +------------- +extract_entities — Extract named entities from text +extract_relations — Extract relationships between entities +add_to_graph — Extract entities/relations from text and add them to the graph +query_graph — Query the graph by keyword +find_related — Find concepts related to a given entity within ``hops`` +""" + +from __future__ import annotations + +import json +import threading +import weakref +from typing import Any, Dict, List, Literal, Optional, Sequence, Type + +from pydantic import BaseModel, Field + +from semantica.utils.logging import get_logger + +from ._availability import CREWAI_AVAILABLE, CREWAI_IMPORT_ERROR # noqa: F401 + +logger = get_logger(__name__) + +# --------------------------------------------------------------------------- +# Optional: CrewAI BaseTool base class +# --------------------------------------------------------------------------- +_BaseTool: Any = object + +if CREWAI_AVAILABLE: + from crewai.tools import BaseTool as _BaseTool # type: ignore + +# One re-entrant lock per graph so concurrent tool invocations sharing a graph +# cannot double-count duplicate adds (check-then-act is not atomic), while +# independent graphs are never serialised against each other. An RLock also +# means an extractor callback that re-enters add_to_graph on the same graph +# cannot deadlock. +_graph_locks_guard = threading.Lock() +_graph_locks: "weakref.WeakKeyDictionary[Any, threading.RLock]" = ( + weakref.WeakKeyDictionary() +) + + +# --------------------------------------------------------------------------- +# Input schema +# --------------------------------------------------------------------------- +class SemanticaKGToolInput(BaseModel): + """ + Input schema for ``SemanticaKGTool``. + + Exactly one action is dispatched per call; the remaining fields are only + used by the actions that need them. + """ + + action: Literal[ + "extract_entities", + "extract_relations", + "add_to_graph", + "query_graph", + "find_related", + ] = Field( + ..., + description=( + "Which graph operation to run. One of: 'extract_entities', " + "'extract_relations', 'add_to_graph', 'query_graph', 'find_related'." + ), + ) + text: Optional[str] = Field( + None, + description=( + "Input text. Used by 'extract_entities', 'extract_relations' and " + "'add_to_graph'." + ), + ) + query: Optional[str] = Field( + None, description="Search query. Used by 'query_graph'." + ) + entity: Optional[str] = Field( + None, + description="Root entity name. Used by 'find_related'.", + ) + hops: int = Field( + 1, + ge=1, + le=10, + description="Maximum relationship hops. Used by 'find_related'.", + ) + + +# --------------------------------------------------------------------------- +# SemanticaKGTool +# --------------------------------------------------------------------------- +class SemanticaKGTool(_BaseTool): # type: ignore[misc] + """ + CrewAI tool that surfaces Semantica's KG pipeline as agent actions. + + Parameters + ---------- + graph: + A ``semantica.context.ContextGraph`` to read/write. A fresh in-memory + graph is used when ``None``. + ner_extractor: + A ``semantica.semantic_extract.NERExtractor`` instance; auto-created + when ``None``. + relation_extractor: + A ``semantica.semantic_extract.RelationExtractor`` instance; auto- + created when ``None``. + """ + + name: str = "semantica_knowledge_graph" + description: str = ( + "Build and query a semantic knowledge graph. Actions: " + "'extract_entities' (extract named entities from 'text'), " + "'extract_relations' (extract relationships from 'text'), " + "'add_to_graph' (extract entities/relations from 'text' and add them " + "to the shared graph), 'query_graph' (keyword search using 'query'), " + "'find_related' (find concepts related to 'entity' within 'hops' " + "hops). Returns JSON." + ) + args_schema: Type[BaseModel] = SemanticaKGToolInput + graph: Any = Field(default=None, exclude=True) + ner_extractor: Any = Field(default=None, exclude=True) + relation_extractor: Any = Field(default=None, exclude=True) + had_live_state: bool = False + reconstructed_state: bool = Field(default=False, exclude=True) + + def __init__( + self, + graph: Any = None, + ner_extractor: Any = None, + relation_extractor: Any = None, + **kwargs: Any, + ) -> None: + if CREWAI_AVAILABLE: + super().__init__( + graph=graph, + ner_extractor=ner_extractor, + relation_extractor=relation_extractor, + **kwargs, + ) + else: + super().__init__() + self.graph = graph + self.ner_extractor = ner_extractor + self.relation_extractor = relation_extractor + # Degraded mode is a plain class — no model_post_init lifecycle. + self._ensure_defaults() + + logger.info("SemanticaKGTool initialised (crewai=%s)", CREWAI_AVAILABLE) + + def model_post_init(self, __context: Any) -> None: + """Re-create default state after validation/deserialisation. + + ``graph``/extractors are excluded from JSON serialisation (CrewAI + checkpoints serialise every tool via ``model_dump(mode="json")``), so a + tool restored from a checkpoint has ``None`` state until this runs. + """ + self._ensure_defaults() + super().model_post_init(__context) + + def _ensure_defaults(self) -> None: + """Lazy-import and build defaults for any missing shared state.""" + # Lazy imports keep the module importable without heavy deps + if self.graph is None: + from semantica.context import ContextGraph + + self.graph = ContextGraph() + if self.had_live_state: + self.reconstructed_state = True + logger.warning( + "SemanticaKGTool: the live graph was lost during " + "serialization/checkpoint restore — an EMPTY graph was " + "reconstructed; re-attach the original graph before " + "continuing" + ) + else: + logger.warning( + "SemanticaKGTool created a fresh in-memory ContextGraph — " + "agents sharing this tool's graph must be wired explicitly" + ) + self.had_live_state = True + if self.ner_extractor is None: + from semantica.semantic_extract import NERExtractor + + self.ner_extractor = NERExtractor() + if self.relation_extractor is None: + from semantica.semantic_extract import RelationExtractor + + self.relation_extractor = RelationExtractor() + + # ------------------------------------------------------------------ + # CrewAI entry points + # ------------------------------------------------------------------ + + def _run( + self, + action: str, + text: Optional[str] = None, + query: Optional[str] = None, + entity: Optional[str] = None, + hops: int = 1, + **kwargs: Any, + ) -> str: + """ + Dispatch a graph action. Always returns a JSON string so the agent + receives a structured, parseable result. + """ + valid = { + "extract_entities", + "extract_relations", + "add_to_graph", + "query_graph", + "find_related", + } + if action not in valid: + return json.dumps( + { + "error": f"Unknown action '{action}'. Valid actions: " + + ", ".join(sorted(valid)) + } + ) + + if action == "extract_entities": + return self._extract_entities(text or "") + if action == "extract_relations": + return self._extract_relations(text or "") + if action == "add_to_graph": + return self._add_from_text(text or "") + if action == "query_graph": + return self._query_graph(query or "") + return self._find_related(entity or "", hops=hops) + + async def _arun( + self, + action: str, + text: Optional[str] = None, + query: Optional[str] = None, + entity: Optional[str] = None, + hops: int = 1, + **kwargs: Any, + ) -> str: + """ + Async variant of ``_run`` for CrewAI's async tool path. + """ + return self._run( + action=action, text=text, query=query, entity=entity, hops=hops, **kwargs + ) + + # ------------------------------------------------------------------ + # Entity/relation field access (handles both Semantica dataclasses and + # third-party shapes like MagicMock/plain dicts in stubs) + # ------------------------------------------------------------------ + + @staticmethod + def _first_str(obj: Any, attrs: Sequence[str]) -> str: + """Return the first attribute value that is a non-empty string.""" + for attr in attrs: + value = getattr(obj, attr, None) + if isinstance(value, str) and value: + return value + if isinstance(obj, dict): + for key in attrs: + value = obj.get(key) + if isinstance(value, str) and value: + return value + return "" + + @classmethod + def _entity_name(cls, e: Any) -> str: + """Best-effort name for an entity-like object.""" + return cls._first_str(e, ("name", "text", "label", "node_id", "id")) + + @classmethod + def _entity_type(cls, e: Any) -> str: + """Best-effort type/label for an entity-like object.""" + return cls._first_str(e, ("type", "label")) or "Entity" + + @classmethod + def _relation_source(cls, r: Any) -> str: + """Best-effort source of a relation-like object.""" + src = cls._first_str(r, ("source",)) + if not src: + src = cls._entity_name(getattr(r, "subject", None)) + return src + + @classmethod + def _relation_target(cls, r: Any) -> str: + """Best-effort target of a relation-like object.""" + tgt = cls._first_str(r, ("target",)) + if not tgt: + tgt = cls._entity_name(getattr(r, "object", None)) + return tgt + + @classmethod + def _relation_type(cls, r: Any) -> str: + """Best-effort relation type of a relation-like object.""" + rtype = cls._first_str(r, ("type", "relation", "predicate")) + return rtype or "related_to" + + @classmethod + def _confidence(cls, e: Any) -> float: + """Normalise an entity/relation confidence value to a float.""" + try: + val = getattr(e, "confidence", None) + if val is None: + return 1.0 + return round(float(val), 4) + except (TypeError, ValueError): + return 1.0 + + @classmethod + def _graph_lock(cls, graph: Any) -> threading.RLock: + """Return the re-entrant lock guarding a specific graph.""" + with _graph_locks_guard: + lock = _graph_locks.get(graph) + if lock is None: + lock = threading.RLock() + _graph_locks[graph] = lock + return lock + + # ------------------------------------------------------------------ + # Actions + # ------------------------------------------------------------------ + + def _extract_entities(self, text: str) -> str: + """Extract named entities from ``text``.""" + try: + raw = self.ner_extractor.extract_entities(text) or [] + entities = [ + { + "name": self._entity_name(e), + "type": self._entity_type(e), + "confidence": self._confidence(e), + } + for e in raw + if self._entity_name(e) + ] + logger.debug("extract_entities → %d entities", len(entities)) + return json.dumps({"entities": entities, "count": len(entities)}) + except Exception as exc: + logger.warning("extract_entities failed: %s", exc) + return json.dumps({"entities": [], "count": 0, "error": str(exc)}) + + def _extract_relations(self, text: str) -> str: + """Extract relationships between entities in ``text``.""" + try: + raw = self.relation_extractor.extract_relations(text) or [] + relations = [ + { + "source": self._relation_source(r), + "relation": self._relation_type(r), + "target": self._relation_target(r), + "confidence": self._confidence(r), + } + for r in raw + ] + logger.debug("extract_relations → %d relations", len(relations)) + return json.dumps({"relations": relations, "count": len(relations)}) + except Exception as exc: + logger.warning("extract_relations failed: %s", exc) + return json.dumps({"relations": [], "count": 0, "error": str(exc)}) + + def _add_from_text(self, text: str) -> str: + """ + Extract entities and relations from ``text`` and add them to the graph. + + Duplicate nodes/edges (same id, or same source/type/target) are + skipped so repeated calls are idempotent. Returns JSON with the + number of nodes/edges added. + """ + nodes_added = 0 + edges_added = 0 + try: + with self._graph_lock(self.graph): + existing_nodes = { + n.get("id") or n.get("node_id") + for n in ( + self.graph.find_nodes() or [] # type: ignore[attr-defined] + ) + if n.get("id") or n.get("node_id") + } + existing_edges = { + (e.get("source"), e.get("type") or "related_to", e.get("target")) + for e in ( + self.graph.find_edges() or [] # type: ignore[attr-defined] + ) + if e.get("source") and e.get("target") + } + + raw_entities = self.ner_extractor.extract_entities(text) or [] + entities: List[Any] = [] + seen: set = set() + for e in raw_entities: + name = self._entity_name(e) + ntype = self._entity_type(e) + if not name or name in seen: + continue + seen.add(name) + entities.append(e) + if name in existing_nodes: + continue + try: + if self.graph.add_node(node_id=name, node_type=ntype): + nodes_added += 1 + existing_nodes.add(name) + except Exception as exc: + logger.debug("add_node(%r) failed: %s", name, exc) + + raw_relations = ( + self.relation_extractor.extract_relations(text, entities=entities) + or [] + ) + for r in raw_relations: + src = self._relation_source(r) + tgt = self._relation_target(r) + rtype = self._relation_type(r) + if not src or not tgt: + continue + key = (src, rtype, tgt) + if key in existing_edges: + continue + try: + if self.graph.add_edge( + source_id=src, target_id=tgt, edge_type=rtype + ): + edges_added += 1 + existing_edges.add(key) + except Exception as exc: + logger.debug("add_edge(%r) failed: %s", key, exc) + logger.debug("add_to_graph: +%d nodes, +%d edges", nodes_added, edges_added) + return json.dumps({"nodes_added": nodes_added, "edges_added": edges_added}) + except Exception as exc: + logger.warning("add_to_graph failed: %s", exc) + return json.dumps({"nodes_added": 0, "edges_added": 0, "error": str(exc)}) + + def _query_graph(self, query: str) -> str: + """Keyword-search graph nodes by id, type and content.""" + try: + q = (query or "").strip().lower() + out: List[dict] = [] + seen: set = set() + + query_method = getattr(self.graph, "query", None) + if query_method is not None: + for match in query_method(query) or []: + node = match.get("node") or {} + nid = node.get("id", "") or node.get("node_id", "") + if not nid or nid in seen: + continue + seen.add(nid) + content = match.get("content") or node.get("content", "") + out.append( + { + "id": nid, + "type": node.get("type", "") or node.get("node_type", ""), + "label": nid, + "content": str(content)[:500], + "score": round(float(match.get("score") or 0.0), 4), + } + ) + + if q: + for n in self.graph.find_nodes() or []: # type: ignore[attr-defined] + if isinstance(n, dict): + nid = n.get("id", "") or n.get("node_id", "") + ntype = n.get("type", "") or n.get("node_type", "") + content = str( + n.get("content") + or (n.get("properties") or {}).get("content", "") + or "" + ) + else: + nid = getattr(n, "id", getattr(n, "label", "")) + ntype = getattr(n, "node_type", "") + content = str(getattr(n, "content", "") or "") + if not nid or nid in seen: + continue + if q in str(nid).lower() or q in str(ntype).lower(): + seen.add(nid) + out.append( + { + "id": nid, + "type": ntype, + "label": nid, + "content": content[:500], + "score": 1.0, + } + ) + return json.dumps({"results": out, "count": len(out)}) + except Exception as exc: + logger.warning("query_graph failed: %s", exc) + return json.dumps({"results": [], "count": 0, "error": str(exc)}) + + def _find_related(self, entity: str, hops: int = 1) -> str: + """Find concepts related to ``entity`` within ``hops`` graph hops. + + Traversal is undirected — an edge counts as related regardless of + direction, so both outgoing and incoming edges are honored. + """ + try: + adjacency: Dict[str, List[str]] = {} + for edge in self.graph.find_edges() or []: # type: ignore[attr-defined] + if isinstance(edge, dict): + src = edge.get("source") + tgt = edge.get("target") + else: + src = getattr(edge, "source", None) + tgt = getattr(edge, "target", None) + if not src or not tgt: + continue + adjacency.setdefault(src, []).append(tgt) + adjacency.setdefault(tgt, []).append(src) + + related: List[str] = [] + frontier = [entity] + visited = {entity} + for _ in range(max(1, hops)): + next_frontier: List[str] = [] + for e in frontier: + for n in adjacency.get(e, []): + if n in visited: + continue + visited.add(n) + next_frontier.append(n) + related.append(n) + frontier = next_frontier + + logger.debug("find_related('%s', hops=%d) → %d", entity, hops, len(related)) + return json.dumps( + {"entity": entity, "related": related, "count": len(related)} + ) + except Exception as exc: + logger.warning("find_related failed: %s", exc) + return json.dumps( + {"entity": entity, "related": [], "count": 0, "error": str(exc)} + ) + + # When crewai is absent there is no BaseTool to provide the public + # ``run``/``arun`` entry points, so expose them directly. With crewai + # installed these are left untouched so crewai's own implementations + # (usage tracking, ``result_as_answer``) win. + if not CREWAI_AVAILABLE: + + def run(self, *args: Any, **kwargs: Any) -> str: + """Run the tool synchronously (degraded mode, no crewai).""" + return self._run(*args, **kwargs) + + async def arun(self, *args: Any, **kwargs: Any) -> str: + """Run the tool asynchronously (degraded mode, no crewai).""" + return self._run(*args, **kwargs) diff --git a/integrations/crewai/knowledge_source.py b/integrations/crewai/knowledge_source.py new file mode 100644 index 00000000..a61bbfce --- /dev/null +++ b/integrations/crewai/knowledge_source.py @@ -0,0 +1,331 @@ +""" +SemanticaKnowledgeSource — expose a Semantica ``ContextGraph`` as a CrewAI +knowledge source. + +Lets a ``Crew`` load the current state of a knowledge graph (nodes, edges, +metadata) into its knowledge storage, so every agent gets retrieval access to +graph knowledge during the kickoff. + +Install +------- + pip install semantica[crewai] + +Example +------- + >>> from integrations.crewai import SemanticaKnowledgeSource + >>> from semantica.context import ContextGraph + >>> from crewai import Agent, Crew, Task + >>> graph = ContextGraph() + >>> graph.add_node(node_id="privacy", node_type="policy") + >>> crew = Crew( + ... agents=[...], + ... tasks=[...], + ... knowledge_sources=[SemanticaKnowledgeSource(graph=graph)], + ... ) + +Compatibility +------------- +Works with ``crewai >= 0.80.0``. The ``BaseKnowledgeSource`` contract changed +between versions (``load_content`` → ``validate_content``/``aadd``), so this +source implements both legacy and current methods. It degrades gracefully +when ``crewai`` is not installed: the class is still importable and carries the +full Semantica API, but cannot be passed to a ``Crew``. +""" + +from __future__ import annotations + +import asyncio +from typing import Any, Dict, List, Optional + +from pydantic import Field + +from semantica.utils.logging import get_logger + +from ._availability import CREWAI_AVAILABLE + +logger = get_logger(__name__) + +# --------------------------------------------------------------------------- +# Optional: CrewAI BaseKnowledgeSource base class +# --------------------------------------------------------------------------- +_BaseKnowledgeSource: Any = object + +if CREWAI_AVAILABLE: + from crewai.knowledge.source.base_knowledge_source import ( + BaseKnowledgeSource as _BaseKnowledgeSource, # type: ignore + ) + + +def _chunk_text_manual(text: str, chunk_size: int, chunk_overlap: int) -> List[str]: + """Fallback plain-text chunker for when CrewAI helpers are unavailable.""" + if not text: + return [] + if int(chunk_size) <= 0: + return [text] + size = max(1, int(chunk_size)) + overlap = max(0, int(chunk_overlap)) + if len(text) <= size: + return [text] + step = max(1, size - overlap) + return [text[i : i + size] for i in range(0, len(text), step)] + + +class SemanticaKnowledgeSource(_BaseKnowledgeSource): # type: ignore[misc] + """ + CrewAI knowledge source backed by a Semantica ``ContextGraph``. + + On ``add()`` the graph's nodes and edges are serialised into readable text + and pushed through the standard CrewAI chunking / storage pipeline, making + graph knowledge retrievable by every agent in the crew. + + Parameters + ---------- + graph: + A ``semantica.context.ContextGraph`` to expose. A fresh in-memory + graph is created when ``None``. + name: + Source name. Defaults to ``"semantica_knowledge_graph"``. + chunk_size: + Max characters per chunk (default 4000). + chunk_overlap: + Character overlap between adjacent chunks (default 200). + """ + + name: str = "semantica_knowledge_graph" + graph: Any = Field(default=None, exclude=True) + chunk_size: int = 4000 + chunk_overlap: int = 200 + had_live_state: bool = False + reconstructed_state: bool = Field(default=False, exclude=True) + + def __init__( + self, + graph: Any = None, + name: Optional[str] = None, + chunk_size: int = 4000, + chunk_overlap: int = 200, + **kwargs: Any, + ) -> None: + if CREWAI_AVAILABLE: + # Do NOT eagerly build a graph here: pydantic calls this ``__init__`` + # during ``model_validate`` (checkpoint restore), and the eager + # build would hide that a live graph was lost. ``model_post_init`` + # rebuilds defaults and flags ``reconstructed_state`` instead. + super().__init__( + graph=graph, + name=name or "semantica_knowledge_graph", + chunk_size=int(chunk_size), + chunk_overlap=int(chunk_overlap), + **kwargs, + ) + else: + if graph is None: + from semantica.context import ContextGraph + + graph = ContextGraph() + super().__init__() + self.graph = graph + self.name = name or "semantica_knowledge_graph" + self.chunk_size = int(chunk_size) + self.chunk_overlap = int(chunk_overlap) + + logger.info( + "SemanticaKnowledgeSource initialised (crewai=%s, chunk_size=%d)", + CREWAI_AVAILABLE, + self.chunk_size, + ) + self.had_live_state = True + + def model_post_init(self, __context: Any) -> None: + """Re-create default state after validation/deserialisation. + + ``graph`` is excluded from JSON serialisation (CrewAI checkpoints + serialise their models via ``model_dump(mode="json")``), so a source + restored from a checkpoint has ``None`` state until this runs. + """ + if self.graph is None: + from semantica.context import ContextGraph + + self.graph = ContextGraph() + if self.had_live_state: + self.reconstructed_state = True + logger.warning( + "SemanticaKnowledgeSource: the live graph was lost during " + "serialization/checkpoint restore — an EMPTY graph was " + "reconstructed; re-attach the original graph before " + "continuing" + ) + else: + logger.warning( + "SemanticaKnowledgeSource created a fresh in-memory " + "ContextGraph — sources sharing knowledge must be wired to " + "the same graph explicitly" + ) + self.had_live_state = True + super().model_post_init(__context) + + # ------------------------------------------------------------------ + # Content extraction + # ------------------------------------------------------------------ + + def load_content(self) -> Dict[str, str]: + """ + Serialise the graph into ``{id: readable_text}`` pairs. + + Nodes are rendered with their type/content/metadata, edges with their + source, relation type and target. This satisfies the legacy CrewAI + ``BaseKnowledgeSource.load_content`` contract. + """ + content: Dict[str, str] = {} + graph = self.graph + if graph is None: + return content + + try: + for node in graph.find_nodes() or []: # type: ignore[attr-defined] + nid = node.get("id") or node.get("node_id") or "" + if not nid: + continue + parts = [ + "Entity", + str(nid), + "type: " + str(node.get("type", "entity")), + ] + if node.get("content"): + parts.append("content: " + str(node["content"])) + if node.get("metadata"): + try: + import json + + parts.append("metadata: " + json.dumps(node["metadata"])) + except Exception: + parts.append("metadata: " + str(node["metadata"])) + content[str(nid)] = " | ".join(parts) + except Exception as exc: + logger.warning( + "SemanticaKnowledgeSource.load_content (nodes) failed: %s", exc + ) + + try: + for idx, edge in enumerate( + graph.find_edges() or [] # type: ignore[attr-defined] + ): + src = edge.get("source") + tgt = edge.get("target") + if not src or not tgt: + continue + rel = edge.get("type") or edge.get("edge_type") or "related_to" + weight = edge.get("weight") + text = f"{src} -[{rel}]-> {tgt}" + if weight is not None: + text += f" (weight: {weight})" + content[f"edge-{idx}"] = text + except Exception as exc: + logger.warning( + "SemanticaKnowledgeSource.load_content (edges) failed: %s", exc + ) + + return content + + def validate_content(self) -> Any: + """ + Validate that a readable graph is attached. + + Satisfies the current CrewAI ``BaseKnowledgeSource.validate_content`` + contract. + """ + if self.graph is None: + raise ValueError("SemanticaKnowledgeSource requires a ContextGraph.") + return True + + # ------------------------------------------------------------------ + # Chunking + storage (abstract in both CrewAI generations) + # ------------------------------------------------------------------ + + def _chunk(self, text: str) -> List[str]: + """Chunk ``text`` using CrewAI's helper when available, else manual.""" + helper = getattr(self, "_chunk_text", None) + if helper is not None: + try: + return list(helper(text) or []) + except Exception as exc: + logger.debug( + "SemanticaKnowledgeSource._chunk_text failed, falling back: %s", exc + ) + return _chunk_text_manual(text, self.chunk_size, self.chunk_overlap) + + def add(self) -> None: + """ + Process the graph into chunks and store them via CrewAI storage. + + Sets both ``chunks`` (current CrewAI) and ``_chunks`` (legacy CrewAI) + so either ``_save_documents`` implementation picks them up. If no + storage has been wired (e.g. not yet attached to a ``Crew``), chunks + are kept in memory. + """ + content = self.load_content() + if not content: + logger.debug("SemanticaKnowledgeSource.add: empty graph — nothing to store") + return + + chunks: List[str] = [] + for _, text in content.items(): + if text: + chunks.extend(self._chunk(text)) + + self.chunks = chunks + self._chunks = chunks + + save = getattr(self, "_save_documents", None) + if save is not None: + if getattr(self, "storage", None) is None: + logger.debug( + "SemanticaKnowledgeSource.add: storage not wired — " + "keeping chunks in memory" + ) + else: + try: + save() + logger.info( + "SemanticaKnowledgeSource.add: stored %d chunks", len(chunks) + ) + return + except Exception as exc: + logger.error( + "SemanticaKnowledgeSource.add: storage save FAILED (%s) — " + "chunks are only kept in memory and agents will retrieve " + "nothing. Configure the Crew embedder (e.g. an OpenAI " + "embedder with OPENAI_API_KEY, or a local embedder) before " + "running the crew.", + exc, + ) + + logger.info( + "SemanticaKnowledgeSource.add: %d chunks ready in memory", len(chunks) + ) + + async def aadd(self) -> None: + """ + Asynchronous variant of ``add()`` (current CrewAI contract). + + The graph serialisation is CPU-bound, so it runs in a thread pool to + avoid blocking the event loop. + """ + loop = asyncio.get_running_loop() + await loop.run_in_executor(None, self.add) + + # ------------------------------------------------------------------ + # Inspection helpers + # ------------------------------------------------------------------ + + def get_content_summary(self) -> Dict[str, Any]: + """ + Summarise what the source exposes (helpful for debugging / testing). + """ + content = self.load_content() + return { + "name": self.name, + "source_count": len(content), + "chunks": len(getattr(self, "chunks", []) or []), + "crewai_available": CREWAI_AVAILABLE, + } diff --git a/pyproject.toml b/pyproject.toml index 03949d4e..341e57ed 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -201,6 +201,10 @@ gpu = [ # ---- Agentic Framework Integrations ---- agno = ["agno>=1.0.0"] +# crewai core provides BaseTool and BaseKnowledgeSource; crewai-tools is not +# needed (it pulls vulnerable transitive deps like chromadb) and would only +# duplicate the prebuilt tooling users can install separately. +crewai = ["crewai>=0.80.0"] # ---- File Watching ---- watch = ["watchdog>=6.0.0"] @@ -242,6 +246,10 @@ explorer-lite = [ ] # Everything (cross-platform — gpu excluded; install semantica[gpu] separately on Linux) +# NOTE: the ``crewai`` extra is intentionally NOT in ``all``: crewai hard-requires +# ``chromadb~=1.1.0``, which carries a pre-authentication code-injection advisory +# (CVE-2026-45829) with no fixed release — including it here would fail the CI +# dependency-audit/security gates. Install it explicitly via ``semantica[crewai]``. all = [ "semantica[dev,viz,infra,cloud,monitoring,watch,llm-all,models-huggingface,split-all,graph-all,tripletstore-oxigraph,vectorstore-all,parse-docling,ingest-parquet,ingest-arrow,shacl,explorer]", "semantica[dev,viz,infra,cloud,monitoring,watch,llm-all,models-huggingface,split-all,graph-all,tripletstore-oxigraph,vectorstore-all,parse-docling,ingest-parquet,ingest-arrow,shacl,agno]" diff --git a/requirements-ci.txt b/requirements-ci.txt index d57fa744..17df46d4 100644 --- a/requirements-ci.txt +++ b/requirements-ci.txt @@ -1,5 +1,5 @@ # This file was autogenerated by uv via the following command: -# uv pip compile -p 3.11 --extra all --generate-hashes -o requirements-ci.txt pyproject.toml +# uv pip compile pyproject.toml --python-version 3.11 --extra all --generate-hashes -o requirements-ci.txt accelerate==1.14.0 \ --hash=sha256:41b9c4377a54e0b460a959b0defa1b736e4ca0a2373252d9a539964c2afe3c8d \ --hash=sha256:e94390c2863b873be18f623f9df48a0d8fe5eff13ea7f1a00092b0a7904888c6 @@ -4123,9 +4123,9 @@ pooch==1.9.0 \ --hash=sha256:de46729579b9857ffd3e741987a2f6d5e0e03219892c167c6578c0091fb511ed \ --hash=sha256:f265597baa9f760d25ceb29d0beb8186c243d6607b0f60b83ecf14078dbc703b # via librosa -portalocker==3.2.0 \ - --hash=sha256:1f3002956a54a8c3730586c5c77bf18fae4149e07eaf1c29fc3faf4d5a3f89ac \ - --hash=sha256:3cdc5f565312224bc570c49337bd21428bba0ef363bbcf58b9ef4a9f11779968 +portalocker==2.7.0 \ + --hash=sha256:032e81d534a88ec1736d03f780ba073f047a06c478b06e2937486f334e955c51 \ + --hash=sha256:a07c5b4f3985c3cf4798369631fb7011adb498e2a46d8440efc75a8f29a0f983 # via qdrant-client pre-commit==4.6.2 \ --hash=sha256:8f5d7bfb021ecdbcd9d49d89847082dd24172ccde534390081a679ad046e2441 \ diff --git a/tests/integrations/crewai/conftest.py b/tests/integrations/crewai/conftest.py new file mode 100644 index 00000000..68991e54 --- /dev/null +++ b/tests/integrations/crewai/conftest.py @@ -0,0 +1,151 @@ +""" +Shared pytest configuration for CrewAI integration tests. + +Installs comprehensive crewai stubs into sys.modules before any test in this +directory runs, so every test file can import the integration modules with +``CREWAI_AVAILABLE == True`` and exercise the real subclassing code paths +without a real crewai installation. + +The stubs mirror the current CrewAI contracts: +- ``crewai.tools.BaseTool`` — Pydantic ``BaseModel`` (arbitrary types allowed) +- ``crewai.knowledge.source.base_knowledge_source.BaseKnowledgeSource`` — + Pydantic model with ``validate_content``/``add``/``aadd`` abstract methods + and ``_chunk_text``/``_save_documents`` helpers. + +The graceful-degradation path (crewai genuinely absent) is covered separately +in ``test_degradation.py`` via a subprocess, so this stub never has to be torn +down mid-session. +""" + +from __future__ import annotations + +import sys +import types +from typing import Any, Optional + +from pydantic import BaseModel, ConfigDict, Field, field_serializer, field_validator + + +def _install_crewai_stubs() -> None: + """Install a full set of crewai stubs into sys.modules.""" + + # ----------------------------------------------------------------------- + # crewai.tools — BaseTool + # ----------------------------------------------------------------------- + class BaseTool(BaseModel): # noqa: D101 + """Stub mirroring crewai.tools.base_tool.BaseTool.""" + + model_config = ConfigDict(arbitrary_types_allowed=True) + + name: str = "base_tool" + description: str = "" + args_schema: Any = None + result_as_answer: bool = False + + @field_serializer("args_schema", when_used="json") + def _ser_args_schema(self, schema): # noqa: D102 + if schema is None: + return None + return {"__schema__": f"{schema.__module__}.{schema.__qualname__}"} + + @field_validator("args_schema", mode="before") + @classmethod + def _restore_args_schema(cls, v): # noqa: D102 + if isinstance(v, dict) and "__schema__" in v: + import importlib + + mod_name, cls_name = v["__schema__"].rsplit(".", 1) + return getattr(importlib.import_module(mod_name), cls_name) + return v + + def run(self, *args: Any, **kwargs: Any) -> str: # noqa: D102 + return self._run(*args, **kwargs) + + async def arun(self, *args: Any, **kwargs: Any) -> str: # noqa: D102 + return await self._arun(*args, **kwargs) + + def _run(self, *args: Any, **kwargs: Any) -> str: # noqa: D102 + raise NotImplementedError + + async def _arun(self, *args: Any, **kwargs: Any) -> str: # noqa: D102 + raise NotImplementedError + + tools_mod = types.ModuleType("crewai.tools") + tools_mod.BaseTool = BaseTool # type: ignore[attr-defined] + + tools_base_mod = types.ModuleType("crewai.tools.base_tool") + tools_base_mod.BaseTool = BaseTool # type: ignore[attr-defined] + + # ----------------------------------------------------------------------- + # crewai.knowledge.source.base_knowledge_source — BaseKnowledgeSource + # ----------------------------------------------------------------------- + class BaseKnowledgeSource(BaseModel): # noqa: D101 + """Stub mirroring crewai.knowledge.source.base_knowledge_source.""" + + model_config = ConfigDict(arbitrary_types_allowed=True) + + chunk_size: int = 4000 + chunk_overlap: int = 200 + chunks: list = Field(default_factory=list) + chunk_embeddings: list = Field(default_factory=list, exclude=True) + storage: Any = None + metadata: dict = Field(default_factory=dict) + collection_name: Optional[str] = None + + def _chunk_text(self, text: str) -> list: # noqa: D102 + return [ + text[i : i + self.chunk_size] + for i in range(0, len(text), self.chunk_size - self.chunk_overlap) + ] + + def _save_documents(self) -> None: # noqa: D102 + if self.storage is not None: + self.storage.save(self.chunks) + else: + raise ValueError("No storage found to save documents.") + + async def _asave_documents(self) -> None: # noqa: D102 + if self.storage is not None: + await self.storage.asave(self.chunks) + else: + raise ValueError("No storage found to save documents.") + + def validate_content(self) -> Any: # noqa: D102 + raise NotImplementedError + + def add(self) -> None: # noqa: D102 + raise NotImplementedError + + async def aadd(self) -> None: # noqa: D102 + raise NotImplementedError + + knowledge_pkg = types.ModuleType("crewai.knowledge") + source_pkg = types.ModuleType("crewai.knowledge.source") + source_base_mod = types.ModuleType("crewai.knowledge.source.base_knowledge_source") + source_base_mod.BaseKnowledgeSource = ( # type: ignore[attr-defined] + BaseKnowledgeSource + ) + source_pkg.BaseKnowledgeSource = BaseKnowledgeSource # type: ignore[attr-defined] + knowledge_pkg.source = source_pkg + + # ----------------------------------------------------------------------- + # Register everything + # ----------------------------------------------------------------------- + crewai = types.ModuleType("crewai") + crewai.tools = tools_mod # type: ignore[attr-defined] + crewai.knowledge = knowledge_pkg # type: ignore[attr-defined] + + _mods = { + "crewai": crewai, + "crewai.tools": tools_mod, + "crewai.tools.base_tool": tools_base_mod, + "crewai.knowledge": knowledge_pkg, + "crewai.knowledge.source": source_pkg, + "crewai.knowledge.source.base_knowledge_source": source_base_mod, + } + for name, mod in _mods.items(): + sys.modules[name] = mod + + +# Install once at import time (conftest is imported before any test file) +_install_crewai_stubs() diff --git a/tests/integrations/crewai/test_decision_tool.py b/tests/integrations/crewai/test_decision_tool.py new file mode 100644 index 00000000..c7d4a1e6 --- /dev/null +++ b/tests/integrations/crewai/test_decision_tool.py @@ -0,0 +1,562 @@ +""" +Tests for SemanticaDecisionTool — decision intelligence CrewAI tool. + +Runs with the crewai stubs installed by conftest, so ``CREWAI_AVAILABLE`` is +``True`` and the real Pydantic/BaseTool subclassing path is exercised. A +MagicMock ``AgentContext`` is used so no vector store / faiss is required. +""" + +from __future__ import annotations + +import json +import unittest +from unittest.mock import MagicMock + +from integrations.crewai import SemanticaDecisionTool +from integrations.crewai.decision_tool import ( + CREWAI_AVAILABLE, + SemanticaDecisionToolInput, +) + + +def _make_context() -> MagicMock: + ctx = MagicMock() + ctx.record_decision.return_value = "dec-test-001" + ctx.find_precedents_advanced.return_value = [ + { + "scenario": "past loan", + "outcome": "approved", + "confidence": 0.9, + "category": "loan", + } + ] + ctx.analyze_decision_influence.return_value = {"centrality": 0.75, "influenced": 3} + ctx.knowledge_graph = MagicMock() + ctx.knowledge_graph.trace_decision_causality = MagicMock( + return_value=["step1", "step2"] + ) + return ctx + + +class TestSemanticaDecisionToolInit(unittest.TestCase): + + def test_crewai_available_via_stub(self): + self.assertTrue(CREWAI_AVAILABLE) + + def test_is_base_tool_subclass(self): + from crewai.tools import BaseTool + + self.assertTrue(issubclass(SemanticaDecisionTool, BaseTool)) + + def test_creates_with_explicit_context(self): + ctx = _make_context() + tool = SemanticaDecisionTool(context=ctx) + self.assertIs(tool.context, ctx) + + def test_creates_context_when_none(self): + tool = SemanticaDecisionTool() + self.assertIsNotNone(tool.context) + + def test_default_metadata(self): + tool = SemanticaDecisionTool(context=_make_context()) + self.assertEqual(tool.name, "semantica_decision") + self.assertTrue(tool.description) + self.assertEqual(tool.args_schema, SemanticaDecisionToolInput) + + def test_input_schema_validates(self): + inp = SemanticaDecisionToolInput(action="record_decision", confidence=0.5) + self.assertEqual(inp.confidence, 0.5) + with self.assertRaises(Exception): + SemanticaDecisionToolInput(action="bogus") + + def test_max_precedents_and_causal_depth_defaults(self): + tool = SemanticaDecisionTool(context=_make_context()) + self.assertEqual(tool.max_precedents, 5) + self.assertEqual(tool.causal_depth, 3) + + +class TestSemanticaDecisionToolSerialization(unittest.TestCase): + """CrewAI checkpoints serialise tools via ``model_dump(mode="json")`` — the + live context must not break that (regression for PydanticSerializationError + on arbitrary state objects).""" + + def test_model_dump_json_excludes_context(self): + tool = SemanticaDecisionTool(context=_make_context()) + dumped = tool.model_dump(mode="json") + self.assertNotIn("context", dumped) + self.assertEqual(dumped["max_precedents"], 5) + self.assertEqual(dumped["causal_depth"], 3) + + def test_model_validate_restores_defaults(self): + tool = SemanticaDecisionTool(context=_make_context()) + restored = SemanticaDecisionTool.model_validate(tool.model_dump(mode="json")) + self.assertIsNotNone(restored.context) + self.assertEqual(restored.max_precedents, 5) + self.assertEqual(restored.causal_depth, 3) + + def test_restore_flags_lost_live_state(self): + """A tool restored from a checkpoint must signal that its live context + was excluded and an empty one reconstructed (``reconstructed_state``).""" + tool = SemanticaDecisionTool(context=_make_context()) + dumped = tool.model_dump(mode="json") + self.assertTrue(dumped["had_live_state"]) + self.assertNotIn("reconstructed_state", dumped) + restored = SemanticaDecisionTool.model_validate(dumped) + self.assertTrue(restored.reconstructed_state) + self.assertFalse(SemanticaDecisionTool().reconstructed_state) + + +class TestRecordDecision(unittest.TestCase): + + def setUp(self): + self.ctx = _make_context() + self.tool = SemanticaDecisionTool(context=self.ctx) + + def test_returns_json_with_decision_id(self): + result = json.loads( + self.tool._run( + action="record_decision", + category="loan", + scenario="Customer A loan application", + reasoning="Good credit score 740", + outcome="approved", + confidence=0.95, + ) + ) + self.assertEqual(result["decision_id"], "dec-test-001") + self.assertEqual(result["status"], "recorded") + + def test_delegates_to_context(self): + self.tool._run( + action="record_decision", + category="content", + scenario="Moderation check", + reasoning="No violations", + outcome="allowed", + confidence=0.88, + ) + self.ctx.record_decision.assert_called_once() + + def test_parses_entities_string(self): + self.tool._run( + action="record_decision", + category="hr", + scenario="Hire decision", + reasoning="Qualified", + outcome="hired", + confidence=0.9, + entities="Alice, ACME Corp, Senior Engineer", + ) + call_kwargs = self.ctx.record_decision.call_args[1] + self.assertIsInstance(call_kwargs["entities"], list) + self.assertEqual(len(call_kwargs["entities"]), 3) + + def test_returns_error_json_on_failure(self): + self.ctx.record_decision.side_effect = RuntimeError("DB unavailable") + result = json.loads( + self.tool._run( + action="record_decision", + category="x", + scenario="y", + reasoning="z", + outcome="failed", + ) + ) + self.assertEqual(result["status"], "failed") + self.assertIn("error", result) + + def test_default_confidence_used(self): + self.tool._run( + action="record_decision", + category="test", + scenario="Default confidence test", + reasoning="N/A", + outcome="pass", + ) + call_kwargs = self.ctx.record_decision.call_args[1] + self.assertEqual(call_kwargs["confidence"], 0.8) + + def test_malformed_confidence_returns_error_json(self): + """A non-numeric confidence must not crash the tool — it is coerced + inside ``_record_decision``'s error handling and reported as JSON.""" + for bad in ("high", None, "0.9"): + result = json.loads( + self.tool._run( + action="record_decision", + category="x", + scenario="y", + reasoning="z", + outcome="failed", + confidence=bad, + ) + ) + if bad == "0.9": + self.assertEqual(result["status"], "recorded") + else: + self.assertEqual(result["status"], "failed") + self.assertIn("error", result) + + def test_missing_fields_get_sane_defaults(self): + """record_decision must not hard-fail when the agent omits optional + fields — category/reasoning/outcome get defaults.""" + result = json.loads(self.tool._run(action="record_decision")) + self.assertEqual(result["status"], "recorded") + call_kwargs = self.ctx.record_decision.call_args[1] + self.assertEqual(call_kwargs["category"], "general") + self.assertEqual(call_kwargs["scenario"], "decision recorded") + self.assertEqual(call_kwargs["reasoning"], "agent decision") + self.assertEqual(call_kwargs["outcome"], "recorded") + + +class TestRealAutoCreatedContext(unittest.TestCase): + """The no-context path builds a real AgentContext with a knowledge graph so + decision tracking is actually enabled (regression for the live + 'Decision tracking is not enabled' failure).""" + + def setUp(self): + self.tool = SemanticaDecisionTool() + + def test_context_is_real_agent_context(self): + from semantica.context import AgentContext + + self.assertIsInstance(self.tool.context, AgentContext) + self.assertIsNotNone(self.tool.context.knowledge_graph) + + def test_record_decision_actually_records(self): + result = json.loads( + self.tool.run( + action="record_decision", + scenario="ship v2", + reasoning="user demand", + confidence=0.9, + ) + ) + self.assertEqual(result["status"], "recorded") + self.assertTrue(result["decision_id"]) + + def test_find_precedents_runs_against_real_context(self): + result = json.loads(self.tool.run(action="find_precedents", scenario="ship v2")) + self.assertIn("precedents", result) + + def test_trace_causal_chain_runs_against_real_context(self): + """Regression: trace_decision_causality takes ``max_depth``, not + ``depth`` — must not raise against a real ContextGraph.""" + rec = json.loads( + self.tool.run( + action="record_decision", + scenario="ship v2", + reasoning="user demand", + confidence=0.9, + ) + ) + trace = json.loads( + self.tool.run(action="trace_causal_chain", decision_id=rec["decision_id"]) + ) + self.assertIn("causal_chain", trace) + self.assertEqual(trace["decision_id"], rec["decision_id"]) + + +class TestFindPrecedents(unittest.TestCase): + + def setUp(self): + self.ctx = _make_context() + self.tool = SemanticaDecisionTool(context=self.ctx) + + def test_returns_json_with_precedents(self): + result = json.loads( + self.tool._run(action="find_precedents", scenario="new loan application") + ) + self.assertIn("precedents", result) + self.assertIsInstance(result["precedents"], list) + + def test_count_in_result(self): + result = json.loads( + self.tool._run(action="find_precedents", scenario="test scenario") + ) + self.assertEqual(result["count"], len(result["precedents"])) + + def test_category_filter_passed(self): + self.tool._run( + action="find_precedents", scenario="scenario", category="finance" + ) + call_kwargs = self.ctx.find_precedents_advanced.call_args[1] + self.assertEqual(call_kwargs.get("category"), "finance") + + def test_limit_propagated_to_backend(self): + self.tool.max_precedents = 20 + self.tool._run(action="find_precedents", scenario="scenario") + call_kwargs = self.ctx.find_precedents_advanced.call_args[1] + self.assertEqual(call_kwargs.get("limit"), 20) + + def test_handles_exception_gracefully(self): + self.ctx.find_precedents_advanced.side_effect = RuntimeError("fail") + result = json.loads(self.tool._run(action="find_precedents", scenario="broken")) + self.assertEqual(result["precedents"], []) + self.assertIn("error", result) + + +class TestTraceCausalChain(unittest.TestCase): + + def setUp(self): + self.ctx = _make_context() + self.tool = SemanticaDecisionTool(context=self.ctx) + + def test_returns_json_with_causal_chain(self): + result = json.loads( + self.tool._run(action="trace_causal_chain", decision_id="dec-001") + ) + self.assertIn("causal_chain", result) + self.assertEqual(result["decision_id"], "dec-001") + + def test_honest_error_when_causal_trace_unavailable(self): + """When the graph cannot trace causality, the tool must say so — it + must NOT substitute similarity-based precedents as a causal chain.""" + del self.ctx.knowledge_graph.trace_decision_causality + result = json.loads( + self.tool._run(action="trace_causal_chain", decision_id="dec-002") + ) + self.assertEqual(result["causal_chain"], []) + self.assertIn("error", result) + self.ctx.knowledge_graph.find_precedents.assert_not_called() + + def test_missing_decision_id_reports_error(self): + result = json.loads(self.tool._run(action="trace_causal_chain")) + self.assertIn("error", result) + self.assertEqual(result["causal_chain"], []) + + def test_depth_used(self): + self.tool._run(action="trace_causal_chain", decision_id="dec-001", depth=5) + self.ctx.knowledge_graph.trace_decision_causality.assert_called_once_with( + "dec-001", max_depth=5 + ) + + def test_graceful_error_when_context_has_no_knowledge_graph(self): + """Regression: an unguarded ``self.context.knowledge_graph`` read raised + AttributeError out of ``_run`` and could hard-fail a crew task. It must + return honest error JSON instead.""" + del self.ctx.knowledge_graph + result = json.loads( + self.tool._run(action="trace_causal_chain", decision_id="dec-003") + ) + self.assertEqual(result["causal_chain"], []) + self.assertIn("error", result) + + +class TestAnalyzeImpact(unittest.TestCase): + + def setUp(self): + self.ctx = _make_context() + self.tool = SemanticaDecisionTool(context=self.ctx) + + def test_returns_json_with_decision_id(self): + result = json.loads( + self.tool._run(action="analyze_impact", decision_id="dec-001") + ) + self.assertEqual(result["decision_id"], "dec-001") + + def test_includes_influence_metrics(self): + result = json.loads( + self.tool._run(action="analyze_impact", decision_id="dec-001") + ) + self.assertIn("centrality", result) + + +class TestCheckPolicy(unittest.TestCase): + + def setUp(self): + self.ctx = _make_context() + self.tool = SemanticaDecisionTool(context=self.ctx) + + def test_returns_json_with_compliant_key(self): + decision = json.dumps( + {"category": "loan", "outcome": "approved", "confidence": 0.9} + ) + result = json.loads( + self.tool._run(action="check_policy", decision_data=decision) + ) + self.assertIn("compliant", result) + + def test_invalid_json_returns_error(self): + result = json.loads( + self.tool._run(action="check_policy", decision_data="{not valid json}") + ) + self.assertFalse(result["compliant"]) + self.assertGreater(len(result["violations"]), 0) + + def test_rule_violation_detected(self): + decision = json.dumps({"confidence": 0.5}) + rules = json.dumps(["confidence >= 0.9"]) + result = json.loads( + self.tool._run( + action="check_policy", decision_data=decision, policy_rules=rules + ) + ) + self.assertFalse(result["compliant"]) + self.assertEqual(len(result["violations"]), 1) + + def test_bool_false_rule_is_compliant(self): + """Regression: ``enabled == false`` with ``enabled: false`` must be + compliant — bool("false") is truthy, so the old coercion inverted it.""" + decision = json.dumps({"enabled": False, "confidence": 0.95}) + rules = json.dumps(["enabled == false"]) + result = json.loads( + self.tool._run( + action="check_policy", decision_data=decision, policy_rules=rules + ) + ) + self.assertTrue(result["compliant"]) + self.assertEqual(result["violations"], []) + + def test_bool_true_rule_is_compliant(self): + decision = json.dumps({"enabled": True}) + result = json.loads( + self.tool._run( + action="check_policy", + decision_data=decision, + policy_rules=json.dumps(["enabled == true"]), + ) + ) + self.assertTrue(result["compliant"]) + + def test_whitespace_padded_strings_are_trimmed(self): + """Regression: ``_coerce_value`` must return the *stripped* string for + non-numeric literals, or padded decision_data fields never match.""" + decision = json.dumps({"status": " approved "}) + result = json.loads( + self.tool._run( + action="check_policy", + decision_data=decision, + policy_rules=json.dumps(["status == approved"]), + ) + ) + self.assertTrue(result["compliant"]) + self.assertEqual(result["violations"], []) + + def test_bool_false_rule_violated_when_true(self): + decision = json.dumps({"enabled": True}) + result = json.loads( + self.tool._run( + action="check_policy", + decision_data=decision, + policy_rules=json.dumps(["enabled == false"]), + ) + ) + self.assertFalse(result["compliant"]) + self.assertEqual(len(result["violations"]), 1) + + def test_zero_one_flag_parsed_as_bool(self): + result = json.loads( + self.tool._run( + action="check_policy", + decision_data=json.dumps({"flag": 1}), + policy_rules=json.dumps(["flag != 0"]), + ) + ) + self.assertTrue(result["compliant"]) + result = json.loads( + self.tool._run( + action="check_policy", + decision_data=json.dumps({"flag": 0}), + policy_rules=json.dumps(["flag != 0"]), + ) + ) + self.assertFalse(result["compliant"]) + + def test_numeric_string_value_compared_numerically(self): + """Regression: a string datum like "0.90" must compare numerically to + rule literal 0.9, not lexicographically.""" + decision = json.dumps({"score": "0.90"}) + result = json.loads( + self.tool._run( + action="check_policy", + decision_data=decision, + policy_rules=json.dumps(["score == 0.9"]), + ) + ) + self.assertTrue(result["compliant"]) + + def test_numeric_string_ordering(self): + result = json.loads( + self.tool._run( + action="check_policy", + decision_data=json.dumps({"pct": "0.95"}), + policy_rules=json.dumps(["pct >= 0.9"]), + ) + ) + self.assertTrue(result["compliant"]) + result = json.loads( + self.tool._run( + action="check_policy", + decision_data=json.dumps({"pct": "0.85"}), + policy_rules=json.dumps(["pct >= 0.9"]), + ) + ) + self.assertFalse(result["compliant"]) + + def test_field_names_with_hyphens_dots_spaces(self): + """Rule field names are not limited to ``\\w+`` — hyphenated/dotted + (and space-containing) JSON keys must be addressable.""" + decision = json.dumps({"risk-score": 0.95, "max.risk": 0.2, "min score": 0.4}) + compliant = json.loads( + self.tool._run( + action="check_policy", + decision_data=decision, + policy_rules=json.dumps( + ["risk-score >= 0.9", "max.risk <= 0.5", "min score >= 0.3"] + ), + ) + ) + self.assertTrue(compliant["compliant"]) + self.assertEqual(compliant["violations"], []) + violated = json.loads( + self.tool._run( + action="check_policy", + decision_data=decision, + policy_rules=json.dumps(["max.risk >= 0.5"]), + ) + ) + self.assertFalse(violated["compliant"]) + self.assertEqual(len(violated["violations"]), 1) + + def test_rule_missing_field_warns_not_silently_compliant(self): + decision = json.dumps({"confidence": 0.95}) + rules = json.dumps(["minimum_score >= 0.9"]) + result = json.loads( + self.tool._run( + action="check_policy", decision_data=decision, policy_rules=rules + ) + ) + self.assertTrue(result["compliant"]) + self.assertEqual(result["violations"], []) + self.assertEqual(len(result["warnings"]), 1) + self.assertIn("minimum_score", result["warnings"][0]) + + def test_decision_data_non_object_rejected(self): + result = json.loads( + self.tool._run( + action="check_policy", + decision_data=json.dumps(["confidence", 0.95]), + policy_rules=json.dumps(["confidence >= 0.9"]), + ) + ) + self.assertFalse(result["compliant"]) + self.assertEqual(len(result["violations"]), 1) + self.assertIn("JSON object", result["violations"][0]) + + def test_unknown_action_returns_error(self): + result = json.loads(self.tool._run(action="nope")) + self.assertIn("error", result) + + def test_run_entrypoint(self): + result = json.loads( + self.tool.run( + action="check_policy", + decision_data=json.dumps({"confidence": 0.95}), + policy_rules=json.dumps(["confidence >= 0.9"]), + ) + ) + self.assertTrue(result["compliant"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/integrations/crewai/test_degradation.py b/tests/integrations/crewai/test_degradation.py new file mode 100644 index 00000000..7986f783 --- /dev/null +++ b/tests/integrations/crewai/test_degradation.py @@ -0,0 +1,103 @@ +""" +Graceful-degradation tests for the CrewAI integration. + +These run the integration modules in a fresh subprocess (no conftest crewai +stubs, no real crewai) to prove that every public class remains importable and +functional when ``crewai`` is absent. A subprocess is used because the other +test files in this directory install crewai stubs into ``sys.modules`` for the +whole pytest session; a subprocess keeps the two scenarios isolated. +""" + +from __future__ import annotations + +import os +import subprocess +import sys +import unittest + +REPO_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(__file__)))) + +_SCRIPT = r""" +import json +import sys + +try: + import crewai # noqa: F401 + real_crewai = True +except ImportError: + real_crewai = False + +from integrations.crewai import ( + CREWAI_AVAILABLE, + SemanticaKGTool, + SemanticaDecisionTool, + SemanticaKnowledgeSource, +) +from semantica.context import ContextGraph + +assert CREWAI_AVAILABLE == real_crewai, ( + f"CREWAI_AVAILABLE={CREWAI_AVAILABLE} but real crewai={real_crewai}" +) + +# --- SemanticaKGTool: importable + functional without crewai ----------------- +graph = ContextGraph() +graph.add_node(node_id="privacy", node_type="policy", content="privacy policy doc") + +tool = SemanticaKGTool(graph=graph) +assert tool.name == "semantica_knowledge_graph" +assert tool.args_schema is not None + +res = json.loads(tool._run(action="query_graph", query="privacy")) +assert res["count"] == 1, res +res = json.loads(tool._run(action="find_related", entity="ghost", hops=1)) +assert res["count"] == 0, res + +# The public run()/arun() entry points must exist without crewai too. +res = json.loads(tool.run(action="query_graph", query="privacy")) +assert res["count"] == 1, res +import asyncio +res = json.loads(asyncio.run(tool.arun(action="query_graph", query="privacy"))) +assert res["count"] == 1, res + +# --- SemanticaKnowledgeSource: importable + functional without crewai -------- +src = SemanticaKnowledgeSource(graph=graph, chunk_size=40, chunk_overlap=5) +assert src.load_content() != {} +assert src.validate_content() is True +src.add() # must not raise; chunks kept in memory +assert len(src.chunks) > 0 + +# --- SemanticaDecisionTool: importable, builds its own context -------------- +dt = SemanticaDecisionTool() +assert dt.name == "semantica_decision" +res = json.loads(dt.run(action="find_precedents", scenario="x")) +assert "precedents" in res, res +res = json.loads(asyncio.run(dt.arun(action="find_precedents", scenario="x"))) +assert "precedents" in res, res + +print("DEGRADATION_OK") +""" + + +class TestDegradation(unittest.TestCase): + + def test_importable_and_functional_without_crewai(self): + result = subprocess.run( + [sys.executable, "-c", _SCRIPT], + cwd=REPO_ROOT, + capture_output=True, + text=True, + timeout=180, + ) + self.assertEqual( + result.returncode, + 0, + msg=( + f"subprocess failed:\nSTDOUT:\n{result.stdout}\n" + f"STDERR:\n{result.stderr}" + ), + ) + self.assertIn("DEGRADATION_OK", result.stdout) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/integrations/crewai/test_kg_tool.py b/tests/integrations/crewai/test_kg_tool.py new file mode 100644 index 00000000..18ee1eda --- /dev/null +++ b/tests/integrations/crewai/test_kg_tool.py @@ -0,0 +1,453 @@ +""" +Tests for SemanticaKGTool — knowledge graph CrewAI tool. + +Runs with the crewai stubs installed by conftest, so ``CREWAI_AVAILABLE`` is +``True`` and the real Pydantic/BaseTool subclassing path is exercised. +""" + +from __future__ import annotations + +import asyncio +import json +import unittest +from unittest.mock import MagicMock + +from integrations.crewai import SemanticaKGTool as ImportedSemanticaKGTool +from integrations.crewai.kg_tool import ( + CREWAI_AVAILABLE, + CREWAI_IMPORT_ERROR, + SemanticaKGTool, + SemanticaKGToolInput, +) +from semantica.context import ContextGraph + + +# --------------------------------------------------------------------------- +# Fakes +# --------------------------------------------------------------------------- +def _fake_entity(name="Tesla", etype="ORG", conf=0.9): + e = MagicMock() + e.name = name + e.type = etype + e.confidence = conf + return e + + +def _fake_relation(src="Tesla", rel="FOUNDED_BY", tgt="Elon Musk", conf=0.85): + r = MagicMock() + r.source = src + r.type = rel + r.target = tgt + r.confidence = conf + return r + + +class _FakeNER: + def extract_entities(self, text): + return [_fake_entity("Tesla"), _fake_entity("Elon Musk", "PERSON")] + + +class _FakeRelExtractor: + def extract_relations(self, text, entities=None): + return [_fake_relation()] + + +class _DataclassNER: + """Returns Semantica's real ``Entity`` dataclass shape (text/label, no name).""" + + def extract_entities(self, text): + from semantica.semantic_extract.types import Entity + + return [ + Entity(text="Tesla", label="ORG", start_char=0, end_char=5), + Entity(text="Elon Musk", label="PERSON", start_char=17, end_char=26), + ] + + +class _DataclassRelExtractor: + """Returns Semantica's real ``Relation`` dataclass shape (subject/object).""" + + def __init__(self): + self.received_entities = None + + def extract_relations(self, text, entities=None): + from semantica.semantic_extract.types import Entity, Relation + + self.received_entities = entities + return [ + Relation( + subject=Entity(text="Tesla", label="ORG", start_char=0, end_char=5), + predicate="FOUNDED_BY", + object=Entity( + text="Elon Musk", label="PERSON", start_char=17, end_char=26 + ), + ) + ] + + +class TestSemanticaKGToolInit(unittest.TestCase): + + def test_crewai_available_via_stub(self): + self.assertTrue(CREWAI_AVAILABLE) + self.assertIsNone(CREWAI_IMPORT_ERROR) + + def test_is_base_tool_subclass(self): + from crewai.tools import BaseTool + + self.assertTrue(issubclass(SemanticaKGTool, BaseTool)) + + def test_exposed_from_package_init(self): + self.assertIs(ImportedSemanticaKGTool, SemanticaKGTool) + + def test_creates_with_explicit_graph(self): + graph = ContextGraph() + tool = SemanticaKGTool(graph=graph) + self.assertIs(tool.graph, graph) + + def test_creates_fresh_graph_when_none(self): + tool = SemanticaKGTool( + ner_extractor=_FakeNER(), relation_extractor=_FakeRelExtractor() + ) + self.assertIsNotNone(tool.graph) + self.assertIsInstance(tool.graph, ContextGraph) + + def test_default_metadata(self): + tool = SemanticaKGTool( + ner_extractor=_FakeNER(), relation_extractor=_FakeRelExtractor() + ) + self.assertEqual(tool.name, "semantica_knowledge_graph") + self.assertTrue(tool.description) + self.assertEqual(tool.args_schema, SemanticaKGToolInput) + + def test_input_schema_validates(self): + inp = SemanticaKGToolInput(action="query_graph", query="privacy", hops=2) + self.assertEqual(inp.hops, 2) + with self.assertRaises(Exception): + SemanticaKGToolInput(action="bogus") + + def test_custom_kwargs_forwarded(self): + tool = SemanticaKGTool( + ner_extractor=_FakeNER(), + relation_extractor=_FakeRelExtractor(), + result_as_answer=True, + ) + self.assertTrue(tool.result_as_answer) + + +class TestSemanticaKGToolSerialization(unittest.TestCase): + """CrewAI checkpoints serialise tools via ``model_dump(mode="json")`` — the + live graph/extractors must not break that (regression for + PydanticSerializationError on arbitrary state objects).""" + + def setUp(self): + self.tool = SemanticaKGTool( + graph=ContextGraph(), + ner_extractor=_FakeNER(), + relation_extractor=_FakeRelExtractor(), + ) + + def test_model_dump_json_excludes_shared_state(self): + dumped = self.tool.model_dump(mode="json") + self.assertNotIn("graph", dumped) + self.assertNotIn("ner_extractor", dumped) + self.assertNotIn("relation_extractor", dumped) + self.assertEqual(dumped["name"], "semantica_knowledge_graph") + + def test_model_validate_restores_defaults(self): + restored = SemanticaKGTool.model_validate(self.tool.model_dump(mode="json")) + self.assertIsInstance(restored.graph, ContextGraph) + self.assertIs(restored.args_schema, SemanticaKGToolInput) + self.assertEqual(restored.name, "semantica_knowledge_graph") + + def test_model_validate_restored_tool_still_runs(self): + restored = SemanticaKGTool.model_validate(self.tool.model_dump(mode="json")) + restored.graph.add_node(node_id="privacy", node_type="policy") + result = json.loads(restored._run(action="query_graph", query="privacy")) + self.assertEqual(result["count"], 1) + + def test_restore_flags_lost_live_state(self): + """A tool restored from a checkpoint must signal that its live graph + was excluded and an empty one reconstructed (``reconstructed_state``).""" + dumped = self.tool.model_dump(mode="json") + self.assertTrue(dumped["had_live_state"]) + self.assertNotIn("reconstructed_state", dumped) + restored = SemanticaKGTool.model_validate(dumped) + self.assertTrue(restored.reconstructed_state) + self.assertFalse(SemanticaKGTool().reconstructed_state) + + +class TestSemanticaKGToolActions(unittest.TestCase): + + def setUp(self): + self.graph = ContextGraph() + self.tool = SemanticaKGTool( + graph=self.graph, + ner_extractor=_FakeNER(), + relation_extractor=_FakeRelExtractor(), + ) + + def test_extract_entities(self): + result = json.loads( + self.tool._run( + action="extract_entities", text="Tesla was founded by Elon Musk" + ) + ) + self.assertEqual(result["count"], 2) + self.assertEqual(result["entities"][0]["name"], "Tesla") + self.assertEqual(result["entities"][0]["type"], "ORG") + + def test_extract_relations(self): + result = json.loads( + self.tool._run( + action="extract_relations", text="Tesla was founded by Elon Musk" + ) + ) + self.assertEqual(result["count"], 1) + self.assertEqual(result["relations"][0]["source"], "Tesla") + self.assertEqual(result["relations"][0]["target"], "Elon Musk") + + def test_add_to_graph_populates_graph(self): + result = json.loads( + self.tool._run(action="add_to_graph", text="Tesla was founded by Elon Musk") + ) + self.assertGreaterEqual(result["nodes_added"], 2) + self.assertGreaterEqual(result["edges_added"], 1) + nodes = self.graph.find_nodes() + node_ids = {n["id"] for n in nodes} + self.assertIn("Tesla", node_ids) + self.assertIn("Elon Musk", node_ids) + + def test_add_to_graph_is_idempotent(self): + self.tool._run(action="add_to_graph", text="Tesla was founded by Elon Musk") + second = json.loads( + self.tool._run(action="add_to_graph", text="Tesla was founded by Elon Musk") + ) + self.assertEqual(second["nodes_added"], 0) + self.assertEqual(second["edges_added"], 0) + + def test_query_graph_finds_matching_node(self): + self.graph.add_node( + node_id="privacy", node_type="policy", content="privacy policy doc" + ) + result = json.loads(self.tool._run(action="query_graph", query="privacy")) + self.assertEqual(result["count"], 1) + self.assertEqual(result["results"][0]["id"], "privacy") + + def test_query_graph_no_match(self): + result = json.loads( + self.tool._run(action="query_graph", query="nothing-matches") + ) + self.assertEqual(result["count"], 0) + self.assertEqual(result["results"], []) + + def test_query_graph_searches_node_content(self): + """query_graph must match node content, not just ids/types.""" + self.graph.add_node( + node_id="n1", + node_type="policy", + content="all refunds must be processed within 30 days", + ) + result = json.loads(self.tool._run(action="query_graph", query="refunds")) + self.assertEqual(result["count"], 1) + self.assertEqual(result["results"][0]["id"], "n1") + + def test_query_graph_matches_type(self): + self.graph.add_node(node_id="n2", node_type="risk") + result = json.loads(self.tool._run(action="query_graph", query="risk")) + self.assertEqual(result["count"], 1) + self.assertEqual(result["results"][0]["id"], "n2") + + def test_query_graph_result_shape_is_consistent(self): + """Every result — content match or id/type match — must carry the same + keys (id, type, label, content, score) so agents get one schema.""" + self.graph.add_node( + node_id="n1", + node_type="policy", + content="all refunds within 30 days", + ) + by_content = json.loads(self.tool._run(action="query_graph", query="refunds"))[ + "results" + ][0] + expected_keys = {"id", "type", "label", "content", "score"} + self.assertEqual(set(by_content.keys()), expected_keys) + + by_id = json.loads(self.tool._run(action="query_graph", query="n1"))["results"][ + 0 + ] + self.assertEqual(set(by_id.keys()), expected_keys) + self.assertEqual(by_id["content"], "all refunds within 30 days") + self.assertEqual(by_id["score"], 1.0) + + def test_extract_entities_skips_nameless_entities(self): + class _NamelessNER: + def extract_entities(self, text): + e = MagicMock() + e.name = None + e.type = "MISC" + e.confidence = 0.5 + return [e] + + tool = SemanticaKGTool( + graph=self.graph, + ner_extractor=_NamelessNER(), + relation_extractor=_FakeRelExtractor(), + ) + result = json.loads(tool._run(action="extract_entities", text="text")) + self.assertEqual(result["count"], 0) + self.assertEqual(result["entities"], []) + + def test_find_related_multi_hop(self): + self.graph.add_node(node_id="A", node_type="concept") + self.graph.add_node(node_id="B", node_type="concept") + self.graph.add_node(node_id="C", node_type="concept") + self.graph.add_edge(source_id="A", target_id="B", edge_type="related_to") + self.graph.add_edge(source_id="B", target_id="C", edge_type="related_to") + result = json.loads(self.tool._run(action="find_related", entity="A", hops=2)) + self.assertEqual(result["count"], 2) + self.assertIn("B", result["related"]) + self.assertIn("C", result["related"]) + + def test_find_related_unknown_entity(self): + result = json.loads( + self.tool._run(action="find_related", entity="Ghost", hops=1) + ) + self.assertEqual(result["count"], 0) + self.assertEqual(result["related"], []) + + def test_find_related_honors_incoming_edges(self): + """find_related must be undirected: a node whose only edge is + incoming (A -> B) is still related to A.""" + self.graph.add_node(node_id="OpenAI", node_type="ORG") + self.graph.add_node(node_id="Google", node_type="ORG") + self.graph.add_edge( + source_id="OpenAI", target_id="Google", edge_type="related_to" + ) + result = json.loads(self.tool._run(action="find_related", entity="Google")) + self.assertEqual(result["related"], ["OpenAI"]) + result_out = json.loads(self.tool._run(action="find_related", entity="OpenAI")) + self.assertEqual(result_out["related"], ["Google"]) + + def test_unknown_action_returns_error(self): + result = json.loads(self.tool._run(action="do_something_else")) + self.assertIn("error", result) + self.assertIn("do_something_else", result["error"]) + + def test_extract_entities_empty_text_is_graceful(self): + result = json.loads(self.tool._run(action="extract_entities", text="")) + self.assertIn("entities", result) + + def test_extract_entities_confidence_none_defaults_to_one(self): + """A single entity with ``confidence=None`` must not nuke the whole + extract result — it normalises to 1.0 instead of raising float(None).""" + + class _NoneConfNER: + def extract_entities(self, text): + e = MagicMock() + e.name = "X" + e.type = "MISC" + e.confidence = None + return [e] + + tool = SemanticaKGTool( + graph=self.graph, + ner_extractor=_NoneConfNER(), + relation_extractor=_FakeRelExtractor(), + ) + result = json.loads(tool._run(action="extract_entities", text="text")) + self.assertEqual(result["count"], 1) + self.assertEqual(result["entities"][0]["name"], "X") + self.assertEqual(result["entities"][0]["confidence"], 1.0) + self.assertNotIn("error", result) + + def test_graph_lock_is_per_graph(self): + """Independent graphs must not share a batch lock.""" + g2 = ContextGraph() + lock_a = self.tool._graph_lock(self.graph) + lock_a_again = self.tool._graph_lock(self.graph) + lock_b = self.tool._graph_lock(g2) + self.assertIs(lock_a, lock_a_again) + self.assertIsNot(lock_a, lock_b) + + +class TestSemanticaKGToolDataclassShapes(unittest.TestCase): + """Real Semantica ``Entity``/``Relation`` dataclasses (text/label, + subject/object) instead of MagicMock-shaped fakes.""" + + def setUp(self): + self.ner = _DataclassNER() + self.rel = _DataclassRelExtractor() + self.graph = ContextGraph() + self.tool = SemanticaKGTool( + graph=self.graph, ner_extractor=self.ner, relation_extractor=self.rel + ) + + def test_extract_entities_reads_text_label(self): + result = json.loads( + self.tool._run(action="extract_entities", text="Tesla founded by Elon Musk") + ) + self.assertEqual(result["count"], 2) + self.assertEqual(result["entities"][0]["name"], "Tesla") + self.assertEqual(result["entities"][0]["type"], "ORG") + self.assertEqual(result["entities"][1]["name"], "Elon Musk") + self.assertEqual(result["entities"][1]["type"], "PERSON") + + def test_extract_relations_reads_subject_object(self): + result = json.loads( + self.tool._run( + action="extract_relations", text="Tesla founded by Elon Musk" + ) + ) + self.assertEqual(result["count"], 1) + self.assertEqual(result["relations"][0]["source"], "Tesla") + self.assertEqual(result["relations"][0]["relation"], "FOUNDED_BY") + self.assertEqual(result["relations"][0]["target"], "Elon Musk") + + def test_add_to_graph_passes_entity_objects_to_relation_extractor(self): + result = json.loads( + self.tool._run(action="add_to_graph", text="Tesla founded by Elon Musk") + ) + self.assertEqual(result["nodes_added"], 2) + self.assertEqual(result["edges_added"], 1) + from semantica.semantic_extract.types import Entity + + self.assertIsNotNone(self.rel.received_entities) + for e in self.rel.received_entities: + self.assertIsInstance(e, Entity) + node_ids = {n["id"] for n in self.graph.find_nodes()} + self.assertIn("Tesla", node_ids) + self.assertIn("Elon Musk", node_ids) + edge_keys = { + (e["source"], e["type"], e["target"]) for e in self.graph.find_edges() + } + self.assertIn(("Tesla", "FOUNDED_BY", "Elon Musk"), edge_keys) + + +class TestSemanticaKGToolCrewAIEntrypoints(unittest.TestCase): + + def setUp(self): + self.tool = SemanticaKGTool( + graph=ContextGraph(), + ner_extractor=_FakeNER(), + relation_extractor=_FakeRelExtractor(), + ) + + def test_run_delegates_to_run(self): + result = json.loads( + self.tool.run(action="extract_entities", text="Tesla led by Elon Musk") + ) + self.assertEqual(result["count"], 2) + + def test_arun_async(self): + async def _call(): + return await self.tool.arun(action="query_graph", query="x") + + result = json.loads(asyncio.run(_call())) + self.assertIn("results", result) + + def test_run_returns_string(self): + out = self.tool.run(action="extract_entities", text="hello world") + self.assertIsInstance(out, str) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/integrations/crewai/test_knowledge_source.py b/tests/integrations/crewai/test_knowledge_source.py new file mode 100644 index 00000000..f76cc815 --- /dev/null +++ b/tests/integrations/crewai/test_knowledge_source.py @@ -0,0 +1,228 @@ +""" +Tests for SemanticaKnowledgeSource — CrewAI knowledge source backed by a +Semantica ContextGraph. + +Runs with the crewai stubs installed by conftest, so ``CREWAI_AVAILABLE`` is +``True`` and the real Pydantic/BaseKnowledgeSource subclassing path (including +the current ``validate_content`` / ``add`` / ``aadd`` contract) is exercised. +""" + +from __future__ import annotations + +import asyncio +import unittest + +from integrations.crewai import SemanticaKnowledgeSource +from integrations.crewai.knowledge_source import CREWAI_AVAILABLE, _chunk_text_manual +from semantica.context import ContextGraph + + +class _FakeStorage: + def __init__(self): + self.saved_chunks: list = [] + + def save(self, chunks: list) -> None: + self.saved_chunks.extend(chunks) + + async def asave(self, chunks: list) -> None: + self.saved_chunks.extend(chunks) + + +class _RaisingStorage(_FakeStorage): + """Mirrors real crewai: storage is wired but ``save`` raises ``ValueError`` + (e.g. the embedder has no credentials configured).""" + + def save(self, chunks: list) -> None: + raise ValueError("The OPENAI_API_KEY environment variable is not set.") + + async def asave(self, chunks: list) -> None: + raise ValueError("The OPENAI_API_KEY environment variable is not set.") + + +def _build_graph() -> ContextGraph: + graph = ContextGraph() + graph.add_node(node_id="privacy", node_type="policy", content="privacy policy doc") + graph.add_node(node_id="fraud", node_type="risk", content="fraud detection rules") + graph.add_edge(source_id="privacy", target_id="fraud", edge_type="constrains") + return graph + + +class TestSemanticaKnowledgeSourceInit(unittest.TestCase): + + def test_crewai_available_via_stub(self): + self.assertTrue(CREWAI_AVAILABLE) + + def test_is_base_knowledge_source_subclass(self): + from crewai.knowledge.source import BaseKnowledgeSource + + self.assertTrue(issubclass(SemanticaKnowledgeSource, BaseKnowledgeSource)) + + def test_creates_with_explicit_graph(self): + graph = _build_graph() + src = SemanticaKnowledgeSource(graph=graph) + self.assertIs(src.graph, graph) + + def test_creates_fresh_graph_when_none(self): + src = SemanticaKnowledgeSource() + self.assertIsNotNone(src.graph) + self.assertIsInstance(src.graph, ContextGraph) + + def test_default_metadata(self): + src = SemanticaKnowledgeSource(graph=_build_graph()) + self.assertEqual(src.name, "semantica_knowledge_graph") + self.assertEqual(src.chunk_size, 4000) + self.assertEqual(src.chunk_overlap, 200) + + def test_custom_chunking_params(self): + src = SemanticaKnowledgeSource( + graph=_build_graph(), chunk_size=50, chunk_overlap=10 + ) + self.assertEqual(src.chunk_size, 50) + self.assertEqual(src.chunk_overlap, 10) + + +class TestLoadContent(unittest.TestCase): + + def setUp(self): + self.graph = _build_graph() + self.src = SemanticaKnowledgeSource(graph=self.graph) + + def test_nodes_serialized(self): + content = self.src.load_content() + text = "\n".join(content.values()) + self.assertIn("privacy", text) + self.assertIn("fraud", text) + self.assertIn("policy", text) + + def test_edges_serialized(self): + content = self.src.load_content() + text = "\n".join(content.values()) + self.assertIn("-[" + "constrains" + "]->", text) + + def test_empty_graph_returns_empty(self): + src = SemanticaKnowledgeSource(graph=ContextGraph()) + self.assertEqual(src.load_content(), {}) + + def test_validate_content_passes(self): + self.assertTrue(self.src.validate_content()) + + def test_validate_content_raises_without_graph(self): + self.src.graph = None + with self.assertRaises(ValueError): + self.src.validate_content() + + +class TestAdd(unittest.TestCase): + + def setUp(self): + self.graph = _build_graph() + self.src = SemanticaKnowledgeSource( + graph=self.graph, chunk_size=40, chunk_overlap=5 + ) + + def test_add_saves_chunks_to_storage(self): + storage = _FakeStorage() + self.src.storage = storage + self.src.add() + self.assertGreater(len(storage.saved_chunks), 0) + self.assertTrue(all(isinstance(c, str) and c for c in storage.saved_chunks)) + + def test_add_without_storage_keeps_chunks_in_memory(self): + self.src.add() + self.assertGreater(len(self.src.chunks), 0) + self.assertGreater(len(self.src._chunks), 0) + + def test_add_wired_storage_failure_logs_error_not_debug(self): + """Regression: real crewai raises ``ValueError`` for a missing embedder + even though storage IS wired. That used to fall into the "storage not + wired" DEBUG branch, silently hiding the failure — it must log an + actionable ERROR instead.""" + self.src.storage = _RaisingStorage() + with self.assertLogs( + f"semantica.{SemanticaKnowledgeSource.__module__}", level="ERROR" + ) as caught: + self.src.add() + joined = "\n".join(caught.output) + self.assertIn("storage save FAILED", joined) + self.assertIn("OPENAI_API_KEY", joined) + self.assertGreater(len(self.src.chunks), 0) + + def test_add_empty_graph_no_chunks(self): + src = SemanticaKnowledgeSource( + graph=ContextGraph(), chunk_size=40, chunk_overlap=5 + ) + src.add() + self.assertEqual(src.chunks, []) + + def test_aadd_async(self): + storage = _FakeStorage() + self.src.storage = storage + asyncio.run(self.src.aadd()) + self.assertGreater(len(storage.saved_chunks), 0) + + def test_content_summary(self): + summary = self.src.get_content_summary() + self.assertEqual(summary["name"], "semantica_knowledge_graph") + self.assertGreater(summary["source_count"], 0) + self.assertTrue(summary["crewai_available"]) + + +class TestSemanticaKnowledgeSourceSerialization(unittest.TestCase): + """CrewAI checkpoints serialise their models via ``model_dump(mode="json")`` + — the live graph must not break that (regression for + PydanticSerializationError on arbitrary state objects).""" + + def test_model_dump_json_excludes_graph(self): + src = SemanticaKnowledgeSource(graph=_build_graph()) + dumped = src.model_dump(mode="json") + self.assertNotIn("graph", dumped) + self.assertEqual(dumped["name"], "semantica_knowledge_graph") + + def test_model_validate_restores_graph(self): + src = SemanticaKnowledgeSource(graph=_build_graph()) + restored = SemanticaKnowledgeSource.model_validate(src.model_dump(mode="json")) + self.assertIsInstance(restored.graph, ContextGraph) + + def test_restored_source_still_loads_content(self): + """A checkpoint-restored source gets a fresh graph (the live graph is + excluded from serialisation); once a graph is attached it works.""" + src = SemanticaKnowledgeSource(graph=_build_graph()) + restored = SemanticaKnowledgeSource.model_validate(src.model_dump(mode="json")) + restored.graph = _build_graph() + self.assertNotEqual(restored.load_content(), {}) + + def test_restore_flags_lost_live_state(self): + """A source restored from a checkpoint must signal that its live graph + was excluded and an empty one reconstructed (``reconstructed_state``). + Regression: an eager graph build in ``__init__`` used to hide this.""" + src = SemanticaKnowledgeSource(graph=_build_graph()) + dumped = src.model_dump(mode="json") + self.assertTrue(dumped["had_live_state"]) + self.assertNotIn("reconstructed_state", dumped) + restored = SemanticaKnowledgeSource.model_validate(dumped) + self.assertTrue(restored.reconstructed_state) + self.assertFalse(SemanticaKnowledgeSource().reconstructed_state) + self.assertIsInstance(SemanticaKnowledgeSource().graph, ContextGraph) + + +class TestManualChunker(unittest.TestCase): + + def test_short_text_single_chunk(self): + self.assertEqual(_chunk_text_manual("hello", 40, 5), ["hello"]) + + def test_empty_text(self): + self.assertEqual(_chunk_text_manual("", 40, 5), []) + + def test_long_text_overlaps(self): + chunks = _chunk_text_manual("a" * 100, 40, 10) + self.assertGreater(len(chunks), 1) + self.assertTrue(all(len(c) <= 40 for c in chunks)) + # Overlap means consecutive chunks share tail/head content + self.assertIn("a" * 10, chunks[0][-10:] + chunks[1][:10]) + + def test_zero_chunk_size_guarded(self): + self.assertEqual(_chunk_text_manual("hello world", 0, 5), ["hello world"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/integrations/crewai/test_real_crewai_integration.py b/tests/integrations/crewai/test_real_crewai_integration.py new file mode 100644 index 00000000..b9b9d619 --- /dev/null +++ b/tests/integrations/crewai/test_real_crewai_integration.py @@ -0,0 +1,123 @@ +""" +End-to-end integration tests against the REAL crewai package. + +These run in a subprocess because the stubs in ``conftest.py`` install a fake +``crewai`` module into ``sys.modules`` for the whole pytest session — the same +interpreter can never see both. Each test launches a fresh interpreter; if +crewai is genuinely not installed there, the test is skipped. + +This covers the failure class the stubs cannot: ``Crew``-level serialization +(list[BaseTool] inside Agent.tools), checkpoint restore via ``model_validate``, +and knowledge-source behaviour with a real ``Crew``. +""" + +from __future__ import annotations + +import subprocess +import sys +import textwrap +import unittest +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[3] + +_SCRIPT = textwrap.dedent( + """ + import os + import json + import sys + + sys.path.insert(0, os.getcwd()) + + try: + import crewai + except ImportError: + print("CREWAI_IMPORT_FAILED") + sys.exit(2) + + import crewai as crewai_mod + from crewai import Agent, Task, Crew + + from semantica.context import ContextGraph + from integrations.crewai import ( + SemanticaKGTool, + SemanticaDecisionTool, + SemanticaKnowledgeSource, + ) + + os.environ["CREWAI_DESERIALIZE_CALLBACKS"] = "1" + + # --- 1. Crew-level serialization round-trip ------------------------------ + graph = ContextGraph() + graph.add_node(node_id="privacy", node_type="policy", + content="privacy policy: no data sharing") + tool = SemanticaKGTool(graph=graph) + + decision_ctx = SemanticaDecisionTool() + decision_tool = SemanticaDecisionTool(context=decision_ctx.context) + + agent = Agent(role="researcher", goal="answer questions", + backstory="retrieves from a knowledge graph", + tools=[tool, decision_tool]) + task = Task(description="answer", expected_output="an answer", agent=agent) + crew = Crew(agents=[agent], tasks=[task]) + + dump = crew.model_dump(mode="json") + agents = dump["agents"] + assert len(agents) == 1, f"expected 1 agent, got {len(agents)}" + dumped_tools = agents[0]["tools"] + assert len(dumped_tools) == 2, f"expected 2 tools, got {len(dumped_tools)}" + for t in dumped_tools: + assert isinstance(t, dict), f"tool not serialized to dict: {type(t)}" + assert "graph" not in t, "live graph leaked into serialized tool" + assert "context" not in t, "live context leaked into serialized tool" + assert "ner_extractor" not in t, "extractor leaked into serialized tool" + + # --- 2. Restore a tool from the crew dump -------------------------------- + kg_dump = dumped_tools[0] + assert kg_dump["name"] == "semantica_knowledge_graph", kg_dump["name"] + restored = SemanticaKGTool.model_validate(kg_dump) + assert restored.graph is not None, "restored tool did not self-heal a graph" + q = json.loads(restored._run(action="query_graph", query="privacy")) + assert "results" in q, f"restored tool query_graph failed: {q}" + + # --- 3. Knowledge source with no embedder must not crash a Crew ---------- + ks = SemanticaKnowledgeSource(graph=graph) + agent2 = Agent(role="researcher2", goal="answer", + backstory="retrieves from knowledge") + task2 = Task(description="q", expected_output="a", agent=agent2) + crew2 = Crew(agents=[agent2], tasks=[task2], + knowledge_sources=[ks]) + assert ks.chunks, "knowledge source retained no chunks in memory" + assert crew2.knowledge is not None, "crew.knowledge not created" + + print("REAL_CREWAI_OK") + """ +) + + +class TestRealCrewAIIntegration(unittest.TestCase): + + def _run(self) -> subprocess.CompletedProcess: + return subprocess.run( + [sys.executable, "-c", _SCRIPT], + cwd=str(REPO_ROOT), + capture_output=True, + text=True, + timeout=240, + ) + + def test_crew_level_round_trip_with_real_crewai(self): + proc = self._run() + if proc.returncode == 2: + self.skipTest("real crewai is not installed in this environment") + self.assertEqual( + proc.returncode, + 0, + msg=f"subprocess failed:\n{proc.stdout}\n{proc.stderr}", + ) + self.assertIn("REAL_CREWAI_OK", proc.stdout) + + +if __name__ == "__main__": + unittest.main() From 8177d887538560d137edc705c7d60abcb9e23faa Mon Sep 17 00:00:00 2001 From: "Guofang.Tang" <136770748@qq.com> Date: Sun, 16 Aug 2026 14:23:24 +0800 Subject: [PATCH 055/105] fix(kg): preserve isolated nodes in graph analytics (#1011) * fix(kg): preserve isolated nodes in graph analytics * fix(kg): support node fallbacks and community payloads --------- --- semantica/kg/_graph_view.py | 206 ++++++++++++++++++++++++++ semantica/kg/centrality_calculator.py | 72 +-------- semantica/kg/community_detector.py | 127 ++++++---------- semantica/kg/connectivity_analyzer.py | 49 +----- tests/kg/test_analytics_node_scope.py | 112 ++++++++++++++ 5 files changed, 374 insertions(+), 192 deletions(-) create mode 100644 semantica/kg/_graph_view.py create mode 100644 tests/kg/test_analytics_node_scope.py diff --git a/semantica/kg/_graph_view.py b/semantica/kg/_graph_view.py new file mode 100644 index 00000000..f467125b --- /dev/null +++ b/semantica/kg/_graph_view.py @@ -0,0 +1,206 @@ +"""Internal graph view helpers shared by KG analytics modules.""" + +from dataclasses import dataclass +from typing import Any, Dict, Iterable, List, Optional, Set, Tuple + + +@dataclass +class GraphView: + """Normalized node and edge view used by graph analytics.""" + + nodes: List[Any] + edges: List[Tuple[Any, Any]] + + +def build_graph_view(graph: Any) -> GraphView: + """Build a graph view without dropping explicitly declared nodes. + + Graph analytics accepts graph dictionaries, ContextGraph-like objects, and + NetworkX graphs. Nodes declared without an incident edge remain in the + returned view so callers can choose how to handle isolated nodes. + """ + nodes: List[Any] = [] + edges: List[Tuple[Any, Any]] = [] + seen_nodes: Set[Any] = set() + seen_edges: Set[Tuple[Any, Any]] = set() + + def add_node(value: Any) -> Optional[Any]: + node_id = _node_id(value) + if node_id is None or node_id == "": + return None + if node_id not in seen_nodes: + seen_nodes.add(node_id) + nodes.append(node_id) + return node_id + + for node in _extract_nodes(graph): + add_node(node) + + for raw_edge in _extract_edges(graph): + edge = _edge_endpoints(raw_edge) + if edge is None: + continue + source, target = edge + source = add_node(source) + target = add_node(target) + if source is None or target is None: + continue + if (source, target) not in seen_edges: + seen_edges.add((source, target)) + edges.append((source, target)) + + return GraphView(nodes=nodes, edges=edges) + + +def build_adjacency(graph: Any, directed: bool = False) -> Dict[Any, List[Any]]: + """Build an adjacency list while preserving isolated graph nodes.""" + view = build_graph_view(graph) + adjacency: Dict[Any, List[Any]] = {node: [] for node in view.nodes} + + for source, target in view.edges: + if target not in adjacency[source]: + adjacency[source].append(target) + if not directed and source not in adjacency[target]: + adjacency[target].append(source) + + return adjacency + + +def _extract_nodes(graph: Any) -> Iterable[Any]: + if isinstance(graph, dict): + raw_nodes: List[Any] = [] + for key in ("entities", "nodes"): + values = graph.get(key, []) + if isinstance(values, dict): + raw_nodes.extend(values.keys()) + elif values: + raw_nodes.extend(values) + return raw_nodes + + raw_nodes = getattr(graph, "nodes", None) + if callable(raw_nodes): + return raw_nodes() + if isinstance(raw_nodes, dict): + return raw_nodes.keys() + if raw_nodes is not None: + return raw_nodes + + get_nodes = getattr(graph, "get_nodes", None) + if callable(get_nodes): + return get_nodes() + return [] + + +def _extract_edges(graph: Any) -> Iterable[Any]: + if isinstance(graph, dict): + raw_edges: List[Any] = [] + for key in ("relationships", "edges"): + values = graph.get(key, []) + if values: + raw_edges.extend(values) + return raw_edges + + raw_edges: List[Any] = [] + relationships = getattr(graph, "relationships", None) + if relationships is not None: + raw_edges.extend(relationships) + edges = getattr(graph, "edges", None) + if callable(edges): + raw_edges.extend(edges()) + elif edges is not None: + raw_edges.extend(edges) + if raw_edges: + return raw_edges + + get_relationships = getattr(graph, "get_relationships", None) + if callable(get_relationships): + return get_relationships() + return [] + + +def _edge_endpoints(edge: Any) -> Optional[Tuple[Any, Any]]: + if isinstance(edge, (tuple, list)) and len(edge) >= 2: + return edge[0], edge[1] + + if isinstance(edge, dict): + source = _first_value( + edge, + "source", + "source_id", + "subject", + "start", + "start_id", + "from", + "src", + "START_ID", + ":START_ID", + ) + target = _first_value( + edge, + "target", + "target_id", + "object", + "end", + "end_id", + "to", + "dst", + "END_ID", + ":END_ID", + ) + else: + source = _first_attribute( + edge, + "source_id", + "source", + "subject", + "start", + "start_id", + "from_id", + ) + target = _first_attribute( + edge, + "target_id", + "target", + "object", + "end", + "end_id", + "to_id", + ) + + if source is None or target is None: + return None + return source, target + + +def _node_id(value: Any) -> Any: + if isinstance(value, dict): + value = _first_value( + value, "id", "node_id", "entity_id", "key", "name", "text" + ) + elif not isinstance(value, (str, int, float, bool, bytes, tuple)): + value = _first_attribute( + value, "node_id", "id", "entity_id", "key", "name", "text" + ) + + if value is None: + return None + try: + hash(value) + except TypeError: + return str(value) + return value + + +def _first_value(mapping: Dict[str, Any], *keys: str) -> Any: + for key in keys: + if key in mapping and mapping[key] not in (None, ""): + return mapping[key] + return None + + +def _first_attribute(value: Any, *names: str) -> Any: + for name in names: + attribute = getattr(value, name, None) + if attribute not in (None, ""): + return attribute + return None diff --git a/semantica/kg/centrality_calculator.py b/semantica/kg/centrality_calculator.py index 9fe9a956..ac59db6e 100644 --- a/semantica/kg/centrality_calculator.py +++ b/semantica/kg/centrality_calculator.py @@ -43,7 +43,7 @@ Author: Semantica Contributors License: MIT """ -from collections import defaultdict, deque +from collections import deque from typing import Any, Dict, List, Optional import numpy as np @@ -51,6 +51,7 @@ from scipy import sparse from ..utils.logging import get_logger from ..utils.progress_tracker import get_progress_tracker +from ._graph_view import build_adjacency, build_graph_view class CentralityCalculator: @@ -518,76 +519,15 @@ class CentralityCalculator: def _build_adjacency(self, graph) -> Dict[str, List[str]]: """Build adjacency list from graph.""" - adjacency = defaultdict(list) - - # Extract relationships - relationships = [] - if hasattr(graph, "relationships"): - relationships = graph.relationships - elif hasattr(graph, "get_relationships"): - relationships = graph.get_relationships() - elif isinstance(graph, dict): - relationships = graph.get("relationships", graph.get("edges", [])) - elif hasattr(graph, "edges") and not callable(graph.edges): - # ContextGraph-style: edges is a list of dataclass objects with source_id/target_id - for edge in (graph.edges or []): - if isinstance(edge, dict): - src = edge.get("source") or edge.get("source_id") - tgt = edge.get("target") or edge.get("target_id") - else: - src = getattr(edge, "source_id", None) or getattr(edge, "source", None) - tgt = getattr(edge, "target_id", None) or getattr(edge, "target", None) - if src and tgt: - src, tgt = str(src), str(tgt) - if tgt not in adjacency[src]: - adjacency[src].append(tgt) - if src not in adjacency[tgt]: - adjacency[tgt].append(src) - return dict(adjacency) - - # Build adjacency - for rel in relationships: - # Handle tuple/list edges (e.g., from NetworkX) - if isinstance(rel, (tuple, list)) and len(rel) >= 2: - source, target = str(rel[0]), str(rel[1]) - if source and target: - if target not in adjacency[source]: - adjacency[source].append(target) - if source not in adjacency[target]: - adjacency[target].append(source) - continue - source = rel.get("source") or rel.get("subject") - target = rel.get("target") or rel.get("object") - - # Extract IDs if objects are passed - if source and not isinstance(source, (str, int, float)): - if isinstance(source, dict): - source = source.get("id") or source.get("entity_id") or source.get("text") or str(source) - else: - source = getattr(source, "id", getattr(source, "text", str(source))) - - if target and not isinstance(target, (str, int, float)): - if isinstance(target, dict): - target = target.get("id") or target.get("entity_id") or target.get("text") or str(target) - else: - target = getattr(target, "id", getattr(target, "text", str(target))) - - if source and target: - if target not in adjacency[source]: - adjacency[source].append(target) - if source not in adjacency[target]: - adjacency[target].append(source) - - return dict(adjacency) + return build_adjacency(graph) def _to_networkx(self, graph): """Convert graph to NetworkX format.""" - adjacency = self._build_adjacency(graph) + view = build_graph_view(graph) nx_graph = self.nx.Graph() - for source, targets in adjacency.items(): - for target in targets: - nx_graph.add_edge(source, target) + nx_graph.add_nodes_from(view.nodes) + nx_graph.add_edges_from(view.edges) return nx_graph diff --git a/semantica/kg/community_detector.py b/semantica/kg/community_detector.py index 8aaaa236..01fa6064 100644 --- a/semantica/kg/community_detector.py +++ b/semantica/kg/community_detector.py @@ -49,6 +49,16 @@ from typing import Any, Dict, List, Optional from ..utils.logging import get_logger from ..utils.progress_tracker import get_progress_tracker +from ._graph_view import build_adjacency, build_graph_view + + +def _is_hashable(value: Any) -> bool: + """Return whether a community identifier can be used in a set.""" + try: + hash(value) + except TypeError: + return False + return True class CommunityDetector: @@ -157,17 +167,18 @@ class CommunityDetector: nx_graph = self._to_networkx(graph) - # Check if graph is empty or has no edges + # An empty graph has no communities. A graph with nodes but + # no edges still has singleton communities. num_nodes = nx_graph.number_of_nodes() num_edges = nx_graph.number_of_edges() self.logger.debug(f"Graph stats: nodes={num_nodes}, edges={num_edges}") - if num_nodes == 0 or num_edges == 0: - self.logger.warning("Graph is empty or has no edges, returning 0 communities") + if num_nodes == 0: + self.logger.warning("Graph is empty, returning 0 communities") self.progress_tracker.stop_tracking( tracking_id, status="completed", - message="Detected 0 communities (empty graph/no edges)", + message="Detected 0 communities (empty graph)", ) return { "communities": [], @@ -350,17 +361,7 @@ class CommunityDetector: adjacency = self._build_adjacency(graph) - # Extract community structure - if isinstance(communities, dict): - node_communities = communities - elif isinstance(communities, dict) and "node_assignments" in communities: - node_communities = communities["node_assignments"] - else: - # Convert list of communities to node assignments - node_communities = {} - for i, community in enumerate(communities): - for node in community: - node_communities[node] = i + node_communities = self._to_node_assignments(communities) # Calculate metrics num_communities = len(set(node_communities.values())) @@ -408,16 +409,7 @@ class CommunityDetector: metrics = self.calculate_community_metrics(graph, communities) - # Extract node assignments - if isinstance(communities, dict) and "node_assignments" in communities: - node_communities = communities["node_assignments"] - elif isinstance(communities, dict): - node_communities = communities - else: - node_communities = {} - for i, community in enumerate(communities): - for node in community: - node_communities[node] = i + node_communities = self._to_node_assignments(communities) # Analyze connectivity between communities adjacency = self._build_adjacency(graph) @@ -440,6 +432,32 @@ class CommunityDetector: "edge_ratio": intra_community_edges / (inter_community_edges + 1), } + @staticmethod + def _to_node_assignments(communities: Any) -> Dict[Any, Any]: + """Normalize community results to a node-to-community mapping.""" + if isinstance(communities, dict): + assignments = communities.get("node_assignments") + if isinstance(assignments, dict): + return assignments + + detected_communities = communities.get("communities") + if isinstance(detected_communities, (list, tuple)): + communities = detected_communities + elif "communities" in communities: + raise ValueError("Community results must contain a list of communities") + elif not all(_is_hashable(value) for value in communities.values()): + raise ValueError( + "Community assignments must map nodes to hashable community IDs" + ) + else: + return communities + + node_assignments: Dict[Any, Any] = {} + for community_id, community in enumerate(communities or []): + for node in community: + node_assignments[node] = community_id + return node_assignments + def detect_communities( self, graph: Any, algorithm: str = "louvain", method: str = None, **options ) -> Dict[str, Any]: @@ -478,57 +496,7 @@ class CommunityDetector: def _build_adjacency(self, graph) -> Dict[str, List[str]]: """Build adjacency list from graph.""" - from collections import defaultdict - - adjacency = defaultdict(list) - - # Extract relationships - relationships = [] - raw_edges = [] # flat (u, v) tuples - if hasattr(graph, "relationships"): - relationships = graph.relationships - elif hasattr(graph, "get_relationships"): - relationships = graph.get_relationships() - elif isinstance(graph, dict): - relationships = graph.get("relationships", []) - # Also handle 'edges' key (list of tuples or dicts) - for edge in graph.get("edges", []): - if isinstance(edge, (list, tuple)) and len(edge) >= 2: - raw_edges.append((str(edge[0]), str(edge[1]))) - elif isinstance(edge, dict): - relationships.append(edge) - - # Add raw (u, v) edges - for u, v in raw_edges: - if u and v: - adjacency[u].append(v) - adjacency[v].append(u) - - # Build adjacency - for rel in relationships: - source = rel.get("source") or rel.get("subject") - target = rel.get("target") or rel.get("object") - - # Extract IDs if objects are passed - if source and not isinstance(source, (str, int, float)): - if isinstance(source, dict): - source = source.get("id") or source.get("entity_id") or source.get("text") or str(source) - else: - source = getattr(source, "id", getattr(source, "text", str(source))) - - if target and not isinstance(target, (str, int, float)): - if isinstance(target, dict): - target = target.get("id") or target.get("entity_id") or target.get("text") or str(target) - else: - target = getattr(target, "id", getattr(target, "text", str(target))) - - if source and target: - if target not in adjacency[source]: - adjacency[source].append(target) - if source not in adjacency[target]: - adjacency[target].append(source) - - return dict(adjacency) + return build_adjacency(graph) def _to_networkx(self, graph): """Convert graph to NetworkX format.""" @@ -536,12 +504,11 @@ class CommunityDetector: if hasattr(graph, 'nodes') and hasattr(graph, 'edges') and hasattr(graph, 'number_of_nodes'): return graph - adjacency = self._build_adjacency(graph) + view = build_graph_view(graph) nx_graph = self.nx.Graph() - for source, targets in adjacency.items(): - for target in targets: - nx_graph.add_edge(source, target) + nx_graph.add_nodes_from(view.nodes) + nx_graph.add_edges_from(view.edges) return nx_graph diff --git a/semantica/kg/connectivity_analyzer.py b/semantica/kg/connectivity_analyzer.py index 00d2ad22..463c9ed5 100644 --- a/semantica/kg/connectivity_analyzer.py +++ b/semantica/kg/connectivity_analyzer.py @@ -48,11 +48,12 @@ Author: Semantica Contributors License: MIT """ -from collections import defaultdict, deque +from collections import deque from typing import Any, Dict, List, Optional, Set, Tuple from ..utils.logging import get_logger from ..utils.progress_tracker import get_progress_tracker +from ._graph_view import build_adjacency class ConnectivityAnalyzer: @@ -385,51 +386,7 @@ class ConnectivityAnalyzer: def _build_adjacency(self, graph) -> Dict[str, List[str]]: """Build adjacency list from graph.""" - adjacency = defaultdict(list) - - # Extract relationships - relationships = [] - if hasattr(graph, "relationships"): - relationships = graph.relationships - elif hasattr(graph, "get_relationships"): - relationships = graph.get_relationships() - elif isinstance(graph, dict): - relationships = graph.get("relationships", graph.get("edges", [])) - - # Build adjacency - for rel in relationships: - # Handle tuple/list edges (e.g., from NetworkX) - if isinstance(rel, (tuple, list)) and len(rel) >= 2: - source, target = str(rel[0]), str(rel[1]) - if source and target: - if target not in adjacency[source]: - adjacency[source].append(target) - if source not in adjacency[target]: - adjacency[target].append(source) - continue - source = rel.get("source") or rel.get("subject") - target = rel.get("target") or rel.get("object") - - # Extract IDs if objects are passed - if source and not isinstance(source, (str, int, float)): - if isinstance(source, dict): - source = source.get("id") or source.get("entity_id") or source.get("text") or str(source) - else: - source = getattr(source, "id", getattr(source, "text", str(source))) - - if target and not isinstance(target, (str, int, float)): - if isinstance(target, dict): - target = target.get("id") or target.get("entity_id") or target.get("text") or str(target) - else: - target = getattr(target, "id", getattr(target, "text", str(target))) - - if source and target: - if target not in adjacency[source]: - adjacency[source].append(target) - if source not in adjacency[target]: - adjacency[target].append(source) - - return dict(adjacency) + return build_adjacency(graph) def _bfs_shortest_path( self, adjacency: Dict[str, List[str]], source: str, target: str diff --git a/tests/kg/test_analytics_node_scope.py b/tests/kg/test_analytics_node_scope.py new file mode 100644 index 00000000..fda5d4f8 --- /dev/null +++ b/tests/kg/test_analytics_node_scope.py @@ -0,0 +1,112 @@ +"""Regression tests for KG analytics node scope handling.""" + +import networkx as nx + +from semantica.kg.centrality_calculator import CentralityCalculator +from semantica.kg.community_detector import CommunityDetector +from semantica.kg.connectivity_analyzer import ConnectivityAnalyzer + + +def _graph_with_isolated_node(): + return { + "entities": [{"id": "A"}, {"id": "B"}, {"id": "C"}], + "relationships": [{"source": "A", "target": "B"}], + } + + +def test_centrality_keeps_declared_isolated_nodes(): + result = CentralityCalculator().calculate_degree_centrality( + _graph_with_isolated_node() + ) + + assert result["total_nodes"] == 3 + assert result["centrality"]["C"] == 0.0 + + +def test_connectivity_reports_declared_isolated_nodes(): + result = ConnectivityAnalyzer().analyze_connectivity( + _graph_with_isolated_node() + ) + + assert result["num_nodes"] == 3 + assert result["num_components"] == 2 + assert ["C"] in result["components"] + assert result["is_connected"] is False + + +def test_community_detection_keeps_declared_isolated_nodes(): + detector = CommunityDetector() + result = detector.detect_communities(_graph_with_isolated_node()) + + assert set(result["node_assignments"]) == {"A", "B", "C"} + metrics = detector.calculate_community_metrics( + _graph_with_isolated_node(), result + ) + assert metrics["num_communities"] == 2 + structure = detector.analyze_community_structure( + _graph_with_isolated_node(), result + ) + assert structure["num_communities"] == 2 + + +def test_community_detection_returns_singletons_for_edgeless_graph(): + graph = {"entities": [{"id": "A"}, {"id": "B"}], "relationships": []} + + result = CommunityDetector().detect_communities(graph) + + assert {frozenset(community) for community in result["communities"]} == { + frozenset({"A"}), + frozenset({"B"}), + } + + +def test_networkx_graph_keeps_isolated_nodes_for_analytics(): + graph = nx.Graph() + graph.add_nodes_from(["A", "B", "C"]) + graph.add_edge("A", "B") + + centrality = CentralityCalculator().calculate_degree_centrality(graph) + connectivity = ConnectivityAnalyzer().analyze_connectivity(graph) + + assert centrality["total_nodes"] == 3 + assert centrality["centrality"]["C"] == 0.0 + assert connectivity["num_nodes"] == 3 + assert connectivity["num_components"] == 2 + + +def test_nodes_edges_payload_keeps_declared_isolated_nodes(): + graph = { + "nodes": [{"id": "A"}, {"id": "B"}, {"id": "C"}], + "edges": [("A", "B")], + } + + result = CentralityCalculator().calculate_degree_centrality(graph) + + assert result["total_nodes"] == 3 + assert result["centrality"]["C"] == 0.0 + + +def test_name_and_text_nodes_are_kept_when_ids_are_missing(): + graph = { + "entities": [{"name": "Alice"}, {"text": "Bob"}], + "relationships": [], + } + + result = CentralityCalculator().calculate_degree_centrality(graph) + + assert result["total_nodes"] == 2 + assert set(result["centrality"]) == {"Alice", "Bob"} + + +def test_community_metrics_accepts_communities_payload(): + detector = CommunityDetector() + graph = { + "entities": [{"id": "A"}, {"id": "B"}, {"id": "C"}], + "relationships": [{"source": "A", "target": "B"}], + } + result = {"communities": [["A", "B"], ["C"]]} + + metrics = detector.calculate_community_metrics(graph, result) + + assert metrics["num_communities"] == 2 + assert metrics["community_sizes"] == {0: 2, 1: 1} From 15171fd31a61a488391ffac97efcfd0ef97ea553 Mon Sep 17 00:00:00 2001 From: pravit-amp <43916793+pravit-amp@users.noreply.github.com> Date: Sun, 16 Aug 2026 02:07:10 -0700 Subject: [PATCH 056/105] fix(parse): import get_progress_tracker in ExcelParser (#1016) ExcelParser.__init__ called get_progress_tracker() without importing it, so every instantiation raised NameError and the class was unusable. The existing test imported ExcelParser but never constructed it, so nothing caught it. Same defect as #530 in SimilarityCalculator, which was fixed without sweeping the rest of the codebase. Add construction coverage for every parser exported from semantica.parse, driven off __all__ so later additions are covered automatically. These live outside test_parse_comprehensive.py, whose setUp patches get_progress_tracker into each parse module and would mock away the interaction under test. Closes #1014 Co-authored-by: Pravit Ampapathini --- semantica/parse/excel_parser.py | 1 + tests/parse/test_parser_construction.py | 73 +++++++++++++++++++++++++ 2 files changed, 74 insertions(+) create mode 100644 tests/parse/test_parser_construction.py diff --git a/semantica/parse/excel_parser.py b/semantica/parse/excel_parser.py index efd84aa5..b8943939 100644 --- a/semantica/parse/excel_parser.py +++ b/semantica/parse/excel_parser.py @@ -37,6 +37,7 @@ from openpyxl import load_workbook from ..utils.exceptions import ProcessingError, ValidationError from ..utils.logging import get_logger +from ..utils.progress_tracker import get_progress_tracker @dataclass diff --git a/tests/parse/test_parser_construction.py b/tests/parse/test_parser_construction.py new file mode 100644 index 00000000..b649954b --- /dev/null +++ b/tests/parse/test_parser_construction.py @@ -0,0 +1,73 @@ +"""Construction coverage for the parse module's public parser classes. + +Regression tests for #1014: ``ExcelParser.__init__`` called ``get_progress_tracker()`` +without importing it, so every instantiation raised ``NameError``. The class was +covered by an import-only test, which passes regardless of whether ``__init__`` +works, so nothing caught it. #530 was the same bug in ``SimilarityCalculator``. + +These tests deliberately do **not** patch ``get_logger``/``get_progress_tracker``. +``tests/parse/test_parse_comprehensive.py`` patches both into every parse module +that exposes them, which would mock away the exact interaction under test here and +let the regression back in silently. +""" + +import unittest + +import semantica.parse as parse_module +from semantica.parse.excel_parser import ExcelParser + + +def _exported_parser_classes(): + """Public parser classes, taken from the package's own ``__all__``. + + Driven off ``__all__`` rather than a hand-written list so a parser added later + is covered without anyone remembering to update this file. + """ + return [ + (name, getattr(parse_module, name)) + for name in parse_module.__all__ + if name.endswith("Parser") + ] + + +class TestExcelParserConstruction(unittest.TestCase): + """ExcelParser must be constructible -- see #1014.""" + + def test_excel_parser_constructs(self): + parser = ExcelParser() + self.assertIsNotNone(parser) + + def test_excel_parser_wires_progress_tracker(self): + """The missing import was for the tracker, so assert it is actually set. + + A bare construction check would pass against a version that dropped the + tracker call entirely; this pins the attribute the import exists to provide. + """ + parser = ExcelParser() + self.assertIsNotNone(parser.progress_tracker) + + +class TestExportedParsersConstruct(unittest.TestCase): + """Every parser the package exports must survive ``__init__``.""" + + def test_all_exported_parsers_construct(self): + classes = _exported_parser_classes() + self.assertGreater(len(classes), 0, "no exported parser classes found") + + for name, cls in classes: + with self.subTest(parser=name): + try: + self.assertIsNotNone(cls()) + except ImportError as exc: + # Parsers backed by an optional dependency raise a deliberate, + # actionable ImportError when it is absent (e.g. DoclingParser + # without `docling`). That is correct behavior, not a defect. + self.assertIn( + "install", + str(exc).lower(), + f"{name} raised ImportError without install guidance: {exc}", + ) + + +if __name__ == "__main__": + unittest.main() From c53ca4e84bd183fcf3aa708b56dadb86ffa88901 Mon Sep 17 00:00:00 2001 From: Mohd Kaif <98801504+KaifAhmad1@users.noreply.github.com> Date: Sun, 16 Aug 2026 15:18:55 +0530 Subject: [PATCH 057/105] docs: formalize issue assignment and duplicate-PR triage workflow (#1030) * docs(contributing): formalize issue assignment and duplicate-PR triage workflow Comments are no longer required before an issue can be assigned - maintainers may assign directly based on recent activity. Also documents the duplicate-PR priority order for triage (contributor PR, claimed issue, activity tiebreak, late duplicates, overlapping scope). * docs(contributing): clarify assignment precedence and define activity tiebreak Addresses Qodo review feedback on PR #1030: the duplicate-PR priority list now states these rules apply on top of the assignment workflow (opening a PR pre-assignment doesn't grant priority), and the "most active" tiebreak now specifies a concrete 60-day window and signals instead of being subjective. --- .github/pull_request_template.md | 2 +- CONTRIBUTING.md | 20 +++++++++++++++++--- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 66f38cd9..b4d34e46 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,4 +1,4 @@ -> **Before you submit:** make sure you followed the [issue workflow in CONTRIBUTING.md](https://github.com/semantica-agi/semantica/blob/main/CONTRIBUTING.md#-working-on-an-existing-issue) — comment on the issue and wait for assignment before opening a PR, to avoid duplicate work. +> **Before you submit:** make sure you followed the [issue workflow in CONTRIBUTING.md](https://github.com/semantica-agi/semantica/blob/main/CONTRIBUTING.md#-working-on-an-existing-issue) — wait for the issue to be assigned to you before opening a PR, to avoid duplicate work. ## Description diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3edcc239..8a6c5c44 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -25,9 +25,9 @@ If you want to work on an open GitHub issue, please follow these steps to keep t 1. **Check the issue.** Look at the issue's assignees and recent comments. If someone is already actively working on it, consider a different issue or ask in the comments whether help is welcome. -2. **Comment before you start.** Leave a comment on the issue saying you'd like to work on it — something like *"I'd like to take this on"* is enough. This gives maintainers the context they need to assign the issue appropriately. +2. **Comment if you'd like the issue reserved.** Leaving a comment like *"I'd like to take this on"* is the fastest way to get assigned, but it isn't required — maintainers can also assign an issue directly to a contributor (e.g., based on recent activity in the repo) without waiting for a comment first. -3. **Wait for assignment.** A maintainer will review the request and assign the issue when appropriate. Please wait for this before investing significant time in implementation, as priorities and approaches can shift. +3. **Wait for assignment.** A maintainer will assign the issue when appropriate, whether or not a comment was left. Please wait for this before investing significant time in implementation, as priorities and approaches can shift. 4. **Create a branch and implement.** Once assigned, fork the repository (if you haven't already), create a dedicated branch, and begin your work. @@ -37,12 +37,26 @@ If you want to work on an open GitHub issue, please follow these steps to keep t 5. **Open a focused PR and link the issue.** When you're ready, open a pull request and reference the issue in the description (e.g., `Closes #123`). Keep the PR scoped to the work described in the issue. -> **Why this matters:** Commenting before opening a PR helps maintainers track who is working on what, assign issues correctly, and prevent two contributors from solving the same problem independently. It also gives you a chance to align on the expected approach before writing code. +> **Why this matters:** Assignment (with or without a comment) helps maintainers track who is working on what and prevent two contributors from solving the same problem independently. It also gives you a chance to align on the expected approach before writing code. Not sure where to start? Try a [`good first issue`](https://github.com/semantica-agi/semantica/labels/good%20first%20issue) or ask in [Discord](https://discord.gg/sV34vps5hH). --- +## 🔀 Duplicate PRs & Issue Priority + +When more than one pull request targets the same issue, maintainers triage using this order of priority. These rules decide between PRs that are otherwise following the [assignment workflow above](#-working-on-an-existing-issue) — opening a PR before being assigned doesn't grant priority on its own, and an unassigned PR can still be closed as a duplicate once someone else is assigned to the issue. + +1. **Contributor-raised issue with an existing PR.** If the person who opened the issue has also opened a PR for it, that PR is prioritized (they still need to be assigned before it's merged). +2. **Maintainer-raised issue with a claim comment.** If we opened the issue and someone has commented asking to work on it, we assign it to them and check their PR before picking up any other PR for the same issue. +3. **No prior assignment or comment.** If multiple PRs exist and no one was assigned or claimed the issue first, priority goes to whichever contributor has the most consistent activity in the repo over the last 60 days (e.g., merged PRs, substantive reviews, or issue triage participation) — not just PR volume. +4. **Late duplicate PRs.** If a PR is opened after another contributor has already been assigned to the issue, we close the duplicate early rather than let it sit open, and point the author to another open issue (or ask them to check `main` for newly opened ones). This avoids contributors spending time updating a PR that won't be merged. +5. **Overlapping scope.** If a PR covers multiple issues, or there's genuine overlap between competing PRs, maintainers discuss it on [Discord](https://discord.gg/sV34vps5hH) before deciding rather than resolving it unilaterally. + +**Why this matters:** it keeps triage predictable, avoids wasted contributor effort on PRs that won't merge, and helps retain active contributors. + +--- + ## 🎯 Ways to Contribute ### 💻 Code From 70aa9d01bf6cf9dac735b344f4a7354fad700b8b Mon Sep 17 00:00:00 2001 From: hari Date: Sun, 16 Aug 2026 15:24:34 +0530 Subject: [PATCH 058/105] fix(normalize): validate symbol currencies (#940) * fix(normalize): validate symbol currencies Signed-off-by: Mr-Neutr0n * fix(normalize): match currency codes by token boundaries Signed-off-by: Mr-Neutr0n --------- Signed-off-by: Mr-Neutr0n Co-authored-by: Zohaib Hassnain <109234410+ZohaibHassan16@users.noreply.github.com> --- semantica/normalize/number_normalizer.py | 17 ++++++++++++----- tests/normalize/test_number_normalizer.py | 18 ++++++++++++++++++ 2 files changed, 30 insertions(+), 5 deletions(-) diff --git a/semantica/normalize/number_normalizer.py b/semantica/normalize/number_normalizer.py index f7876b7c..29911cb9 100644 --- a/semantica/normalize/number_normalizer.py +++ b/semantica/normalize/number_normalizer.py @@ -562,6 +562,11 @@ class CurrencyNormalizer: "SEK", "NOK", "DKK", + "RUB", + "KRW", + "ILS", + "NGN", + "PKR", ] self.logger.debug("Currency normalizer initialized") @@ -606,13 +611,15 @@ class CurrencyNormalizer: # Check for currency code if not currency_code: for code in self.currency_codes: - if code in currency_input.upper(): + match = re.search( + rf"(? Date: Sun, 16 Aug 2026 17:44:02 +0530 Subject: [PATCH 059/105] docs: clarify explainability is system-level, not foundation-model internal (#1033) Adds a consistent scope note to README and docs (concepts, FAQ, index) stating Semantica does not expose or reconstruct an LLM's internal reasoning/chain-of-thought. It explains and audits the AI system around the model: context, provenance, policies, decisions, and execution history. --- README.md | 2 ++ docs/concepts.md | 3 +++ docs/faq.md | 10 ++++++++++ docs/index.md | 6 +++++- 4 files changed, 20 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index fe3272ea..58afaf3f 100644 --- a/README.md +++ b/README.md @@ -1498,6 +1498,8 @@ Semantica is designed for environments where AI outputs must be explainable, aud - **Cybersecurity:** Threat attribution, incident response timelines, and IOC provenance tracking - **Autonomous Systems:** Decision logs, safety validation, and explainable AI for certification +> ⚠️ **This is system-level explainability, not foundation-model explainability.** Semantica does not expose, reconstruct, or explain what happens *inside* the LLM/foundation model — its internal reasoning or chain-of-thought stays opaque, as it does for any external system. What Semantica explains is *outside* the model: the context and data fed in, the decision produced, its provenance, the relevant relationships, the policies applied, and the full execution trail. In short, Semantica explains and audits what the AI system did, not the LLM's private internal reasoning. + --- ## Installation diff --git a/docs/concepts.md b/docs/concepts.md index 698ef7d9..e05564f5 100644 --- a/docs/concepts.md +++ b/docs/concepts.md @@ -16,6 +16,9 @@ At its core, Semantica adds a **context and accountability layer** on top of you - **Accountability Layer** — Provenance tracking, decision intelligence, conflict detection, and W3C PROV-O compliance make every claim in your AI stack auditable and explainable. - **Extension Layer** — `PluginRegistry` and `MethodRegistry` let you replace or augment any component: ingestors, extractors, reasoning engines, backends: without changing framework code. + + **This is system-level explainability, not foundation-model explainability.** Semantica does not expose, reconstruct, or explain what happens *inside* the LLM/foundation model — its internal reasoning or chain-of-thought stays opaque, as it does for any external system. What Semantica explains is *outside* the model: the context and data fed in, the decision produced, its provenance, the relevant relationships, the policies applied, and the full execution trail. In short, Semantica explains and audits *what the AI system did*, not the foundation model's private internal reasoning. + ## Knowledge Graphs diff --git a/docs/faq.md b/docs/faq.md index e050df4c..05c708c1 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -52,6 +52,16 @@ Semantica works alongside these frameworks, not against them. + + +No. This is **system-level explainability, not foundation-model explainability**. Semantica does not expose, reconstruct, or explain what happens *inside* the LLM/foundation model — its internal reasoning or chain-of-thought stays opaque, as it does for any external system. + +What Semantica explains is *outside* the model: what context and data were used, what decision was produced, the provenance behind it, the relevant relationships, the policies applied, and the resulting decision trail. + +In short: Semantica explains and audits *what the AI system did* — not the foundation model's private internal reasoning. + + + Yes: MIT licensed, no vendor lock-in, no paywalled features. Some capabilities require third-party API keys (e.g., OpenAI embeddings, Groq inference), but Semantica itself is always free and open source. diff --git a/docs/index.md b/docs/index.md index 80ce7b12..a16d0107 100644 --- a/docs/index.md +++ b/docs/index.md @@ -192,7 +192,11 @@ decision_id = context.record_decision( ## Built for Where Mistakes Have Consequences -Semantica was designed for domains where every decision must be explainable and every fact must be traceable: +Semantica was designed for domains where every decision must be explainable and every fact must be traceable. + + + **This is system-level explainability, not foundation-model explainability.** Semantica does not expose, reconstruct, or explain what happens *inside* the LLM/foundation model — its internal reasoning or chain-of-thought stays opaque, as it does for any external system. What Semantica explains is *outside* the model: the context and data fed in, the decision produced, its provenance, the relevant relationships, the policies applied, and the full execution trail. See [Core Concepts](concepts) for the full scope note. + **Healthcare & Life Sciences** - Clinical decision support with full audit trails From 4d37920007e75289fe80a40651250cb6aa27cc10 Mon Sep 17 00:00:00 2001 From: Mohd Kaif <98801504+KaifAhmad1@users.noreply.github.com> Date: Sun, 16 Aug 2026 17:51:23 +0530 Subject: [PATCH 060/105] docs: surface explainability scope note near the top of the README (#1034) Moves a concise version of the system-level vs. foundation-model explainability clarification up next to the opening pitch, so it's visible before readers scroll to the high-stakes-domains section. --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 58afaf3f..8b89fd5f 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,8 @@ Most AI agents act without a trail. They store embeddings, not meaning: context Semantica sits underneath your LLM, vector store, and agent framework as a deterministic infrastructure layer: no LLM required for graph construction, reasoning, or provenance. +> ⚠️ **System-level explainability, not foundation-model explainability.** Semantica does not expose or reconstruct what happens *inside* the LLM — its internal reasoning or chain-of-thought stays opaque, as it does for any external system. Semantica explains what's *outside* the model: the context and data fed in, the decision produced, its provenance, relevant relationships, applied policies, and the full execution trail. + **Who it's for:** - **AI/ML platform teams** shipping agents that make consequential decisions and need structured, queryable context built from fragmented raw data, not just a vector index From b8297b8077a2b2417e646816d802de609f3d2dc3 Mon Sep 17 00:00:00 2001 From: Kyou0203 Date: Mon, 17 Aug 2026 01:30:45 +0800 Subject: [PATCH 061/105] docs(explorer): update stale authentication notes after v0.6.5 The Explorer API has required SEMANTICA_API_KEY (X-API-Key header) since v0.6.5, failing closed with 503 when unconfigured. Both the explorer README security note and docs/explorer-setup.md still claimed there was no built-in authentication. Update both to describe the actual behavior: API-key enforcement, the 503 fail-closed mode, and the explicit SEMANTICA_ALLOW_ANONYMOUS=true opt-in for local development. Fixes #1028 --- docs/explorer-setup.md | 2 +- explorer/README.md | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/explorer-setup.md b/docs/explorer-setup.md index f023730e..4d8caa1f 100644 --- a/docs/explorer-setup.md +++ b/docs/explorer-setup.md @@ -162,7 +162,7 @@ semantica-explorer --graph my_graph.json --no-browser ``` - `--host 0.0.0.0` makes Explorer reachable on every network interface. The server has no built-in authentication. Only use this on a trusted private network. + `--host 0.0.0.0` makes Explorer reachable on every network interface. Since v0.6.5 the Explorer API requires `SEMANTICA_API_KEY` (sent as the `X-API-Key` header) and fails closed with `503` when unconfigured; unauthenticated access is only possible when `SEMANTICA_ALLOW_ANONYMOUS=true` is set explicitly. Only use this on a trusted private network. diff --git a/explorer/README.md b/explorer/README.md index 3884aaee..3f5c313e 100644 --- a/explorer/README.md +++ b/explorer/README.md @@ -63,7 +63,9 @@ semantica-explorer --graph my_graph.json --no-browser python -m semantica.explorer --graph my_graph.json ``` -> **Security note:** The Explorer API has no built-in authentication. The default `--host 127.0.0.1` binds to localhost only, so it is not reachable from other machines on your network. If you bind to `0.0.0.0`, all graph data is readable and writable by any host that can reach the port. The CLI will print a warning in that case. +> **Security note:** Since v0.6.5 the Explorer API requires an API key. Set the `SEMANTICA_API_KEY` environment variable and send it as the `X-API-Key` header on every request; without a configured key, protected routes fail closed with `503` rather than serving anonymously. To opt into unauthenticated access for local development only, set `SEMANTICA_ALLOW_ANONYMOUS=true` explicitly. +> +> The default `--host 127.0.0.1` binds to localhost only, so it is not reachable from other machines on your network. If you bind to `0.0.0.0`, all graph data is readable and writable by any host that can reach the port (subject to API-key auth); the CLI will print a warning in that case. --- From 893b6db3c3d2abf2c0656baeb3549fb24a840f13 Mon Sep 17 00:00:00 2001 From: Accute9 Date: Sun, 16 Aug 2026 16:07:45 -0400 Subject: [PATCH 062/105] regression tests added and tested for routing spaCy model loads through cache --- tests/split/test_spacy_model_cache.py | 169 ++++++++++++++++++++++++++ 1 file changed, 169 insertions(+) create mode 100644 tests/split/test_spacy_model_cache.py diff --git a/tests/split/test_spacy_model_cache.py b/tests/split/test_spacy_model_cache.py new file mode 100644 index 00000000..d3b27148 --- /dev/null +++ b/tests/split/test_spacy_model_cache.py @@ -0,0 +1,169 @@ +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest + +from semantica.semantic_extract import methods as se_methods +from semantica.split import methods as split_methods +from semantica.split import semantic_chunker + + +@pytest.fixture(autouse=True) +def clear_cache(): + se_methods.clear_spacy_model_cache() + yield + se_methods.clear_spacy_model_cache() + + +@pytest.fixture(autouse=True) +def force_spacy_available(monkeypatch): + # split.methods and split.semantic_chunker each compute their own + # SPACY_AVAILABLE flag from the real environment at import time; force + # both true so these tests exercise the spaCy branch regardless of + # whether spaCy is actually installed where they run. + monkeypatch.setattr(split_methods, "SPACY_AVAILABLE", True) + monkeypatch.setattr(semantic_chunker, "SPACY_AVAILABLE", True) + + +def _fake_spacy(load): + return SimpleNamespace(load=load, util=SimpleNamespace(is_package=lambda _name: True)) + + +def _nlp_mock(sentences=("Hello world.",)): + """A stand-in spaCy Language object: callable, returns a doc with .sents.""" + nlp = MagicMock() + nlp.return_value = SimpleNamespace( + sents=[SimpleNamespace(text=s) for s in sentences] + ) + return nlp + + +class TestSpacyModelCache: + """split.methods and split.semantic_chunker must share the cached model + defined in semantic_extract.methods instead of each calling spacy.load() + independently. + """ + + def test_split_by_sentences_reuses_cached_model(self, monkeypatch): + calls = [] + + def fake_load(name, **kwargs): + calls.append((name, kwargs)) + return _nlp_mock() + + monkeypatch.setattr(se_methods, "spacy", _fake_spacy(fake_load)) + + split_methods.split_by_sentences("Hello world. Bye world.") + split_methods.split_by_sentences("Another sentence here.") + split_methods.split_by_sentences("A third call.") + + assert len(calls) == 1, "spacy.load should run once, not once per call" + assert calls[0][0] == "en_core_web_sm" + + def test_semantic_chunker_reuses_cached_model_across_instances(self, monkeypatch): + calls = [] + + def fake_load(name, **kwargs): + calls.append((name, kwargs)) + return _nlp_mock() + + monkeypatch.setattr(se_methods, "spacy", _fake_spacy(fake_load)) + + chunker1 = semantic_chunker.SemanticChunker() + chunker2 = semantic_chunker.SemanticChunker() + + assert len(calls) == 1, "each new SemanticChunker should not reload the model" + assert chunker1.nlp is chunker2.nlp + + def test_split_methods_and_semantic_chunker_share_the_cache(self, monkeypatch): + calls = [] + + def fake_load(name, **kwargs): + calls.append((name, kwargs)) + return _nlp_mock() + + monkeypatch.setattr(se_methods, "spacy", _fake_spacy(fake_load)) + + split_methods.split_by_sentences("Test sentence for split.methods.") + semantic_chunker.SemanticChunker() + + assert len(calls) == 1, ( + "split.methods and split.semantic_chunker must share one cached " + "model instead of each loading their own" + ) + + def test_distinct_model_names_load_separately(self, monkeypatch): + calls = [] + + def fake_load(name, **kwargs): + calls.append((name, kwargs)) + return _nlp_mock() + + monkeypatch.setattr(se_methods, "spacy", _fake_spacy(fake_load)) + + sm_chunker = semantic_chunker.SemanticChunker(model="en_core_web_sm") + lg_chunker = semantic_chunker.SemanticChunker(model="en_core_web_lg") + sm_chunker_again = semantic_chunker.SemanticChunker(model="en_core_web_sm") + + assert [name for name, _ in calls] == ["en_core_web_sm", "en_core_web_lg"] + assert sm_chunker.nlp is sm_chunker_again.nlp + assert sm_chunker.nlp is not lg_chunker.nlp + + def test_no_disable_kwarg_requested(self, monkeypatch): + """split.methods and split.semantic_chunker both want the full + pipeline (they need .sents, which requires the parser/senter). If + either one later starts requesting a trimmed pipeline (e.g. + disable=["ner"]), the name-only cache key in load_spacy_model would + silently hand back a cached model built for a different config -- + this test should catch that the moment it happens. + """ + calls = [] + + def fake_load(_name, **kwargs): + calls.append(kwargs) + return _nlp_mock() + + monkeypatch.setattr(se_methods, "spacy", _fake_spacy(fake_load)) + + split_methods.split_by_sentences("Hello world.") + se_methods.clear_spacy_model_cache() + semantic_chunker.SemanticChunker() + + assert calls == [{}, {}], "neither caller should request a partial pipeline" + + def test_missing_model_falls_back_without_poisoning_cache(self, monkeypatch): + attempts = [] + + def failing_load(name, **_kwargs): + attempts.append(name) + raise OSError(f"Can't find model '{name}'") + + monkeypatch.setattr(se_methods, "spacy", _fake_spacy(failing_load)) + + # split_by_sentences should fall back to regex splitting, not raise + chunks = split_methods.split_by_sentences("Hello world. Bye world.") + assert chunks, "fallback splitting should still produce chunks" + + # SemanticChunker should leave .nlp as None rather than propagate + chunker = semantic_chunker.SemanticChunker() + assert chunker.nlp is None + + assert len(attempts) == 2, "a failed load must not be cached" + + # Once the model is available, both callers should now get it, and + # share a single successful load. + def working_load(name, **_kwargs): + attempts.append(name) + return _nlp_mock() + + monkeypatch.setattr(se_methods, "spacy", _fake_spacy(working_load)) + + chunker2 = semantic_chunker.SemanticChunker() + split_methods.split_by_sentences("One more sentence.") + + assert len(attempts) == 3, "the model should load once after it becomes available" + assert chunker2.nlp is not None + + +if __name__ == "__main__": + pytest.main([__file__]) From 0b77e5fe9476fab239f5ec2705f939456bb1d2cb Mon Sep 17 00:00:00 2001 From: Aneesh Mandapati <93799543+Accute9@users.noreply.github.com> Date: Sun, 16 Aug 2026 20:31:06 -0400 Subject: [PATCH 063/105] Refactor for flake8 max line length (88) issue Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- tests/split/test_spacy_model_cache.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/split/test_spacy_model_cache.py b/tests/split/test_spacy_model_cache.py index d3b27148..551b82f9 100644 --- a/tests/split/test_spacy_model_cache.py +++ b/tests/split/test_spacy_model_cache.py @@ -26,7 +26,10 @@ def force_spacy_available(monkeypatch): def _fake_spacy(load): - return SimpleNamespace(load=load, util=SimpleNamespace(is_package=lambda _name: True)) + return SimpleNamespace( + load=load, + util=SimpleNamespace(is_package=lambda _name: True), + ) def _nlp_mock(sentences=("Hello world.",)): From 0f252ab355b60df015937cb75b51484c2f90bc34 Mon Sep 17 00:00:00 2001 From: Accute9 Date: Sun, 16 Aug 2026 21:04:57 -0400 Subject: [PATCH 064/105] Fixed max line length (88) issues and eager imports --- semantica/split/methods.py | 4 ++-- semantica/split/semantic_chunker.py | 4 ++-- tests/split/test_spacy_model_cache.py | 16 +++++++++++++--- 3 files changed, 17 insertions(+), 7 deletions(-) diff --git a/semantica/split/methods.py b/semantica/split/methods.py index f2b3f525..61b67ee0 100644 --- a/semantica/split/methods.py +++ b/semantica/split/methods.py @@ -93,12 +93,11 @@ from ..utils.exceptions import ProcessingError from ..utils.helpers import safe_import from ..utils.logging import get_logger from .semantic_chunker import Chunk -from ..semantic_extract.methods import load_spacy_model logger = get_logger("split_methods") # Try to import optional dependencies -spacy, SPACY_AVAILABLE = safe_import("spacy") +_, SPACY_AVAILABLE = safe_import("spacy") nltk, NLTK_AVAILABLE = safe_import("nltk") tiktoken, TIKTOKEN_AVAILABLE = safe_import("tiktoken") @@ -337,6 +336,7 @@ def split_by_sentences( # Try spaCy first if SPACY_AVAILABLE and kwargs.get("use_spacy", True): try: + from ..semantic_extract.methods import load_spacy_model nlp = load_spacy_model("en_core_web_sm") doc = nlp(text) sentences = [sent.text for sent in doc.sents] diff --git a/semantica/split/semantic_chunker.py b/semantica/split/semantic_chunker.py index d7f72726..2945bbd5 100644 --- a/semantica/split/semantic_chunker.py +++ b/semantica/split/semantic_chunker.py @@ -35,10 +35,9 @@ from ..utils.exceptions import ProcessingError from ..utils.helpers import safe_import from ..utils.logging import get_logger from ..utils.progress_tracker import get_progress_tracker -from ..semantic_extract.methods import load_spacy_model -spacy, SPACY_AVAILABLE = safe_import("spacy") +_, SPACY_AVAILABLE = safe_import("spacy") @dataclass @@ -81,6 +80,7 @@ class SemanticChunker: if SPACY_AVAILABLE: model_name = config.get("model", "en_core_web_sm") try: + from ..semantic_extract.methods import load_spacy_model self.nlp = load_spacy_model(model_name) except OSError: self.logger.warning( diff --git a/tests/split/test_spacy_model_cache.py b/tests/split/test_spacy_model_cache.py index d3b27148..97a4ad87 100644 --- a/tests/split/test_spacy_model_cache.py +++ b/tests/split/test_spacy_model_cache.py @@ -26,7 +26,12 @@ def force_spacy_available(monkeypatch): def _fake_spacy(load): - return SimpleNamespace(load=load, util=SimpleNamespace(is_package=lambda _name: True)) + return SimpleNamespace( + load=load, + util=SimpleNamespace( + is_package=lambda _name: True + ), + ) def _nlp_mock(sentences=("Hello world.",)): @@ -129,7 +134,10 @@ class TestSpacyModelCache: se_methods.clear_spacy_model_cache() semantic_chunker.SemanticChunker() - assert calls == [{}, {}], "neither caller should request a partial pipeline" + assert len(calls) == 2 + assert all("disable" not in kwargs for kwargs in calls), ( + "neither caller should request a partial pipeline" + ) def test_missing_model_falls_back_without_poisoning_cache(self, monkeypatch): attempts = [] @@ -161,7 +169,9 @@ class TestSpacyModelCache: chunker2 = semantic_chunker.SemanticChunker() split_methods.split_by_sentences("One more sentence.") - assert len(attempts) == 3, "the model should load once after it becomes available" + assert len(attempts) == 3, ( + "the model should load once after it becomes available" + ) assert chunker2.nlp is not None From c7415f2e92434c65246d564184292064f9c42224 Mon Sep 17 00:00:00 2001 From: Sameer6305 Date: Mon, 17 Aug 2026 12:40:10 +0530 Subject: [PATCH 065/105] fix: complete spaCy model cache integration --- semantica/semantic_extract/ner_extractor.py | 7 +- tests/split/test_spacy_model_cache.py | 138 +++++++++++++++++++- tests/split/test_splitter.py | 16 +-- tests/test_ner_configurations.py | 29 ++-- 4 files changed, 166 insertions(+), 24 deletions(-) diff --git a/semantica/semantic_extract/ner_extractor.py b/semantica/semantic_extract/ner_extractor.py index e8b57bcd..a920efe1 100644 --- a/semantica/semantic_extract/ner_extractor.py +++ b/semantica/semantic_extract/ner_extractor.py @@ -144,7 +144,12 @@ class NERExtractor: self._ml_runtime_usable = True if "ml" in self.method and SPACY_AVAILABLE: try: - self.nlp = spacy.load(self.model_name) + # Deferred import: keeps semantic_extract.methods out of the + # module-level import graph and routes loading through the + # process-level cache so repeated NERExtractor constructions + # never pay the ~120 ms spacy.load() cost more than once. + from .methods import load_spacy_model + self.nlp = load_spacy_model(self.model_name) except OSError: self.logger.warning( f"spaCy model {self.model_name} not found. ML method will fallback." diff --git a/tests/split/test_spacy_model_cache.py b/tests/split/test_spacy_model_cache.py index d8f38117..de21e433 100644 --- a/tests/split/test_spacy_model_cache.py +++ b/tests/split/test_spacy_model_cache.py @@ -6,6 +6,8 @@ import pytest from semantica.semantic_extract import methods as se_methods from semantica.split import methods as split_methods from semantica.split import semantic_chunker +from semantica.semantic_extract import ner_extractor as ner_extractor_module +from semantica.semantic_extract.ner_extractor import NERExtractor @pytest.fixture(autouse=True) @@ -17,12 +19,13 @@ def clear_cache(): @pytest.fixture(autouse=True) def force_spacy_available(monkeypatch): - # split.methods and split.semantic_chunker each compute their own - # SPACY_AVAILABLE flag from the real environment at import time; force - # both true so these tests exercise the spaCy branch regardless of + # split.methods, split.semantic_chunker, and ner_extractor each compute + # their own SPACY_AVAILABLE flag from the real environment at import time; + # force all true so these tests exercise the spaCy branch regardless of # whether spaCy is actually installed where they run. monkeypatch.setattr(split_methods, "SPACY_AVAILABLE", True) monkeypatch.setattr(semantic_chunker, "SPACY_AVAILABLE", True) + monkeypatch.setattr(ner_extractor_module, "SPACY_AVAILABLE", True) def _fake_spacy(load): @@ -133,9 +136,11 @@ class TestSpacyModelCache: semantic_chunker.SemanticChunker() assert len(calls) == 2 - assert all("disable" not in kwargs for kwargs in calls), ( - "neither caller should request a partial pipeline" - ) + assert all(kwargs == {} for kwargs in calls), ( + "neither caller should pass any pipeline-configuration kwargs; " + "the name-only cache key in load_spacy_model cannot distinguish " + "models loaded with different component configs" + ) def test_missing_model_falls_back_without_poisoning_cache(self, monkeypatch): attempts = [] @@ -173,5 +178,126 @@ class TestSpacyModelCache: assert chunker2.nlp is not None +class TestNERExtractorSpacyModelCache: + """NERExtractor(method="ml") must reuse the centralized cache in + semantic_extract.methods, not call spacy.load() on every construction. + + These tests mirror TestSpacyModelCache but focus on the NERExtractor path, + confirming that all three callers (split_by_sentences, SemanticChunker, and + NERExtractor) draw from the same process-level cache. + """ + + def test_ner_extractor_reuses_cached_model_across_instances(self, monkeypatch): + """Two NERExtractor(method='ml') constructions with the same model name + must cause exactly one underlying spacy.load() call.""" + calls = [] + + def fake_load(name, **kwargs): + calls.append(name) + return _nlp_mock() + + monkeypatch.setattr(se_methods, "spacy", _fake_spacy(fake_load)) + + e1 = NERExtractor(method="ml") + e2 = NERExtractor(method="ml") + e3 = NERExtractor(method="ml", model="en_core_web_sm") + + assert len(calls) == 1, ( + "repeated NERExtractor constructions should not reload the model" + ) + assert e1.nlp is e2.nlp is e3.nlp + + def test_ner_extractor_and_split_callers_share_one_cached_model(self, monkeypatch): + """NERExtractor, SemanticChunker, and split_by_sentences must all use + the same cached Language object for the same model name.""" + calls = [] + + def fake_load(name, **kwargs): + calls.append(name) + return _nlp_mock() + + monkeypatch.setattr(se_methods, "spacy", _fake_spacy(fake_load)) + + split_methods.split_by_sentences("First sentence.") + semantic_chunker.SemanticChunker() + NERExtractor(method="ml") + + assert len(calls) == 1, ( + "split_by_sentences, SemanticChunker, and NERExtractor must share " + "one cached model instead of each loading their own" + ) + + def test_ner_extractor_distinct_model_names_load_separately(self, monkeypatch): + """Different model names must produce separate cache entries.""" + calls = [] + + def fake_load(name, **kwargs): + calls.append(name) + return _nlp_mock() + + monkeypatch.setattr(se_methods, "spacy", _fake_spacy(fake_load)) + + sm = NERExtractor(method="ml", model="en_core_web_sm") + lg = NERExtractor(method="ml", model="en_core_web_lg") + sm_again = NERExtractor(method="ml", model="en_core_web_sm") + + assert calls == ["en_core_web_sm", "en_core_web_lg"] + assert sm.nlp is sm_again.nlp + assert sm.nlp is not lg.nlp + + def test_ner_extractor_failed_load_not_cached_and_retried(self, monkeypatch): + """A missing model must not poison the cache. A subsequent construction + after the model becomes available must succeed and share the loaded model.""" + attempts = [] + + def failing_load(name, **_kwargs): + attempts.append(name) + raise OSError(f"Can't find model '{name}'") + + monkeypatch.setattr(se_methods, "spacy", _fake_spacy(failing_load)) + + # Construction with missing model: nlp must remain None, no crash + extractor1 = NERExtractor(method="ml") + assert extractor1.nlp is None + assert len(attempts) == 1, "one load attempt expected for the missing model" + + # Second construction: must retry (cache must not hold the failure) + extractor2 = NERExtractor(method="ml") + assert extractor2.nlp is None + assert len(attempts) == 2, "a failed load must not be cached" + + # Now install a working model and verify recovery + def working_load(name, **_kwargs): + attempts.append(name) + return _nlp_mock() + + monkeypatch.setattr(se_methods, "spacy", _fake_spacy(working_load)) + + extractor3 = NERExtractor(method="ml") + extractor4 = NERExtractor(method="ml") + + assert extractor3.nlp is not None + assert extractor3.nlp is extractor4.nlp + assert len(attempts) == 3, ( + "exactly one successful load expected after the model becomes available" + ) + + def test_ner_extractor_non_ml_method_does_not_load_model(self, monkeypatch): + """NERExtractor with a non-ml method must not touch the spaCy cache.""" + calls = [] + + def fake_load(name, **kwargs): + calls.append(name) + return _nlp_mock() + + monkeypatch.setattr(se_methods, "spacy", _fake_spacy(fake_load)) + + NERExtractor(method="pattern") + NERExtractor(method="llm") + NERExtractor(method="regex") + + assert calls == [], "non-ml methods must not trigger any spacy.load()" + + if __name__ == "__main__": pytest.main([__file__]) diff --git a/tests/split/test_splitter.py b/tests/split/test_splitter.py index 76cc872f..725b959a 100644 --- a/tests/split/test_splitter.py +++ b/tests/split/test_splitter.py @@ -30,18 +30,16 @@ class TestSplitter(unittest.TestCase): splitter = TextSplitter(method=["recursive", "token"]) self.assertEqual(splitter.methods, ["recursive", "token"]) - @patch('semantica.split.semantic_chunker.spacy') + @patch('semantica.semantic_extract.methods.spacy') def test_semantic_chunker_initialization(self, mock_spacy): - # Mock spacy.load to return a mock nlp object + # SemanticChunker now loads spaCy through the centralized + # load_spacy_model() in semantic_extract.methods, so we patch + # methods.spacy rather than the removed semantic_chunker.spacy binding. mock_nlp = MagicMock() mock_spacy.load.return_value = mock_nlp - - # We need to ensure SPACY_AVAILABLE is True for this test context if possible, - # but it is imported at module level. - # If spacy is not installed, it sets SPACY_AVAILABLE = False. - # We might need to patch the module attribute or just test fallback if spacy missing. - - chunker = SemanticChunker(chunk_size=100) + + with patch('semantica.split.semantic_chunker.SPACY_AVAILABLE', True): + chunker = SemanticChunker(chunk_size=100) self.assertEqual(chunker.chunk_size, 100) def test_chunk_dataclass(self): diff --git a/tests/test_ner_configurations.py b/tests/test_ner_configurations.py index 2fead463..15c2568a 100644 --- a/tests/test_ner_configurations.py +++ b/tests/test_ner_configurations.py @@ -101,9 +101,15 @@ class TestNERConfigurations(unittest.TestCase): self.assertEqual(entities[0].metadata["extraction_method"], "ml") self.assertEqual(entities[0].metadata["model"], "en_core_web_trf") - @patch('semantica.semantic_extract.ner_extractor.spacy') + @patch('semantica.semantic_extract.methods.spacy') def test_ner_ml_init_falls_back_when_spacy_runtime_is_broken(self, mock_spacy): - """Test NER init does not crash when spaCy is installed but unusable at runtime.""" + """Test NER init does not crash when spaCy is installed but unusable at runtime. + + The model load now goes through load_spacy_model() in semantic_extract.methods, + so we patch methods.spacy (not ner_extractor.spacy) to inject the failure. + """ + from semantica.semantic_extract.methods import clear_spacy_model_cache + clear_spacy_model_cache() mock_spacy.load.side_effect = RuntimeError("ConfigSchemaNlp is not fully defined") with patch('semantica.semantic_extract.ner_extractor.SPACY_AVAILABLE', True): @@ -112,17 +118,23 @@ class TestNERConfigurations(unittest.TestCase): self.assertIsNone(extractor.nlp) self.assertFalse(extractor._ml_runtime_usable) - @patch('semantica.semantic_extract.ner_extractor.spacy') @patch('semantica.semantic_extract.methods.get_entity_method') @patch('semantica.semantic_extract.methods.spacy') def test_ner_ml_runtime_failure_disables_repeated_ml_load_attempts( self, mock_methods_spacy, mock_get_method, - mock_init_spacy, ): - """Test degraded ML mode skips repeated spaCy load attempts after init failure.""" - mock_init_spacy.load.side_effect = RuntimeError("ConfigSchemaNlp is not fully defined") + """Test degraded ML mode skips repeated spaCy load attempts after init failure. + + The model load at construction time now goes through load_spacy_model() in + semantic_extract.methods, so methods.spacy is the single mock target for the + init-time failure. After the RuntimeError is raised, _ml_runtime_usable is + False and no further spacy.load (or extract_entities_ml) calls are made. + """ + from semantica.semantic_extract.methods import clear_spacy_model_cache + clear_spacy_model_cache() + mock_methods_spacy.load.side_effect = RuntimeError("ConfigSchemaNlp is not fully defined") mock_ml_method = MagicMock(return_value=[]) mock_get_method.side_effect = lambda name: mock_ml_method if name == "ml" else (lambda *_args, **_kwargs: []) @@ -132,8 +144,9 @@ class TestNERConfigurations(unittest.TestCase): entities = extractor.extract_entities(self.text) self.assertFalse(extractor._ml_runtime_usable) - self.assertEqual(mock_init_spacy.load.call_count, 1) - self.assertEqual(mock_methods_spacy.load.call_count, 0) + # methods.spacy.load called once during __init__ (the RuntimeError); not again + # during extract_entities because _filter_unusable_methods removes "ml". + self.assertEqual(mock_methods_spacy.load.call_count, 1) self.assertEqual(mock_ml_method.call_count, 0) self.assertIsInstance(entities, list) From a8194dfc60a17de99e926f153bc7c8fa3f3a8598 Mon Sep 17 00:00:00 2001 From: KaifAhmad1 Date: Mon, 17 Aug 2026 13:16:17 +0530 Subject: [PATCH 066/105] fix(split): catch broken-runtime spaCy failures in SemanticChunker SemanticChunker.__init__ only caught OSError around load_spacy_model(), while NERExtractor's identical call (fixed earlier in this PR) also catches generic Exception for a model that is installed but fails at runtime. Bring SemanticChunker in line so a broken spaCy config degrades to fallback chunking instead of crashing __init__. Adds a regression test mirroring the existing NERExtractor case, and a CHANGELOG entry for #998/#1042. --- CHANGELOG.md | 8 ++++++++ semantica/split/semantic_chunker.py | 7 +++++++ tests/split/test_spacy_model_cache.py | 18 ++++++++++++++++++ 3 files changed, 33 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 78212679..f670cb22 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -73,6 +73,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **`split`/chunking paths bypassed the centralized spaCy model cache, reloading the model on every call** (#1042, closes #998) by @Accute9, reviewed by @Sameer6305 + - `semantica/split/methods.py`'s `split_by_sentences()` and `semantica/split/semantic_chunker.py`'s `SemanticChunker.__init__` each called `spacy.load()` directly instead of reusing the process-level cache added in #889/`semantic_extract/methods.py`'s `load_spacy_model()` — every call/construction re-paid the ~120ms model-load cost independently of `NERExtractor`, which already used the cache + - Both now route through `load_spacy_model()`, sharing one cached `Language` instance per model name across `split_by_sentences()`, `SemanticChunker`, and `NERExtractor`; a missing model still falls back to regex/paragraph chunking without poisoning the cache for a later successful load + - **Fixed during review** (@Sameer6305): `NERExtractor.__init__()` still had a direct `spacy.load()` call site with the same cache-bypass issue, outside the two files named in #998 but sharing the same root cause; routed through the cache alongside stale test patch targets and a strengthened cache-configuration assertion + - **Fixed during review** (@KaifAhmad1): `SemanticChunker.__init__` only caught `OSError` around `load_spacy_model()`, while the sibling fix to `NERExtractor` in this same PR added a broader `except Exception` for a model that is installed but fails at runtime (e.g. a config incompatible with the installed spaCy version). A broken-but-present model crashed `SemanticChunker()` outright instead of degrading to fallback chunking like every other path in this PR. Added the matching `except Exception` branch, leaving `self.nlp` as `None`; new `test_semantic_chunker_falls_back_when_spacy_runtime_is_broken` mirrors the existing `NERExtractor` regression test for the same scenario + - New `tests/split/test_spacy_model_cache.py`: cache reuse across repeated calls/instances, shared cache between `split_by_sentences()`/`SemanticChunker`/`NERExtractor`, distinct model names loading separately, missing-model fallback without poisoning the cache, and the broken-runtime fallback added above + - `pytest tests/split/test_spacy_model_cache.py tests/split/test_splitter.py tests/split/test_chunkers.py`: all passing (3 pre-existing, unrelated `tests/test_ner_configurations.py` failures confirmed present on `main` before this PR) + - **`export_yaml` raised a raw `AttributeError` on list input, silently wrote empty exports for unrecognized dict keys, and graph payloads were reconciled differently by every exporter** (#958, closes #956, #952, #953) by @pravit-amp, reviewed by @Sameer6305 - Graph payloads circulate under two vocabularies, `entities`/`relationships` and `nodes`/`edges`, and each exporter reconciled them locally with a different idiom — `LPGExporter` in particular dropped every entity whenever `nodes` was present but empty, the exact shape `JSONExporter` emits. A new `normalize_graph_payload()` in `utils/helpers.py` centralizes that decision once, adopted by `LPGExporter`, `ArangoAQLExporter`, `Neo4jCSVExporter`, and both YAML exporters; `ContextGraph.to_dict()` now round-trips through YAML correctly as a result - `export_yaml(records, path)` on a bare list previously failed with `AttributeError` from inside the exporter; it and the other YAML methods now reject non-mapping input with an actionable `ProcessingError` naming the expected keys, since these formats distinguish entities/relationships/triplets and guessing which one a list represents would mislabel the records diff --git a/semantica/split/semantic_chunker.py b/semantica/split/semantic_chunker.py index 2945bbd5..fc6fa6aa 100644 --- a/semantica/split/semantic_chunker.py +++ b/semantica/split/semantic_chunker.py @@ -86,6 +86,13 @@ class SemanticChunker: self.logger.warning( f"spaCy model {model_name} not found. Using fallback chunking." ) + except Exception: + self.logger.warning( + "spaCy model %s failed to initialize and will be disabled " + "for this chunker instance. Using fallback chunking.", + model_name, + exc_info=True, + ) def chunk(self, text: str, **options) -> List[Chunk]: """ diff --git a/tests/split/test_spacy_model_cache.py b/tests/split/test_spacy_model_cache.py index de21e433..00012030 100644 --- a/tests/split/test_spacy_model_cache.py +++ b/tests/split/test_spacy_model_cache.py @@ -177,6 +177,24 @@ class TestSpacyModelCache: ) assert chunker2.nlp is not None + def test_semantic_chunker_falls_back_when_spacy_runtime_is_broken( + self, monkeypatch + ): + """A spaCy model that is installed but unusable at runtime (e.g. a + config incompatible with the installed spaCy version) must degrade + SemanticChunker to fallback chunking, not crash __init__ -- mirrors + TestNERExtractorSpacyModelCache's equivalent broken-runtime test. + """ + + def broken_load(name, **_kwargs): + raise RuntimeError("ConfigSchemaNlp is not fully defined") + + monkeypatch.setattr(se_methods, "spacy", _fake_spacy(broken_load)) + + chunker = semantic_chunker.SemanticChunker() + + assert chunker.nlp is None + class TestNERExtractorSpacyModelCache: """NERExtractor(method="ml") must reuse the centralized cache in From eedf1425cae948d84c5e0fb0a86497995661cffd Mon Sep 17 00:00:00 2001 From: Shahzaib Ahmad Date: Mon, 17 Aug 2026 14:30:12 +0500 Subject: [PATCH 067/105] Fix flatten_dict key collisions (#1062) * Fix flatten_dict key collisions * Fix flatten_dict formatting --------- Co-authored-by: Shahzaib Ahmad --- semantica/utils/helpers.py | 28 ++++++++++++++++++++-------- tests/utils/test_utils.py | 15 +++++++++++++++ 2 files changed, 35 insertions(+), 8 deletions(-) diff --git a/semantica/utils/helpers.py b/semantica/utils/helpers.py index 7462f6db..75031fe8 100644 --- a/semantica/utils/helpers.py +++ b/semantica/utils/helpers.py @@ -398,9 +398,7 @@ def chunk_list(items: List[Any], chunk_size: int) -> List[List[Any]]: Returns: List of chunks """ - return [items[i : i + chunk_size] for i in range(0, len(items), chunk_size)] - - + return [items[i : i + chunk_size] for i in range(0, len(items), chunk_size)] def flatten_dict( d: Dict[str, Any], parent_key: str = "", sep: str = "." ) -> Dict[str, Any]: @@ -414,18 +412,32 @@ def flatten_dict( Returns: Flattened dictionary + + Raises: + ValueError: If two input paths produce the same flattened key. """ - items = [] + result = {} for k, v in d.items(): new_key = f"{parent_key}{sep}{k}" if parent_key else k if isinstance(v, dict): - items.extend(flatten_dict(v, new_key, sep=sep).items()) - else: - items.append((new_key, v)) + nested = flatten_dict(v, new_key, sep=sep) - return dict(items) + for key, value in nested.items(): + if key in result: + raise ValueError( + f"Key collision while flattening dictionary: {key}" + ) + result[key] = value + else: + if new_key in result: + raise ValueError( + f"Key collision while flattening dictionary: {new_key}" + ) + result[new_key] = v + + return result def get_nested_value( diff --git a/tests/utils/test_utils.py b/tests/utils/test_utils.py index 479be1cf..5bbe3be3 100644 --- a/tests/utils/test_utils.py +++ b/tests/utils/test_utils.py @@ -31,6 +31,21 @@ class TestHelpers(unittest.TestCase): dict2 = {"b": {"d": 3}, "e": 4} merged = helpers.merge_dicts(dict1, dict2, deep=True) self.assertEqual(merged, {"a": 1, "b": {"c": 2, "d": 3}, "e": 4}) + def test_flatten_dict(self): + data = {"a": {"b": 1, "c": 2}} + result = helpers.flatten_dict(data) + self.assertEqual(result, {"a.b": 1, "a.c": 2}) + + def test_flatten_dict_key_collision(self): + data = { + "a.b": 1, + "a": { + "b": 2 + } + } + + with self.assertRaises(ValueError): + helpers.flatten_dict(data) def test_safe_import_returns_module_and_flag(self): module, available = helpers.safe_import("json") From 04602a0e0e35d7b353d535c5303d757541901823 Mon Sep 17 00:00:00 2001 From: Sameer Kadam Date: Mon, 17 Aug 2026 18:55:38 +0530 Subject: [PATCH 068/105] fix(security): prevent Authorization header leakage across redirects (#947) (#1067) * fix(security): prevent auth header leakage across redirects * fix(security): harden redirect credential handling Address Copilot and Qodo review findings for #947. - Remove unused variables, imports, and unnecessary pass statements from tests. - Harden cross-origin redirect handling for per-request auth credentials. - Strip session-level auth handlers before cross-origin redirect hops. - Prevent session.auth from regenerating Authorization headers. - Disable trust_env during cross-origin hops to prevent .netrc credential injection. - Restore session auth and trust_env state reliably with try/finally. - Add regression coverage for auth=, session.auth, trust_env, and multi-hop redirects. - Preserve existing security behavior and same-origin authentication semantics. Validated with 189/189 security and affected tests passing. * fix(security): scope allow_private_ips to same-host redirects, fix error handling gaps Follow-up to review findings on #1067: - MCPClient hardcoded allow_private_ips=True for every redirect hop, not just its operator-configured host, so a compromised/malicious MCP server could 302 into private address space (e.g. cloud metadata) unchecked. request_with_ssrf_guard() gains allow_private_ips_on_redirect: a redirect target inherits the original host's private-IP trust only when it matches that host; MCPClient now pins it to False. - detect_public_api() only caught requests.exceptions.RequestException, but the SSRF guard raises ValidationError for blocked hosts/redirects, unlike its sibling ingest_public_api(). Now catches and re-raises it the same way. - detect_public_api()/ingest_public_api() forwarded session/allow_private_ips through **options into request_with_ssrf_guard(), which already passes both explicitly -- a caller supplying either would hit a duplicate-kwarg TypeError. Both are now popped from request_options first. New regression coverage for all three in tests/ingest/, plus a CHANGELOG entry under Unreleased/Security. --------- Co-authored-by: KaifAhmad1 --- CHANGELOG.md | 10 + semantica/ingest/mcp_client.py | 49 +- semantica/ingest/public_api_ingestor.py | 38 +- semantica/ingest/ssrf.py | 227 +++- semantica/seed/seed_manager.py | 7 +- tests/ingest/conftest.py | 35 + .../test_auth_header_redirect_security.py | 1063 +++++++++++++++++ tests/ingest/test_cookbook_integration.py | 40 +- tests/ingest/test_public_api_ingestor.py | 85 +- tests/ingest/test_submodules.py | 135 +-- tests/test_seed_manager.py | 54 + 11 files changed, 1561 insertions(+), 182 deletions(-) create mode 100644 tests/ingest/conftest.py create mode 100644 tests/ingest/test_auth_header_redirect_security.py diff --git a/CHANGELOG.md b/CHANGELOG.md index f670cb22..0ebdc236 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -176,6 +176,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Security +- **`Authorization`/`Proxy-Authorization` credentials could leak to a different origin across HTTP redirects, and several ingest paths bypassed the shared SSRF/redirect guard entirely** (#1067, closes #947) by @Sameer6305, reviewed by @KaifAhmad1 + - `request_with_ssrf_guard()` previously only stripped sensitive headers from per-request `kwargs["headers"]` on a cross-origin redirect; session-level `Authorization`/`Proxy-Authorization` headers, `session.auth`, and `session.trust_env` (`.netrc` lookup) could all still resurrect credentials on the hop to a foreign origin. All five credential sources are now stripped case-insensitively, kept stripped for the remainder of a multi-hop redirect chain (no resurrection even if a later hop returns to the original host), and unconditionally restored via `finally` — including on exceptions and redirect-limit errors + - `MCPClient._send_request_http()` and `PublicAPIIngestor.detect_public_api()`/`ingest_public_api()` called `httpx.post()`/`requests.post()`/`session.request()` directly, bypassing `request_with_ssrf_guard()` entirely. Both now route through the shared guard, including when `validate_no_auth=False` + - `SeedDataManager.load_from_api()` mutated the caller-supplied `headers` dict in place when adding an API-key `Authorization` header, silently leaking the key back into a dict the caller might reuse elsewhere. Now copies before modifying + - **Fixed during review** (@KaifAhmad1): `allow_private_ips=True` (used to let MCP servers run on localhost/internal networks) was applied to every redirect hop, not just the operator-configured host — a compromised or malicious MCP server could 302-redirect to an internal address (e.g. `169.254.169.254` cloud metadata) and the guard would follow it unchecked, defeating the SSRF protection this PR otherwise adds. Added `allow_private_ips_on_redirect` to `request_with_ssrf_guard()`: a redirect target inherits the original host's private-IP trust only when it matches that host; any other host falls back to strict validation. `MCPClient` now pins `allow_private_ips_on_redirect=False`, so only same-host redirects on a trusted MCP server keep working — a cross-host hop into private address space is blocked + - **Fixed during review** (@KaifAhmad1): `detect_public_api()` only caught `requests.exceptions.RequestException`, but `request_with_ssrf_guard()` raises `ValidationError` (a disjoint hierarchy) for SSRF-blocked hosts, blocked redirect targets, missing `Location`, or exceeded redirect limits — unlike its sibling `ingest_public_api()`, which already caught it. Callers (including `is_public_api()`) got an undocumented raw `ValidationError` instead of `ProcessingError`, and the error-logging call was skipped. Now catches `(ValidationError, ProcessingError)` and re-raises, matching the sibling method + - **Fixed during review** (@KaifAhmad1): `detect_public_api()`/`ingest_public_api()` forwarded `**options` into `request_with_ssrf_guard(..., session=self.session, allow_private_ips=self.allow_private_ips, **request_options)` without stripping `session`/`allow_private_ips` from `request_options` first — a caller passing either through the per-call `**options` (a plausible mistake, since `allow_private_ips` is also a documented constructor-level knob) got a raw `TypeError: got multiple values for keyword argument`. Both are now popped from `request_options` before the call + - New regression coverage added during review: `TestAllowPrivateIpsOnRedirect` (cross-host redirect into private space blocked, same-host redirect trust preserved, default behavior unchanged for existing callers that don't pass the new kwarg) and `TestMCPClientAuthRedirect::test_redirect_to_private_ip_is_blocked`/`test_same_host_redirect_on_private_mcp_server_is_not_blocked` in `tests/ingest/test_auth_header_redirect_security.py`; `test_detect_public_api_propagates_ssrf_validation_error` and duplicate-kwarg regression tests for both methods in `tests/ingest/test_public_api_ingestor.py` + - `pytest tests/ingest/test_auth_header_redirect_security.py tests/ingest/test_public_api_ingestor.py tests/test_seed_manager.py tests/ingest/test_submodules.py tests/ingest/test_cookbook_integration.py`: 111 passed + - **`FeedIngestor`/`FeedMonitor` (RSS/Atom feed ingestion) had no SSRF protection, allowing requests to internal/private network targets** (#928, closes #927) by @ZohaibHassan16 - `FeedIngestor.ingest_feed()`, `discover_feeds()` (link-tag fetch, common-path HEAD probe, and feed-validation GET), and `FeedMonitor.check_updates()` all called `requests.get()`/`requests.head()` directly with default redirect-following and no scheme allowlist or private/loopback/link-local IP validation — despite `semantica/ingest/ssrf.py`'s `request_with_ssrf_guard()` already existing and being used by `web_ingestor.py`/`api_ingestor.py`. `ingest_feed()`'s own URL check only verified `urlparse(url).scheme`/`.netloc` were non-empty, never that the scheme was http/https or that the resolved target IP was safe. Reachable via the public `ingest_feed()`/`ingest()` entry points with any caller-supplied feed URL - All 5 call sites now route through `request_with_ssrf_guard()`, which validates scheme (http/https only) and resolved IP before the request, and re-validates every redirect `Location` before following it — closing both the direct-IP and redirect-chain SSRF paths. Added an `allow_private_ips` config option to both `FeedIngestor` and `FeedMonitor`, consistent with the other ingestors diff --git a/semantica/ingest/mcp_client.py b/semantica/ingest/mcp_client.py index 2b33dfe1..302e3365 100644 --- a/semantica/ingest/mcp_client.py +++ b/semantica/ingest/mcp_client.py @@ -41,6 +41,7 @@ from typing import Any, Dict, List, Optional, Union from ..utils.exceptions import ProcessingError, ValidationError from ..utils.logging import get_logger +from .ssrf import request_with_ssrf_guard @dataclass @@ -341,36 +342,38 @@ class MCPClient: raise def _send_request_http(self, request: Dict[str, Any]) -> Optional[Dict[str, Any]]: - """Send request via HTTP.""" - try: - import httpx + """Send request via HTTP, with redirect-safe credential handling. - response = httpx.post( + Uses ``request_with_ssrf_guard`` so that: + + * ``Authorization`` / ``Proxy-Authorization`` headers are **not** + forwarded to a different origin if the MCP server issues a redirect + (issue #947). + * The redirect chain is bounded (default 10 hops). + + ``allow_private_ips=True`` is set because MCP servers are explicitly + configured by the operator and frequently run on localhost or an + internal network — the same trust model as ``allow_private_ips`` opt-in + in the other ingestors. That trust covers only ``self.url`` itself: + ``allow_private_ips_on_redirect=False`` keeps redirect targets held to + the normal public-address check, so a compromised or malicious MCP + server cannot use a redirect to route the client into private/ + internal address space (e.g. cloud metadata) that the operator never + configured. Scheme validation (http/https only) and the + auth-stripping logic remain active regardless of these flags. + """ + try: + response = request_with_ssrf_guard( + "POST", self.url, - json=request, headers=self.headers, + json=request, timeout=self.config.get("timeout", 30.0), + allow_private_ips=True, + allow_private_ips_on_redirect=False, ) response.raise_for_status() return response.json() - except (ImportError, OSError): - # Fallback to requests if httpx not available - try: - import requests - - response = requests.post( - self.url, - json=request, - headers=self.headers, - timeout=self.config.get("timeout", 30.0), - ) - response.raise_for_status() - return response.json() - except (ImportError, OSError): - raise ProcessingError( - "HTTP transport requires 'httpx' or 'requests' package. " - "Install with: pip install httpx or pip install requests" - ) except Exception as e: self.logger.error(f"Failed to send HTTP request: {e}") raise diff --git a/semantica/ingest/public_api_ingestor.py b/semantica/ingest/public_api_ingestor.py index afefbe27..2ea15b20 100644 --- a/semantica/ingest/public_api_ingestor.py +++ b/semantica/ingest/public_api_ingestor.py @@ -45,6 +45,7 @@ except ModuleNotFoundError: # pragma: no cover - fallback for minimal installs from ..utils.exceptions import ProcessingError, ValidationError from ..utils.logging import get_logger from .api_ingestor import APIData, RESTIngestor +from .ssrf import request_with_ssrf_guard AUTH_HEADER_NAMES = { "authorization", @@ -359,18 +360,31 @@ class PublicAPIIngestor(RESTIngestor): request_options = options.copy() timeout = request_options.pop("timeout", self.config.get("timeout", 30)) rate_limit_delay = request_options.pop("rate_limit_delay", None) + # session and allow_private_ips are always supplied explicitly below; + # drop any caller-provided copies so request_with_ssrf_guard() does + # not receive duplicate keyword arguments. + request_options.pop("session", None) + request_options.pop("allow_private_ips", None) request_headers = self._merged_headers(headers) try: self._wait_if_needed(rate_limit_delay=rate_limit_delay) - response = self.session.request( - method=method, - url=endpoint, + # Route through the SSRF guard so that: + # * redirects to private/loopback IPs are blocked, and + # * Authorization / Proxy-Authorization are stripped on + # cross-origin redirects (issue #947). + response = request_with_ssrf_guard( + method, + endpoint, + session=self.session, headers=request_headers, params=params, timeout=timeout, + allow_private_ips=self.allow_private_ips, **request_options, ) + except (ValidationError, ProcessingError): + raise except requests.exceptions.RequestException as exc: self.logger.error(f"Failed to detect public API {endpoint}: {exc}") raise ProcessingError(f"Failed to detect public API: {exc}") from exc @@ -440,18 +454,30 @@ class PublicAPIIngestor(RESTIngestor): request_options = options.copy() timeout = request_options.pop("timeout", self.config.get("timeout", 30)) + # session and allow_private_ips are always supplied explicitly below; + # drop any caller-provided copies so request_with_ssrf_guard() does + # not receive duplicate keyword arguments. + request_options.pop("session", None) + request_options.pop("allow_private_ips", None) request_headers = self._merged_headers(headers) try: self._wait_if_needed(rate_limit_delay=rate_limit_delay) - response = self.session.request( - method=method, - url=endpoint, + # Route through the SSRF guard so that: + # * redirects to private/loopback IPs are blocked, and + # * Authorization / Proxy-Authorization are stripped on + # cross-origin redirects even when validate_no_auth=False + # (issue #947). + response = request_with_ssrf_guard( + method, + endpoint, + session=self.session, headers=request_headers, params=params, data=data, json=json_data, timeout=timeout, + allow_private_ips=self.allow_private_ips, **request_options, ) diff --git a/semantica/ingest/ssrf.py b/semantica/ingest/ssrf.py index 083fbcca..488ae3cf 100644 --- a/semantica/ingest/ssrf.py +++ b/semantica/ingest/ssrf.py @@ -268,6 +268,7 @@ def request_with_ssrf_guard( *, session: Optional[requests.Session] = None, allow_private_ips: bool = False, + allow_private_ips_on_redirect: Optional[bool] = None, max_redirects: int = _DEFAULT_MAX_REDIRECTS, **kwargs: Any, ) -> requests.Response: @@ -277,10 +278,65 @@ def request_with_ssrf_guard( public URL to bounce into private/loopback/link-local space. This helper disables automatic redirects and re-validates each ``Location`` target before issuing the next hop. + + ``allow_private_ips`` trusts the caller's own *url* (e.g. an + operator-configured internal endpoint). That trust follows a redirect + only when the redirect target's host matches the original host (e.g. a + same-host path redirect on a private/localhost server); a redirect to a + *different* host is validated with ``allow_private_ips_on_redirect`` + instead, which defaults to ``allow_private_ips`` for backward + compatibility but can be pinned to ``False`` by callers that want to + trust only the original host and never extend private-IP eligibility to + any other host a redirect chain might reach — otherwise a private-IP- + eligible endpoint could be tricked into redirecting into arbitrary + internal address space (e.g. cloud metadata) the caller never + configured. + + Authorization / credential-header handling (issue #947) + -------------------------------------------------------- + Credentials are stripped from **all** sources that ``requests`` can use to + attach an ``Authorization`` header whenever a redirect changes origin: + + 1. ``kwargs["headers"]`` — per-request header dict (already handled). + 2. ``session.headers`` — session-level headers that ``requests`` merges + automatically; cleared for the hop and restored via ``finally``. + 3. ``kwargs["auth"]`` — per-request auth tuple/callable; removed from the + local ``kwargs`` copy when stripping is required. This copy never + escapes to the caller, so there is nothing to restore. + 4. ``session.auth`` — session-level auth handler that ``requests`` merges + via ``merge_setting(auth, self.auth)`` inside ``prepare_request``; + cleared for the hop and restored via ``finally``. + 5. ``session.trust_env`` — when ``True``, ``requests`` reads ``~/.netrc`` + for the *redirect target* host and calls ``prepare_auth()`` with those + credentials even after sources 3 and 4 are cleared; disabled for + cross-origin hops and restored via ``finally``. + + Leaving any one of these intact allows ``requests`` to re-attach + credentials on the hop to the foreign origin, defeating the header-level + strip. + + Session state that was removed is unconditionally restored in a ``finally`` + block so the session is left in its original state after this call returns, + regardless of how it exits (normal return, exception, redirect cap). The + loop is sequential and single-threaded within one call, so the mutation is + safe as long as the caller does not share the session across concurrent + threads (the standard Semantica pattern: one session per ingestor instance). + + Once credentials have been stripped for a cross-origin hop they are NOT + re-added for subsequent hops in the same chain, even if a later hop + happens to point back to the original host. This prevents credential + resurrection via crafted multi-hop redirect chains. """ kwargs = dict(kwargs) kwargs.pop("allow_redirects", None) + redirect_allow_private_ips = ( + allow_private_ips + if allow_private_ips_on_redirect is None + else allow_private_ips_on_redirect + ) + _original_host = (urlparse(url).hostname or "").lower() + validate_url_for_request(url, allow_private_ips=allow_private_ips) requester = session.request if session is not None else requests.request @@ -288,56 +344,141 @@ def request_with_ssrf_guard( current_method = method.upper() redirects_followed = 0 - while True: - response = requester( - current_method, - current_url, - allow_redirects=False, - **kwargs, - ) + # -- issue #947: snapshot every session-level credential source so we can + # restore them unconditionally when this call exits. + _SENSITIVE = ("Authorization", "Proxy-Authorization") + _session_auth_backup: dict = {} + _session_auth_handler_backup: Any = None # session.auth backup + _session_trust_env_backup: bool = True # session.trust_env backup - if response.status_code not in _REDIRECT_STATUS_CODES: - return response + if session is not None: + for _h in _SENSITIVE: + # requests stores session headers in a case-insensitive dict; + # .get() matches regardless of the casing used at insertion time. + _val = session.headers.get(_h) + if _val is not None: + _session_auth_backup[_h] = _val + # Snapshot session.auth (HTTPBasicAuth, tuple, callable, or None). + _session_auth_handler_backup = session.auth + # Snapshot session.trust_env (controls .netrc / env proxy lookup). + _session_trust_env_backup = session.trust_env - if redirects_followed >= max_redirects: - response.close() - raise ValidationError( - f"Exceeded maximum redirects ({max_redirects}) while " - f"fetching '{url}'" + # Track whether credentials have been stripped for this redirect chain. + # Once stripped they must not reappear on any subsequent hop. + _auth_stripped = False + + try: + while True: + response = requester( + current_method, + current_url, + allow_redirects=False, + **kwargs, ) - location = response.headers.get("Location") - if not location or not str(location).strip(): - response.close() - raise ValidationError( - f"Redirect from '{current_url}' is missing a Location header" + if response.status_code not in _REDIRECT_STATUS_CODES: + return response + + if redirects_followed >= max_redirects: + response.close() + raise ValidationError( + f"Exceeded maximum redirects ({max_redirects}) while " + f"fetching '{url}'" + ) + + location = response.headers.get("Location") + if not location or not str(location).strip(): + response.close() + raise ValidationError( + f"Redirect from '{current_url}' is missing a Location header" + ) + + next_url = urljoin(current_url, str(location).strip()) + next_host = (urlparse(next_url).hostname or "").lower() + # A redirect back to the original host inherits the caller's + # trust in that host (e.g. a same-host path redirect on a + # private/localhost MCP server). A redirect to a *different* + # host must not inherit that trust, even if the original host + # was private/internal — otherwise a compromised or malicious + # endpoint could redirect into arbitrary private address space + # (e.g. cloud metadata) the caller never configured. + hop_allow_private_ips = ( + allow_private_ips + if next_host and next_host == _original_host + else redirect_allow_private_ips ) + validate_url_for_request(next_url, allow_private_ips=hop_allow_private_ips) - next_url = urljoin(current_url, str(location).strip()) - validate_url_for_request(next_url, allow_private_ips=allow_private_ips) + # Do not leak sensitive headers or auth handlers to a different + # origin on redirects. All four credential sources are cleared: + # • kwargs["headers"] — per-request header dict + # • session.headers — session-level header dict + # • kwargs["auth"] — per-request auth tuple/callable + # • session.auth — session-level auth handler + # + # Once stripped (_auth_stripped=True), credentials stay absent for + # the remainder of the chain — even if a later hop targets the + # original host — to prevent credential resurrection. + if _auth_stripped or _should_strip_auth(current_url, next_url): + _auth_stripped = True - # Do not leak sensitive headers to a different origin on redirects: - # reuse the caller's headers only while host, port, and scheme keep - # the credential safe, mirroring requests' should_strip_auth. - if _should_strip_auth(current_url, next_url): - kwargs = dict(kwargs) - headers = dict(kwargs.get("headers") or {}) - for sensitive in ("Authorization", "Proxy-Authorization"): - headers.pop(sensitive, None) - kwargs["headers"] = headers + # 1. Strip from per-request kwargs headers. + kwargs = dict(kwargs) + headers = dict(kwargs.get("headers") or {}) + for sensitive in _SENSITIVE: + headers.pop(sensitive, None) + # Also remove any case variant the caller may have used + # (e.g. "authorization" or "AUTHORIZATION"). + for key in list(headers): + if key.lower() == sensitive.lower(): + del headers[key] + kwargs["headers"] = headers - # Match requests' historical method rewriting for 301/302/303. - if ( - response.status_code in _STRIP_BODY_ON_REDIRECT - and current_method not in {"GET", "HEAD"} - ): - current_method = "GET" - for key in ("data", "json", "files"): - kwargs.pop(key, None) + # 2. Strip per-request auth kwarg so requests cannot call + # prepare_auth() with the caller's credential on this hop. + kwargs.pop("auth", None) - # Params apply to the original request URL only; Location is authoritative. - kwargs.pop("params", None) + # 3. Strip session-level headers so requests cannot re-inject + # them when merging session + per-request headers for this hop. + if session is not None: + for sensitive in _SENSITIVE: + # CaseInsensitiveDict.pop(key, None) handles any casing. + session.headers.pop(sensitive, None) - response.close() - current_url = next_url - redirects_followed += 1 + # 4. Clear session.auth so prepare_request's merge_setting() + # cannot fall back to the session-level auth handler and + # reattach credentials on the foreign-origin hop. + session.auth = None + + # 5. Disable .netrc / environment-proxy credential lookup so + # requests cannot inject credentials from ~/.netrc for the + # redirect target host on this hop. + session.trust_env = False + + # Match requests' historical method rewriting for 301/302/303. + if ( + response.status_code in _STRIP_BODY_ON_REDIRECT + and current_method not in {"GET", "HEAD"} + ): + current_method = "GET" + for key in ("data", "json", "files"): + kwargs.pop(key, None) + + # Params apply to the original request URL only; Location is authoritative. + kwargs.pop("params", None) + + response.close() + current_url = next_url + redirects_followed += 1 + + finally: + # Unconditionally restore every session credential source we touched, + # so the session is in its original state after this call returns or raises. + if session is not None: + if _session_auth_backup: + for _h, _v in _session_auth_backup.items(): + session.headers[_h] = _v + # Restore session.auth to whatever it was before this call. + session.auth = _session_auth_handler_backup + # Restore session.trust_env (.netrc / env-proxy lookup flag). + session.trust_env = _session_trust_env_backup diff --git a/semantica/seed/seed_manager.py b/semantica/seed/seed_manager.py index 16f21ea1..6e52c382 100644 --- a/semantica/seed/seed_manager.py +++ b/semantica/seed/seed_manager.py @@ -501,8 +501,11 @@ class SeedDataManager: else: full_url = api_url - # Prepare headers - request_headers = headers or {} + # Prepare headers — copy the caller's dict so we never mutate it in-place. + # Without the copy, adding "Authorization" here would silently modify the + # caller's original dict and potentially leak the key to subsequent calls + # that reuse the same dict without expecting it to contain credentials. + request_headers = dict(headers) if headers else {} if api_key: request_headers["Authorization"] = f"Bearer {api_key}" diff --git a/tests/ingest/conftest.py b/tests/ingest/conftest.py new file mode 100644 index 00000000..98f913a5 --- /dev/null +++ b/tests/ingest/conftest.py @@ -0,0 +1,35 @@ +""" +Shared pytest fixtures for the ingest test suite. + +The ``mock_dns`` fixture is applied to *every* test in this directory +(``autouse=True``). It stubs out ``socket.getaddrinfo`` inside the SSRF +guard module so that unit tests that mock ``requests.Session.request`` do not +accidentally hit the network for DNS resolution — which would fail in offline +CI environments and cause intermittent timeouts. + +Tests that explicitly need to exercise DNS-related behaviour (e.g. checking +that a hostname resolving to a private IP is blocked) override this fixture +by patching ``semantica.ingest.ssrf.socket.getaddrinfo`` with their own +``side_effect`` *inside* the test body; that inner patch wins because +``unittest.mock.patch`` applies patches in innermost-last order. +""" +from __future__ import annotations + +import socket +from unittest.mock import patch + +import pytest + +_PUBLIC_IP = "93.184.216.34" # example.com — a safe, routable public address + + +@pytest.fixture(autouse=True) +def mock_dns(): + """Map every hostname to a safe public IP for the duration of each test.""" + with patch( + "semantica.ingest.ssrf.socket.getaddrinfo", + return_value=[ + (socket.AF_INET, socket.SOCK_STREAM, 6, "", (_PUBLIC_IP, 0)) + ], + ): + yield diff --git a/tests/ingest/test_auth_header_redirect_security.py b/tests/ingest/test_auth_header_redirect_security.py new file mode 100644 index 00000000..882a9d59 --- /dev/null +++ b/tests/ingest/test_auth_header_redirect_security.py @@ -0,0 +1,1063 @@ +"""Security regression tests for issue #947. + +Prevents Authorization / Proxy-Authorization headers from leaking across +cross-origin redirects in request_with_ssrf_guard, MCPClient, and +PublicAPIIngestor. + +Each test is focused on a single, specific security property so that a future +regression immediately pinpoints the broken invariant. +""" +from __future__ import annotations + +import socket +from unittest.mock import MagicMock, patch + +import pytest +import requests + +from semantica.ingest.mcp_client import MCPClient +from semantica.ingest.public_api_ingestor import PublicAPIIngestor +from semantica.ingest.ssrf import request_with_ssrf_guard +from semantica.utils.exceptions import ValidationError + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +_PUBLIC_IP = "93.184.216.34" # example.com — public, safe + + +def _public_getaddrinfo(host, *args, **kwargs): + """DNS stub that maps every hostname to a safe public IP.""" + return [(socket.AF_INET, socket.SOCK_STREAM, 6, "", (_PUBLIC_IP, 0))] + + +def _make_session_with_auth(token: str = "Bearer secret") -> requests.Session: + """Return a real requests.Session with Authorization in session.headers.""" + sess = requests.Session() + sess.headers["Authorization"] = token + return sess + + +def _mock_redirect(location: str, status: int = 302) -> MagicMock: + r = MagicMock() + r.status_code = status + r.headers = {"Location": location} + r.close = MagicMock() + return r + + +def _mock_final(status: int = 200) -> MagicMock: + r = MagicMock() + r.status_code = status + r.headers = {} + r.close = MagicMock() + return r + + +# =========================================================================== +# Section 1 – request_with_ssrf_guard: session.headers stripping (#947) +# =========================================================================== + + +class TestSessionHeadersStripping: + """Authorization stored in session.headers must not reach a foreign origin.""" + + @patch("semantica.ingest.ssrf.socket.getaddrinfo", side_effect=_public_getaddrinfo) + def test_session_authorization_stripped_on_cross_origin_redirect(self, _): + """session.headers["Authorization"] must not appear in the hop to a new host.""" + sess = _make_session_with_auth() + redirect = _mock_redirect("https://other.example/final") + final = _mock_final() + + with patch.object(sess, "request", side_effect=[redirect, final]) as mock_req: + request_with_ssrf_guard("GET", "https://example.com/start", session=sess) + + assert mock_req.call_count == 2 + # The second call must not carry Authorization in kwargs["headers"]. + second_headers = mock_req.call_args_list[1].kwargs.get("headers", {}) + assert "Authorization" not in second_headers + # Also verify requests won't re-inject it via session (the guard must + # have cleared it from sess.headers before the second call). + assert "Authorization" not in sess.headers or sess.headers.get("Authorization") == "Bearer secret" + # Post-call restoration: session must be restored. + assert sess.headers.get("Authorization") == "Bearer secret" + + @patch("semantica.ingest.ssrf.socket.getaddrinfo", side_effect=_public_getaddrinfo) + def test_session_headers_cleared_before_second_hop_not_just_restored_after(self, _): + """Prove session.headers["Authorization"] is absent AT CALL TIME of the second hop. + + This test closes the gap where a mock-based test only checks kwargs["headers"] + but not whether session.headers was actually cleared before requests' internal + header-merge would re-inject the credential. + + Strategy: capture a snapshot of sess.headers at each call invocation so we + can assert it was empty during the second hop — not just after the guard returns. + """ + sess = _make_session_with_auth("Bearer proof-token") + redirect = _mock_redirect("https://other.example/final") + final = _mock_final() + + snapshots: list = [] + + def capturing_side_effect(*args, **kwargs): + # Snapshot what session.headers contain at the exact moment of this call. + snapshots.append(dict(sess.headers)) + return [redirect, final][len(snapshots) - 1] + + with patch.object(sess, "request", side_effect=capturing_side_effect): + request_with_ssrf_guard("GET", "https://example.com/start", session=sess) + + assert len(snapshots) == 2 + + # Hop 1 (same origin, pre-redirect): Authorization PRESENT in session.headers. + assert snapshots[0].get("Authorization") == "Bearer proof-token", ( + "Authorization must be in session.headers for the first (same-origin) call" + ) + + # Hop 2 (cross-origin): Authorization ABSENT from session.headers. + # This is what prevents requests from re-injecting it via its header-merge step. + assert "Authorization" not in snapshots[1], ( + "Authorization must have been removed from session.headers BEFORE the " + "second (cross-origin) call — removing it only from kwargs is not enough " + "because requests.Session merges session.headers at call time." + ) + + # After the guard returns, session state is fully restored. + assert sess.headers.get("Authorization") == "Bearer proof-token", ( + "session.headers must be restored after the guard returns" + ) + + @patch("semantica.ingest.ssrf.socket.getaddrinfo", side_effect=_public_getaddrinfo) + def test_session_authorization_preserved_on_same_origin_redirect(self, _): + """Same-origin redirect must keep Authorization in session.headers untouched.""" + sess = _make_session_with_auth() + redirect = _mock_redirect("https://example.com/page2") + final = _mock_final() + + with patch.object(sess, "request", side_effect=[redirect, final]) as mock_req: + request_with_ssrf_guard("GET", "https://example.com/start", session=sess) + + assert mock_req.call_count == 2 + # When no stripping occurred, kwargs["headers"] is unchanged from + # the caller (no headers kwarg was passed here, so it may be absent + # or empty — what matters is that the session header was NOT cleared). + assert sess.headers.get("Authorization") == "Bearer secret" + + @patch("semantica.ingest.ssrf.socket.getaddrinfo", side_effect=_public_getaddrinfo) + def test_session_credentials_restored_after_successful_request(self, _): + """Session headers must be restored after a redirect chain completes normally.""" + sess = _make_session_with_auth("Bearer my-token") + redirect = _mock_redirect("https://other.example/final") + final = _mock_final() + + with patch.object(sess, "request", side_effect=[redirect, final]): + request_with_ssrf_guard("GET", "https://example.com/start", session=sess) + + assert sess.headers.get("Authorization") == "Bearer my-token" + + @patch("semantica.ingest.ssrf.socket.getaddrinfo", side_effect=_public_getaddrinfo) + def test_session_credentials_restored_after_ssrf_exception(self, _): + """Session headers must be restored even when the guard raises ValidationError.""" + sess = _make_session_with_auth("Bearer my-token") + # Redirect to a loopback address — guard will raise. + redirect = _mock_redirect("http://127.0.0.1/secret") + + with patch.object(sess, "request", return_value=redirect): + with pytest.raises(ValidationError): + request_with_ssrf_guard( + "GET", "https://example.com/start", session=sess + ) + + assert sess.headers.get("Authorization") == "Bearer my-token" + + @patch("semantica.ingest.ssrf.socket.getaddrinfo", side_effect=_public_getaddrinfo) + def test_session_credentials_restored_after_max_redirects_exceeded(self, _): + """Session headers must be restored when the max-redirect cap is hit.""" + sess = _make_session_with_auth("Bearer loop-token") + hop = _mock_redirect("https://other.example/loop") + + # All hops redirect to the same foreign host → exceeds cap. + with patch.object(sess, "request", return_value=hop): + with pytest.raises(ValidationError, match="Exceeded maximum"): + request_with_ssrf_guard( + "GET", + "https://example.com/start", + session=sess, + max_redirects=2, + ) + + assert sess.headers.get("Authorization") == "Bearer loop-token" + + @patch("semantica.ingest.ssrf.socket.getaddrinfo", side_effect=_public_getaddrinfo) + def test_proxy_authorization_stripped_on_cross_origin_redirect(self, _): + """Proxy-Authorization must be stripped alongside Authorization.""" + sess = requests.Session() + sess.headers["Proxy-Authorization"] = "Basic cHJveHk6cGFzcw==" + redirect = _mock_redirect("https://other.example/final") + final = _mock_final() + + with patch.object(sess, "request", side_effect=[redirect, final]) as mock_req: + request_with_ssrf_guard("GET", "https://example.com/start", session=sess) + + second_headers = mock_req.call_args_list[1].kwargs.get("headers", {}) + assert "Proxy-Authorization" not in second_headers + # Restored after call. + assert "Proxy-Authorization" in sess.headers + + @patch("semantica.ingest.ssrf.socket.getaddrinfo", side_effect=_public_getaddrinfo) + def test_both_auth_headers_stripped_simultaneously(self, _): + """Both Authorization and Proxy-Authorization must be stripped together.""" + sess = requests.Session() + sess.headers["Authorization"] = "Bearer tok" + sess.headers["Proxy-Authorization"] = "Basic abc" + redirect = _mock_redirect("https://other.example/final") + final = _mock_final() + + with patch.object(sess, "request", side_effect=[redirect, final]) as mock_req: + request_with_ssrf_guard("GET", "https://example.com/start", session=sess) + + second_headers = mock_req.call_args_list[1].kwargs.get("headers", {}) + assert "Authorization" not in second_headers + assert "Proxy-Authorization" not in second_headers + # Restored after call. + assert sess.headers.get("Authorization") == "Bearer tok" + assert sess.headers.get("Proxy-Authorization") == "Basic abc" + + +# =========================================================================== +# Section 1b – request_with_ssrf_guard: auth= kwarg and session.auth stripping +# =========================================================================== + + +class TestAuthHandlerStripping: + """kwargs['auth'] and session.auth must not reach a foreign origin. + + requests uses two additional credential channels beyond header dicts: + • auth= kwarg → passed to PreparedRequest.prepare_auth() directly + • session.auth → merged by Session.prepare_request() via merge_setting() + and then calls prepare_auth() — so even if headers are + stripped, a live session.auth re-attaches Authorization. + + Both must be cleared on cross-origin redirect. + """ + + @patch("semantica.ingest.ssrf.socket.getaddrinfo", side_effect=_public_getaddrinfo) + def test_kwargs_auth_stripped_on_cross_origin_redirect(self, _): + """auth= kwarg must not be forwarded to the second hop on a different host. + + Verifies that the second call to the underlying requester does NOT + receive an 'auth' kwarg, so requests cannot call prepare_auth() and + regenerate an Authorization header for the foreign origin. + """ + redirect = _mock_redirect("https://other.example/final") + final = _mock_final() + + with patch( + "semantica.ingest.ssrf.requests.request", + side_effect=[redirect, final], + ) as mock_req: + request_with_ssrf_guard( + "GET", + "https://example.com/start", + auth=("user", "secret-password"), + ) + + assert mock_req.call_count == 2 + + # First hop: auth= kwarg is present (same origin, no strip yet). + first_auth = mock_req.call_args_list[0].kwargs.get("auth") + assert first_auth == ("user", "secret-password"), ( + "auth= kwarg must be forwarded on the first (same-origin) hop" + ) + + # Second hop: auth= kwarg must be absent (cross-origin — stripped). + second_auth = mock_req.call_args_list[1].kwargs.get("auth") + assert second_auth is None, ( + "auth= kwarg must be removed before the cross-origin hop so " + "requests cannot call prepare_auth() and reattach Authorization" + ) + + @patch("semantica.ingest.ssrf.socket.getaddrinfo", side_effect=_public_getaddrinfo) + def test_kwargs_auth_preserved_on_same_origin_redirect(self, _): + """auth= kwarg must survive a same-host redirect unchanged.""" + redirect = _mock_redirect("https://example.com/new-path") + final = _mock_final() + + with patch( + "semantica.ingest.ssrf.requests.request", + side_effect=[redirect, final], + ) as mock_req: + request_with_ssrf_guard( + "GET", + "https://example.com/start", + auth=("user", "secret-password"), + ) + + assert mock_req.call_count == 2 + second_auth = mock_req.call_args_list[1].kwargs.get("auth") + assert second_auth == ("user", "secret-password"), ( + "auth= kwarg must be kept for same-origin redirects" + ) + + @patch("semantica.ingest.ssrf.socket.getaddrinfo", side_effect=_public_getaddrinfo) + def test_session_auth_cleared_before_cross_origin_hop(self, _): + """session.auth must be None AT CALL TIME of the cross-origin hop. + + This test uses the same snapshot-at-invocation technique as the + session.headers equivalent: capture session.auth at the exact moment + each call is issued, so we can prove the handler was absent before + requests' merge_setting() could reattach it. + """ + sess = requests.Session() + sess.auth = ("user", "secret-password") + redirect = _mock_redirect("https://other.example/final") + final = _mock_final() + + auth_snapshots: list = [] + + def capturing_side_effect(*args, **kwargs): + # Snapshot session.auth at the exact moment of this call. + auth_snapshots.append(sess.auth) + return [redirect, final][len(auth_snapshots) - 1] + + with patch.object(sess, "request", side_effect=capturing_side_effect): + request_with_ssrf_guard("GET", "https://example.com/start", session=sess) + + assert len(auth_snapshots) == 2 + + # Hop 1 (same origin): session.auth is PRESENT. + assert auth_snapshots[0] == ("user", "secret-password"), ( + "session.auth must be intact for the first (same-origin) call" + ) + + # Hop 2 (cross-origin): session.auth must be ABSENT (None). + assert auth_snapshots[1] is None, ( + "session.auth must have been cleared BEFORE the cross-origin call " + "so requests' merge_setting() cannot reattach the credential" + ) + + # After the guard returns, session.auth must be fully restored. + assert sess.auth == ("user", "secret-password"), ( + "session.auth must be restored after the guard returns" + ) + + @patch("semantica.ingest.ssrf.socket.getaddrinfo", side_effect=_public_getaddrinfo) + def test_session_auth_preserved_on_same_origin_redirect(self, _): + """session.auth must not be touched for same-host redirects.""" + sess = requests.Session() + sess.auth = ("user", "secret-password") + redirect = _mock_redirect("https://example.com/page2") + final = _mock_final() + + auth_snapshots: list = [] + + def capturing_side_effect(*args, **kwargs): + auth_snapshots.append(sess.auth) + return [redirect, final][len(auth_snapshots) - 1] + + with patch.object(sess, "request", side_effect=capturing_side_effect): + request_with_ssrf_guard("GET", "https://example.com/start", session=sess) + + assert len(auth_snapshots) == 2 + # Both hops see session.auth intact. + assert auth_snapshots[0] == ("user", "secret-password") + assert auth_snapshots[1] == ("user", "secret-password") + assert sess.auth == ("user", "secret-password") + + @patch("semantica.ingest.ssrf.socket.getaddrinfo", side_effect=_public_getaddrinfo) + def test_session_auth_restored_after_successful_request(self, _): + """session.auth must be restored to its original value after the call.""" + sess = requests.Session() + sess.auth = ("user", "secret-password") + redirect = _mock_redirect("https://other.example/final") + final = _mock_final() + + with patch.object(sess, "request", side_effect=[redirect, final]): + request_with_ssrf_guard("GET", "https://example.com/start", session=sess) + + assert sess.auth == ("user", "secret-password") + + @patch("semantica.ingest.ssrf.socket.getaddrinfo", side_effect=_public_getaddrinfo) + def test_session_auth_restored_after_ssrf_exception(self, _): + """session.auth must be restored even when the guard raises.""" + sess = requests.Session() + sess.auth = ("user", "secret-password") + # Redirect to loopback — guard raises ValidationError. + redirect = _mock_redirect("http://127.0.0.1/secret") + + with patch.object(sess, "request", return_value=redirect): + with pytest.raises(ValidationError): + request_with_ssrf_guard( + "GET", "https://example.com/start", session=sess + ) + + assert sess.auth == ("user", "secret-password") + + @patch("semantica.ingest.ssrf.socket.getaddrinfo", side_effect=_public_getaddrinfo) + def test_session_auth_none_by_default_remains_none(self, _): + """When session.auth is None (default), the finally block must not set it + to something unexpected — restoring None is a no-op, not a corruption.""" + sess = requests.Session() + assert sess.auth is None + redirect = _mock_redirect("https://other.example/final") + final = _mock_final() + + with patch.object(sess, "request", side_effect=[redirect, final]): + request_with_ssrf_guard("GET", "https://example.com/start", session=sess) + + assert sess.auth is None + + @patch("semantica.ingest.ssrf.socket.getaddrinfo", side_effect=_public_getaddrinfo) + def test_kwargs_auth_does_not_reappear_in_multihop_chain(self, _): + """Once auth= is stripped at hop 2, it must not reappear at hop 3.""" + hop1 = _mock_redirect("https://other.example/step2") # cross-origin: strip + hop2 = _mock_redirect("https://other.example/final") # same host as hop1: stay stripped + final = _mock_final() + + with patch( + "semantica.ingest.ssrf.requests.request", + side_effect=[hop1, hop2, final], + ) as mock_req: + request_with_ssrf_guard( + "GET", + "https://example.com/start", + auth=("user", "pass"), + ) + + assert mock_req.call_count == 3 + # Hop 1: auth present (same origin). + assert mock_req.call_args_list[0].kwargs.get("auth") == ("user", "pass") + # Hop 2: stripped. + assert mock_req.call_args_list[1].kwargs.get("auth") is None + # Hop 3: stays stripped — no resurrection. + assert mock_req.call_args_list[2].kwargs.get("auth") is None + + @patch("semantica.ingest.ssrf.socket.getaddrinfo", side_effect=_public_getaddrinfo) + def test_session_trust_env_disabled_before_cross_origin_hop(self, _): + """session.trust_env must be False AT CALL TIME of the cross-origin hop. + + When trust_env=True, requests reads ~/.netrc for the redirect target host + and calls prepare_auth() with those credentials — even after session.auth + and kwargs['auth'] are cleared. Disabling trust_env before the hop closes + this bypass channel. + """ + sess = requests.Session() + sess.trust_env = True # explicit default + redirect = _mock_redirect("https://other.example/final") + final = _mock_final() + + trust_env_snapshots: list = [] + + def capturing_side_effect(*args, **kwargs): + trust_env_snapshots.append(sess.trust_env) + return [redirect, final][len(trust_env_snapshots) - 1] + + with patch.object(sess, "request", side_effect=capturing_side_effect): + request_with_ssrf_guard("GET", "https://example.com/start", session=sess) + + assert len(trust_env_snapshots) == 2 + + # Hop 1 (same origin): trust_env is True (unchanged). + assert trust_env_snapshots[0] is True, ( + "trust_env must be unchanged for the first (same-origin) call" + ) + + # Hop 2 (cross-origin): trust_env must be False to block .netrc lookup. + assert trust_env_snapshots[1] is False, ( + "trust_env must be False BEFORE the cross-origin call to prevent " + "requests from looking up ~/.netrc credentials for the redirect target" + ) + + # After the guard returns, trust_env must be restored. + assert sess.trust_env is True, ( + "session.trust_env must be restored after the guard returns" + ) + + @patch("semantica.ingest.ssrf.socket.getaddrinfo", side_effect=_public_getaddrinfo) + def test_session_trust_env_restored_after_exception(self, _): + """session.trust_env must be restored even when the guard raises.""" + sess = requests.Session() + sess.trust_env = True + redirect = _mock_redirect("http://127.0.0.1/secret") # will raise ValidationError + + with patch.object(sess, "request", return_value=redirect): + with pytest.raises(ValidationError): + request_with_ssrf_guard( + "GET", "https://example.com/start", session=sess + ) + + assert sess.trust_env is True + + @patch("semantica.ingest.ssrf.socket.getaddrinfo", side_effect=_public_getaddrinfo) + def test_session_trust_env_false_stays_false_after_call(self, _): + """If trust_env was already False, it must stay False after the call.""" + sess = requests.Session() + sess.trust_env = False # caller explicitly disabled .netrc + redirect = _mock_redirect("https://other.example/final") + final = _mock_final() + + with patch.object(sess, "request", side_effect=[redirect, final]): + request_with_ssrf_guard("GET", "https://example.com/start", session=sess) + + assert sess.trust_env is False # restored to the original False value + + +# =========================================================================== +# Section 2 – request_with_ssrf_guard: credential resurrection prevention +# =========================================================================== + + +class TestCredentialResurrection: + """Stripped credentials must not reappear for later hops in the same chain.""" + + @patch("semantica.ingest.ssrf.socket.getaddrinfo", side_effect=_public_getaddrinfo) + def test_credentials_do_not_reappear_after_cross_origin_hop(self, _): + """A subsequent same-origin-as-hop-2 redirect must not restore the credential.""" + # Chain: example.com → other.example (strip) → other.example/page2 (stay stripped) + hop1 = _mock_redirect("https://other.example/step2") + hop2 = _mock_redirect("https://other.example/final") # same host as hop1 target + final = _mock_final() + + with patch( + "semantica.ingest.ssrf.requests.request", + side_effect=[hop1, hop2, final], + ) as mock_req: + request_with_ssrf_guard( + "GET", + "https://example.com/start", + headers={"Authorization": "Bearer secret"}, + ) + + assert mock_req.call_count == 3 + # Hop 1 (example.com): credential present + h1 = mock_req.call_args_list[0].kwargs.get("headers", {}) + assert h1.get("Authorization") == "Bearer secret" + # Hop 2 (other.example): stripped + h2 = mock_req.call_args_list[1].kwargs.get("headers", {}) + assert "Authorization" not in h2 + # Hop 3 (still other.example): stays stripped — must NOT reappear + h3 = mock_req.call_args_list[2].kwargs.get("headers", {}) + assert "Authorization" not in h3 + + @patch("semantica.ingest.ssrf.socket.getaddrinfo", side_effect=_public_getaddrinfo) + def test_session_auth_does_not_reappear_in_multihop_chain(self, _): + """session.headers auth stripped for hop 2 must stay absent for hop 3.""" + sess = _make_session_with_auth("Bearer multi") + hop1 = _mock_redirect("https://other.example/step2") # cross-origin: strip + hop2 = _mock_redirect("https://other.example/final") # same-as-hop1: stay stripped + final = _mock_final() + + with patch.object(sess, "request", side_effect=[hop1, hop2, final]) as mock_req: + request_with_ssrf_guard("GET", "https://example.com/start", session=sess) + + # After the call the session is restored. + assert sess.headers.get("Authorization") == "Bearer multi" + + h2 = mock_req.call_args_list[1].kwargs.get("headers", {}) + assert "Authorization" not in h2 + h3 = mock_req.call_args_list[2].kwargs.get("headers", {}) + assert "Authorization" not in h3 + + +# =========================================================================== +# Section 3 – request_with_ssrf_guard: specific redirect-type coverage +# =========================================================================== + + +class TestRedirectTypesAndOriginChanges: + """Per-type and per-scenario auth-stripping rules.""" + + @patch("semantica.ingest.ssrf.socket.getaddrinfo", side_effect=_public_getaddrinfo) + def test_strips_on_307_cross_origin(self, _): + """307 Temporary Redirect to a different host must strip credentials.""" + redirect = MagicMock() + redirect.status_code = 307 + redirect.headers = {"Location": "https://other.example/final"} + redirect.close = MagicMock() + final = _mock_final() + + with patch( + "semantica.ingest.ssrf.requests.request", + side_effect=[redirect, final], + ) as mock_req: + request_with_ssrf_guard( + "GET", + "https://example.com/start", + headers={"Authorization": "Bearer tok"}, + ) + + second = mock_req.call_args_list[1].kwargs.get("headers", {}) + assert "Authorization" not in second + + @patch("semantica.ingest.ssrf.socket.getaddrinfo", side_effect=_public_getaddrinfo) + def test_strips_on_308_cross_origin(self, _): + """308 Permanent Redirect to a different host must strip credentials.""" + redirect = MagicMock() + redirect.status_code = 308 + redirect.headers = {"Location": "https://other.example/final"} + redirect.close = MagicMock() + final = _mock_final() + + with patch( + "semantica.ingest.ssrf.requests.request", + side_effect=[redirect, final], + ) as mock_req: + request_with_ssrf_guard( + "GET", + "https://example.com/start", + headers={"Authorization": "Bearer tok"}, + ) + + second = mock_req.call_args_list[1].kwargs.get("headers", {}) + assert "Authorization" not in second + + @patch("semantica.ingest.ssrf.socket.getaddrinfo", side_effect=_public_getaddrinfo) + def test_strips_on_port_change(self, _): + """Redirect that changes the port (non-default) must strip credentials.""" + redirect = _mock_redirect("https://example.com:8443/final") + final = _mock_final() + + with patch( + "semantica.ingest.ssrf.requests.request", + side_effect=[redirect, final], + ) as mock_req: + request_with_ssrf_guard( + "GET", + "https://example.com/start", + headers={"Authorization": "Bearer tok"}, + ) + + second = mock_req.call_args_list[1].kwargs.get("headers", {}) + assert "Authorization" not in second + + @patch("semantica.ingest.ssrf.socket.getaddrinfo", side_effect=_public_getaddrinfo) + def test_strips_on_subdomain_change(self, _): + """Redirect from apex to subdomain (different hostname) must strip credentials.""" + redirect = _mock_redirect("https://api.example.com/final") + final = _mock_final() + + with patch( + "semantica.ingest.ssrf.requests.request", + side_effect=[redirect, final], + ) as mock_req: + request_with_ssrf_guard( + "GET", + "https://example.com/start", + headers={"Authorization": "Bearer tok"}, + ) + + second = mock_req.call_args_list[1].kwargs.get("headers", {}) + assert "Authorization" not in second + + @patch("semantica.ingest.ssrf.socket.getaddrinfo", side_effect=_public_getaddrinfo) + def test_keeps_on_https_443_explicit_to_implicit(self, _): + """https://example.com:443 → https://example.com (same, just drop explicit port).""" + redirect = _mock_redirect("https://example.com/final") + final = _mock_final() + + with patch( + "semantica.ingest.ssrf.requests.request", + side_effect=[redirect, final], + ) as mock_req: + request_with_ssrf_guard( + "GET", + "https://example.com:443/start", + headers={"Authorization": "Bearer tok"}, + ) + + second = mock_req.call_args_list[1].kwargs.get("headers", {}) + assert second.get("Authorization") == "Bearer tok" + + @patch("semantica.ingest.ssrf.socket.getaddrinfo", side_effect=_public_getaddrinfo) + def test_case_insensitive_header_stripped(self, _): + """Lowercase/UPPERCASE variants of Authorization must also be stripped.""" + redirect = _mock_redirect("https://other.example/final") + final = _mock_final() + + with patch( + "semantica.ingest.ssrf.requests.request", + side_effect=[redirect, final], + ) as mock_req: + request_with_ssrf_guard( + "GET", + "https://example.com/start", + # Pass a lowercase variant to verify case-insensitive stripping. + headers={"authorization": "Bearer lower", "AUTHORIZATION": "Bearer upper"}, + ) + + second = mock_req.call_args_list[1].kwargs.get("headers", {}) + for key in second: + assert key.lower() != "authorization", ( + f"Authorization header variant {key!r} was not stripped" + ) + + +class TestAllowPrivateIpsOnRedirect: + """allow_private_ips must not extend to a redirect target on a different host.""" + + def test_cross_host_redirect_to_private_ip_is_blocked_when_pinned(self): + """allow_private_ips_on_redirect=False must block a cross-host hop into private space.""" + redirect = _mock_redirect("http://169.254.169.254/latest/meta-data/") + + with patch( + "semantica.ingest.ssrf.requests.request", + return_value=redirect, + ): + with pytest.raises(ValidationError, match="blocked"): + request_with_ssrf_guard( + "GET", + "https://trusted.example.com/start", + allow_private_ips=True, + allow_private_ips_on_redirect=False, + ) + + def test_same_host_redirect_keeps_private_ip_trust_when_pinned(self): + """A same-host redirect must still inherit the original host's trust.""" + redirect = _mock_redirect("http://localhost:8000/v2") + final = _mock_final() + + with patch( + "semantica.ingest.ssrf.requests.request", + side_effect=[redirect, final], + ) as mock_req: + request_with_ssrf_guard( + "GET", + "http://localhost:8000/start", + allow_private_ips=True, + allow_private_ips_on_redirect=False, + ) + + assert mock_req.call_count == 2 + + @patch("semantica.ingest.ssrf.socket.getaddrinfo", side_effect=_public_getaddrinfo) + def test_default_behavior_unchanged_without_the_new_kwarg(self, _): + """Existing callers that never pass allow_private_ips_on_redirect keep old behavior.""" + redirect = _mock_redirect("http://169.254.169.254/latest/meta-data/") + + with patch( + "semantica.ingest.ssrf.requests.request", + side_effect=[redirect, _mock_final()], + ) as mock_req: + # allow_private_ips=True with no override: redirect target validation + # falls back to allow_private_ips, matching pre-fix behavior for the + # existing opt-in ingestors (web/feed/api/public-api/seed). + request_with_ssrf_guard( + "GET", + "https://trusted.example.com/start", + allow_private_ips=True, + ) + + assert mock_req.call_count == 2 + + +# =========================================================================== +# Section 4 – MCPClient: redirect auth-stripping (#947) +# =========================================================================== + + +class TestMCPClientAuthRedirect: + """MCPClient._send_request_http must not leak credentials on cross-origin redirect.""" + + def _mock_mcp_response(self, payload=None): + resp = MagicMock() + resp.status_code = 200 + resp.headers = {} + resp.raise_for_status = MagicMock() + resp.json.return_value = payload or { + "jsonrpc": "2.0", + "id": 1, + "result": {"serverInfo": {}, "capabilities": {}}, + } + return resp + + @patch("semantica.ingest.ssrf.socket.getaddrinfo", side_effect=_public_getaddrinfo) + def test_cross_origin_redirect_strips_authorization(self, _): + """Authorization must not reach a different host after an MCP server redirect.""" + redirect = _mock_redirect("https://other.example/mcp") + redirect.status_code = 302 + final = self._mock_mcp_response() + + client = MCPClient( + url="https://mcp.example.com/mcp", + headers={"Authorization": "Bearer mcp-token"}, + ) + + with patch( + "semantica.ingest.ssrf.requests.request", + side_effect=[redirect, final], + ) as mock_req: + client._send_request_http({"jsonrpc": "2.0", "method": "ping"}) + + assert mock_req.call_count == 2 + second_headers = mock_req.call_args_list[1].kwargs.get("headers", {}) + assert "Authorization" not in second_headers + + @patch("semantica.ingest.ssrf.socket.getaddrinfo", side_effect=_public_getaddrinfo) + def test_same_origin_redirect_preserves_authorization(self, _): + """Same-host redirect must keep Authorization intact.""" + redirect = _mock_redirect("https://mcp.example.com/mcp/v2") + redirect.status_code = 301 + final = self._mock_mcp_response() + + client = MCPClient( + url="https://mcp.example.com/mcp", + headers={"Authorization": "Bearer mcp-token"}, + ) + + with patch( + "semantica.ingest.ssrf.requests.request", + side_effect=[redirect, final], + ) as mock_req: + client._send_request_http({"jsonrpc": "2.0", "method": "ping"}) + + assert mock_req.call_count == 2 + second_headers = mock_req.call_args_list[1].kwargs.get("headers", {}) + assert second_headers.get("Authorization") == "Bearer mcp-token" + + def test_localhost_mcp_server_is_not_blocked(self): + """localhost MCP endpoints must work (allow_private_ips=True).""" + final = self._mock_mcp_response() + client = MCPClient(url="http://localhost:8000/mcp") + + with patch( + "semantica.ingest.ssrf.requests.request", + return_value=final, + ) as mock_req: + client._send_request_http({"jsonrpc": "2.0", "method": "ping"}) + + mock_req.assert_called_once() + + def test_loopback_ip_mcp_server_is_not_blocked(self): + """127.0.0.1 MCP endpoints must work (allow_private_ips=True).""" + final = self._mock_mcp_response() + client = MCPClient(url="http://127.0.0.1:9000/mcp") + + with patch( + "semantica.ingest.ssrf.requests.request", + return_value=final, + ) as mock_req: + client._send_request_http({"jsonrpc": "2.0", "method": "ping"}) + + mock_req.assert_called_once() + + def test_same_host_redirect_on_private_mcp_server_is_not_blocked(self): + """A same-host redirect on a trusted private/localhost MCP server must still work.""" + redirect = _mock_redirect("http://localhost:8000/mcp/v2") + final = self._mock_mcp_response() + + client = MCPClient(url="http://localhost:8000/mcp") + + with patch( + "semantica.ingest.ssrf.requests.request", + side_effect=[redirect, final], + ) as mock_req: + client._send_request_http({"jsonrpc": "2.0", "method": "ping"}) + + assert mock_req.call_count == 2 + + @patch("semantica.ingest.ssrf.socket.getaddrinfo", side_effect=_public_getaddrinfo) + def test_redirect_to_private_ip_is_blocked(self, _): + """A redirect from a public MCP server to a private/internal IP must be blocked. + + allow_private_ips=True trusts the operator-configured MCP host itself; + it must not let a compromised or malicious server redirect the client + into private address space (e.g. cloud metadata) via a cross-host hop. + """ + redirect = _mock_redirect("http://169.254.169.254/latest/meta-data/") + + client = MCPClient(url="https://mcp.example.com/mcp") + + with patch( + "semantica.ingest.ssrf.requests.request", + return_value=redirect, + ): + with pytest.raises(ValidationError, match="blocked"): + client._send_request_http({"jsonrpc": "2.0", "method": "ping"}) + + @patch("semantica.ingest.ssrf.socket.getaddrinfo", side_effect=_public_getaddrinfo) + def test_scheme_downgrade_strips_authorization(self, _): + """https MCP server that redirects to http must strip the credential.""" + redirect = _mock_redirect("http://mcp.example.com/mcp") + redirect.status_code = 302 + final = self._mock_mcp_response() + + client = MCPClient( + url="https://mcp.example.com/mcp", + headers={"Authorization": "Bearer downgrade-test"}, + ) + + with patch( + "semantica.ingest.ssrf.requests.request", + side_effect=[redirect, final], + ) as mock_req: + client._send_request_http({"jsonrpc": "2.0", "method": "ping"}) + + second_headers = mock_req.call_args_list[1].kwargs.get("headers", {}) + assert "Authorization" not in second_headers + + @patch("semantica.ingest.ssrf.socket.getaddrinfo", side_effect=_public_getaddrinfo) + def test_max_redirect_cap_respected(self, _): + """Infinite redirect loop must raise ValidationError.""" + hop = _mock_redirect("https://mcp.example.com/mcp/loop") + + client = MCPClient(url="https://mcp.example.com/mcp") + + with patch( + "semantica.ingest.ssrf.requests.request", + return_value=hop, + ): + with pytest.raises((ValidationError, Exception), match="[Rr]edirect|[Ee]xceeded"): + client._send_request_http({"jsonrpc": "2.0", "method": "ping"}) + + @patch("semantica.ingest.ssrf.socket.getaddrinfo", side_effect=_public_getaddrinfo) + def test_allow_redirects_false_enforced(self, _): + """The guard must pass allow_redirects=False on every hop.""" + final = self._mock_mcp_response() + client = MCPClient( + url="https://mcp.example.com/mcp", + headers={"Authorization": "Bearer tok"}, + ) + + with patch( + "semantica.ingest.ssrf.requests.request", + return_value=final, + ) as mock_req: + client._send_request_http({"jsonrpc": "2.0", "method": "ping"}) + + assert mock_req.call_args.kwargs.get("allow_redirects") is False + + +# =========================================================================== +# Section 5 – PublicAPIIngestor: redirect auth-stripping (#947) +# =========================================================================== + + +def _mock_public_response(status: int = 200, json_payload=None) -> MagicMock: + resp = MagicMock() + resp.status_code = status + resp.headers = {"Content-Type": "application/json"} + resp.json.return_value = json_payload or [{"id": 1}] + resp.text = "" + if status >= 400: + resp.raise_for_status.side_effect = requests.exceptions.HTTPError( + f"{status} error" + ) + else: + resp.raise_for_status.return_value = None + resp.close = MagicMock() + return resp + + +class TestPublicAPIIngestorRedirectSecurity: + """PublicAPIIngestor must not leak credentials on redirect and must block SSRF.""" + + @patch("semantica.ingest.ssrf.socket.getaddrinfo", side_effect=_public_getaddrinfo) + def test_redirect_to_private_ip_blocked_in_detect(self, _): + """detect_public_api() must reject a redirect that resolves to a private IP.""" + redirect = _mock_redirect("http://169.254.169.254/latest/meta-data/") + + with patch("requests.Session") as MockSession: + mock_session = MockSession.return_value + mock_session.headers = {} + mock_session.request.return_value = redirect + mock_session.request.return_value.close = MagicMock() + + ingestor = PublicAPIIngestor(rate_limit_delay=0) + with pytest.raises(ValidationError, match="blocked"): + ingestor.detect_public_api("https://example.com/api") + + @patch("semantica.ingest.ssrf.socket.getaddrinfo", side_effect=_public_getaddrinfo) + def test_redirect_to_private_ip_blocked_in_ingest(self, _): + """ingest_public_api() must reject a redirect that resolves to a private IP.""" + redirect = _mock_redirect("http://10.0.0.1/internal") + + with patch("requests.Session") as MockSession: + mock_session = MockSession.return_value + mock_session.headers = {} + mock_session.request.return_value = redirect + mock_session.request.return_value.close = MagicMock() + + ingestor = PublicAPIIngestor(rate_limit_delay=0) + with pytest.raises(ValidationError, match="blocked"): + ingestor.ingest_public_api("https://example.com/api") + + @patch("semantica.ingest.ssrf.socket.getaddrinfo", side_effect=_public_getaddrinfo) + def test_session_auth_not_leaked_on_cross_origin_redirect_ingest(self, _): + """Session-level auth header must not reach a foreign host via ingest_public_api.""" + redirect = _mock_redirect("https://other.example/api") + final = _mock_public_response(json_payload=[{"id": 1}]) + + # Simulate a session that somehow has Authorization (e.g. misconfiguration). + with patch("requests.Session") as MockSession: + mock_session = MockSession.return_value + mock_session.headers = {"Authorization": "Bearer leaked"} + mock_session.request.side_effect = [redirect, final] + + ingestor = PublicAPIIngestor( + rate_limit_delay=0, validate_no_auth=False + ) + # Inject the auth-bearing session directly. + ingestor.session = mock_session + + ingestor.ingest_public_api("https://example.com/api") + + assert mock_session.request.call_count == 2 + second_headers = mock_session.request.call_args_list[1].kwargs.get( + "headers", {} + ) + assert "Authorization" not in second_headers + + @patch("semantica.ingest.ssrf.socket.getaddrinfo", side_effect=_public_getaddrinfo) + def test_allow_redirects_false_enforced_in_detect(self, _): + """detect_public_api() must pass allow_redirects=False to the underlying call.""" + final = _mock_public_response() + + with patch("requests.Session") as MockSession: + mock_session = MockSession.return_value + mock_session.headers = {} + mock_session.request.return_value = final + + ingestor = PublicAPIIngestor(rate_limit_delay=0) + ingestor.detect_public_api("https://example.com/api") + + assert mock_session.request.call_args.kwargs.get("allow_redirects") is False + + @patch("semantica.ingest.ssrf.socket.getaddrinfo", side_effect=_public_getaddrinfo) + def test_allow_redirects_false_enforced_in_ingest(self, _): + """ingest_public_api() must pass allow_redirects=False to the underlying call.""" + final = _mock_public_response(json_payload=[{"id": 1}]) + + with patch("requests.Session") as MockSession: + mock_session = MockSession.return_value + mock_session.headers = {} + mock_session.request.return_value = final + + ingestor = PublicAPIIngestor(rate_limit_delay=0) + ingestor.ingest_public_api("https://example.com/api") + + assert mock_session.request.call_args.kwargs.get("allow_redirects") is False + + @patch("semantica.ingest.ssrf.socket.getaddrinfo", side_effect=_public_getaddrinfo) + def test_validate_no_auth_false_does_not_bypass_redirect_stripping(self, _): + """Even with validate_no_auth=False the guard strips auth on cross-origin redirect.""" + redirect = _mock_redirect("https://other.example/api") + final = _mock_public_response(json_payload=[{"id": 1}]) + + with patch("requests.Session") as MockSession: + mock_session = MockSession.return_value + mock_session.headers = {} + mock_session.request.side_effect = [redirect, final] + + ingestor = PublicAPIIngestor( + rate_limit_delay=0, validate_no_auth=False + ) + ingestor.ingest_public_api( + "https://example.com/api", + headers={"Authorization": "Bearer should-be-stripped"}, + ) + + second_headers = mock_session.request.call_args_list[1].kwargs.get( + "headers", {} + ) + assert "Authorization" not in second_headers diff --git a/tests/ingest/test_cookbook_integration.py b/tests/ingest/test_cookbook_integration.py index c5120d65..ad470558 100644 --- a/tests/ingest/test_cookbook_integration.py +++ b/tests/ingest/test_cookbook_integration.py @@ -10,19 +10,20 @@ class TestCookbookIntegration: @pytest.fixture def mock_mcp_server(self): - # We need to patch both httpx and requests because MCPClient tries httpx first - with patch("httpx.post") as mock_httpx_post, \ - patch("requests.post") as mock_requests_post: - - def side_effect(url, json=None, **kwargs): + # MCPClient._send_request_http now routes through request_with_ssrf_guard, + # which calls requests.request (not httpx.post / requests.post directly). + # Patch at the point where the guard issues the actual HTTP call. + with patch("semantica.ingest.ssrf.requests.request") as mock_request: + + def side_effect(method, url, json=None, **kwargs): if not json: return MagicMock() - - method = json.get("method") + + rpc_method = json.get("method") response_mock = MagicMock() response_mock.status_code = 200 - - if method == "initialize": + + if rpc_method == "initialize": response_mock.json.return_value = { "jsonrpc": "2.0", "id": json.get("id"), @@ -32,7 +33,7 @@ class TestCookbookIntegration: "serverInfo": {"name": "test_server", "version": "1.0"} } } - elif method == "resources/list": + elif rpc_method == "resources/list": response_mock.json.return_value = { "jsonrpc": "2.0", "id": json.get("id"), @@ -44,7 +45,7 @@ class TestCookbookIntegration: ] } } - elif method == "tools/list": + elif rpc_method == "tools/list": response_mock.json.return_value = { "jsonrpc": "2.0", "id": json.get("id"), @@ -56,7 +57,7 @@ class TestCookbookIntegration: ] } } - elif method == "resources/read": + elif rpc_method == "resources/read": response_mock.json.return_value = { "jsonrpc": "2.0", "id": json.get("id"), @@ -66,13 +67,13 @@ class TestCookbookIntegration: ] } } - elif method == "tools/call": + elif rpc_method == "tools/call": tool_name = json.get("params", {}).get("name") content = [{"type": "text", "text": "Tool Output"}] - + if tool_name == "query_inventory": content = [{"type": "text", "text": '{"warehouse_id": "WH001", "level": 100}'}] - + response_mock.json.return_value = { "jsonrpc": "2.0", "id": json.get("id"), @@ -86,12 +87,11 @@ class TestCookbookIntegration: "id": json.get("id"), "result": {} } - + return response_mock - - mock_httpx_post.side_effect = side_effect - mock_requests_post.side_effect = side_effect - yield mock_httpx_post + + mock_request.side_effect = side_effect + yield mock_request def test_financial_data_integration(self, mock_mcp_server): """ diff --git a/tests/ingest/test_public_api_ingestor.py b/tests/ingest/test_public_api_ingestor.py index 61119427..920a99c8 100644 --- a/tests/ingest/test_public_api_ingestor.py +++ b/tests/ingest/test_public_api_ingestor.py @@ -198,15 +198,82 @@ def test_public_api_detection_reports_auth_required() -> None: headers={"WWW-Authenticate": "Bearer"}, ) - detection = PublicAPIIngestor(rate_limit_delay=0).detect_public_api( - "https://api.example.com/private" - ) + with patch( + "semantica.ingest.ssrf.socket.getaddrinfo", + return_value=[(None, None, None, None, ("93.184.216.34", 0))], + ): + detection = PublicAPIIngestor(rate_limit_delay=0).detect_public_api( + "https://api.example.com/private" + ) assert detection.is_public is False assert detection.requires_auth is True assert detection.response_status == 401 +def test_detect_public_api_propagates_ssrf_validation_error() -> None: + """detect_public_api() must surface ValidationError, not swallow it. + + request_with_ssrf_guard() raises ValidationError (not + requests.exceptions.RequestException) for SSRF-blocked hosts, so + detect_public_api()'s error handling must catch it explicitly like its + sibling ingest_public_api() already does. + """ + with patch("requests.Session") as mock_session_class: + mock_session = mock_session_class.return_value + mock_session.headers = {} + + with patch( + "semantica.ingest.ssrf.socket.getaddrinfo", + return_value=[(None, None, None, None, ("127.0.0.1", 0))], + ): + with pytest.raises(ValidationError): + PublicAPIIngestor(rate_limit_delay=0).detect_public_api( + "https://blocked.example.com/data" + ) + + +def test_detect_public_api_rejects_duplicate_session_and_allow_private_ips_kwargs() -> None: + """Passing session/allow_private_ips through **options must not crash. + + Both are always supplied explicitly to request_with_ssrf_guard(); caller + copies must be dropped from **options rather than causing a + 'got multiple values for keyword argument' TypeError. + """ + with patch("requests.Session") as mock_session_class: + mock_session = mock_session_class.return_value + mock_session.headers = {} + mock_session.request.return_value = _mock_response( + headers={"Content-Type": "application/json"} + ) + + detection = PublicAPIIngestor(rate_limit_delay=0).detect_public_api( + "https://jsonplaceholder.typicode.com/posts", + allow_private_ips=True, + session=object(), + ) + + assert detection.is_public is True + + +def test_ingest_public_api_rejects_duplicate_session_and_allow_private_ips_kwargs() -> None: + with patch("requests.Session") as mock_session_class: + mock_session = mock_session_class.return_value + mock_session.headers = {} + mock_session.request.return_value = _mock_response( + json_payload=[{"id": 1}], + headers={"Content-Type": "application/json"}, + ) + + result = PublicAPIIngestor(rate_limit_delay=0).ingest_public_api( + "https://jsonplaceholder.typicode.com/posts", + allow_private_ips=True, + session=object(), + ) + + assert result.response_status == 200 + + def test_public_api_ingestor_rejects_authentication_inputs() -> None: with patch("requests.Session") as mock_session_class: mock_session = mock_session_class.return_value @@ -238,10 +305,14 @@ def test_public_api_ingestor_parses_string_boolean_config() -> None: config={"validate_no_auth": "false"}, rate_limit_delay=0, ) - result = ingestor.ingest_public_api( - "https://api.example.com/data", - headers={"Authorization": "Bearer token"}, - ) + with patch( + "semantica.ingest.ssrf.socket.getaddrinfo", + return_value=[(None, None, None, None, ("93.184.216.34", 0))], + ): + result = ingestor.ingest_public_api( + "https://api.example.com/data", + headers={"Authorization": "Bearer token"}, + ) assert ingestor.validate_no_auth is False assert result.data == payload diff --git a/tests/ingest/test_submodules.py b/tests/ingest/test_submodules.py index e17cd330..cac9c092 100644 --- a/tests/ingest/test_submodules.py +++ b/tests/ingest/test_submodules.py @@ -195,89 +195,62 @@ class TestMCPIngestor: class TestMCPClient: def test_call_tool(self): - # Patch requests.post globally if requests is used, or httpx.post if httpx is used. - # The code tries importing httpx, then requests. - # We should patch both or ensure we catch the right one. - # Simpler to patch sys.modules to simulate httpx missing, then patch requests. - - with patch.dict(sys.modules, {'httpx': None}): - with patch("requests.post") as mock_post: - mock_response = MagicMock() - mock_response.status_code = 200 - - # Sequence of calls: - # 1. connect() calls _connect_http() -> calls _initialize() -> calls _send_request() - # _send_request() calls requests.post with method="initialize" - # 2. call_tool() calls _send_request() with method="tools/call" - - # Response for initialize - init_response = { - "jsonrpc": "2.0", - "result": {"serverInfo": {"name": "test", "version": "1.0"}}, - "id": 1 - } - - # Response for tool call - tool_response = { - "jsonrpc": "2.0", - "result": {"content": [{"type": "text", "text": "Tool Result"}]}, - "id": 2 - } - - mock_response.json.side_effect = [init_response, tool_response] - mock_post.return_value = mock_response - - client = MCPClient(url="http://localhost:8000") - client.connect() - - result = client.call_tool("my_tool", {"arg": "val"}) - - # result is the dict returned by tool call? - # call_tool returns dict? - # Check MCPClient.call_tool implementation - # It calls _send_request, which returns response.json(). - # But wait, call_tool might process the result. - # Let's check call_tool implementation in mcp_client.py (not read yet, but assumed). - # Wait, I read mcp_client.py but didn't check call_tool specifically. - # Assuming call_tool returns result part or whole response. - - # Actually, let's verify call_tool in mcp_client.py - pass + # MCPClient._send_request_http now routes through request_with_ssrf_guard, + # which calls requests.request (not requests.post) with allow_redirects=False. + # Patch the requests.request call inside ssrf.py. + with patch("semantica.ingest.ssrf.requests.request") as mock_request: + mock_response = MagicMock() + mock_response.status_code = 200 + + # Response for initialize + init_response = { + "jsonrpc": "2.0", + "result": {"serverInfo": {"name": "test", "version": "1.0"}}, + "id": 1, + } + + # Response for tool call + tool_response = { + "jsonrpc": "2.0", + "result": {"content": [{"type": "text", "text": "Tool Result"}]}, + "id": 2, + } + + mock_response.json.side_effect = [init_response, tool_response] + mock_request.return_value = mock_response + + client = MCPClient(url="http://localhost:8000") + client.connect() + + client.call_tool("my_tool", {"arg": "val"}) def test_call_tool_mock_check(self): - # Redoing the test with more specific mocking logic - with patch.dict(sys.modules, {'httpx': None}): - with patch("requests.post") as mock_post: - mock_response = MagicMock() - mock_response.status_code = 200 - - # initialize response - init_response = { - "jsonrpc": "2.0", - "result": {"serverInfo": {"name": "test", "version": "1.0"}}, - "id": 1 - } - - # tool call response - Assuming call_tool returns the 'result' part of JSON-RPC response - # If call_tool implementation wraps it, we need to know. - # Let's assume standard behavior for now. - tool_response = { - "jsonrpc": "2.0", - "result": {"content": [{"type": "text", "text": "Tool Result"}]}, - "id": 2 - } - - mock_response.json.side_effect = [init_response, tool_response] - mock_post.return_value = mock_response - - client = MCPClient(url="http://localhost:8000") - client.connect() - - result = client.call_tool("my_tool", {"arg": "val"}) - - # Verify result. - # If call_tool returns the 'result' dict from JSON-RPC: - assert result["content"] == [{"type": "text", "text": "Tool Result"}] + # Redo with the corrected patch target. + with patch("semantica.ingest.ssrf.requests.request") as mock_request: + mock_response = MagicMock() + mock_response.status_code = 200 + + init_response = { + "jsonrpc": "2.0", + "result": {"serverInfo": {"name": "test", "version": "1.0"}}, + "id": 1, + } + + tool_response = { + "jsonrpc": "2.0", + "result": {"content": [{"type": "text", "text": "Tool Result"}]}, + "id": 2, + } + + mock_response.json.side_effect = [init_response, tool_response] + mock_request.return_value = mock_response + + client = MCPClient(url="http://localhost:8000") + client.connect() + + result = client.call_tool("my_tool", {"arg": "val"}) + + assert result["content"] == [{"type": "text", "text": "Tool Result"}] class TestGDriveIngestor: def test_init_raises_if_no_google_libs(self): diff --git a/tests/test_seed_manager.py b/tests/test_seed_manager.py index c66149cc..95d490a1 100644 --- a/tests/test_seed_manager.py +++ b/tests/test_seed_manager.py @@ -209,6 +209,60 @@ def test_load_from_api_allows_private_when_configured(mock_guard, seed_manager): call_kwargs = mock_guard.call_args[1] assert call_kwargs["allow_private_ips"] is True + +@patch("semantica.seed.seed_manager.request_with_ssrf_guard") +def test_load_from_api_does_not_mutate_caller_headers_dict(mock_guard, seed_manager): + """Regression test for issue #947 audit: load_from_api must not mutate the + caller's headers dict in-place when api_key is provided. + + Before the fix, ``request_headers = headers or {}`` aliased the caller's dict. + Writing ``request_headers["Authorization"] = ...`` then silently modified the + caller's original dict, potentially leaking credentials to subsequent calls + that reused the same headers dict without expecting it to carry Authorization. + """ + mock_response = MagicMock() + mock_response.json.return_value = {"results": []} + mock_guard.return_value = mock_response + + # Caller owns this dict and expects it to be unchanged after the call. + original_headers = {"X-Custom-Header": "value"} + headers_before = dict(original_headers) # snapshot + + seed_manager.load_from_api( + api_url="http://api.example.com", + api_key="secret-key", + headers=original_headers, + ) + + # The caller's dict must be unchanged — Authorization must NOT have been added. + assert original_headers == headers_before, ( + "load_from_api must not mutate the caller's headers dict; " + f"expected {headers_before!r}, got {original_headers!r}" + ) + + # The guard must still have received Authorization (in its own copy). + call_kwargs = mock_guard.call_args[1] + guard_headers = call_kwargs.get("headers", {}) + assert guard_headers.get("Authorization") == "Bearer secret-key" + + +@patch("semantica.seed.seed_manager.request_with_ssrf_guard") +def test_load_from_api_does_not_mutate_empty_headers_dict(mock_guard, seed_manager): + """When headers=None, a fresh dict is created — no aliasing to a shared mutable default.""" + mock_response = MagicMock() + mock_response.json.return_value = {"results": []} + mock_guard.return_value = mock_response + + seed_manager.load_from_api( + api_url="http://api.example.com", + api_key="key", + headers=None, + ) + + call_kwargs = mock_guard.call_args[1] + guard_headers = call_kwargs.get("headers", {}) + assert guard_headers.get("Authorization") == "Bearer key" + def test_load_source(seed_manager, temp_data_dir): json_file = temp_data_dir / "source.json" with open(json_file, "w") as f: From c58686b4ec2db675adb482b1df099c18b1354584 Mon Sep 17 00:00:00 2001 From: unknown <1784931579@qq.com> Date: Tue, 18 Aug 2026 03:06:16 +0800 Subject: [PATCH 069/105] Address review: edge labels carry text and follow an Effects toggle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two findings from the Qodo review: - Sigma's edge label renderer draws data.label, but the graph stores the relationship type in edgeType — enabling renderEdgeLabels alone left edges blank. The edgeReducer now maps edgeType onto label (suppressed for hidden edges). - renderEdgeLabels was hardcoded on with no way to disable it. It now follows a new edgeLabelsEnabled entry in the Effects panel (default on), wired through the existing GraphEffectToggle/GraphEffectsState plumbing, so dense graphs get their label-free edges back. --- .../src/workspaces/GraphWorkspace/GraphCanvas.tsx | 15 +++++++++++++++ .../workspaces/GraphWorkspace/GraphWorkspace.tsx | 1 + .../plugins/explorationEffectsPlugin.tsx | 5 +++++ .../plugins/explorationEffectsPluginPhaseC.tsx | 6 ++++++ explorer/src/workspaces/GraphWorkspace/types.ts | 2 ++ 5 files changed, 29 insertions(+) diff --git a/explorer/src/workspaces/GraphWorkspace/GraphCanvas.tsx b/explorer/src/workspaces/GraphWorkspace/GraphCanvas.tsx index 328bfd6c..575c884b 100644 --- a/explorer/src/workspaces/GraphWorkspace/GraphCanvas.tsx +++ b/explorer/src/workspaces/GraphWorkspace/GraphCanvas.tsx @@ -1215,6 +1215,10 @@ function applySceneState( size: resolvedStyle.size, zIndex: resolvedStyle.zIndex, curvature: resolvedStyle.curvature, + // #1009: Sigma's edge label renderer draws data.label — the graph + // stores the relationship type in edgeType, which the renderer never + // saw, so enabling renderEdgeLabels alone left edges blank. + label: resolvedStyle.hidden ? undefined : String(attrs.edgeType ?? data.label ?? ""), }; }); @@ -1941,6 +1945,17 @@ export const GraphCanvas = forwardRef( }); }, [behaviors, dispatchToBehaviors, getBehaviorContext, graphReady, syncCameraState]); + // #1009: renderEdgeLabels follows the Effects-panel toggle instead of + // staying hardcoded — dense graphs get their label-free edges back. + useEffect(() => { + const sigma = sigmaRef.current; + if (!sigma) { + return; + } + sigma.setSetting("renderEdgeLabels", effectsState.edgeLabelsEnabled); + sigma.scheduleRefresh(); + }, [effectsState.edgeLabelsEnabled]); + useEffect(() => { return () => { const sigma = sigmaRef.current; diff --git a/explorer/src/workspaces/GraphWorkspace/GraphWorkspace.tsx b/explorer/src/workspaces/GraphWorkspace/GraphWorkspace.tsx index e077980b..1a9cface 100644 --- a/explorer/src/workspaces/GraphWorkspace/GraphWorkspace.tsx +++ b/explorer/src/workspaces/GraphWorkspace/GraphWorkspace.tsx @@ -148,6 +148,7 @@ const DEFAULT_EFFECTS_STATE: GraphEffectsState = { communitiesEnabled: false, centralityEnabled: false, legendEnabled: false, + edgeLabelsEnabled: true, diagnosticsEnabled: false, lensMode: "neighborhood", effectQuality: "bounded", diff --git a/explorer/src/workspaces/GraphWorkspace/plugins/explorationEffectsPlugin.tsx b/explorer/src/workspaces/GraphWorkspace/plugins/explorationEffectsPlugin.tsx index 9f174358..6b9b9972 100644 --- a/explorer/src/workspaces/GraphWorkspace/plugins/explorationEffectsPlugin.tsx +++ b/explorer/src/workspaces/GraphWorkspace/plugins/explorationEffectsPlugin.tsx @@ -30,6 +30,11 @@ const EFFECT_ROWS: EffectRowConfig[] = [ label: "Neighborhood Lens", description: "Local emphasis around the hovered or selected node.", }, + { + key: "edgeLabelsEnabled", + label: "Edge Labels", + description: "Draw the relationship type on graph edges. Off restores label-free edges on dense graphs.", + }, { key: "legendEnabled", label: "Semantic Legend", diff --git a/explorer/src/workspaces/GraphWorkspace/plugins/explorationEffectsPluginPhaseC.tsx b/explorer/src/workspaces/GraphWorkspace/plugins/explorationEffectsPluginPhaseC.tsx index 3fb936bb..58e1ad7e 100644 --- a/explorer/src/workspaces/GraphWorkspace/plugins/explorationEffectsPluginPhaseC.tsx +++ b/explorer/src/workspaces/GraphWorkspace/plugins/explorationEffectsPluginPhaseC.tsx @@ -47,6 +47,11 @@ const SCENE_EFFECT_ROWS: EffectRowConfig[] = [ label: "Contours", description: "Low-contrast density halos around the strongest visible anchors.", }, + { + key: "edgeLabelsEnabled", + label: "Edge Labels", + description: "Draw the relationship type on graph edges. Off restores label-free edges on dense graphs.", + }, { key: "legendEnabled", label: "Regions Summary", @@ -83,6 +88,7 @@ const AVAILABILITY_KEYS: Record Date: Mon, 17 Aug 2026 22:18:39 +0100 Subject: [PATCH 070/105] test(export): guard Parquet tests on pyarrow itself, not the exporter import (#1056) * test(export): guard Parquet tests on pyarrow itself, not the exporter import Closes #1054 * test(export): guard on PARQUET_AVAILABLE so the skip matches the runtime check find_spec only proves pyarrow is discoverable, not importable. Addresses review feedback on #1056. --------- --- ...st_030_context_graph_realworld_extended.py | 56 +++++++++---------- 1 file changed, 28 insertions(+), 28 deletions(-) diff --git a/tests/test_030_context_graph_realworld_extended.py b/tests/test_030_context_graph_realworld_extended.py index 28d0f8f0..28ad1506 100644 --- a/tests/test_030_context_graph_realworld_extended.py +++ b/tests/test_030_context_graph_realworld_extended.py @@ -54,6 +54,11 @@ from semantica.context.decision_models import ( validate_decision, ) +# ── Export module ────────────────────────────────────────────────────────────── +# Set by the exporter's own `import pyarrow` attempt; False when pyarrow is +# missing or unimportable. +from semantica.export.parquet_exporter import PARQUET_AVAILABLE + # ── KG module ────────────────────────────────────────────────────────────────── from semantica.kg import ( CentralityCalculator, @@ -981,6 +986,17 @@ class TestParquetExportRealData: Requires: pyarrow (optional dep — tests skip if not installed). """ + # ParquetExporter imports fine without pyarrow and only raises ImportError + # when an export actually runs, so guarding on that import never skips + # anything. Guard on the exporter's own availability flag instead: it is set + # by the same `import pyarrow` / `import pyarrow.parquet` the exporter gates + # on, so the skip condition cannot drift from the runtime check — including + # when pyarrow is present on the path but fails to import. + pytestmark = pytest.mark.skipif( + not PARQUET_AVAILABLE, + reason="pyarrow not installed", + ) + @pytest.fixture def kg_data(self): return { @@ -1000,16 +1016,12 @@ class TestParquetExportRealData: } def test_parquet_exporter_importable(self): - try: - from semantica.export import ParquetExporter - except ImportError as e: - pytest.skip(f"ParquetExporter not available: {e}") + from semantica.export import ParquetExporter + + assert ParquetExporter is not None def test_parquet_export_entities_to_file(self, kg_data, tmp_path): - try: - from semantica.export import ParquetExporter - except ImportError: - pytest.skip("pyarrow not installed") + from semantica.export import ParquetExporter exporter = ParquetExporter(compression="snappy") out_path = tmp_path / "github_entities.parquet" @@ -1018,10 +1030,7 @@ class TestParquetExportRealData: assert out_path.stat().st_size > 0 def test_parquet_export_relationships_to_file(self, kg_data, tmp_path): - try: - from semantica.export import ParquetExporter - except ImportError: - pytest.skip("pyarrow not installed") + from semantica.export import ParquetExporter exporter = ParquetExporter(compression="gzip") out_path = tmp_path / "github_relationships.parquet" @@ -1030,10 +1039,7 @@ class TestParquetExportRealData: assert out_path.stat().st_size > 0 def test_parquet_export_knowledge_graph(self, kg_data, tmp_path): - try: - from semantica.export import ParquetExporter - except ImportError: - pytest.skip("pyarrow not installed") + from semantica.export import ParquetExporter exporter = ParquetExporter(compression="snappy") base_path = tmp_path / "github_kg" @@ -1043,30 +1049,24 @@ class TestParquetExportRealData: assert len(files) >= 1 def test_parquet_export_snappy_compression(self, kg_data, tmp_path): - try: - from semantica.export import ParquetExporter - except ImportError: - pytest.skip("pyarrow not installed") + from semantica.export import ParquetExporter + exporter = ParquetExporter(compression="snappy") out_path = tmp_path / "snappy_test.parquet" exporter.export_entities(kg_data["entities"], str(out_path)) assert out_path.exists() def test_parquet_export_none_compression(self, kg_data, tmp_path): - try: - from semantica.export import ParquetExporter - except ImportError: - pytest.skip("pyarrow not installed") + from semantica.export import ParquetExporter + exporter = ParquetExporter(compression="none") out_path = tmp_path / "uncompressed_test.parquet" exporter.export_entities(kg_data["entities"], str(out_path)) assert out_path.exists() def test_parquet_convenience_function(self, kg_data, tmp_path): - try: - from semantica.export.methods import export_parquet - except ImportError: - pytest.skip("pyarrow not installed") + from semantica.export.methods import export_parquet + out_path = tmp_path / "convenience_test.parquet" export_parquet(kg_data["entities"], str(out_path)) assert out_path.exists() From dae21166a14dfc929c326b584c3a86fdcf3006b2 Mon Sep 17 00:00:00 2001 From: Kyou0203 Date: Tue, 18 Aug 2026 12:48:01 +0800 Subject: [PATCH 071/105] docs(explorer): clarify auth behavior and document auth env vars Address review feedback: - State that only protected routes require the API key and note that /api/health and /api/info are intentionally unauthenticated. - Note the CLI warning on non-loopback binds only fires in anonymous mode or when SEMANTICA_API_KEY is unset. - Add SEMANTICA_API_KEY and SEMANTICA_ALLOW_ANONYMOUS to the Environment variables table. --- explorer/README.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/explorer/README.md b/explorer/README.md index 3f5c313e..89616096 100644 --- a/explorer/README.md +++ b/explorer/README.md @@ -63,9 +63,9 @@ semantica-explorer --graph my_graph.json --no-browser python -m semantica.explorer --graph my_graph.json ``` -> **Security note:** Since v0.6.5 the Explorer API requires an API key. Set the `SEMANTICA_API_KEY` environment variable and send it as the `X-API-Key` header on every request; without a configured key, protected routes fail closed with `503` rather than serving anonymously. To opt into unauthenticated access for local development only, set `SEMANTICA_ALLOW_ANONYMOUS=true` explicitly. +> **Security note:** Since v0.6.5 the Explorer API requires an API key on protected routes. Set the `SEMANTICA_API_KEY` environment variable and send it as the `X-API-Key` header; without a configured key, protected routes fail closed with `503` rather than serving anonymously. To opt into unauthenticated access for local development only, set `SEMANTICA_ALLOW_ANONYMOUS=true` explicitly. (`/api/health` and `/api/info` are intentionally unauthenticated.) > -> The default `--host 127.0.0.1` binds to localhost only, so it is not reachable from other machines on your network. If you bind to `0.0.0.0`, all graph data is readable and writable by any host that can reach the port (subject to API-key auth); the CLI will print a warning in that case. +> The default `--host 127.0.0.1` binds to localhost only, so it is not reachable from other machines on your network. If you bind to `0.0.0.0`, all graph data is readable and writable by any host that can reach the port (subject to API-key auth). The CLI prints a warning when binding to a non-loopback host in anonymous mode or when `SEMANTICA_API_KEY` is unset. --- @@ -150,6 +150,8 @@ This writes the compiled assets to `../semantica/static/`. The Python server the | --- | --- | --- | | `EXPLORER_CORS_ORIGINS` | `http://localhost:5173,http://127.0.0.1:5173` | Comma-separated list of allowed CORS origins | | `EXPLORER_CORS_CREDENTIALS` | `false` | Set to `true` to allow credentialed cross-origin requests (only needed behind an authenticating reverse proxy) | +| `SEMANTICA_API_KEY` | *(unset)* | API key required on protected routes since v0.6.5; send it as the `X-API-Key` header. When unset, protected routes fail closed with `503`. | +| `SEMANTICA_ALLOW_ANONYMOUS` | `false` | Set to `true` to opt into unauthenticated access (local development only). | --- From 5c2901ae27004a799e18cd3d6dfcdb9edcf524da Mon Sep 17 00:00:00 2001 From: pravit-amp <43916793+pravit-amp@users.noreply.github.com> Date: Mon, 17 Aug 2026 23:19:32 -0700 Subject: [PATCH 072/105] docs(context): fix unrunnable ContextGraph docstring example (#921) * docs(context): fix unrunnable ContextGraph docstring example The module docstring's Example Usage block called add_node/add_edge with keyword arguments they do not accept. add_node(node_id, node_type, ...) takes node_type positionally and has no properties parameter, so the documented call raised TypeError; add_edge's parameter is edge_type, so type= fell through to **properties and polluted edge metadata while appearing to work. Two of the three broken forms failed silently rather than raising, storing a nested properties dict or a stray type key instead of erroring. Add regression tests that execute the documented calls and assert the docstring itself does not reintroduce the invalid kwargs. Co-Authored-By: Claude Opus 5 * test(context): close two blind spots in the docstring regression guards The guards added in the previous commit could pass while checking nothing. _example_block() terminated the capture at the first "\n\n". The Example Usage block already contains ">>> " spacer lines, so any reformatting that turned one into a bare blank line would truncate the capture -- potentially to empty -- and the guards would then scan a block that no longer held the add_node/add_edge calls they exist to police. Both guards also iterated over re.findall() without asserting a match. Zero matches meant zero assertions and a green test, so the two failure modes compounded: a truncated block produced no matches, and no matches produced a pass. Terminate the block at the next top-level section header (^\S) or end of docstring instead, so blank lines inside the example are harmless, and assert the captured block, the parsed statement list, and each guard's match list are all non-empty. Extract statements with doctest.DocTestParser rather than a line regex. This also catches a call reformatted across "..." continuation lines, which the ">>> graph.add_node(.*" pattern silently skipped, and lets test_documented_calls_execute exec the docstring's own statements instead of a retyped copy that could drift from it. Full doctest.testmod isn't usable here: add_node/add_edge return True and the docs carry no expected-output lines, so it reports 4 spurious failures. Narrow the kwarg check to (? * fix(context): correct precedent lookup in docstring example --------- Co-authored-by: Pravit Ampapathini Co-authored-by: Claude Opus 5 Co-authored-by: Sameer Kadam Co-authored-by: Mohd Kaif <98801504+KaifAhmad1@users.noreply.github.com> --- semantica/context/context_graph.py | 8 +- .../test_context_graph_docstring_example.py | 142 ++++++++++++++++++ 2 files changed, 146 insertions(+), 4 deletions(-) create mode 100644 tests/context/test_context_graph_docstring_example.py diff --git a/semantica/context/context_graph.py b/semantica/context/context_graph.py index 28b431ef..ad6ecf3f 100644 --- a/semantica/context/context_graph.py +++ b/semantica/context/context_graph.py @@ -72,9 +72,9 @@ Example Usage: ... node_embeddings=True) >>> >>> # Basic graph operations - >>> graph.add_node("Python", type="language", properties={"popularity": "high"}) - >>> graph.add_node("Programming", type="concept") - >>> graph.add_edge("Python", "Programming", type="related_to") + >>> graph.add_node("Python", "language", popularity="high") + >>> graph.add_node("Programming", "concept") + >>> graph.add_edge("Python", "Programming", "related_to") >>> centrality = graph.get_node_centrality("Python") >>> similar = graph.find_similar_nodes("Python", similarity_type="content") >>> analysis = graph.analyze_graph_with_kg() @@ -88,7 +88,7 @@ Example Usage: ... confidence=0.95, ... entities=["customer_123", "property_456"] ... ) - >>> precedents = graph.find_precedents("loan_approval", limit=5) + >>> precedents = graph.find_precedents(decision_id, limit=5) >>> influence = graph.analyze_decision_influence(decision_id) >>> insights = graph.get_decision_insights() >>> causality = graph.trace_decision_causality(decision_id) diff --git a/tests/context/test_context_graph_docstring_example.py b/tests/context/test_context_graph_docstring_example.py new file mode 100644 index 00000000..8dcd4039 --- /dev/null +++ b/tests/context/test_context_graph_docstring_example.py @@ -0,0 +1,142 @@ +#!/usr/bin/env python3 +"""Regression tests for the ContextGraph module docstring example. + +The "Example Usage" block in ``semantica/context/context_graph.py`` previously +called ``add_node``/``add_edge`` with keyword arguments those methods do not +accept (``type=`` and ``properties=``), so the documented example raised +``TypeError`` -- and the near-miss variants silently nested the properties dict +instead of failing. + +These tests keep the documented example executable and pin the two behaviours +that made the original mistake easy to miss. +""" + +import doctest +import re +from typing import Dict, List + +import pytest + +import semantica.context.context_graph as context_graph_module +from semantica.context.context_graph import ContextGraph + +# The example block runs to the next top-level section header (a line starting +# in column 0, e.g. "Production Use Cases:") or the end of the docstring. +# Terminating on the next header rather than on a blank line keeps the capture +# intact when the example gains blank lines or extra paragraphs. +_EXAMPLE_BLOCK_RE = re.compile(r"^Example Usage:\n(.*?)(?=^\S|\Z)", re.DOTALL | re.MULTILINE) + +# ``type=`` as its own keyword, but not the legitimate ``node_type=``/``edge_type=``. +_BARE_TYPE_KWARG_RE = re.compile(r"(? str: + """Return the 'Example Usage' block from the module docstring.""" + doc = context_graph_module.__doc__ or "" + match = _EXAMPLE_BLOCK_RE.search(doc) + assert match, "module docstring no longer contains an 'Example Usage:' block" + block = match.group(1).strip() + assert block, "the 'Example Usage:' block in the module docstring is empty" + return block + + +def _example_statements() -> List[str]: + """Return the documented ``>>>`` statements, continuation lines included.""" + statements = [example.source for example in doctest.DocTestParser().get_examples(_example_block())] + assert statements, "the 'Example Usage:' block no longer contains any '>>>' statements" + return statements + + +def _statements_calling(method: str) -> List[str]: + """Return the documented statements that call ``graph.(``.""" + return [stmt for stmt in _example_statements() if "graph.{}(".format(method) in stmt] + + +def _run_example() -> Dict[str, object]: + """Execute the documented example verbatim and return its namespace.""" + source = "".join(_example_statements()) + namespace: Dict[str, object] = {} + exec(compile(source, "", "exec"), namespace) + return namespace + + +class TestDocstringExampleIsRunnable: + """The documented example must execute exactly as written.""" + + def test_documented_calls_execute(self): + # Run the docstring text itself so this test cannot drift from the docs. + ns = _run_example() + graph = ns["graph"] + + assert "Python" in graph.nodes + assert "Programming" in graph.nodes + assert graph.nodes["Python"].node_type == "language" + assert graph.nodes["Programming"].node_type == "concept" + + neighbors = graph.get_neighbors("Python", hops=1) + assert any(n["id"] == "Programming" for n in neighbors) + + # record_decision must return a non-empty string ID. + assert isinstance(ns["decision_id"], str) and ns["decision_id"] + # find_precedents must be called with that ID and return a list. + assert isinstance(ns["precedents"], list) + + def test_node_properties_are_stored_flat(self): + """``popularity`` must land as a top-level property, not nested. + + Passing the previously documented ``properties={...}`` does not raise -- + it stores a dict *inside* the properties dict, which is why the original + docs bug could reach a user's graph unnoticed. + """ + graph = ContextGraph(advanced_analytics=False) + graph.add_node("Python", "language", popularity="high") + + assert graph.nodes["Python"].properties == {"popularity": "high"} + assert graph.find_node("Python")["metadata"]["popularity"] == "high" + assert "properties" not in graph.nodes["Python"].properties + + def test_edge_type_is_positional_not_a_property(self): + """``related_to`` must be the edge type, not a stray metadata key.""" + graph = ContextGraph(advanced_analytics=False) + graph.add_node("Python", "language") + graph.add_node("Programming", "concept") + graph.add_edge("Python", "Programming", "related_to") + + edge = graph.edges[0] + assert edge.edge_type == "related_to" + assert "type" not in edge.metadata + + +class TestDocstringExampleDoesNotRegress: + """Guard the docstring text itself, not just equivalent code.""" + + def test_add_node_example_supplies_node_type_positionally(self): + calls = _statements_calling("add_node") + assert calls, "the 'Example Usage:' block no longer calls graph.add_node()" + for call in calls: + assert not _BARE_TYPE_KWARG_RE.search(call), ( + f"add_node example passes type= as a keyword: {call!r}. " + "node_type is positional-required; type= falls through to " + "**properties and the call raises TypeError." + ) + assert "properties=" not in call, ( + f"add_node example passes properties=: {call!r}. " + "add_node has no properties parameter; extra properties are " + "passed as **kwargs." + ) + + def test_add_edge_example_supplies_edge_type_positionally(self): + calls = _statements_calling("add_edge") + assert calls, "the 'Example Usage:' block no longer calls graph.add_edge()" + for call in calls: + assert not _BARE_TYPE_KWARG_RE.search(call), ( + f"add_edge example passes type= as a keyword: {call!r}. " + "The parameter is edge_type; type= is silently absorbed into " + "**properties and pollutes edge metadata." + ) + + def test_broken_form_still_raises(self): + """Pin the signature contract the example has to respect.""" + graph = ContextGraph(advanced_analytics=False) + with pytest.raises(TypeError, match="node_type"): + graph.add_node("Python", type="language", properties={"popularity": "high"}) From 0f308b2078af6dac71322f4943dc12b2af4fc249 Mon Sep 17 00:00:00 2001 From: Sakshi Jain Date: Tue, 18 Aug 2026 12:00:37 +0530 Subject: [PATCH 073/105] feat(explorer): add markdown content preview and source view --- explorer/package-lock.json | 1474 ++++++++++++++++- explorer/package.json | 6 +- .../GraphWorkspace/GraphInspectorPanel.tsx | 14 + .../GraphWorkspace/MarkdownContentViewer.tsx | 337 ++++ explorer/tests/markdownContentViewer.test.ts | 70 + 5 files changed, 1895 insertions(+), 6 deletions(-) create mode 100644 explorer/src/workspaces/GraphWorkspace/MarkdownContentViewer.tsx create mode 100644 explorer/tests/markdownContentViewer.test.ts diff --git a/explorer/package-lock.json b/explorer/package-lock.json index 98e9bc17..f8ffecff 100644 --- a/explorer/package-lock.json +++ b/explorer/package-lock.json @@ -24,6 +24,8 @@ "react-arborist": "^3.4.3", "react-dom": "^19.2.4", "react-dropzone": "^15.0.0", + "react-markdown": "^10.1.0", + "remark-gfm": "^4.0.1", "sigma": "^3.0.2", "vis-data": "^8.0.3", "vis-timeline": "^8.5.0" @@ -1565,6 +1567,15 @@ "@types/d3-selection": "*" } }, + "node_modules/@types/debug": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, "node_modules/@types/esrecurse": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", @@ -1576,9 +1587,17 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, "license": "MIT" }, + "node_modules/@types/estree-jsx": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", + "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==", + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, "node_modules/@types/hammerjs": { "version": "2.0.46", "resolved": "https://registry.npmjs.org/@types/hammerjs/-/hammerjs-2.0.46.tgz", @@ -1586,6 +1605,15 @@ "license": "MIT", "peer": true }, + "node_modules/@types/hast": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.5.tgz", + "integrity": "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -1593,6 +1621,21 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, "node_modules/@types/node": { "version": "24.12.2", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.2.tgz", @@ -1607,7 +1650,6 @@ "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", - "devOptional": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -1631,6 +1673,12 @@ "optional": true, "peer": true }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.58.2", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.58.2.tgz", @@ -1874,6 +1922,12 @@ "url": "https://opencollective.com/typescript-eslint" } }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.3.tgz", + "integrity": "sha512-60YRaenCQcVjYEKOcG824+DRGGIQ3VKErcBoAEDJZz5bKIs2ZG+X/H9Nk+Q6EVkwJk5QNApxbrc5QtBSwtrXAg==", + "license": "ISC" + }, "node_modules/@vitejs/plugin-react": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", @@ -1992,6 +2046,16 @@ "@babel/types": "^7.26.0" } }, + "node_modules/bail": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", + "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/balanced-match": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", @@ -2083,12 +2147,72 @@ ], "license": "CC-BY-4.0" }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-reference-invalid": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", + "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/classcat": { "version": "5.0.5", "resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz", "integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==", "license": "MIT" }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/commander": { "version": "2.20.3", "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", @@ -2139,7 +2263,6 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "devOptional": true, "license": "MIT" }, "node_modules/d3-color": { @@ -2251,7 +2374,6 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -2265,6 +2387,19 @@ } } }, + "node_modules/decode-named-character-reference": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", + "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", + "license": "MIT", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -2272,6 +2407,28 @@ "dev": true, "license": "MIT" }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/dnd-core": { "version": "14.0.1", "resolved": "https://registry.npmjs.org/dnd-core/-/dnd-core-14.0.1.tgz", @@ -2549,6 +2706,16 @@ "node": ">=4.0" } }, + "node_modules/estree-util-is-identifier-name": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", + "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -2568,6 +2735,12 @@ "node": ">=0.8.x" } }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -2819,6 +2992,46 @@ "graphology-types": ">=0.23.0" } }, + "node_modules/hast-util-to-jsx-runtime": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", + "integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "style-to-js": "^1.0.0", + "unist-util-position": "^5.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/hermes-estree": { "version": "0.25.1", "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", @@ -2845,6 +3058,16 @@ "react-is": "^16.7.0" } }, + "node_modules/html-url-attributes": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", + "integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -2865,6 +3088,46 @@ "node": ">=0.8.19" } }, + "node_modules/inline-style-parser": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", + "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", + "license": "MIT" + }, + "node_modules/is-alphabetical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", + "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-alphanumerical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", + "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", + "license": "MIT", + "dependencies": { + "is-alphabetical": "^2.0.0", + "is-decimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-decimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", + "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -2888,6 +3151,28 @@ "node": ">=0.10.0" } }, + "node_modules/is-hexadecimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", + "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -2995,6 +3280,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/longest-streak": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", + "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -3026,6 +3321,16 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/markdown-table": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", + "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/marked": { "version": "14.0.0", "resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz", @@ -3039,12 +3344,857 @@ "node": ">= 18" } }, + "node_modules/mdast-util-find-and-replace": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", + "integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "escape-string-regexp": "^5.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-find-and-replace/node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mdast-util-from-markdown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz", + "integrity": "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark": "^4.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz", + "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==", + "license": "MIT", + "dependencies": { + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-gfm-autolink-literal": "^2.0.0", + "mdast-util-gfm-footnote": "^2.0.0", + "mdast-util-gfm-strikethrough": "^2.0.0", + "mdast-util-gfm-table": "^2.0.0", + "mdast-util-gfm-task-list-item": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-autolink-literal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz", + "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "ccount": "^2.0.0", + "devlop": "^1.0.0", + "mdast-util-find-and-replace": "^3.0.0", + "micromark-util-character": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-strikethrough": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz", + "integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-table": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz", + "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "markdown-table": "^3.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-task-list-item": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz", + "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-expression": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", + "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-jsx": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz", + "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "parse-entities": "^4.0.0", + "stringify-entities": "^4.0.0", + "unist-util-stringify-position": "^4.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdxjs-esm": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz", + "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-phrasing": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", + "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-markdown": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", + "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "longest-streak": "^3.0.0", + "mdast-util-phrasing": "^4.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "unist-util-visit": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", + "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/memoize-one": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz", "integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==", "license": "MIT" }, + "node_modules/micromark": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", + "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", + "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-gfm": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz", + "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==", + "license": "MIT", + "dependencies": { + "micromark-extension-gfm-autolink-literal": "^2.0.0", + "micromark-extension-gfm-footnote": "^2.0.0", + "micromark-extension-gfm-strikethrough": "^2.0.0", + "micromark-extension-gfm-table": "^2.0.0", + "micromark-extension-gfm-tagfilter": "^2.0.0", + "micromark-extension-gfm-task-list-item": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-autolink-literal": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz", + "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==", + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-strikethrough": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz", + "integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-table": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz", + "integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-tagfilter": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz", + "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==", + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-task-list-item": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz", + "integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-string": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", + "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, "node_modules/minimatch": { "version": "10.2.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", @@ -3095,7 +4245,6 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/nanoid": { @@ -3205,6 +4354,31 @@ "mnemonist": "^0.39.2" } }, + "node_modules/parse-entities": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", + "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "character-entities-legacy": "^3.0.0", + "character-reference-invalid": "^2.0.0", + "decode-named-character-reference": "^1.0.0", + "is-alphanumerical": "^2.0.0", + "is-decimal": "^2.0.0", + "is-hexadecimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/parse-entities/node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", + "license": "MIT" + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -3349,6 +4523,16 @@ "@egjs/hammerjs": "^2.0.17" } }, + "node_modules/property-information": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.2.0.tgz", + "integrity": "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -3459,6 +4643,33 @@ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "license": "MIT" }, + "node_modules/react-markdown": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz", + "integrity": "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "hast-util-to-jsx-runtime": "^2.0.0", + "html-url-attributes": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.0.0", + "unified": "^11.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + }, + "peerDependencies": { + "@types/react": ">=18", + "react": ">=18" + } + }, "node_modules/react-refresh": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", @@ -3492,6 +4703,72 @@ "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", "license": "MIT" }, + "node_modules/remark-gfm": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", + "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-gfm": "^3.0.0", + "micromark-extension-gfm": "^3.0.0", + "remark-parse": "^11.0.0", + "remark-stringify": "^11.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-parse": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", + "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-rehype": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz", + "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "mdast-util-to-hast": "^13.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-stringify": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz", + "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-to-markdown": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/rollup": { "version": "4.60.2", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.2.tgz", @@ -3596,12 +4873,54 @@ "node": ">=0.10.0" } }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/state-local": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/state-local/-/state-local-1.0.7.tgz", "integrity": "sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==", "license": "MIT" }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/style-to-js": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", + "integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==", + "license": "MIT", + "dependencies": { + "style-to-object": "1.0.14" + } + }, + "node_modules/style-to-object": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz", + "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==", + "license": "MIT", + "dependencies": { + "inline-style-parser": "0.2.7" + } + }, "node_modules/tinyglobby": { "version": "0.2.16", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", @@ -3619,6 +4938,26 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/trough": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", + "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/ts-api-utils": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", @@ -3715,6 +5054,93 @@ "devOptional": true, "license": "MIT" }, + "node_modules/unified": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", + "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "bail": "^2.0.0", + "devlop": "^1.0.0", + "extend": "^3.0.0", + "is-plain-obj": "^4.0.0", + "trough": "^2.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", @@ -3779,6 +5205,34 @@ "uuid": "dist-node/bin/uuid" } }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/vis-data": { "version": "8.0.3", "resolved": "https://registry.npmjs.org/vis-data/-/vis-data-8.0.3.tgz", @@ -4020,6 +5474,16 @@ "optional": true } } + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } } } } diff --git a/explorer/package.json b/explorer/package.json index 162f2bc0..0c6f913a 100644 --- a/explorer/package.json +++ b/explorer/package.json @@ -8,8 +8,10 @@ "build": "tsc -b && vite build", "lint": "eslint .", "preview": "vite preview", + "test": "node --import tsx --test tests/markdownContentViewer.test.ts tests/graphSceneState.display.test.ts tests/temporalLifecycle.test.ts", "test:graph-store": "node --test tests/graphStore.multi-edge.test.mjs", - "test:graph-workspace": "node --import tsx --test tests/graphSceneState.display.test.ts tests/temporalLifecycle.test.ts", + "test:graph-workspace": "node --import tsx --test tests/markdownContentViewer.test.ts tests/graphSceneState.display.test.ts tests/temporalLifecycle.test.ts", + "test:markdown-viewer": "node --import tsx --test tests/markdownContentViewer.test.ts", "test:plugin-registry": "node --import tsx --test tests/pluginRegistry.temporal.test.mjs" }, "dependencies": { @@ -29,6 +31,8 @@ "react-arborist": "^3.4.3", "react-dom": "^19.2.4", "react-dropzone": "^15.0.0", + "react-markdown": "^10.1.0", + "remark-gfm": "^4.0.1", "sigma": "^3.0.2", "vis-data": "^8.0.3", "vis-timeline": "^8.5.0" diff --git a/explorer/src/workspaces/GraphWorkspace/GraphInspectorPanel.tsx b/explorer/src/workspaces/GraphWorkspace/GraphInspectorPanel.tsx index d2720756..3cae43f6 100644 --- a/explorer/src/workspaces/GraphWorkspace/GraphInspectorPanel.tsx +++ b/explorer/src/workspaces/GraphWorkspace/GraphInspectorPanel.tsx @@ -3,6 +3,7 @@ import { Loader2 } from "lucide-react"; import { graph } from "../../store/graphStore"; import { GRAPH_THEME, withAlpha } from "./graphTheme"; import type { GraphSelectedNodeKind } from "./types"; +import { MarkdownContentViewer } from "./MarkdownContentViewer"; export type LinkPrediction = { target: string; @@ -364,6 +365,11 @@ export function GraphInspectorPanel({ ([key]) => !["x","y","valid_from","valid_until","content","source","source_url","pmid","pmids","evidence","provenance","confidence"].includes(key), ); + const nodeContent = (typeof attributes?.content === "string" && attributes.content) + ? attributes.content + : (typeof properties.content === "string" && properties.content) + ? properties.content + : ""; return (
{children}{children}