Compare commits

...
18 Commits
Author SHA1 Message Date
KaifAhmad1 c6316ba4bd Release 0.2.2 2026-01-15 00:42:07 +05:30
Mohd Kaif b6d630fc74 Merge pull request #191 from Hawksight-AI/semantic-extract
Improve `semantic_extract` performance and add Groq LLM smoke tests
2026-01-14 17:21:26 +05:30
Mohd Kaif 3f2cb49e50 Delete PR_DESCRIPTION.md 2026-01-14 17:17:32 +05:30
KaifAhmad1 c7814616a9 Improve semantic_extract performance and add Groq LLM smoke tests 2026-01-14 17:11:26 +05:30
Mohd Kaif 531014fbda Update version and description in pyproject.toml 2026-01-14 14:05:36 +05:30
Mohd Kaif 1cf9b34e3e Merge pull request #190 from Hawksight-AI/utils
docs: update CHANGELOG.md with recent changes
2026-01-14 12:51:40 +05:30
KaifAhmad1 2e81c86489 docs: update CHANGELOG.md with recent changes 2026-01-14 12:49:29 +05:30
Mohd Kaif 1690fec3f7 Merge pull request #189 from Hawksight-AI/utils
resolve dependencies, migrate Gemini SDK, and sanitize notebooks
2026-01-14 12:42:38 +05:30
KaifAhmad1 72a6ddb48f Merge remote-tracking branch 'origin/utils' into utils 2026-01-14 12:38:44 +05:30
KaifAhmad1 a5da533d55 chore: resolve dependencies, migrate Gemini SDK, and sanitize notebooks 2026-01-14 12:37:29 +05:30
Mohd Kaif be8856cfcf Merge pull request #188 from Hawksight-AI/semantic-extract
[SECURITY] Enhance caching security by excluding sensitive keys and using SHA-256
2026-01-14 00:25:46 +05:30
KaifAhmad1 d2e599bcb0 [SECURITY] Enhance caching security by excluding sensitive keys and using SHA-256 2026-01-14 00:22:41 +05:30
Mohd Kaif 05d0bbf86c Merge pull request #187 from Hawksight-AI/semantic-extract
Performance Bottlenecks and Scaling Limitations in semantic_extract
2026-01-14 00:15:06 +05:30
KaifAhmad1 dd7fcd3ddb [FEATURE] Performance Bottlenecks and Scaling Limitations in semantic_extract #186
- Implemented high-throughput parallel batch processing across all core extractors (NERExtractor, RelationExtractor, TripletExtractor, EventDetector, SemanticNetworkExtractor) using ThreadPoolExecutor.

- Added max_workers configuration parameter (default: 1) to all extractor extract() methods.

- Implemented parallel processing for large document chunking in _extract_entities_chunked and _extract_relations_chunked.

- Enhanced ProgressTracker to be thread-safe.

- Optimized setUpClass in tests to reduce Groq LLM initialization overhead.

- Updated documentation and usage examples.
2026-01-14 00:11:30 +05:30
Mohd Kaif 43f55e1028 Delete RELEASE_NOTES_v0.2.0.md 2026-01-13 00:33:59 +05:30
Mohd Kaif e20c522c62 Merge pull request #185 from Hawksight-AI/docs
Update Earning Call Notebook
2026-01-13 00:13:16 +05:30
KaifAhmad1 fd9f0b2526 Add all changes 2026-01-13 00:10:44 +05:30
KaifAhmad1 d8e04c29e9 Security fix: Upgrade protobuf to 4.25.8 and add PR description 2026-01-07 19:11:58 +05:30
36 changed files with 2872 additions and 1046 deletions
+51
View File
@@ -7,6 +7,52 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [0.2.2] - 2026-01-15
### Added
- **Parallel Extraction Engine**:
- Implemented high-throughput parallel batch processing across all core extractors (`NERExtractor`, `RelationExtractor`, `TripletExtractor`, `EventDetector`, `SemanticNetworkExtractor`) using `concurrent.futures.ThreadPoolExecutor`.
- Added `max_workers` configuration parameter (default: 1) to all extractor `extract()` methods, allowing users to tune concurrency based on available CPU cores or API rate limits.
- **Parallel Chunking**: Implemented parallel processing for large document chunking in `_extract_entities_chunked` and `_extract_relations_chunked`, significantly reducing latency for long-form text analysis.
- **Thread-Safe Progress Tracking**: Enhanced `ProgressTracker` to handle concurrent updates from multiple threads without race conditions during batch processing.
- **Semantic Extract Performance & Regression**:
- Added edge-case regression suite covering max worker defaults, LLM prompt entity filtering, and extractor reuse.
- Added a runnable real-use-case benchmark script for batch latency across `NERExtractor`, `RelationExtractor`, `TripletExtractor`, `EventDetector`, `SemanticAnalyzer`, and `SemanticNetworkExtractor`.
- Added Groq LLM smoke tests that exercise LLM-based entities/relations/triplets when `GROQ_API_KEY` is available via environment configuration.
### Security
- **Credential Sanitization**:
- Removed hardcoded API keys from 8 cookbook notebooks to prevent secret leakage.
- Enforced environment variable usage for `GROQ_API_KEY` across all examples.
- **Secure Caching**:
- Updated `ExtractionCache` to exclude sensitive parameters (e.g., `api_key`, `token`, `password`) from cache key generation, preventing secret leakage and enabling safe cache sharing.
- Upgraded cache key hashing algorithm from MD5 to **SHA-256** for enhanced collision resistance and security.
### Changed
- **Gemini SDK Migration**:
- Migrated `GeminiProvider` to use the new `google-genai` SDK (v0.1.0+) to address deprecation warnings.
- Implemented graceful fallback to `google.generativeai` for backward compatibility.
- **Dependency Resolution**:
- Pinned `opentelemetry-api` and `opentelemetry-sdk` to `1.37.0` to resolve pip conflicts.
- Updated `protobuf` and `grpcio` constraints for better stability.
- **Entity Filtering Scope**:
- Removed entity filtering from non-LLM extraction flows to avoid accuracy regressions.
- Applied entity downselection only to LLM relation prompt construction, while matching returned entities against the full original entity list.
- **Batch Concurrency Defaults**:
- Standardized `max_workers` defaulting across `semantic_extract` and tuned for low-latency: ML-backed methods default to single-worker, while pattern/regex/rules/LLM/huggingface methods use a higher parallelism default capped by CPU.
- Raised the global `optimization.max_workers` default to 8 for better throughput on batch workloads.
### Performance
- **Bottleneck Optimization (GitHub Issue #186)**:
- **Resolved Bottleneck #1 (Sequential Processing)**: Replaced sequential `for` loops with parallel execution for both document-level batches and intra-document chunks.
- **Performance Gains**: Achieved **~1.89x speedup** in real-world extraction scenarios (tested with Groq `llama-3.3-70b-versatile` on standard datasets).
- **Initialization Optimization**: Refactored test suite to use class-level `setUpClass` for LLM provider initialization, eliminating redundant API client creation overhead.
- **Low-Latency Entity Matching**:
- Avoided heavyweight embedding stack imports on common matches by improving fast matching heuristics and short-circuiting before embedding similarity.
- Optimized entity matching to prioritize exact/substring/word-boundary matches and only fall back to embedding similarity when needed, reducing CPU overhead in LLM relation/triplet mapping.
## [0.2.1] - 2026-01-12
### Fixed
@@ -16,6 +62,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Fixed `AttributeError` in provider integration by ensuring consistent parameter passing via `**kwargs`.
- **Constraint Relaxations**:
- Removed hardcoded `max_length` constraints from `Entity`, `Relation`, and `Triplet` classes to support long-form semantic extraction (e.g., long descriptions or names).
- Fixed orchestrator lazy property initialization and configuration normalization logic in `Orchestrator`.
- Resolved `AssertionError` in orchestrator tests by aligning test mocks with production component usage.
- Fixed dependency compatibility issues by pinning `protobuf>=5.29.1,<7.0` and `grpcio>=1.71.2`.
- Added missing dependencies `GitPython` and `chardet` to `pyproject.toml`.
- Verified and aligned `FileObject.text` property usage in GraphRAG notebooks for consistent content decoding.
### Changed
- **Chunking Defaults**:
+1 -1
View File
@@ -28,7 +28,7 @@
*The missing fabric between raw data and AI engineering. A comprehensive open-source framework for building semantic layers and knowledge engineering systems that transform unstructured data into AI-ready knowledge — powering Knowledge Graph-Powered RAG (GraphRAG), AI Agents, Multi-Agent Systems, and AI applications with structured semantic knowledge.*
**100% Open Source****MIT Licensed****Latest Version: 0.2.1****Production Ready****Community Driven**
**100% Open Source****MIT Licensed****Latest Version: 0.2.2****Production Ready****Community Driven**
[**Discord**](https://discord.gg/pMHguUzG)
+3 -3
View File
@@ -26,10 +26,10 @@ Before releasing, ensure:
The project uses GitHub Actions for automated releases to PyPI.
1. **Tag the commit**: Create a new git tag for the version (e.g., `v0.2.1`).
1. **Tag the commit**: Create a new git tag for the version (e.g., `v0.2.2`).
```bash
git tag -a v0.2.1 -m "Release v0.2.1"
git push origin v0.2.1
git tag -a v0.2.2 -m "Release v0.2.2"
git push origin v0.2.2
```
2. **GitHub Action**: The `Release` workflow will automatically trigger, build the package, create a GitHub Release, and publish to PyPI using Trusted Publishing.
-95
View File
@@ -1,95 +0,0 @@
# Semantica v0.2.0 Release Notes
We are excited to announce the release of Semantica v0.2.0! This release brings major enhancements to graph database support, document parsing, extraction robustness, and provenance tracking.
## 🚀 Highlights
### Amazon Neptune Support
- **Native Integration**: Added `AmazonNeptuneStore` for full integration with Amazon Neptune via Bolt and OpenCypher.
- **Enterprise Security**: Implemented `NeptuneAuthTokenManager` for AWS IAM SigV4 signing with automatic token refresh.
- **Resilience**: Added robust connection handling with retry logic and backoff for transient errors.
### Docling Integration
- **High-Fidelity Parsing**: New `DoclingParser` in `semantica.parse` leverages the Docling library for superior document understanding.
- **Multi-Format Support**: Parse PDF, DOCX, PPTX, XLSX, HTML, and images with state-of-the-art table extraction.
### Robust Extraction Fallbacks
- **No More Empty Results**: Implemented a "ML/LLM -> Pattern -> Last Resort" fallback chain across all extractors.
- **Last Resort Strategies**:
- **NER**: Identifies capitalized words as generic entities when models fail.
- **Relations**: Infers weak connections between adjacent entities.
### Provenance & Tracking
- **Traceability**: Added `batch_index` and `document_id` metadata to all extracted elements (entities, relations, triplets).
- **Transparency**: Added count tracking to batch processing logs.
## 📋 Changelog
### Added
- **Amazon Neptune Support**:
- Added `AmazonNeptuneStore` providing Amazon Neptune graph database integration via Bolt protocol and OpenCypher.
- Implemented `NeptuneAuthTokenManager` extending Neo4j AuthManager for AWS IAM SigV4 signing with automatic token refresh.
- Added robust connection handling: retry logic with backoff for transient errors (signature expired, connection closed) and driver recreation.
- Added `graph-amazon-neptune` optional dependency group (boto3, neo4j).
- Comprehensive test suite covering all GraphStore interface methods.
- **Docling Integration**:
- Added `DoclingParser` in `semantica.parse` for high-fidelity document parsing using the Docling library.
- Supports multi-format parsing (PDF, DOCX, PPTX, XLSX, HTML, images) with superior table extraction and structure understanding.
- Implemented as a standalone parser supporting local execution, OCR, and multiple export formats (Markdown, HTML, JSON).
- **Robust Extraction Fallbacks**:
- Implemented comprehensive fallback chains ("ML/LLM" -> "Pattern" -> "Last Resort") across `NERExtractor`, `RelationExtractor`, and `TripletExtractor` to prevent empty result lists.
- Added "Last Resort" pattern matching in `NERExtractor` to identify capitalized words as generic entities when all other methods fail.
- Added "Last Resort" adjacency-based relation extraction in `RelationExtractor` to create weak connections between adjacent entities if no relations are found.
- Added fallback logic in `TripletExtractor` to convert relations to triplets or use rule-based extraction if standard methods fail.
- **Provenance & Tracking**:
- Added count tracking to batch processing logs in `NERExtractor`, `RelationExtractor`, and `TripletExtractor`.
- Added `batch_index` and `document_id` to the metadata of all extracted entities, relations, triplets, semantic roles, and clusters for better traceability.
- **Semantic Extract Improvements**:
- Introduced `auto-chunking` for long text processing in LLM extraction methods (`extract_entities_llm`, `extract_relations_llm`, `extract_triplets_llm`).
- Added `silent_fail` parameter to LLM extraction methods for configurable error handling.
- Implemented robust JSON parsing and automatic retry logic (3 attempts with exponential backoff) in `BaseProvider` for all LLM providers.
- Enhanced `GroqProvider` with better diagnostics and connectivity testing.
- Added comprehensive entity, relation, and triplet deduplication for chunked extraction.
- Added `semantica/semantic_extract/schemas.py` with canonical Pydantic models for consistent structured output.
- **Testing**:
- Added comprehensive robustness test suite `tests/semantic_extract/test_robustness_fallback.py` for validating extraction fallbacks and metadata propagation.
- Added comprehensive unit test suite `tests/embeddings/test_model_switching.py` for verifying dynamic model transitions and dimension updates.
- Added end-to-end integration test suite for Knowledge Graph pipeline validation (GraphBuilder -> EntityResolver -> GraphAnalyzer).
- **Other**:
- Added missing dependencies `GitPython` and `chardet` to `pyproject.toml`.
- Robustified ID extraction across `CentralityCalculator`, `CommunityDetector`, and `ConnectivityAnalyzer` to handle various entity formats.
- Improved `Entity` class hashability and equality logic in `utils/types.py`.
### Changed
- **Deduplication & Conflict Logic**:
- Removed internal deduplication logic from `NERExtractor`, `RelationExtractor`, and `TripletExtractor`.
- Removed consistency/conflict checking from `ExtractionValidator` to defer to dedicated `semantica/conflicts` module.
- Removed `_deduplicate_*` methods from `semantica/semantic_extract/methods.py`.
- **Batch Processing & Consistency**:
- Standardized batch processing across all extractors (`NERExtractor`, `RelationExtractor`, `TripletExtractor`, `SemanticNetworkExtractor`, `EventDetector`, `SemanticAnalyzer`, `CoreferenceResolver`) using a unified `extract`/`analyze`/`resolve` method pattern with progress tracking.
- Added provenance metadata (`batch_index`, `document_id`) to `SemanticNetwork` nodes/edges, `Event` objects, `SemanticRole` results, `CoreferenceChain` mentions, and `SemanticCluster` (tracking source `document_ids`).
- Updated `SemanticClusterer.cluster` and `SemanticAnalyzer.cluster_semantically` to accept list of dictionaries (with `content` and `id` keys) for better document tracking during clustering.
- Removed legacy `check_triplet_consistency` from `TripletExtractor`.
- Removed `validate_consistency` and `_check_consistency` from `ExtractionValidator`.
- **Weighted Scoring**:
- Clarified weighted confidence scoring (50% Method Confidence + 50% Type Similarity) in comments.
- Explicitly labeled "Type Similarity" as "user-provided" in code comments to remove ambiguity.
- **Refactoring**:
- Fixed orchestrator lazy property initialization and configuration normalization logic in `Orchestrator`.
- Verified and aligned `FileObject.text` property usage in GraphRAG notebooks for consistent content decoding.
### Fixed
- **Critical Fixes**:
- Resolved `NameError` in `extraction_validator.py` by adding missing `Union` import.
- Resolved issues where extractors would return empty lists for valid input text when primary extraction methods failed.
- Fixed metadata initialization issue in batch processing where `batch_index` and `document_id` were occasionally missing from extracted items.
- Ensured `LLMExtraction` methods (`enhance_entities`, `enhance_relations`) return original input instead of failing or returning empty results when LLM providers are unavailable.
- **Component Fixes**:
- Fixed model switching bug in `TextEmbedder` where internal state was not cleared, preventing dynamic updates between `fastembed` and `sentence_transformers` (#160).
- Implemented model-intrinsic embedding dimension detection in `TextEmbedder` to ensure consistency between models and vector databases.
- Updated `set_model` to properly refresh configuration and dimensions during model switches.
- Fixed `TypeError: unhashable type: 'Entity'` in `GraphAnalyzer` when processing graphs with raw `Entity` objects or dictionaries in relationships (#159).
- Resolved `AssertionError` in orchestrator tests by aligning test mocks with production component usage.
- Fixed dependency compatibility issues by pinning `protobuf==4.25.3` and `grpcio==1.67.1`.
- Fixed a bug in `TripletExtractor` where the `validate_triplets` method was shadowed by an internal attribute.
- Fixed incorrect `TextSplitter` import path in the `semantic_extract.methods` module.
+1
View File
@@ -6,6 +6,7 @@ We actively support the following versions of Semantica with security updates:
| Version | Supported |
| ------- | ------------------ |
| 0.2.2 | :white_check_mark: |
| 0.2.1 | :white_check_mark: |
| 0.2.0 | :white_check_mark: |
| 0.1.1 | :white_check_mark: |
@@ -110,7 +110,7 @@
"source": [
"# Set up API keys\n",
"# Note: In production, use environment variables: export GROQ_API_KEY=\"your-key\"\n",
"os.environ[\"GROQ_API_KEY\"] = os.getenv(\"GROQ_API_KEY\", \"Your Groq API\")\n"
"os.environ[\"GROQ_API_KEY\"] = os.getenv(\"GROQ_API_KEY\", \"\")\n"
]
},
{
@@ -30,7 +30,7 @@
"# Environment Setup\n",
"import os\n",
"\n",
"os.environ['GROQ_API_KEY'] = os.getenv('GROQ_API_KEY', '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')\n",
"os.environ['GROQ_API_KEY'] = os.getenv('GROQ_API_KEY', '')\n",
"\n",
"# Install Semantica and all required dependencies\n",
"%pip install -qU semantica networkx matplotlib plotly pandas faiss-cpu beautifulsoup4 groq sentence-transformers\n"
@@ -84,7 +84,7 @@
"source": [
"# Set up API keys\n",
"# Note: In production, use environment variables: export GROQ_API_KEY=\"your-key\"\n",
"os.environ[\"GROQ_API_KEY\"] = os.getenv(\"GROQ_API_KEY\", \"your-groq-api-key-here\")\n",
"os.environ[\"GROQ_API_KEY\"] = os.getenv(\"GROQ_API_KEY\", \"\")\n",
"\n",
"print(\"API keys configured.\")\n"
]
@@ -109,7 +109,7 @@
"source": [
"import os\n",
"\n",
"os.environ[\"GROQ_API_KEY\"] = os.getenv(\"GROQ_API_KEY\", \"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\")\n"
"os.environ[\"GROQ_API_KEY\"] = os.getenv(\"GROQ_API_KEY\", \"\")\n"
]
},
{
@@ -85,7 +85,7 @@
"source": [
"import os\n",
"\n",
"os.environ[\"GROQ_API_KEY\"] = os.getenv(\"GROQ_API_KEY\", \"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\")\n"
"os.environ[\"GROQ_API_KEY\"] = os.getenv(\"GROQ_API_KEY\", \"\")\n"
]
},
{
@@ -81,7 +81,7 @@
"source": [
"import os\n",
"\n",
"os.environ[\"GROQ_API_KEY\"] = os.getenv(\"GROQ_API_KEY\", \"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\")\n",
"os.environ[\"GROQ_API_KEY\"] = os.getenv(\"GROQ_API_KEY\", \"\")\n",
"\n",
"# Configuration constants\n",
"EMBEDDING_DIMENSION = 384\n",
@@ -98,7 +98,7 @@
"source": [
"import os\n",
"\n",
"os.environ[\"GROQ_API_KEY\"] = os.getenv(\"GROQ_API_KEY\", \"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\")\n",
"os.environ[\"GROQ_API_KEY\"] = os.getenv(\"GROQ_API_KEY\", \"\")\n",
"\n",
"# Configuration constants\n",
"EMBEDDING_DIMENSION = 384\n",
@@ -35,14 +35,14 @@
"## End-to-End Workflow\n",
"\n",
"**Workflow:** \n",
"Dual PDF Input → Docling Parsing → Normalization & Chunking → Entity, Relation & Triplet Extraction → Conflict Resolution & Deduplication → Knowledge Graph Construction → Amazon Neptune → GraphRAG → Agent Memory & Context → Strategic Q&A\n",
"Dual PDF Input → Docling Parsing → Normalization & Chunking → Entity, Relation Extraction → Conflict Resolution & Deduplication → Knowledge Graph Construction → Amazon Neptune → GraphRAG → Agent Memory & Context → Strategic Q&A\n",
"\n",
"---\n",
"\n",
"## Pipeline Capabilities\n",
"\n",
"- High-fidelity PDF parsing (text, tables, structure) \n",
"- Semantic extraction of entities, relationships, and triplets \n",
"- Semantic extraction of entities, and relationships\n",
"- Conflict detection and resolution with confidence awareness \n",
"- Entity deduplication and canonicalization \n",
"- Knowledge graph construction and validation \n",
@@ -280,7 +280,6 @@
"metadata": {},
"outputs": [],
"source": [
"import os\n",
"from semantica.semantic_extract import NERExtractor\n",
"\n",
"ner = NERExtractor(\n",
@@ -291,25 +290,19 @@
" api_key=GROQ_API_KEY,\n",
")\n",
"\n",
"ENTITY_TYPES = [\n",
" \"ORGANIZATION\", \"ORG\", \"PERSON\", \"MONEY\", \"CURRENCY\",\n",
" \"PERCENT\", \"PERCENTAGE\", \"DATE\", \"TIME\", \"PRODUCT\",\n",
" \"LOCATION\", \"GPE\", \"EVENT\", \"QUANTITY\", \"CARDINAL\",\n",
"ENTITY_TYPES = [\"ORGANIZATION\", \"PERSON\", \"MONEY\", \"PERCENT\", \"DATE\", \"EVENT\"]\n",
"\n",
"all_entities = [\n",
" e\n",
" for c in chunks\n",
" for e in ner.extract_entities(\n",
" get_chunk_text(c),\n",
" entity_types=ENTITY_TYPES,\n",
" )\n",
" if get_chunk_text(c).strip()\n",
"]\n",
"\n",
"all_entities = []\n",
"\n",
"for chunk in chunks:\n",
" text = get_chunk_text(chunk)\n",
" if text.strip():\n",
" all_entities += ner.extract_entities(text, entity_types=ENTITY_TYPES)\n",
"\n",
"print(\"Entity extraction completed\")\n",
"print(\"Total entities extracted:\", len(all_entities))\n",
"\n",
"print(\"\\nSample entities\")\n",
"for e in all_entities[:10]:\n",
" print(f\"{e.label}: {e.text}\")"
"print(\"Entities:\", len(all_entities))"
]
},
{
@@ -382,105 +375,39 @@
"\n",
"relation_extractor = RelationExtractor(\n",
" method=\"llm\",\n",
" confidence_threshold=0.5,\n",
" confidence_threshold=0.6,\n",
" relation_types=[\n",
" \"HAS_REVENUE\", \"HAS_EPS\", \"HAS_MARGIN\", \"HAS_PROFIT\", \"HAS_GROWTH\",\n",
" \"PROVIDES_GUIDANCE\", \"STATES\", \"ANNOUNCES\", \"REPORTS\", \"EXPECTS\",\n",
" \"OPERATES_IN\", \"LOCATED_IN\", \"PARTNERS_WITH\", \"SERVES\",\n",
" \"COMPARED_TO\", \"INCREASED_BY\", \"DECREASED_BY\", \"CHANGED_BY\",\n",
" \"DURING\", \"IN_QUARTER\", \"FOR_PERIOD\",\n",
" \"RELATED_TO\", \"PART_OF\", \"AFFECTS\",\n",
" \"HAS_REVENUE\",\n",
" \"HAS_GROWTH\",\n",
" \"REPORTS\",\n",
" \"PROVIDES_GUIDANCE\",\n",
" \"IN_QUARTER\",\n",
" \"FOR_PERIOD\",\n",
" \"RELATED_TO\",\n",
" ],\n",
" api_key=GROQ_API_KEY,\n",
")\n",
"\n",
"def get_chunk_text(chunk):\n",
" return getattr(chunk, \"content\", getattr(chunk, \"text\", \"\")) or \"\"\n",
"\n",
"relationships = []\n",
"\n",
"for chunk in chunks:\n",
" text = get_chunk_text(chunk)\n",
"\n",
" relations = relation_extractor.extract_relations(\n",
" text,\n",
"relationships = [\n",
" r\n",
" for c in chunks\n",
" for r in relation_extractor.extract_relations(\n",
" text=get_chunk_text(c),\n",
" entities=all_entities,\n",
" provider=\"groq\",\n",
" llm_model=\"llama-3.1-8b-instant\",\n",
" temperature=0.0,\n",
" )\n",
"\n",
" relationships += relations\n",
"\n",
"print(\"Relationship extraction completed\")\n",
"print(\"Total chunks:\", len(chunks))\n",
"print(\"Total relationships extracted:\", len(relationships))\n",
"\n",
"if relationships:\n",
" r = relationships[0]\n",
" print(\"Sample relationship:\")\n",
" print(f\"{r.subject.text} → {r.predicate} → {r.object.text}\")"
" if get_chunk_text(c).strip()\n",
"]\n",
"print(\"Relationships:\", len(relationships))\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Step 6: Extract RDF Triplets\n",
"\n",
"Extract RDF triplets (subject-predicate-object) using TripletExtractor with Groq LLM.\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"from semantica.semantic_extract import TripletExtractor\n",
"\n",
"triplet_extractor = TripletExtractor(\n",
" method=\"llm\",\n",
" include_temporal=True,\n",
" include_provenance=True,\n",
" provider=\"groq\",\n",
" llm_model=\"llama-3.1-8b-instant\",\n",
" temperature=0.0,\n",
" api_key=GROQ_API_KEY,\n",
")\n",
"\n",
"def get_chunk_text(chunk):\n",
" return getattr(chunk, \"content\", getattr(chunk, \"text\", \"\")) or \"\"\n",
"\n",
"triplets = []\n",
"\n",
"for chunk in chunks:\n",
" text = get_chunk_text(chunk)\n",
"\n",
" triplets += triplet_extractor.extract_triplets(\n",
" text,\n",
" entities=all_entities,\n",
" relations=relationships if relationships else None,\n",
" )\n",
"\n",
"if hasattr(triplet_extractor, \"validate_triplets\"):\n",
" triplets = triplet_extractor.validate_triplets(triplets)\n",
"\n",
"print(\"Triplet extraction completed\")\n",
"print(\"Total chunks:\", len(chunks))\n",
"print(\"Total RDF triplets:\", len(triplets))\n",
"\n",
"if triplets:\n",
" t = triplets[0]\n",
" print(\"Sample triplet:\")\n",
" print(f\"{t.subject} → {t.predicate} → {t.object}\")\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Step 7: Detect Conflicts\n",
"## Step 6: Detect Conflicts\n",
"\n",
"Detect conflicts in extracted entities and relationships using ConflictDetector.\n"
]
@@ -533,7 +460,7 @@
"cell_type": "markdown",
"metadata": {},
"source": [
"## Step 8: Resolve Conflicts\n",
"## Step 7: Resolve Conflicts\n",
"\n",
"Resolve detected conflicts using ConflictResolver with voting strategy.\n"
]
@@ -571,7 +498,7 @@
"cell_type": "markdown",
"metadata": {},
"source": [
"## Step 9: Deduplicate Entities\n",
"## Step 8: Deduplicate Entities\n",
"\n",
"Detect and merge duplicate entities using DuplicateDetector and EntityMerger.\n"
]
@@ -621,7 +548,7 @@
"cell_type": "markdown",
"metadata": {},
"source": [
"## Step 10: Build Knowledge Graph\n",
"## Step 9: Build Knowledge Graph\n",
"\n",
"Build knowledge graph from cleaned entities, relationships, and triplets using GraphBuilder.\n"
]
@@ -676,7 +603,7 @@
"cell_type": "markdown",
"metadata": {},
"source": [
"## Step 11: Analyze Knowledge Graph\n",
"## Step 10: Analyze Knowledge Graph\n",
"\n",
"This step evaluates the structure and quality of the knowledge graph.\n",
"\n",
@@ -726,7 +653,7 @@
"cell_type": "markdown",
"metadata": {},
"source": [
"## Step 12: Persist Knowledge Graph in Amazon Neptune\n",
"## Step 11: Persist Knowledge Graph in Amazon Neptune\n",
"\n",
"After cleaning, conflict resolution, and deduplication, the final step is to\n",
"persist the **canonical knowledge graph** into a production graph database.\n",
@@ -841,7 +768,7 @@
"cell_type": "markdown",
"metadata": {},
"source": [
"## Step 13: Context Retrieval\n",
"## Step 12: Context Retrieval\n",
"\n",
"Set up hybrid retrieval (vector + graph) using ContextRetriever for GraphRAG queries.\n"
]
@@ -894,7 +821,7 @@
"cell_type": "markdown",
"metadata": {},
"source": [
"## Step 14: Agent Memory (Long-Term Context)\n",
"## Step 13: Agent Memory (Long-Term Context)\n",
"\n",
"This step enables long-term memory for agents by storing important facts,\n",
"metrics, and entities extracted from the knowledge graph.\n",
@@ -965,7 +892,7 @@
"cell_type": "markdown",
"metadata": {},
"source": [
"## Step 15: Agent Context\n",
"## Step 14: Agent Context\n",
"\n",
"**AgentContext** provides a unified context layer that combines **vector-based RAG**\n",
"with **graph-based GraphRAG** for grounded and explainable retrieval.\n",
@@ -1063,7 +990,7 @@
"cell_type": "markdown",
"metadata": {},
"source": [
"## Step 16: Answer Generation\n",
"## Step 15: Answer Generation\n",
"\n",
"Generate answers to financial questions using Groq LLM with retrieved context and knowledge graph.\n"
]
@@ -1133,7 +1060,7 @@
"cell_type": "markdown",
"metadata": {},
"source": [
"## Step 17: Export Results\n",
"## Step 16: Export Results\n",
"\n",
"Export knowledge graph and analysis results to JSON and RDF formats.\n"
]
@@ -1155,7 +1082,6 @@
"analysis_summary = {\n",
" \"entities\": len(knowledge_graph.get(\"entities\", [])),\n",
" \"relationships\": len(knowledge_graph.get(\"relationships\", [])),\n",
" \"triplets\": len(triplets),\n",
" \"conflicts_resolved\": len(resolved_conflicts),\n",
" \"merged_entities\": len(merged_entities),\n",
" \"communities\": num_communities,\n",
@@ -83,7 +83,7 @@
"source": [
"import os\n",
"\n",
"os.environ[\"GROQ_API_KEY\"] = os.getenv(\"GROQ_API_KEY\", \"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\")\n",
"os.environ[\"GROQ_API_KEY\"] = os.getenv(\"GROQ_API_KEY\", \"\")\n",
"\n",
"# Configuration constants\n",
"EMBEDDING_DIMENSION = 384\n",
@@ -80,7 +80,7 @@
"source": [
"import os\n",
"\n",
"os.environ[\"GROQ_API_KEY\"] = os.getenv(\"GROQ_API_KEY\", \"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\")\n",
"os.environ[\"GROQ_API_KEY\"] = os.getenv(\"GROQ_API_KEY\", \"\")\n",
"\n",
"# Configuration constants\n",
"EMBEDDING_DIMENSION = 384\n",
+5 -5
View File
@@ -12,22 +12,22 @@ How to cite Semantica in academic papers and research.
author = {Hawksight AI},
year = {2026},
url = {https://github.com/Hawksight-AI/semantica},
version = {0.2.1},
version = {0.2.2},
doi = {10.5281/zenodo.XXXXXXX}
}
```
### APA
Hawksight AI. (2026). *Semantica: An Open Source Framework for Semantic Layers and Knowledge Engineering* (Version 0.2.1) [Computer software]. https://github.com/Hawksight-AI/semantica
Hawksight AI. (2026). *Semantica: An Open Source Framework for Semantic Layers and Knowledge Engineering* (Version 0.2.2) [Computer software]. https://github.com/Hawksight-AI/semantica
### MLA
Hawksight AI. *Semantica: An Open Source Framework for Semantic Layers and Knowledge Engineering*. Version 0.2.1, GitHub, 2026, https://github.com/Hawksight-AI/semantica.
Hawksight AI. *Semantica: An Open Source Framework for Semantic Layers and Knowledge Engineering*. Version 0.2.2, GitHub, 2026, https://github.com/Hawksight-AI/semantica.
### Chicago
Hawksight AI. *Semantica: An Open Source Framework for Semantic Layers and Knowledge Engineering*. Version 0.2.1. GitHub, 2026. https://github.com/Hawksight-AI/semantica.
Hawksight AI. *Semantica: An Open Source Framework for Semantic Layers and Knowledge Engineering*. Version 0.2.2. GitHub, 2026. https://github.com/Hawksight-AI/semantica.
### IEEE
Hawksight AI, "Semantica: An Open Source Framework for Semantic Layers and Knowledge Engineering," Version 0.2.1, GitHub, 2026. [Online]. Available: https://github.com/Hawksight-AI/semantica
Hawksight AI, "Semantica: An Open Source Framework for Semantic Layers and Knowledge Engineering," Version 0.2.2, GitHub, 2026. [Online]. Available: https://github.com/Hawksight-AI/semantica
---
+6 -1
View File
@@ -23,7 +23,7 @@ The **Semantic Extract Module** extracts structured information from unstructure
- **High Accuracy**: LLM-based extraction for complex schemas
- **Flexible Configuration**: Customize extraction for your domain
- **Confidence Scores**: Get confidence scores for all extractions
- **Batch Processing**: Efficient batch processing for large datasets
- **Batch Processing**: Efficient parallel batch processing for large datasets
- **Coreference Resolution**: Resolve pronouns to their entity references
### How It Works
@@ -187,6 +187,7 @@ Core entity extraction implementation used by notebooks and lower-level integrat
| `silent_fail` | bool | `False` | Return empty list on error instead of raising (LLM only) |
| `max_text_length` | int | `64000` | Max text length for auto-chunking (LLM only) |
| `max_tokens` | int | `None` | Max output tokens for LLM generation |
| `max_workers` | int | `1` | Threads for parallel batch processing |
| `**config` | dict | `{}` | Method-specific config (e.g., `model`, `provider`) |
**Methods:**
@@ -234,6 +235,7 @@ Extracts relationships between entities.
| `bidirectional` | bool | `False` | Extract bidirectional relations |
| `confidence_threshold` | float | `0.6` | Minimum confidence score |
| `max_distance` | int | `50` | Max token distance between entities |
| `max_workers` | int | `1` | Threads for parallel batch processing |
**Methods:**
@@ -310,6 +312,7 @@ Identifies events with temporal information and participants.
| `extract_participants` | bool | `True` | Extract event participants |
| `extract_location` | bool | `True` | Extract event locations |
| `extract_time` | bool | `True` | Extract temporal information |
| `max_workers` | int | `1` | Threads for parallel batch processing |
**Methods:**
@@ -344,6 +347,7 @@ Extracts RDF triplets (Subject-Predicate-Object).
| `silent_fail` | bool | `False` | Return empty list on error instead of raising (LLM only) |
| `max_text_length` | int | `64000` | Max text length for auto-chunking (LLM only) |
| `max_tokens` | int | `None` | Max output tokens for LLM generation |
| `max_workers` | int | `1` | Threads for parallel batch processing |
**Methods:**
@@ -374,6 +378,7 @@ Extracts structured semantic networks with nodes and edges.
|-----------|------|---------|-------------|
| `ner_method` | str | `None` | Method for node extraction |
| `relation_method` | str | `None` | Method for edge extraction |
| `max_workers` | int | `1` | Threads for parallel batch processing |
| `**config` | dict | `{}` | Configuration for underlying extractors |
**Methods:**
+165 -284
View File
@@ -4,317 +4,198 @@ build-backend = "setuptools.build_meta"
[project]
name = "semantica"
version = "0.2.1"
description = "🧠 Semantica - An Open Source Framework for building Semantic Layers and Knowledge Engineering "
version = "0.2.2"
description = "🧠 Semantica - An Open Source Framework for building Semantic Layers and Knowledge Engineering"
readme = "README.md"
license = {text = "MIT"}
authors = [
{name = "Hawksight AI", email = "semantica-dev@users.noreply.github.com"}
]
maintainers = [
{name = "Hawksight AI", email = "semantica-dev@users.noreply.github.com"}
]
license = { text = "MIT" }
authors = [{ name = "Hawksight AI", email = "semantica-dev@users.noreply.github.com" }]
maintainers = [{ name = "Hawksight AI", email = "semantica-dev@users.noreply.github.com" }]
requires-python = ">=3.8"
classifiers = [
"Development Status :: 3 - Alpha",
"Intended Audience :: Developers",
"Intended Audience :: Science/Research",
"License :: OSI Approved :: MIT License",
"Operating System :: OS Independent",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Topic :: Scientific/Engineering :: Artificial Intelligence",
"Topic :: Software Development :: Libraries :: Python Modules",
"Topic :: Text Processing :: Linguistic",
"Topic :: Database :: Database Engines/Servers",
"Topic :: Internet :: WWW/HTTP :: Indexing/Search"
"Development Status :: 3 - Alpha",
"Intended Audience :: Developers",
"Intended Audience :: Science/Research",
"License :: OSI Approved :: MIT License",
"Operating System :: OS Independent",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Topic :: Scientific/Engineering :: Artificial Intelligence",
"Topic :: Software Development :: Libraries :: Python Modules"
]
keywords = [
"semantic-layer", "knowledge-engineering", "nlp", "knowledge-graph",
"embeddings", "entity-extraction", "relationship-extraction", "rdf",
"ontology", "semantic-analysis", "ai", "machine-learning"
"semantic-layer", "knowledge-graph", "nlp", "embeddings",
"entity-extraction", "relationship-extraction", "rdf", "ontology"
]
# ---------------- CORE DEPENDENCIES (SAFE DEFAULT) ----------------
dependencies = [
"numpy>=1.21.0",
"pandas>=1.3.0",
"scikit-learn>=1.0.0",
"umap-learn>=0.5.0",
"spacy>=3.4.0",
"transformers>=4.20.0",
"torch>=1.12.0",
"sentence-transformers>=2.2.0",
"rdflib>=6.2.0",
"networkx>=2.8.0",
"matplotlib>=3.5.0",
"seaborn>=0.11.0",
"plotly>=5.10.0",
"ipywidgets>=8.0.0",
"requests>=2.28.0",
"GitPython>=3.1.30",
"chardet>=5.1.0",
"protobuf==4.25.8",
"grpcio==1.67.1",
"beautifulsoup4>=4.11.0",
"lxml>=4.9.0",
"pypdf2>=2.10.0",
"python-docx>=0.8.11",
"docling>=1.0.0",
"openpyxl>=3.0.10",
"pillow>=9.2.0",
"librosa>=0.9.0",
"opencv-python>=4.6.0",
"faiss-cpu>=1.7.0",
"fastembed>=0.2.0",
"onnxruntime>=1.17.0",
"tokenizers>=0.15.0",
"weaviate-client>=3.15.0",
"qdrant-client>=1.3.0",
"neo4j>=5.0.0",
"falkordb>=1.0.0",
"pymongo>=4.2.0",
"sqlalchemy>=1.4.0",
"psycopg2-binary>=2.9.0",
"pymysql>=1.0.0",
"redis>=4.3.0",
"celery>=5.2.0",
"kafka-python>=2.0.0",
"pulsar-client>=3.0.0",
"pika>=1.3.0",
"boto3>=1.24.0",
"azure-storage-blob>=12.12.0",
"google-cloud-storage>=2.5.0",
"pydantic>=2.0.0",
"fastmcp>=0.1.0",
"groq>=0.4.0",
"openai>=1.0.0",
"litellm>=1.0.0",
"instructor>=1.0.0",
"click>=8.1.0",
"rich>=12.5.0",
"tqdm>=4.64.0",
"pyyaml>=6.0",
"toml>=0.10.0",
"python-dotenv>=0.20.0",
"loguru>=0.6.0",
"structlog>=22.1.0",
"prometheus-client>=0.14.0",
"opentelemetry-api>=1.12.0",
"opentelemetry-sdk>=1.12.0",
"opentelemetry-instrumentation",
"fastapi>=0.78.0",
"uvicorn>=0.18.0",
"pytest>=7.1.0",
"pytest-cov>=3.0.0",
"pytest-asyncio>=0.19.0",
"black>=22.6.0",
"isort>=5.10.0",
"flake8>=4.0.0",
"mypy>=0.971",
"pre-commit>=2.19.0"
"numpy>=1.21.0",
"pandas>=1.3.0",
"scikit-learn>=1.0.0",
"umap-learn>=0.5.0",
"spacy>=3.4.0",
"transformers>=4.20.0",
"torch>=1.12.0",
"sentence-transformers>=2.2.0",
"rdflib>=6.2.0",
"networkx>=2.8.0",
"matplotlib>=3.5.0",
"seaborn>=0.11.0",
"plotly>=5.10.0",
"ipywidgets>=8.0.0",
"requests>=2.28.0",
"GitPython>=3.1.30",
"chardet>=5.1.0",
"protobuf>=5.29.1,<7.0",
"grpcio>=1.71.2",
"beautifulsoup4>=4.11.0",
"lxml>=4.9.0",
"pypdf2>=2.10.0",
"python-docx>=0.8.11",
"openpyxl>=3.0.10",
"pillow>=9.2.0",
"librosa>=0.9.0",
"opencv-python>=4.6.0",
"faiss-cpu>=1.7.0",
"fastembed>=0.2.0",
"onnxruntime>=1.17.0",
"tokenizers>=0.15.0",
"pydantic>=2.0.0",
"click>=8.1.0",
"rich>=12.5.0",
"tqdm>=4.64.0",
"pyyaml>=6.0",
"toml>=0.10.0",
"python-dotenv>=0.20.0",
"loguru>=0.6.0",
"structlog>=22.1.0"
]
[project.urls]
Homepage = "https://github.com/Hawksight-AI/semantica"
Repository = "https://github.com/Hawksight-AI/semantica"
"Bug Tracker" = "https://github.com/Hawksight-AI/semantica/issues"
Discussions = "https://github.com/Hawksight-AI/semantica/discussions"
# ---------------- OPTIONAL DEPENDENCIES ----------------
[project.optional-dependencies]
dev = [
"pytest>=7.1.0",
"pytest-cov>=3.0.0",
"pytest-asyncio>=0.19.0",
"black>=22.6.0",
"isort>=5.10.0",
"flake8>=4.0.0",
"mypy>=0.971",
"pre-commit>=2.19.0",
"jupyter>=1.0.0",
"ipykernel>=6.15.0",
"notebook>=6.4.0"
]
viz = [
"pyvis>=0.3.0",
"graphviz>=0.20.0",
"umap-learn>=0.5.0",
"d3blocks>=1.0.0"
]
gpu = [
"torch>=1.12.0",
"faiss-gpu>=1.7.0",
"cupy>=10.0.0"
]
cloud = [
"boto3>=1.24.0",
"azure-storage-blob>=12.12.0",
"google-cloud-storage>=2.5.0",
"kubernetes>=24.0.0",
"helm>=3.10.0"
]
monitoring = [
"prometheus-client>=0.14.0",
"opentelemetry-api>=1.12.0",
"opentelemetry-sdk>=1.12.0",
"opentelemetry-instrumentation>=0.32.0",
"grafana-api>=1.0.0",
"elasticsearch>=8.5.0"
]
llm-openai = [
"openai>=1.0.0"
]
llm-gemini = [
"google-generativeai>=0.3.0"
]
llm-groq = [
"groq>=0.4.0"
]
llm-anthropic = [
"anthropic>=0.18.0"
]
llm-ollama = [
"ollama>=0.1.0"
]
llm-deepseek = [
"deepseek>=0.1.0"
]
llm-litellm = [
"litellm>=1.0.0"
]
llm-instructor = [
"instructor>=1.0.0"
]
# ---- LLM Providers ----
llm-openai = ["openai>=1.0.0"]
llm-groq = ["groq>=0.4.0"]
llm-gemini = ["google-genai>=0.1.0"]
llm-anthropic = ["anthropic>=0.18.0"]
llm-ollama = ["ollama>=0.1.0"]
llm-deepseek = ["deepseek>=0.1.0"]
llm-litellm = ["litellm>=1.0.0"]
llm-instructor = ["instructor>=1.0.0"]
llm-all = [
"semantica[llm-openai,llm-gemini,llm-groq,llm-anthropic,llm-ollama,llm-deepseek,llm-litellm,llm-instructor]"
]
models-huggingface = [
"transformers>=4.20.0",
"torch>=1.12.0"
]
split-tiktoken = [
"tiktoken>=0.5.0"
]
split-community = [
"python-louvain>=0.16"
]
split-topic = [
"bertopic>=0.15.0",
"gensim>=4.3.0"
]
split-all = [
"semantica[split-tiktoken,split-community,split-topic]"
]
graph-neo4j = [
"neo4j>=5.0.0"
]
graph-falkordb = [
"falkordb>=1.0.0",
"redis>=4.3.0"
]
graph-amazon-neptune = [
"boto3>=1.24.0",
"neo4j>=5.0.0"
]
graph-all = [
"semantica[graph-neo4j,graph-falkordb,graph-amazon-neptune]"
]
parse-docling = [
"docling>=1.0.0"
]
all = [
"semantica[dev,viz,gpu,cloud,monitoring,llm-all,models-huggingface,split-all,graph-all,parse-docling]"
"semantica[llm-openai,llm-groq,llm-gemini,llm-anthropic,llm-ollama,llm-deepseek,llm-litellm,llm-instructor]"
]
# ---- Document Parsing ----
parse-docling = ["docling>=1.0.0"]
# ---- Embedding / Models ----
models-huggingface = [
"transformers>=4.20.0",
"torch>=1.12.0"
]
# ---- Graph Backends ----
graph-neo4j = ["neo4j>=5.0.0"]
graph-falkordb = ["falkordb>=1.0.0", "redis>=4.3.0"]
graph-amazon-neptune = ["boto3>=1.24.0", "neo4j>=5.0.0"]
graph-all = [
"semantica[graph-neo4j,graph-falkordb,graph-amazon-neptune]"
]
# ---- Infra / Queues / Workers ----
infra = [
"redis>=4.3.0",
"celery>=5.2.0",
"kafka-python>=2.0.0",
"pulsar-client>=3.0.0",
"pika>=1.3.0"
]
# ---- Cloud Providers ----
cloud = [
"boto3>=1.24.0",
"azure-storage-blob>=12.12.0",
"google-cloud-storage>=2.5.0"
]
# ---- Monitoring (FIXED) ----
monitoring = [
"prometheus-client>=0.14.0",
"opentelemetry-api>=1.30.0,<2.0.0",
"opentelemetry-sdk>=1.30.0,<2.0.0",
"opentelemetry-semantic-conventions>=0.58b0,<0.61b0",
"opentelemetry-instrumentation>=0.58b0,<0.61b0"
]
# ---- Visualization ----
viz = [
"pyvis>=0.3.0",
"graphviz>=0.20.0",
"d3blocks>=1.0.0"
]
# ---- GPU ----
gpu = [
"faiss-gpu>=1.7.0",
"cupy>=10.0.0"
]
# ---- Splitting / Chunking ----
split-tiktoken = ["tiktoken>=0.5.0"]
split-community = ["python-louvain>=0.16"]
split-topic = ["bertopic>=0.15.0", "gensim>=4.3.0"]
split-all = [
"semantica[split-tiktoken,split-community,split-topic]"
]
# ---- Dev ----
dev = [
"pytest>=7.1.0",
"pytest-cov>=3.0.0",
"pytest-asyncio>=0.19.0",
"black>=22.6.0",
"isort>=5.10.0",
"flake8>=4.0.0",
"mypy>=0.971",
"pre-commit>=2.19.0",
"jupyter>=1.0.0",
"ipykernel>=6.15.0"
]
# ---- Everything ----
all = [
"semantica[dev,viz,gpu,infra,cloud,monitoring,llm-all,models-huggingface,split-all,graph-all,parse-docling]"
]
# ---------------- ENTRYPOINTS ----------------
[project.scripts]
semantica = "semantica.cli:main"
semantica-server = "semantica.server:main"
semantica-worker = "semantica.worker:main"
# ---------------- TOOLING ----------------
[tool.setuptools.packages.find]
where = ["."]
include = ["semantica*"]
exclude = ["tests*", "docs*", "examples*"]
[tool.setuptools.package-data]
semantica = ["*.yaml", "*.yml", "*.json", "*.toml", "*.txt", "*.md"]
[tool.black]
line-length = 88
target-version = ['py38', 'py39', 'py310', 'py311', 'py312']
include = '\.pyi?$'
extend-exclude = '''
/(
# directories
\.eggs
| \.git
| \.hg
| \.mypy_cache
| \.tox
| \.venv
| build
| dist
)/
'''
[tool.isort]
profile = "black"
multi_line_output = 3
line_length = 88
known_first_party = ["semantica"]
known_third_party = ["numpy", "pandas", "scikit-learn", "spacy", "transformers", "torch"]
[tool.mypy]
python_version = "3.9"
warn_return_any = true
warn_unused_configs = true
disallow_untyped_defs = true
disallow_incomplete_defs = true
check_untyped_defs = true
disallow_untyped_decorators = true
no_implicit_optional = true
warn_redundant_casts = true
warn_unused_ignores = true
warn_no_return = true
warn_unreachable = true
strict_equality = true
show_error_codes = true
[tool.pytest.ini_options]
minversion = "7.0"
addopts = "-ra -q --strict-markers --strict-config"
testpaths = ["tests"]
python_files = ["test_*.py", "*_test.py"]
python_classes = ["Test*"]
python_functions = ["test_*"]
markers = [
"slow: marks tests as slow (deselect with '-m \"not slow\"')",
"integration: marks tests as integration tests",
"unit: marks tests as unit tests",
"gpu: marks tests that require GPU",
"cloud: marks tests that require cloud services"
]
[tool.coverage.run]
source = ["semantica"]
omit = [
"*/tests/*",
"*/test_*",
"*/__pycache__/*",
"*/migrations/*"
]
[tool.coverage.report]
exclude_lines = [
"pragma: no cover",
"def __repr__",
"if self.debug:",
"if settings.DEBUG",
"raise AssertionError",
"raise NotImplementedError",
"if 0:",
"if __name__ == .__main__.:",
"class .*\\bProtocol\\):",
"@(abc\\.)?abstractmethod"
]
+1 -1
View File
@@ -10,7 +10,7 @@ Main exports:
- Config: Configuration management
"""
__version__ = "0.2.1"
__version__ = "0.2.2"
__author__ = "Semantica Contributors"
__license__ = "MIT"
+184
View File
@@ -0,0 +1,184 @@
"""
Result Caching Module
This module provides caching mechanisms for extraction results to avoid redundant
computations and API calls. It implements an LRU (Least Recently Used) cache
with Time-To-Live (TTL) support.
Key Features:
- LRU Caching: Evicts least recently used items when cache is full
- TTL Support: Expires items after a configurable duration
- Namespaced Caching: Separate caches for entities, relations, and triplets
- Hash-based Keys: Uses stable hashing for text and parameters
Classes:
- ExtractionCache: Main cache manager
- CacheItem: Container for cached data with metadata
Author: Semantica Contributors
License: MIT
"""
import time
import hashlib
import json
from collections import OrderedDict
from typing import Any, Dict, Optional, Union, List
from threading import Lock
from ..utils.logging import get_logger
class CacheItem:
"""Container for cached data."""
def __init__(self, value: Any, ttl: Optional[int] = None):
self.value = value
self.timestamp = time.time()
self.ttl = ttl
def is_expired(self) -> bool:
"""Check if item has expired."""
if self.ttl is None:
return False
return time.time() - self.timestamp > self.ttl
class ExtractionCache:
"""
LRU Cache for extraction results.
Thread-safe implementation.
"""
def __init__(self, max_size: int = 1000, ttl: int = 3600):
"""
Initialize the cache.
Args:
max_size: Maximum number of items to store per namespace
ttl: Time to live in seconds (default 1 hour)
"""
self.max_size = max_size
self.ttl = ttl
self._caches: Dict[str, OrderedDict] = {
"entities": OrderedDict(),
"relations": OrderedDict(),
"triplets": OrderedDict()
}
self._locks: Dict[str, Lock] = {
"entities": Lock(),
"relations": Lock(),
"triplets": Lock()
}
self.logger = get_logger("extraction_cache")
self.enabled = True
def _generate_key(self, text: str, **params) -> str:
"""
Generate a stable cache key based on text and parameters.
Note: Sensitive parameters like 'api_key' are excluded from the cache key
to prevent security risks and ensure cache sharing where appropriate.
"""
# Filter out sensitive keys
sensitive_keys = {'api_key', 'token', 'password', 'secret', 'auth', 'authorization'}
filtered_params = {k: v for k, v in params.items() if k.lower() not in sensitive_keys}
# Create a stable string representation of params
# Sort keys to ensure consistent ordering
param_str = json.dumps(filtered_params, sort_keys=True, default=str)
# Combine text and params
content = f"{text}|{param_str}"
# Return hash (SHA-256 for better security than MD5)
return hashlib.sha256(content.encode('utf-8')).hexdigest()
def get(self, namespace: str, text: str, **params) -> Optional[Any]:
"""
Retrieve item from cache.
Args:
namespace: Cache namespace ("entities", "relations", "triplets")
text: Input text used for extraction
**params: Extraction parameters used
Returns:
Cached result or None if not found/expired
"""
if not self.enabled:
return None
if namespace not in self._caches:
return None
key = self._generate_key(text, **params)
with self._locks[namespace]:
cache = self._caches[namespace]
if key in cache:
item = cache[key]
# Check expiration
if item.is_expired():
del cache[key]
return None
# Move to end (mark as recently used)
cache.move_to_end(key)
return item.value
return None
def set(self, namespace: str, text: str, value: Any, **params) -> None:
"""
Add item to cache.
Args:
namespace: Cache namespace
text: Input text
value: Result to cache
**params: Extraction parameters
"""
if not self.enabled:
return
if namespace not in self._caches:
self.logger.warning(f"Unknown cache namespace: {namespace}")
return
key = self._generate_key(text, **params)
item = CacheItem(value, self.ttl)
with self._locks[namespace]:
cache = self._caches[namespace]
# If key exists, update and move to end
if key in cache:
cache.move_to_end(key)
cache[key] = item
# Evict if full
if len(cache) > self.max_size:
cache.popitem(last=False) # Remove first (least recently used)
def clear(self, namespace: Optional[str] = None):
"""Clear cache(s)."""
if namespace:
if namespace in self._caches:
with self._locks[namespace]:
self._caches[namespace].clear()
else:
for ns in self._caches:
with self._locks[ns]:
self._caches[ns].clear()
def get_stats(self) -> Dict[str, Dict[str, int]]:
"""Get cache statistics."""
stats = {}
for ns, cache in self._caches.items():
stats[ns] = {
"size": len(cache),
"max_size": self.max_size
}
return stats
# Global cache instance
extraction_cache = ExtractionCache()
+76 -1
View File
@@ -40,8 +40,9 @@ License: MIT
"""
import os
import multiprocessing
from pathlib import Path
from typing import Dict, Optional
from typing import Dict, Optional, Any
from ..utils.logging import get_logger
@@ -53,9 +54,23 @@ class Config:
"""Initialize configuration manager."""
self.logger = get_logger("config")
self._configs: Dict[str, Dict] = {}
# Default optimization settings
self._configs["optimization"] = {
"enable_cache": True,
"cache_size": 1000,
"max_workers": 8,
"enable_batching": True,
"batch_size": 10,
"max_tokens_per_batch": 2000
}
self._load_config_file(config_file)
self._load_env_vars()
def get_optimization_config(self) -> Dict:
"""Get optimization configuration."""
return self._configs.get("optimization", {})
def _load_config_file(self, config_file: Optional[str]):
"""Load configuration from file."""
if config_file and Path(config_file).exists():
@@ -114,6 +129,66 @@ class Config:
return self._configs[provider].get("api_key")
return os.getenv(f"{provider.upper()}_API_KEY")
def get(self, key: str, default: Any = None) -> Any:
"""
Get configuration value by key.
Searches in top-level configs and optimization settings.
"""
# 1. Check top-level keys
if key in self._configs:
return self._configs[key]
# 2. Check optimization settings (common keys)
if "optimization" in self._configs and key in self._configs["optimization"]:
return self._configs["optimization"][key]
# 3. Handle specific mapping for optimization keys
# Map cache_enabled -> enable_cache if needed
if key == "cache_enabled":
return self._configs.get("optimization", {}).get("enable_cache", default)
return default
# Global config instance
config = Config()
def resolve_max_workers(
explicit: Optional[int] = None,
local_config: Optional[Dict[str, Any]] = None,
methods: Optional[Any] = None,
) -> int:
def to_int(val: Any, default: int) -> int:
try:
return int(val)
except Exception:
return default
if isinstance(methods, str):
normalized_methods = [methods]
elif isinstance(methods, (list, tuple, set)):
normalized_methods = [m for m in methods if isinstance(m, str)]
else:
normalized_methods = []
if explicit is not None:
value = to_int(explicit, 1)
elif local_config and "max_workers" in local_config:
value = to_int(local_config.get("max_workers", 1), 1)
else:
value = to_int(config.get("max_workers", 5), 5)
if "ml" in normalized_methods and explicit is None and not (local_config and "max_workers" in local_config):
value = 1
if value < 1:
value = 1
cpu_count = multiprocessing.cpu_count() or 1
if value > cpu_count:
value = cpu_count
if value > 32:
value = 32
return value
+118 -73
View File
@@ -85,68 +85,59 @@ class Event:
class EventDetector:
"""Event detection and extraction handler."""
def __init__(
self,
event_types: Optional[List[str]] = None,
extract_participants: bool = True,
extract_location: bool = True,
extract_time: bool = True,
method: Union[str, List[str]] = None,
config=None,
**kwargs
):
def __init__(self, method: str = "llm", **config):
"""
Initialize event detector.
Args:
event_types: Specific event types to detect (e.g., ["launch", "acquisition"])
extract_participants: Whether to extract event participants
extract_location: Whether to extract event locations
extract_time: Whether to extract temporal information
method: Extraction method(s) for underlying NER/relation extractors.
Can be passed to ner_method and relation_method in config.
config: Legacy config dict (deprecated, use kwargs)
**kwargs: Configuration options:
- ner_method: Method for NER extraction (if entities need to be extracted)
- relation_method: Method for relation extraction (if relations need to be extracted)
- Other options passed to sub-components
method: Extraction method ("llm", "pattern")
**config: Configuration options
"""
self.logger = get_logger("event_detector")
self.config = config or {}
self.config.update(kwargs)
self.config = config
self.method = method
self.progress_tracker = get_progress_tracker()
# Ensure progress tracker is enabled
if not self.progress_tracker.enabled:
self.progress_tracker.enabled = True
# Store parameters
self.event_types_filter = event_types
self.extract_participants = extract_participants
self.extract_location = extract_location
self.extract_time = extract_time
# Initialize components
self.event_classifier = EventClassifier(**config)
self.temporal_processor = TemporalEventProcessor(**config)
# Configure extraction options
self.extract_participants = config.get("extract_participants", True)
self.extract_location = config.get("extract_location", True)
self.extract_time = config.get("extract_time", True)
self.event_types_filter = config.get("event_types", [])
# Define event patterns
self.event_patterns = {
"acquisition": r"\b(acquired|acquisition|buying|bought|merger|merged)\b",
"partnership": r"\b(partnered|partnership|collaborate|collaboration)\b",
"launch": r"\b(launch|launched|releasing|released|unveil|unveiled)\b",
"investment": r"\b(invest|invested|investment|funding|raised)\b",
"legal": r"\b(sue|sued|lawsuit|litigation|legal action)\b",
}
# Pre-compile location patterns
self.location_patterns = [
re.compile(r"in\s+([A-Z][a-z]+(?:\s+[A-Z][a-z]+)*)"),
re.compile(r"at\s+([A-Z][a-z]+(?:\s+[A-Z][a-z]+)*)"),
]
# Pre-compile time patterns
self.time_patterns = [
re.compile(r"on\s+([A-Z][a-z]+\s+\d{1,2},?\s+\d{4})"),
re.compile(r"in\s+(\d{4})"),
re.compile(r"(\d{1,2}[/-]\d{1,2}[/-]\d{2,4})"),
]
# Store method for passing to extractors if needed
if method is not None:
self.config["ner_method"] = method
self.config["relation_method"] = method
self.event_classifier = EventClassifier(**self.config.get("classifier", {}))
self.temporal_processor = TemporalEventProcessor(
**self.config.get("temporal", {})
)
self.relationship_extractor = EventRelationshipExtractor(
**self.config.get("relationship", {})
)
# Event patterns
self.event_patterns = {
"founded": r"founded|created|established",
"acquired": r"acquired|bought|purchased",
"launched": r"launched|released|introduced",
"announced": r"announced|declared|stated",
"meeting": r"met|meeting|conference|summit",
}
def extract(
self,
text: Union[str, List[str], List[Dict[str, Any]]],
@@ -175,9 +166,10 @@ class EventDetector:
)
try:
results = []
results = [None] * len(text) # Pre-allocate to maintain order
total_items = len(text)
total_events_count = 0
processed_count = 0
# Determine update interval
if total_items <= 10:
@@ -193,33 +185,77 @@ class EventDetector:
message=f"Starting batch detection... 0/{total_items} (remaining: {total_items})"
)
for idx, item in enumerate(text):
# Prepare arguments for single item
doc_text = item["content"] if isinstance(item, dict) and "content" in item else str(item)
from .config import resolve_max_workers
max_workers = resolve_max_workers(
explicit=kwargs.get("max_workers"),
local_config=self.config,
methods=[self.config.get("ner_method"), self.config.get("relation_method"), self.config.get("method")],
)
def process_item(idx, item):
try:
# Prepare arguments for single item
doc_text = item["content"] if isinstance(item, dict) and "content" in item else str(item)
# Detect
events = self.detect_events(doc_text, **kwargs)
# Add provenance metadata
for event in events:
if event.metadata is None:
event.metadata = {}
event.metadata["batch_index"] = idx
if isinstance(item, dict) and "id" in item:
event.metadata["document_id"] = item["id"]
return idx, events
except Exception as e:
self.logger.error(f"Error processing item {idx}: {e}")
# Return empty list on failure to continue processing
return idx, []
if max_workers > 1:
import concurrent.futures
# Detect
events = self.detect_events(doc_text, **kwargs)
with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor:
# Submit tasks
future_to_idx = {}
for idx, item in enumerate(text):
future = executor.submit(process_item, idx, item)
future_to_idx[future] = idx
for future in concurrent.futures.as_completed(future_to_idx):
idx, events = future.result()
results[idx] = events
total_events_count += len(events)
processed_count += 1
# Update progress
if processed_count % update_interval == 0 or processed_count == total_items:
remaining = total_items - processed_count
self.progress_tracker.update_progress(
tracking_id,
processed=processed_count,
total=total_items,
message=f"Processing... {processed_count}/{total_items} (remaining: {remaining}) - Detected {total_events_count} events"
)
else:
# Sequential processing
for idx, item in enumerate(text):
_, events = process_item(idx, item)
results[idx] = events
total_events_count += len(events)
processed_count += 1
# Add provenance metadata
for event in events:
if event.metadata is None:
event.metadata = {}
event.metadata["batch_index"] = idx
if isinstance(item, dict) and "id" in item:
event.metadata["document_id"] = item["id"]
results.append(events)
total_events_count += len(events)
# Update progress
if (idx + 1) % update_interval == 0 or (idx + 1) == total_items:
remaining = total_items - (idx + 1)
self.progress_tracker.update_progress(
tracking_id,
processed=idx + 1,
total=total_items,
message=f"Processing... {idx + 1}/{total_items} (remaining: {remaining}) - Detected {total_events_count} events"
)
# Update progress
if processed_count % update_interval == 0 or processed_count == total_items:
remaining = total_items - processed_count
self.progress_tracker.update_progress(
tracking_id,
processed=processed_count,
total=total_items,
message=f"Processing... {processed_count}/{total_items} (remaining: {remaining}) - Detected {total_events_count} events"
)
self.progress_tracker.stop_tracking(
tracking_id,
@@ -238,17 +274,26 @@ class EventDetector:
# Single item
return self.detect_events(text, **kwargs)
def detect_events(self, text: str, **options) -> List[Event]:
def detect_events(
self,
text: Union[str, List[str], List[Dict[str, Any]]],
pipeline_id: Optional[str] = None,
**options,
) -> Union[List[Event], List[List[Event]]]:
"""
Detect events in text content.
Args:
text: Input text
pipeline_id: Optional pipeline ID for progress tracking (batch mode)
**options: Detection options
Returns:
list: List of detected events
"""
if isinstance(text, list):
return self.extract(text, pipeline_id=pipeline_id, **options)
tracking_id = self.progress_tracker.start_tracking(
module="semantic_extract",
submodule="EventDetector",
+459 -187
View File
@@ -107,7 +107,8 @@ License: MIT
import re
import difflib
from typing import Any, Dict, List, Optional, Union
from concurrent.futures import ThreadPoolExecutor, as_completed
from typing import Any, Dict, List, Optional, Tuple, Union
from ..utils.exceptions import ProcessingError
from ..utils.logging import get_logger
@@ -116,6 +117,8 @@ from .providers import HuggingFaceModelLoader, create_provider
from .registry import method_registry
from .relation_extractor import Relation
from .triplet_extractor import Triplet
from .cache import ExtractionCache
from .config import config
try:
from .schemas import EntitiesResponse, RelationsResponse, TripletsResponse
@@ -125,6 +128,13 @@ except ImportError:
logger = get_logger("methods")
# Initialize global result cache
_result_cache = ExtractionCache(
ttl=config.get("cache_ttl", 3600)
)
if not config.get("cache_enabled", True):
_result_cache.enabled = False
# Try to import spaCy
from ..utils.helpers import safe_import
@@ -196,159 +206,308 @@ def get_nlp_model():
pass
return None
def calculate_similarity(text: str, candidates: List[str]) -> float:
# Common synonyms for entity matching optimization
_ENTITY_SYNONYMS = {
# Entity Types
"person": ["people", "human", "name", "individual", "artist", "actor", "author", "politician"],
"org": ["company", "organization", "business", "institution", "agency", "brand", "corporation"],
"organization": ["company", "business", "institution", "agency", "brand", "corporation"],
"gpe": ["location", "place", "city", "country", "state", "nation", "region"],
"loc": ["location", "place", "region", "area"],
"date": ["time", "year", "day", "month", "period", "duration"],
"money": ["cost", "price", "value", "currency", "amount"],
"product": ["item", "object", "commodity", "goods", "device", "tool", "vehicle", "software", "app"],
"event": ["incident", "occasion", "activity", "happening", "ceremony"],
"drug": ["medication", "medicine", "pharmaceutical", "chemical", "treatment", "therapy"],
"chemical": ["drug", "substance", "compound", "element"],
"disease": ["condition", "illness", "sickness", "disorder", "syndrome", "ailment"],
# Relation Types
"founded_by": ["founder", "creator", "established_by", "started_by", "originator"],
"acquired": ["bought", "purchased", "acquisition", "takeover", "ownership", "merged_with"],
"subsidiary_of": ["owned_by", "parent_company", "part_of", "division_of", "unit_of"],
"works_for": ["employee_of", "employed_by", "staff_of", "team_member", "employs", "hired_by"],
"located_in": ["based_in", "headquartered_in", "situated_in", "found_in", "operates_in"],
"ceo_of": ["leader_of", "head_of", "director_of", "president_of", "chief_executive", "managed_by"],
"invested_in": ["funded", "financed", "backed", "shareholder_of", "venture_capital"],
"partner_with": ["collaborate_with", "joint_venture", "alliance", "deal_with", "partnership"],
"competitor_of": ["rival", "competes_with", "opponent", "nemesis"],
"manufacturer_of": ["producer_of", "maker_of", "creator_of", "builder_of"],
"treats": ["cures", "heals", "remedy_for", "used_for", "prescribed_for"],
"causes": ["leads_to", "results_in", "triggers", "produces", "creates"],
"diagnosed_with": ["suffers_from", "has_condition", "patient_of", "victim_of"],
}
def find_best_match_index(text: str, candidates: List[str]) -> Tuple[int, float]:
"""
Calculate the maximum similarity between text and a list of candidates.
Uses a hybrid approach: Exact -> Substring -> Vectors -> Fuzzy.
Find the best matching candidate index and score.
Uses hybrid similarity approach: Exact -> Synonym -> Substring -> Embeddings -> Vector -> Fuzzy.
Optimized for batch processing to avoid redundant embedding calculations.
Returns:
Tuple[int, float]: (best_candidate_index, best_score). Index is -1 if no candidates.
"""
if not candidates:
return 0.0
return -1, 0.0
if not text:
return 0.0
return -1, 0.0
text_lower = text.lower().strip()
if not text_lower:
return 0.0
return -1, 0.0
candidates_lower = [c.lower().strip() for c in candidates]
best_idx = -1
best_score = 0.0
# 1. Exact Match (Fastest)
candidates_lower = [c.lower().strip() for c in candidates if c]
if text_lower in candidates_lower:
return 1.0
try:
idx = candidates_lower.index(text_lower)
return idx, 1.0
except ValueError:
pass
# 1b. Common Synonyms (Fast Heuristic)
# Map common NER labels and Relations to user-friendly types
synonyms = {
# Entity Types
"person": ["people", "human", "name", "individual", "artist", "actor", "author", "politician"],
"org": ["company", "organization", "business", "institution", "agency", "brand", "corporation"],
"organization": ["company", "business", "institution", "agency", "brand", "corporation"],
"gpe": ["location", "place", "city", "country", "state", "nation", "region"],
"loc": ["location", "place", "region", "area"],
"date": ["time", "year", "day", "month", "period", "duration"],
"money": ["cost", "price", "value", "currency", "amount"],
"product": ["item", "object", "commodity", "goods", "device", "tool", "vehicle", "software", "app"],
"event": ["incident", "occasion", "activity", "happening", "ceremony"],
"drug": ["medication", "medicine", "pharmaceutical", "chemical", "treatment", "therapy"],
"chemical": ["drug", "substance", "compound", "element"],
"disease": ["condition", "illness", "sickness", "disorder", "syndrome", "ailment"],
# Relation Types
"founded_by": ["founder", "creator", "established_by", "started_by", "originator"],
"acquired": ["bought", "purchased", "acquisition", "takeover", "ownership", "merged_with"],
"subsidiary_of": ["owned_by", "parent_company", "part_of", "division_of", "unit_of"],
"works_for": ["employee_of", "employed_by", "staff_of", "team_member", "employs", "hired_by"],
"located_in": ["based_in", "headquartered_in", "situated_in", "found_in", "operates_in"],
"ceo_of": ["leader_of", "head_of", "director_of", "president_of", "chief_executive", "managed_by"],
"invested_in": ["funded", "financed", "backed", "shareholder_of", "venture_capital"],
"partner_with": ["collaborate_with", "joint_venture", "alliance", "deal_with", "partnership"],
"competitor_of": ["rival", "competes_with", "opponent", "nemesis"],
"manufacturer_of": ["producer_of", "maker_of", "creator_of", "builder_of"],
"treats": ["cures", "heals", "remedy_for", "used_for", "prescribed_for"],
"causes": ["leads_to", "results_in", "triggers", "produces", "creates"],
"diagnosed_with": ["suffers_from", "has_condition", "patient_of", "victim_of"],
}
# Use global _ENTITY_SYNONYMS dictionary
synonyms = _ENTITY_SYNONYMS
# Check text synonyms
if text_lower in synonyms:
for syn in synonyms[text_lower]:
if syn in candidates_lower:
return 0.95
# Also check reverse: if candidate is in synonyms of text
# Check if any candidate is a synonym of the text
for cand in candidates_lower:
return candidates_lower.index(syn), 0.95
# Check if any candidate is a synonym of text
for i, cand in enumerate(candidates_lower):
if cand in synonyms:
if text_lower in synonyms[cand]:
return 0.95
if 0.95 > best_score:
best_score = 0.95
best_idx = i
# 2. Substring Match (Fast)
# Give a boost if one is contained in the other, but penalize by length difference
for cand in candidates_lower:
if text_lower == cand:
return 1.0
word_pat = None
try:
word_pat = re.compile(rf"\b{re.escape(text_lower)}\b")
except Exception:
word_pat = None
for i, cand in enumerate(candidates_lower):
if not cand: continue
score = 0.0
if text_lower in cand or cand in text_lower:
# Calculate length ratio
ratio = min(len(text_lower), len(cand)) / max(len(text_lower), len(cand))
# Base score 0.85 for containment, adjusted by ratio
# e.g. "Apple" in "Apple Inc" -> 0.85 * (5/9) ~= 0.47 (too low?)
# Let's be more generous for containment
score = 0.9 * ratio + 0.1 # Boost slightly
if score > best_score:
best_score = score
score = 0.9 * ratio + 0.1
if word_pat and word_pat.search(cand):
score = max(score, 0.88)
if score > best_score:
best_score = score
best_idx = i
# 3. Text Embeddings (High Accuracy Semantic) - Batch Optimized
if best_score >= 0.85:
return best_idx, float(best_score)
# 3. Text Embeddings (High Accuracy Semantic)
# This is the most accurate method for diverse/unknown domains
embedder = get_text_embedder()
embedding_idx = -1
embedding_score = 0.0
if embedder:
try:
# Embed text and candidates
# Batch embedding is faster and scalable without caching
all_texts = [text] + candidates
embeddings = list(embedder.embed_batch(all_texts))
# Batch embedding: [text, cand1, cand2, ...]
# We filter out empty candidates to save compute, but need to map back to original indices
valid_cands_with_idx = [(c, i) for i, c in enumerate(candidates) if c and c.strip()]
if embeddings and len(embeddings) > 1:
text_emb = embeddings[0]
cand_embs = embeddings[1:]
if valid_cands_with_idx:
texts_to_embed = [text] + [c for c, i in valid_cands_with_idx]
embeddings = list(embedder.embed_batch(texts_to_embed))
# Calculate cosine similarity manually or via numpy
import numpy as np
text_norm = np.linalg.norm(text_emb)
if text_norm > 0:
for cand_emb in cand_embs:
cand_norm = np.linalg.norm(cand_emb)
if cand_norm > 0:
sim = np.dot(text_emb, cand_emb) / (text_norm * cand_norm)
if sim > embedding_score:
embedding_score = sim
if embeddings and len(embeddings) > 1:
text_emb = embeddings[0]
cand_embs = embeddings[1:]
import numpy as np
text_norm = np.linalg.norm(text_emb)
if text_norm > 0:
# Vectorized cosine similarity
cand_matrix = np.array(cand_embs)
cand_norms = np.linalg.norm(cand_matrix, axis=1)
# Avoid division by zero
cand_norms[cand_norms == 0] = 1e-10
dot_products = np.dot(cand_matrix, text_emb)
sims = dot_products / (cand_norms * text_norm)
max_sim_idx = np.argmax(sims)
max_sim = float(sims[max_sim_idx])
if max_sim > embedding_score:
embedding_score = max_sim
# Map back to original index
embedding_idx = valid_cands_with_idx[max_sim_idx][1]
except Exception as e:
logger.debug(f"Embedding calculation failed: {e}")
pass
if embedding_score > best_score:
best_score = embedding_score
best_idx = embedding_idx
# 4. Vector Similarity (Legacy/Fallback)
# Only use if we haven't found a good match yet and embeddings failed/unavailable
if best_score < 0.9:
nlp = get_nlp_model()
vector_score = 0.0
if nlp and nlp.vocab.vectors.shape[0] > 0:
try:
# Only use vectors if the word is in vocab or we have a good model
doc = nlp(text)
if doc.vector_norm:
for candidate in candidates:
cand_doc = nlp(candidate)
if cand_doc.vector_norm:
score = doc.similarity(cand_doc)
if score > vector_score:
vector_score = score
except Exception:
pass
if vector_score > best_score:
best_score = vector_score
vector_score = 0.0
vector_idx = -1
# 4. Fuzzy Match (Fallback/Refinement)
# If vector score is low (e.g. OOV words), fuzzy match might be better
# But difflib is slow for many candidates.
# Only run if we don't have a very high score yet
if nlp and nlp.vocab.vectors.shape[0] > 0:
try:
doc = nlp(text)
if doc.vector_norm:
for i, candidate in enumerate(candidates):
if not candidate: continue
cand_doc = nlp(candidate)
if cand_doc.vector_norm:
score = doc.similarity(cand_doc)
if score > vector_score:
vector_score = score
vector_idx = i
except Exception:
pass
if vector_score > best_score:
best_score = vector_score
best_idx = vector_idx
# 5. Fuzzy Match (Fallback)
if best_score < 0.9:
for cand in candidates_lower:
# Quick check for common characters
for i, cand in enumerate(candidates_lower):
if not cand: continue
# SequenceMatcher
score = difflib.SequenceMatcher(None, text_lower, cand).ratio()
if score > best_score:
best_score = score
best_idx = i
return float(best_score)
return best_idx, float(best_score)
def calculate_similarity(text: str, candidates: List[str]) -> float:
"""
Calculate the maximum similarity between text and a list of candidates.
Wrapper around find_best_match_index.
"""
_, score = find_best_match_index(text, candidates)
return score
def match_entity(text: str, entities: List[Entity], threshold: float = 0.8) -> Optional[Entity]:
"""
Find the best matching entity for the given text.
Uses optimized batch similarity matching.
"""
if not text or not entities:
return None
# Optimization: Check exact match first (case-insensitive)
text_lower = text.lower().strip()
for entity in entities:
if entity.text.lower().strip() == text_lower:
return entity
# Use batch matcher
candidates = [e.text for e in entities]
best_idx, best_score = find_best_match_index(text, candidates)
if best_idx >= 0 and best_score >= threshold:
return entities[best_idx]
return None
def filter_entities_for_text(
text: str,
entities: List[Entity],
max_keep: int = 80,
) -> List[Entity]:
if not text or not entities:
return []
if max_keep < 1:
return []
if len(entities) <= max_keep:
return entities
text_lower = text.lower()
stop_tokens = {
"inc",
"incorporated",
"corp",
"corporation",
"co",
"company",
"ltd",
"llc",
"plc",
"group",
"holdings",
"limited",
"the",
"and",
"or",
"of",
"in",
"on",
"at",
"for",
"to",
"a",
"an",
}
seen = set()
matched: List[Entity] = []
for entity in entities:
ent_text = getattr(entity, "text", "")
if not ent_text:
continue
key = ent_text.lower().strip()
if not key or key in seen:
continue
seen.add(key)
if key in text_lower:
matched.append(entity)
continue
tokens = re.findall(r"[a-z0-9]+", key)
keep = False
for tok in tokens:
if len(tok) < 2:
continue
if tok in stop_tokens:
continue
if tok in text_lower:
keep = True
break
if keep:
matched.append(entity)
if matched:
if len(matched) > max_keep:
matched.sort(key=lambda e: len(getattr(e, "text", "")), reverse=True)
return matched[:max_keep]
return matched
entities_sorted = sorted(entities, key=lambda e: len(getattr(e, "text", "")), reverse=True)
return entities_sorted[:max_keep]
def calculate_weighted_confidence(
item_type: str,
@@ -604,6 +763,19 @@ def extract_entities_llm(
if "llm_model" in kwargs:
model = kwargs.pop("llm_model")
# Check cache
cache_params = {
"provider": provider,
"model": model,
"max_text_length": max_text_length,
"structured_output_mode": structured_output_mode,
"entity_types": kwargs.get("entity_types"),
}
cached_result = _result_cache.get("entities", text, **cache_params)
if cached_result:
logger.debug(f"Cache hit for entity extraction ({len(cached_result)} entities)")
return cached_result
# 1. PRE-EXTRACTION VALIDATION
if not text or not text.strip():
error_msg = "Text is empty or whitespace only"
@@ -668,8 +840,21 @@ def extract_entities_llm(
You may also use related or similar entity types if they better match the context (e.g., variations, synonyms, or domain-specific types).
If an entity doesn't fit any of the preferred types, use the most appropriate type from the preferred list or a closely related type."""
else:
entity_types_instruction = """Entity types should be one of: PERSON, ORG, GPE, DATE, EVENT, PRODUCT, CONCEPT, or related types.
Use the most appropriate type for each entity, including variations or synonyms if they better match the context."""
entity_types_instruction = """Entity types should be one of:
- PERSON (People, names, roles)
- ORG (Companies, organizations, institutions, brands)
- GPE (Countries, cities, states, locations)
- DATE (Dates, years, time periods)
- EVENT (Named events, conferences)
- PRODUCT (Software, hardware, vehicles)
- CONCEPT (Abstract ideas, technologies)
Use the most appropriate type for each entity.
Examples:
- 'Microsoft' is an ORG
- 'Satya Nadella' is a PERSON
- Job titles/roles like 'CEO', 'CTO', 'President', 'Engineer' are CONCEPT unless part of a person's name
- 'Python' is a PRODUCT or CONCEPT depending on context."""
if not SCHEMAS_AVAILABLE:
raise ImportError("Pydantic schemas not available. Install pydantic/instructor to use LLM extraction.")
@@ -720,6 +905,7 @@ Text to extract from:
))
logger.info(f"Successfully extracted {len(entities)} entities using {provider}/{model} (typed)")
_result_cache.set("entities", text, entities, **cache_params)
return entities
except Exception as e:
@@ -812,27 +998,45 @@ def _extract_entities_chunked(
chunks = splitter.split(text)
all_entities = []
for i, chunk in enumerate(chunks):
logger.debug(f"Extracting entities from chunk {i+1}/{len(chunks)}")
# We recursively call extract_entities_llm with the chunk
# but ensure we don't trigger re-chunking by setting max_text_length large
chunk_entities = extract_entities_llm(
chunk.text,
provider=provider,
model=model,
silent_fail=False, # We want to know if a chunk fails
max_text_length=len(chunk.text) + 1,
structured_output_mode=structured_output_mode,
**kwargs
)
# Adjust entity positions to account for chunk offset
for entity in chunk_entities:
entity.start_char += chunk.start_index
entity.end_char += chunk.start_index
from .config import resolve_max_workers
max_workers = resolve_max_workers(explicit=kwargs.get("max_workers"))
with ThreadPoolExecutor(max_workers=max_workers) as executor:
future_to_chunk = {}
for i, chunk in enumerate(chunks):
logger.debug(f"Scheduling entity extraction for chunk {i+1}/{len(chunks)}")
# We recursively call extract_entities_llm with the chunk
# but ensure we don't trigger re-chunking by setting max_text_length large
future = executor.submit(
extract_entities_llm,
chunk.text,
provider=provider,
model=model,
silent_fail=False, # We want to know if a chunk fails
max_text_length=len(chunk.text) + 1,
structured_output_mode=structured_output_mode,
**kwargs
)
future_to_chunk[future] = (i, chunk)
all_entities.extend(chunk_entities)
for future in as_completed(future_to_chunk):
i, chunk = future_to_chunk[future]
try:
chunk_entities = future.result()
# Adjust entity positions to account for chunk offset
for entity in chunk_entities:
entity.start_char += chunk.start_index
entity.end_char += chunk.start_index
all_entities.append(entity)
except Exception as e:
if not silent_fail:
logger.error(f"Chunk {i+1} failed: {e}")
raise
logger.warning(f"Chunk {i+1} failed (silent): {e}")
return all_entities
@@ -1327,6 +1531,21 @@ def extract_relations_llm(
if "llm_model" in kwargs:
model = kwargs.pop("llm_model")
# Check cache
cache_params = {
"provider": provider,
"model": model,
"max_text_length": max_text_length,
"structured_output_mode": structured_output_mode,
"relation_types": kwargs.get("relation_types"),
# Include entities hash/str in cache key implicitly via **cache_params
"entities_hash": hash(tuple(sorted([e.text for e in entities]))) if entities else 0
}
cached_result = _result_cache.get("relations", text, **cache_params)
if cached_result:
logger.debug(f"Cache hit for relation extraction ({len(cached_result)} relations)")
return cached_result
# 1. PRE-EXTRACTION VALIDATION
if not text or not text.strip():
error_msg = "Text is empty or whitespace only"
@@ -1386,7 +1605,22 @@ def extract_relations_llm(
**kwargs
)
entities_str = ", ".join([f"{e.text} ({e.label})" for e in entities])
original_entities = entities
max_entities_prompt = kwargs.get("max_entities_prompt", kwargs.get("max_entities", 80))
try:
max_entities_prompt = int(max_entities_prompt)
except Exception:
max_entities_prompt = 80
prompt_entities = original_entities
if max_entities_prompt > 0 and len(original_entities) > max_entities_prompt:
prompt_entities = filter_entities_for_text(
text,
original_entities,
max_keep=max_entities_prompt,
)
entities_str = ", ".join([f"{e.text} ({e.label})" for e in prompt_entities])
# Use custom relation types if provided
relation_types = kwargs.get("relation_types")
@@ -1434,15 +1668,9 @@ Entities found in text: {entities_str}"""
# Convert back to internal Relation format
relations = []
for r_out in result_obj.relations:
# Find matching entities
subject_entity = next(
(e for e in entities if e.text.lower() == r_out.subject.lower()),
None,
)
object_entity = next(
(e for e in entities if e.text.lower() == r_out.object.lower()),
None
)
# Find matching entities using hybrid similarity
subject_entity = match_entity(r_out.subject, original_entities)
object_entity = match_entity(r_out.object, original_entities)
if subject_entity and object_entity:
relations.append(Relation(
@@ -1459,6 +1687,7 @@ Entities found in text: {entities_str}"""
))
logger.info(f"Successfully extracted {len(relations)} relations using {provider}/{model} (typed)")
_result_cache.set("relations", text, relations, **cache_params)
return relations
except Exception as e:
@@ -1523,14 +1752,9 @@ def _parse_relation_result(
subject_text = str(subject_text)
object_text = str(object_text)
# Find matching entities
subject_entity = next(
(e for e in entities if e.text.lower() == subject_text.lower()),
None,
)
object_entity = next(
(e for e in entities if e.text.lower() == object_text.lower()), None
)
# Find matching entities using hybrid similarity
subject_entity = match_entity(subject_text, entities)
object_entity = match_entity(object_text, entities)
if subject_entity and object_entity:
relations.append(
@@ -1571,29 +1795,47 @@ def _extract_relations_chunked(
chunks = splitter.split(text)
all_relations = []
for i, chunk in enumerate(chunks):
# Only include entities that appear in this chunk (or close to it)
chunk_entities = [
e for e in entities
if e.start_char >= chunk.start_index - 100 and e.end_char <= chunk.end_index + 100
]
if not chunk_entities:
continue
from .config import resolve_max_workers
max_workers = resolve_max_workers(explicit=kwargs.get("max_workers"))
with ThreadPoolExecutor(max_workers=max_workers) as executor:
future_to_chunk = {}
for i, chunk in enumerate(chunks):
# Only include entities that appear in this chunk (or close to it)
chunk_entities = [
e for e in entities
if e.start_char >= chunk.start_index - 100 and e.end_char <= chunk.end_index + 100
]
logger.debug(f"Extracting relations from chunk {i+1}/{len(chunks)} with {len(chunk_entities)} entities")
chunk_rels = extract_relations_llm(
chunk.text,
entities=chunk_entities,
provider=provider,
model=model,
silent_fail=False,
max_text_length=len(chunk.text) + 1,
structured_output_mode=structured_output_mode,
**kwargs
)
all_relations.extend(chunk_rels)
if not chunk_entities:
continue
logger.debug(f"Scheduling relation extraction for chunk {i+1}/{len(chunks)} with {len(chunk_entities)} entities")
future = executor.submit(
extract_relations_llm,
chunk.text,
entities=chunk_entities,
provider=provider,
model=model,
silent_fail=False,
max_text_length=len(chunk.text) + 1,
structured_output_mode=structured_output_mode,
**kwargs
)
future_to_chunk[future] = i
for future in as_completed(future_to_chunk):
i = future_to_chunk[future]
try:
chunk_rels = future.result()
all_relations.extend(chunk_rels)
except Exception as e:
if not silent_fail:
logger.error(f"Chunk {i+1} failed: {e}")
raise
logger.warning(f"Chunk {i+1} failed (silent): {e}")
return all_relations
@@ -1636,12 +1878,8 @@ def extract_triplets_pattern(
predicate_text = match.group("predicate")
object_text = match.group("object")
subject_entity = next(
(e for e in entities if e.text.lower() == subject_text.lower()), None
)
object_entity = next(
(e for e in entities if e.text.lower() == object_text.lower()), None
)
subject_entity = match_entity(subject_text, entities)
object_entity = match_entity(object_text, entities)
if subject_entity and object_entity:
triplets.append(
@@ -1745,6 +1983,22 @@ def extract_triplets_llm(
if "llm_model" in kwargs:
model = kwargs.pop("llm_model")
# Check cache
cache_params = {
"provider": provider,
"model": model,
"max_text_length": max_text_length,
"structured_output_mode": structured_output_mode,
"triplet_types": kwargs.get("triplet_types"),
# Include entities/relations hash in cache key implicitly via **cache_params
"entities_hash": hash(tuple(sorted([e.text for e in entities]))) if entities else 0,
"relations_hash": hash(tuple(sorted([str(r) for r in relations]))) if relations else 0
}
cached_result = _result_cache.get("triplets", text, **cache_params)
if cached_result:
logger.debug(f"Cache hit for triplet extraction ({len(cached_result)} triplets)")
return cached_result
# 1. PRE-EXTRACTION VALIDATION
if not text or not text.strip():
error_msg = "Text is empty or whitespace only"
@@ -1854,6 +2108,7 @@ Text to extract from:
))
logger.info(f"Successfully extracted {len(triplets)} triplets using {provider}/{model} (typed)")
_result_cache.set("triplets", text, triplets, **cache_params)
return triplets
except Exception as e:
@@ -1944,20 +2199,37 @@ def _extract_triplets_chunked(
chunks = splitter.split(text)
all_triplets = []
for i, chunk in enumerate(chunks):
logger.debug(f"Extracting triplets from chunk {i+1}/{len(chunks)}")
chunk_triplets = extract_triplets_llm(
chunk.text,
provider=provider,
model=model,
silent_fail=False,
max_text_length=len(chunk.text) + 1,
structured_output_mode=structured_output_mode,
**kwargs
)
all_triplets.extend(chunk_triplets)
from .config import resolve_max_workers
max_workers = resolve_max_workers(explicit=kwargs.get("max_workers"))
with ThreadPoolExecutor(max_workers=max_workers) as executor:
future_to_chunk = {}
for i, chunk in enumerate(chunks):
logger.debug(f"Scheduling triplet extraction for chunk {i+1}/{len(chunks)}")
future = executor.submit(
extract_triplets_llm,
chunk.text,
provider=provider,
model=model,
silent_fail=False,
max_text_length=len(chunk.text) + 1,
structured_output_mode=structured_output_mode,
**kwargs
)
future_to_chunk[future] = i
for future in as_completed(future_to_chunk):
i = future_to_chunk[future]
try:
chunk_triplets = future.result()
all_triplets.extend(chunk_triplets)
except Exception as e:
if not silent_fail:
logger.error(f"Chunk {i+1} failed: {e}")
raise
logger.warning(f"Chunk {i+1} failed (silent): {e}")
return all_triplets
+81 -24
View File
@@ -178,9 +178,11 @@ class NERExtractor:
)
try:
results = []
results = [None] * len(text)
total_items = len(text)
total_entities_count = 0
processed_count = 0
# Update more frequently: every 1% or at least every 10 items, but always update for small datasets
if total_items <= 10:
update_interval = 1 # Update every item for small datasets
@@ -188,15 +190,22 @@ class NERExtractor:
update_interval = max(1, min(10, total_items // 100))
# Initial progress update - ALWAYS show this
remaining = total_items
self.progress_tracker.update_progress(
tracking_id,
processed=0,
total=total_items,
message=f"Starting batch extraction... 0/{total_items} (remaining: {remaining})"
message=f"Starting batch extraction... 0/{total_items}"
)
for idx, item in enumerate(text, 1):
from .config import resolve_max_workers
max_workers = resolve_max_workers(
explicit=kwargs.get("max_workers"),
local_config=self.config,
methods=self.method,
)
# Helper function for single item processing
def process_item(idx, item):
try:
current_entities = []
if isinstance(item, dict) and "content" in item:
@@ -214,30 +223,69 @@ class NERExtractor:
for ent in current_entities:
if ent.metadata is None:
ent.metadata = {}
ent.metadata["batch_index"] = idx - 1
ent.metadata["batch_index"] = idx
if isinstance(item, dict) and "id" in item:
ent.metadata["document_id"] = item["id"]
results.append(current_entities)
total_entities_count += len(current_entities)
except Exception:
results.append([])
return idx, current_entities
except Exception as e:
self.logger.warning(f"Failed to process item {idx}: {e}")
return idx, []
if max_workers > 1:
import concurrent.futures
remaining = total_items - idx
# Update progress: always update for small datasets, or at intervals for large ones
should_update = (
idx % update_interval == 0 or
idx == total_items or
idx == 1 or
total_items <= 10 # Always update for small datasets
)
if should_update:
self.progress_tracker.update_progress(
tracking_id,
processed=idx,
total=total_items,
message=f"Processing documents... {idx}/{total_items} (remaining: {remaining}) - Extracted {total_entities_count} entities so far"
with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor:
# Submit all tasks
future_to_idx = {
executor.submit(process_item, idx, item): idx
for idx, item in enumerate(text)
}
for future in concurrent.futures.as_completed(future_to_idx):
idx, entities = future.result()
results[idx] = entities
total_entities_count += len(entities)
processed_count += 1
# Update progress
should_update = (
processed_count % update_interval == 0 or
processed_count == total_items or
processed_count == 1 or
total_items <= 10
)
if should_update:
remaining = total_items - processed_count
self.progress_tracker.update_progress(
tracking_id,
processed=processed_count,
total=total_items,
message=f"Processing documents... {processed_count}/{total_items} (remaining: {remaining}) - Extracted {total_entities_count} entities so far"
)
else:
# Sequential processing
for idx, item in enumerate(text):
_, entities = process_item(idx, item)
results[idx] = entities
total_entities_count += len(entities)
processed_count += 1
# Update progress
should_update = (
processed_count % update_interval == 0 or
processed_count == total_items or
processed_count == 1 or
total_items <= 10
)
if should_update:
remaining = total_items - processed_count
self.progress_tracker.update_progress(
tracking_id,
processed=processed_count,
total=total_items,
message=f"Processing documents... {processed_count}/{total_items} (remaining: {remaining}) - Extracted {total_entities_count} entities so far"
)
self.progress_tracker.stop_tracking(
tracking_id,
@@ -253,12 +301,18 @@ class NERExtractor:
else:
return self.extract_entities(text, **kwargs)
def extract_entities(self, text: str, **options) -> List[Entity]:
def extract_entities(
self,
text: Union[str, List[Dict[str, Any]], List[str]],
pipeline_id: Optional[str] = None,
**options,
) -> Union[List[Entity], List[List[Entity]]]:
"""
Extract named entities from text.
Args:
text: Input text
pipeline_id: Optional pipeline ID for progress tracking (batch mode)
**options: Extraction options:
- entity_types: Filter by entity types (list)
- min_confidence: Minimum confidence threshold
@@ -267,6 +321,9 @@ class NERExtractor:
Returns:
list: List of extracted entities
"""
if isinstance(text, list):
return self.extract(text, pipeline_id=pipeline_id, **options)
tracking_id = self.progress_tracker.start_tracking(
module="semantic_extract",
submodule="NERExtractor",
+138 -50
View File
@@ -530,26 +530,40 @@ class GeminiProvider(BaseProvider):
self.api_key = api_key or config.get_api_key("gemini")
self.model = model
self.client = None
self._use_new_genai = False
self._init_client()
def _init_client(self):
"""Initialize Gemini client."""
try:
import google.generativeai as genai
from google import genai as new_genai
if self.api_key:
genai.configure(api_key=self.api_key)
self.client = genai.GenerativeModel(self.model)
except (ImportError, OSError):
self.client = new_genai.Client(api_key=self.api_key)
self._use_new_genai = True
return
except Exception:
pass
try:
import google.generativeai as old_genai
if self.api_key:
old_genai.configure(api_key=self.api_key)
self.client = old_genai.GenerativeModel(self.model)
self._use_new_genai = False
except Exception:
self.client = None
self.logger.warning(
"google-generativeai library not installed. Install with: pip install semantica[llm-gemini]"
)
self.logger.warning("Gemini SDK not installed. Install with: pip install semantica[llm-gemini]")
def is_available(self) -> bool:
"""Check if provider is available."""
return self.client is not None
def _resp_text(self, resp: Any) -> str:
if hasattr(resp, "text"):
return getattr(resp, "text")
try:
return resp.candidates[0].content.parts[0].text
except Exception:
return str(resp)
def generate(self, prompt: str, **kwargs) -> str:
"""Generate text from prompt."""
if not self.client:
@@ -557,32 +571,46 @@ class GeminiProvider(BaseProvider):
"Gemini client not initialized. Set GEMINI_API_KEY or pass api_key."
)
generation_config = {"temperature": kwargs.get("temperature", 0.3)}
if "max_tokens" in kwargs:
generation_config["max_output_tokens"] = kwargs["max_tokens"]
# Pass through other common parameters
for param in ["top_p", "top_k", "stop_sequences", "candidate_count"]:
if param in kwargs:
generation_config[param] = kwargs[param]
response = self.client.generate_content(
prompt, generation_config=generation_config
)
return response.text
if self._use_new_genai:
model = kwargs.get("model", self.model)
temperature = kwargs.get("temperature", 0.3)
create_kwargs = {"model": model, "contents": prompt, "config": {"temperature": temperature}}
if "max_tokens" in kwargs:
create_kwargs["config"]["max_output_tokens"] = kwargs["max_tokens"]
for p in ["top_p", "top_k", "stop_sequences", "candidate_count"]:
if p in kwargs:
create_kwargs["config"][p] = kwargs[p]
resp = self.client.models.generate_content(**create_kwargs)
return self._resp_text(resp)
else:
generation_config = {"temperature": kwargs.get("temperature", 0.3)}
if "max_tokens" in kwargs:
generation_config["max_output_tokens"] = kwargs["max_tokens"]
for param in ["top_p", "top_k", "stop_sequences", "candidate_count"]:
if param in kwargs:
generation_config[param] = kwargs[param]
response = self.client.generate_content(prompt, generation_config=generation_config)
return self._resp_text(response)
def generate_structured(self, prompt: str, **kwargs) -> dict:
"""Generate structured output."""
if not self.client:
raise ProcessingError("Gemini client not initialized.")
# Add JSON format instruction to prompt
json_prompt = f"{prompt}\n\nReturn the response as valid JSON only."
response = self.client.generate_content(json_prompt)
try:
return self._parse_json(response.text)
except Exception as e:
raise ProcessingError(f"Failed to parse JSON from Gemini response: {e}")
if self._use_new_genai:
model = kwargs.get("model", self.model)
resp = self.client.models.generate_content(model=model, contents=json_prompt)
try:
return self._parse_json(self._resp_text(resp))
except Exception as e:
raise ProcessingError(f"Failed to parse JSON from Gemini response: {e}")
else:
response = self.client.generate_content(json_prompt)
try:
return self._parse_json(self._resp_text(response))
except Exception as e:
raise ProcessingError(f"Failed to parse JSON from Gemini response: {e}")
class GroqProvider(BaseProvider):
@@ -1183,28 +1211,88 @@ class HuggingFaceModelLoader:
return [{"triplet": decoded}]
def create_provider(name: str, **kwargs) -> BaseProvider:
"""Create provider - checks registry for custom providers."""
# Check registry first
custom_provider = provider_registry.get(name)
if custom_provider:
return custom_provider(**kwargs)
class ProviderPool:
"""Pool for reusing provider instances."""
def __init__(self):
self._providers: Dict[str, BaseProvider] = {}
self.logger = get_logger("provider_pool")
# Built-in providers
builtin = {
"openai": OpenAIProvider,
"gemini": GeminiProvider,
"groq": GroqProvider,
"anthropic": AnthropicProvider,
"ollama": OllamaProvider,
"huggingface_llm": HuggingFaceLLMProvider,
"deepseek": DeepSeekProvider,
}
def get(self, name: str, **kwargs) -> BaseProvider:
"""Get or create a provider instance."""
# Create a cache key from name and kwargs
# Filter out non-hashable items or volatile args if any
# For now, we assume kwargs are configuration options that should match
# Helper to make dict hashable
def make_hashable(value):
if isinstance(value, dict):
return tuple(sorted((k, make_hashable(v)) for k, v in value.items()))
elif isinstance(value, list):
return tuple(make_hashable(v) for v in value)
return value
provider_class = builtin.get(name.lower())
if not provider_class:
raise ValueError(
f"Unknown provider: {name}. Register custom provider or use built-in: {list(builtin.keys())}"
)
key_parts = [name]
for k, v in sorted(kwargs.items()):
# Skip some keys if they shouldn't affect pooling?
# For now, all init args matter for the instance identity.
key_parts.append((k, make_hashable(v)))
key = str(tuple(key_parts))
if key in self._providers:
return self._providers[key]
self.logger.debug(f"Creating new provider instance for {name}")
provider = self._create_provider(name, **kwargs)
self._providers[key] = provider
return provider
def _create_provider(self, name: str, **kwargs) -> BaseProvider:
"""Internal creation logic."""
# Check registry first
custom_provider = provider_registry.get(name)
if custom_provider:
return custom_provider(**kwargs)
return provider_class(**kwargs)
# Built-in providers
builtin = {
"openai": OpenAIProvider,
"gemini": GeminiProvider,
"groq": GroqProvider,
"anthropic": AnthropicProvider,
"ollama": OllamaProvider,
"huggingface_llm": HuggingFaceLLMProvider,
"deepseek": DeepSeekProvider,
}
provider_class = builtin.get(name.lower())
if not provider_class:
raise ValueError(
f"Unknown provider: {name}. Register custom provider or use built-in: {list(builtin.keys())}"
)
return provider_class(**kwargs)
def clear(self):
"""Clear the provider pool."""
self._providers.clear()
# Global provider pool
_provider_pool = ProviderPool()
def create_provider(name: str, use_pool: bool = True, **kwargs) -> BaseProvider:
"""
Create provider - checks registry for custom providers.
Args:
name: Provider name
use_pool: Whether to use the provider pool (default: True)
**kwargs: Provider arguments
"""
if use_pool:
return _provider_pool.get(name, **kwargs)
return _provider_pool._create_provider(name, **kwargs)
+111 -52
View File
@@ -201,10 +201,12 @@ class RelationExtractor:
)
try:
results = []
# Ensure lists are same length
min_len = min(len(text), len(entities))
results = [None] * min_len
total_relations_count = 0
processed_count = 0
# Update more frequently: every 1% or at least every 10 items, but always update for small datasets
if min_len <= 10:
update_interval = 1 # Update every item for small datasets
@@ -212,58 +214,101 @@ class RelationExtractor:
update_interval = max(1, min(10, min_len // 100))
# Initial progress update - ALWAYS show this
remaining = min_len
self.progress_tracker.update_progress(
tracking_id,
processed=0,
total=min_len,
message=f"Starting batch extraction... 0/{min_len} (remaining: {remaining})"
message=f"Starting batch extraction... 0/{min_len}"
)
for i in range(min_len):
doc_item = text[i]
ent_item = entities[i]
doc_text = ""
if isinstance(doc_item, dict) and "content" in doc_item:
doc_text = doc_item["content"]
elif isinstance(doc_item, str):
doc_text = doc_item
else:
doc_text = str(doc_item)
# Ensure ent_item is a list of entities
if not isinstance(ent_item, list):
ent_item = [] # Should not happen if entities is List[List[Entity]]
current_relations = self.extract_relations(doc_text, ent_item, **kwargs)
# Add provenance metadata
for rel in current_relations:
if rel.metadata is None:
rel.metadata = {}
rel.metadata["batch_index"] = i
if isinstance(doc_item, dict) and "id" in doc_item:
rel.metadata["document_id"] = doc_item["id"]
from .config import resolve_max_workers
max_workers = resolve_max_workers(
explicit=kwargs.get("max_workers"),
local_config=self.config,
methods=self.method,
)
results.append(current_relations)
total_relations_count += len(current_relations)
def process_item(i, doc_item, ent_item):
try:
doc_text = ""
if isinstance(doc_item, dict) and "content" in doc_item:
doc_text = doc_item["content"]
elif isinstance(doc_item, str):
doc_text = doc_item
else:
doc_text = str(doc_item)
# Ensure ent_item is a list of entities
if not isinstance(ent_item, list):
ent_item = [] # Should not happen if entities is List[List[Entity]]
current_relations = self.extract_relations(doc_text, ent_item, **kwargs)
# Add provenance metadata
for rel in current_relations:
if rel.metadata is None:
rel.metadata = {}
rel.metadata["batch_index"] = i
if isinstance(doc_item, dict) and "id" in doc_item:
rel.metadata["document_id"] = doc_item["id"]
return i, current_relations
except Exception as e:
self.logger.warning(f"Failed to process item {i}: {e}")
return i, []
if max_workers > 1:
import concurrent.futures
remaining = min_len - (i + 1)
# Update progress: always update for small datasets, or at intervals for large ones
should_update = (
(i + 1) % update_interval == 0 or
(i + 1) == min_len or
i == 0 or
min_len <= 10 # Always update for small datasets
)
if should_update:
self.progress_tracker.update_progress(
tracking_id,
processed=i + 1,
total=min_len,
message=f"Processing documents... {i + 1}/{min_len} (remaining: {remaining}) - Extracted {total_relations_count} relations so far"
with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor:
# Submit tasks
future_to_idx = {
executor.submit(process_item, i, text[i], entities[i]): i
for i in range(min_len)
}
for future in concurrent.futures.as_completed(future_to_idx):
i, relations = future.result()
results[i] = relations
total_relations_count += len(relations)
processed_count += 1
should_update = (
processed_count % update_interval == 0 or
processed_count == min_len or
processed_count == 1 or
min_len <= 10
)
if should_update:
remaining = min_len - processed_count
self.progress_tracker.update_progress(
tracking_id,
processed=processed_count,
total=min_len,
message=f"Processing documents... {processed_count}/{min_len} (remaining: {remaining}) - Extracted {total_relations_count} relations so far"
)
else:
# Sequential processing
for i in range(min_len):
_, relations = process_item(i, text[i], entities[i])
results[i] = relations
total_relations_count += len(relations)
processed_count += 1
should_update = (
processed_count % update_interval == 0 or
processed_count == min_len or
processed_count == 1 or
min_len <= 10
)
if should_update:
remaining = min_len - processed_count
self.progress_tracker.update_progress(
tracking_id,
processed=processed_count,
total=min_len,
message=f"Processing documents... {processed_count}/{min_len} (remaining: {remaining}) - Extracted {total_relations_count} relations so far"
)
self.progress_tracker.stop_tracking(
tracking_id,
@@ -284,14 +329,19 @@ class RelationExtractor:
return []
def extract_relations(
self, text: str, entities: List[Entity], **options
) -> List[Relation]:
self,
text: Union[str, List[Dict[str, Any]], List[str]],
entities: Union[List[Entity], List[List[Entity]]],
pipeline_id: Optional[str] = None,
**options,
) -> Union[List[Relation], List[List[Relation]]]:
"""
Extract relations between entities.
Args:
text: Input text
entities: List of extracted entities
pipeline_id: Optional pipeline ID for progress tracking (batch mode)
**options: Extraction options:
- method: Override method (if not set in __init__)
- min_confidence: Minimum confidence threshold
@@ -300,7 +350,18 @@ class RelationExtractor:
Returns:
list: List of extracted relations
"""
from .methods import get_relation_method
if isinstance(text, list):
if entities is None:
entities_batch = [[] for _ in range(len(text))]
elif isinstance(entities, list) and (not entities):
entities_batch = [[] for _ in range(len(text))]
elif isinstance(entities, list) and all(isinstance(e, Entity) for e in entities):
entities_batch = [entities for _ in range(len(text))]
else:
entities_batch = entities
return self.extract(text, entities_batch, pipeline_id=pipeline_id, **options)
from .methods import get_relation_method, match_entity
tracking_id = self.progress_tracker.start_tracking(
module="semantic_extract",
@@ -487,11 +548,9 @@ class RelationExtractor:
self, text: str, entities: List[Entity]
) -> List[Relation]:
"""Extract relations using pattern matching."""
from .methods import match_entity
relations = []
# Create entity lookup by text
entity_map = {e.text.lower(): e for e in entities}
# Check each relation pattern
for relation_type, patterns in self.relation_patterns.items():
for pattern in patterns:
@@ -499,8 +558,8 @@ class RelationExtractor:
subject_text = match.group("subject").strip()
object_text = match.group("object").strip()
subject_entity = entity_map.get(subject_text.lower())
object_entity = entity_map.get(object_text.lower())
subject_entity = match_entity(subject_text, entities)
object_entity = match_entity(object_text, entities)
if subject_entity and object_entity:
# Get context around the match
+75 -31
View File
@@ -148,8 +148,9 @@ class SemanticAnalyzer:
)
try:
results = []
results = [None] * len(text)
total_items = len(text)
processed_count = 0
# Determine update interval
if total_items <= 10:
@@ -165,40 +166,83 @@ class SemanticAnalyzer:
message=f"Starting batch analysis... 0/{total_items} (remaining: {total_items})"
)
for idx, item in enumerate(text):
# Prepare arguments for single item
doc_text = item["content"] if isinstance(item, dict) and "content" in item else str(item)
# Analyze
analysis = self.analyze_semantics(doc_text, **kwargs)
from .config import resolve_max_workers
max_workers = resolve_max_workers(
explicit=kwargs.get("max_workers"),
local_config=self.config,
)
# Add provenance metadata
analysis["batch_index"] = idx
if isinstance(item, dict) and "id" in item:
analysis["document_id"] = item["id"]
# Also inject into semantic roles if present
if "semantic_roles" in analysis:
for role in analysis["semantic_roles"]:
# role is a dict here because analyze_semantics converts it
if "metadata" not in role:
role["metadata"] = {}
role["metadata"]["batch_index"] = idx
if isinstance(item, dict) and "id" in item:
role["metadata"]["document_id"] = item["id"]
def process_item(idx, item):
try:
doc_text = item["content"] if isinstance(item, dict) and "content" in item else str(item)
analysis = self.analyze_semantics(doc_text, **kwargs)
results.append(analysis)
analysis["batch_index"] = idx
if isinstance(item, dict) and "id" in item:
analysis["document_id"] = item["id"]
# Update progress
if (idx + 1) % update_interval == 0 or (idx + 1) == total_items:
remaining = total_items - (idx + 1)
self.progress_tracker.update_progress(
tracking_id,
processed=idx + 1,
total=total_items,
message=f"Processing... {idx + 1}/{total_items} (remaining: {remaining})"
if "semantic_roles" in analysis:
for role in analysis["semantic_roles"]:
if "metadata" not in role:
role["metadata"] = {}
role["metadata"]["batch_index"] = idx
if isinstance(item, dict) and "id" in item:
role["metadata"]["document_id"] = item["id"]
return idx, analysis
except Exception as e:
self.logger.warning(f"Failed to analyze item {idx}: {e}")
return idx, {"error": str(e), "batch_index": idx}
if max_workers > 1:
import concurrent.futures
with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor:
future_to_idx = {
executor.submit(process_item, idx, item): idx
for idx, item in enumerate(text)
}
for future in concurrent.futures.as_completed(future_to_idx):
idx, analysis = future.result()
results[idx] = analysis
processed_count += 1
should_update = (
processed_count % update_interval == 0
or processed_count == total_items
or processed_count == 1
or total_items <= 10
)
if should_update:
remaining = total_items - processed_count
self.progress_tracker.update_progress(
tracking_id,
processed=processed_count,
total=total_items,
message=f"Processing... {processed_count}/{total_items} (remaining: {remaining})"
)
else:
for idx, item in enumerate(text):
_, analysis = process_item(idx, item)
results[idx] = analysis
processed_count += 1
should_update = (
processed_count % update_interval == 0
or processed_count == total_items
or processed_count == 1
or total_items <= 10
)
if should_update:
remaining = total_items - processed_count
self.progress_tracker.update_progress(
tracking_id,
processed=processed_count,
total=total_items,
message=f"Processing... {processed_count}/{total_items} (remaining: {remaining})"
)
self.progress_tracker.stop_tracking(
tracking_id,
@@ -41,6 +41,7 @@ print(f"Extracted {len(entities)} entities and {len(relations)} relations")
All extractors support batch processing for high-throughput extraction. You can pass a list of strings or a list of dictionaries (with `content` and `id` keys).
**Features:**
- **Parallel Processing**: Multi-threaded extraction for high throughput (control via `max_workers`).
- **Progress Tracking**: Automatically shows a progress bar for large batches.
- **Provenance Metadata**: Each extracted item includes `batch_index` and `document_id` in its `metadata`.
@@ -52,9 +53,13 @@ documents = [
{"id": "doc_2", "content": "Microsoft Corporation was founded by Bill Gates."}
]
extractor = NERExtractor()
# Initialize with parallel processing enabled
extractor = NERExtractor(max_workers=4)
batch_results = extractor.extract(documents)
# OR override during extraction call
# batch_results = extractor.extract(documents, max_workers=8)
for i, doc_entities in enumerate(batch_results):
print(f"Document {i} entities:")
for entity in doc_entities:
@@ -149,6 +149,9 @@ class SemanticNetworkExtractor:
self.config["ner_method"] = method
self.config["relation_method"] = method
self._ner_extractor = None
self._relation_extractor = None
def extract(
self,
text: Union[str, List[str], List[Dict[str, Any]]],
@@ -181,8 +184,9 @@ class SemanticNetworkExtractor:
)
try:
results = []
results = [None] * len(text)
total_items = len(text)
processed_count = 0
# Determine update interval
if total_items <= 10:
@@ -198,54 +202,109 @@ class SemanticNetworkExtractor:
message=f"Starting batch extraction... 0/{total_items} (remaining: {total_items})"
)
for idx, item in enumerate(text):
# Prepare arguments for single item
doc_text = item["content"] if isinstance(item, dict) and "content" in item else str(item)
doc_entities = None
if entities and isinstance(entities, list) and idx < len(entities):
doc_entities = entities[idx]
doc_relations = None
if relations and isinstance(relations, list) and idx < len(relations):
doc_relations = relations[idx]
from .config import resolve_max_workers
max_workers = resolve_max_workers(
explicit=kwargs.get("max_workers"),
local_config=self.config,
)
# Extract
network = self.extract_network(
doc_text,
entities=doc_entities,
relations=doc_relations,
**kwargs
)
# Add provenance metadata to nodes and edges
batch_meta = {"batch_index": idx}
if isinstance(item, dict) and "id" in item:
batch_meta["document_id"] = item["id"]
# Update network metadata
network.metadata.update(batch_meta)
# Update nodes metadata
for node in network.nodes:
node.metadata.update(batch_meta)
def process_item(idx, item, doc_entities, doc_relations):
try:
doc_text = item["content"] if isinstance(item, dict) and "content" in item else str(item)
# Update edges metadata
for edge in network.edges:
edge.metadata.update(batch_meta)
results.append(network)
# Update progress
if (idx + 1) % update_interval == 0 or (idx + 1) == total_items:
remaining = total_items - (idx + 1)
self.progress_tracker.update_progress(
tracking_id,
processed=idx + 1,
total=total_items,
message=f"Processing... {idx + 1}/{total_items} (remaining: {remaining})"
# Extract
network = self.extract_network(
doc_text,
entities=doc_entities,
relations=doc_relations,
**kwargs
)
# Add provenance metadata to nodes and edges
batch_meta = {"batch_index": idx}
if isinstance(item, dict) and "id" in item:
batch_meta["document_id"] = item["id"]
# Update network metadata
network.metadata.update(batch_meta)
# Update nodes metadata
for node in network.nodes:
node.metadata.update(batch_meta)
# Update edges metadata
for edge in network.edges:
edge.metadata.update(batch_meta)
return idx, network
except Exception as e:
self.logger.warning(f"Failed to process item {idx}: {e}")
return idx, None
if max_workers > 1:
import concurrent.futures
with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor:
# Submit tasks
future_to_idx = {}
for idx, item in enumerate(text):
doc_entities = None
if entities and isinstance(entities, list) and idx < len(entities):
doc_entities = entities[idx]
doc_relations = None
if relations and isinstance(relations, list) and idx < len(relations):
doc_relations = relations[idx]
future = executor.submit(process_item, idx, item, doc_entities, doc_relations)
future_to_idx[future] = idx
for future in concurrent.futures.as_completed(future_to_idx):
idx, network = future.result()
if network:
results[idx] = network
processed_count += 1
# Update progress
if (processed_count) % update_interval == 0 or (processed_count) == total_items:
remaining = total_items - processed_count
self.progress_tracker.update_progress(
tracking_id,
processed=processed_count,
total=total_items,
message=f"Processing... {processed_count}/{total_items} (remaining: {remaining})"
)
else:
# Sequential processing
for idx, item in enumerate(text):
doc_entities = None
if entities and isinstance(entities, list) and idx < len(entities):
doc_entities = entities[idx]
doc_relations = None
if relations and isinstance(relations, list) and idx < len(relations):
doc_relations = relations[idx]
_, network = process_item(idx, item, doc_entities, doc_relations)
if network:
results[idx] = network
processed_count += 1
# Update progress
if (processed_count) % update_interval == 0 or (processed_count) == total_items:
remaining = total_items - processed_count
self.progress_tracker.update_progress(
tracking_id,
processed=processed_count,
total=total_items,
message=f"Processing... {processed_count}/{total_items} (remaining: {remaining})"
)
# Filter out None results if any failed
results = [r for r in results if r is not None]
self.progress_tracker.stop_tracking(
tracking_id,
status="completed",
@@ -265,11 +324,12 @@ class SemanticNetworkExtractor:
def extract_network(
self,
text: str,
entities: Optional[List[Entity]] = None,
relations: Optional[List[Relation]] = None,
text: Union[str, List[str], List[Dict[str, Any]]],
entities: Optional[Union[List[Entity], List[List[Entity]]]] = None,
relations: Optional[Union[List[Relation], List[List[Relation]]]] = None,
pipeline_id: Optional[str] = None,
**options,
) -> SemanticNetwork:
) -> Union[SemanticNetwork, List[SemanticNetwork]]:
"""
Extract semantic network from text.
@@ -282,6 +342,23 @@ class SemanticNetworkExtractor:
Returns:
SemanticNetwork: Extracted semantic network
"""
if isinstance(text, list):
entities_batch = entities
if entities is not None and isinstance(entities, list) and (not entities or all(isinstance(e, Entity) for e in entities)):
entities_batch = [entities for _ in range(len(text))] if entities else [[] for _ in range(len(text))]
relations_batch = relations
if relations is not None and isinstance(relations, list) and (not relations or all(isinstance(r, Relation) for r in relations)):
relations_batch = [relations for _ in range(len(text))] if relations else [[] for _ in range(len(text))]
return self.extract(
text,
entities=entities_batch,
relations=relations_batch,
pipeline_id=pipeline_id,
**options,
)
tracking_id = self.progress_tracker.start_tracking(
module="semantic_extract",
submodule="SemanticNetworkExtractor",
@@ -301,15 +378,16 @@ class SemanticNetworkExtractor:
# Pass method if specified
if "ner_method" in self.config:
ner_config["method"] = self.config["ner_method"]
ner = NERExtractor(
**ner_config,
**{
k: v
for k, v in self.config.items()
if k not in ["ner", "relation"]
},
)
entities = ner.extract_entities(text, **options)
if self._ner_extractor is None:
self._ner_extractor = NERExtractor(
**ner_config,
**{
k: v
for k, v in self.config.items()
if k not in ["ner", "relation"]
},
)
entities = self._ner_extractor.extract_entities(text, **options)
# Extract relations if not provided
if relations is None:
@@ -320,15 +398,16 @@ class SemanticNetworkExtractor:
# Pass method if specified
if "relation_method" in self.config:
rel_config["method"] = self.config["relation_method"]
rel_extractor = RelationExtractor(
**rel_config,
**{
k: v
for k, v in self.config.items()
if k not in ["ner", "relation"]
},
)
relations = rel_extractor.extract_relations(text, entities, **options)
if self._relation_extractor is None:
self._relation_extractor = RelationExtractor(
**rel_config,
**{
k: v
for k, v in self.config.items()
if k not in ["ner", "relation"]
},
)
relations = self._relation_extractor.extract_relations(text, entities, **options)
# Build network
total_steps = 2 # Create nodes, create edges
+137 -47
View File
@@ -143,6 +143,13 @@ class TripletExtractor:
if not self.progress_tracker.enabled:
self.progress_tracker.enabled = True
if method is not None:
self.config["ner_method"] = method
self.config["relation_method"] = method
self._ner_extractor = None
self._relation_extractor = None
# Store parameters
self.triplet_types = triplet_types
self.include_temporal = include_temporal
@@ -191,9 +198,10 @@ class TripletExtractor:
)
try:
results = []
results = [None] * len(text)
total_items = len(text)
total_triplets_count = 0
processed_count = 0
# Determine update interval
if total_items <= 10:
@@ -206,50 +214,91 @@ class TripletExtractor:
tracking_id,
processed=0,
total=total_items,
message=f"Starting batch extraction... 0/{total_items} (remaining: {total_items})"
message=f"Starting batch extraction... 0/{total_items}"
)
for idx, item in enumerate(text):
# Prepare arguments for single item
doc_text = item["content"] if isinstance(item, dict) and "content" in item else str(item)
doc_entities = None
if entities and isinstance(entities, list) and idx < len(entities):
doc_entities = entities[idx]
doc_relations = None
if relations and isinstance(relations, list) and idx < len(relations):
doc_relations = relations[idx]
from .config import resolve_max_workers
max_workers = resolve_max_workers(
explicit=kwargs.get("max_workers"),
local_config=self.config,
methods=self.method,
)
# Extract
current_triplets = self.extract_triplets(
doc_text,
entities=doc_entities,
relations=doc_relations,
**kwargs
)
def process_item(idx, item):
try:
# Prepare arguments for single item
doc_text = item["content"] if isinstance(item, dict) and "content" in item else str(item)
doc_entities = None
if entities and isinstance(entities, list) and idx < len(entities):
doc_entities = entities[idx]
doc_relations = None
if relations and isinstance(relations, list) and idx < len(relations):
doc_relations = relations[idx]
# Add provenance metadata
for triplet in current_triplets:
if triplet.metadata is None:
triplet.metadata = {}
triplet.metadata["batch_index"] = idx
if isinstance(item, dict) and "id" in item:
triplet.metadata["document_id"] = item["id"]
results.append(current_triplets)
total_triplets_count += len(current_triplets)
# Update progress
if (idx + 1) % update_interval == 0 or (idx + 1) == total_items:
remaining = total_items - (idx + 1)
self.progress_tracker.update_progress(
tracking_id,
processed=idx + 1,
total=total_items,
message=f"Processing... {idx + 1}/{total_items} (remaining: {remaining}) - Extracted {total_triplets_count} triplets"
# Extract
current_triplets = self.extract_triplets(
doc_text,
entities=doc_entities,
relations=doc_relations,
**kwargs
)
# Add provenance metadata
for triplet in current_triplets:
if triplet.metadata is None:
triplet.metadata = {}
triplet.metadata["batch_index"] = idx
if isinstance(item, dict) and "id" in item:
triplet.metadata["document_id"] = item["id"]
return idx, current_triplets
except Exception as e:
self.logger.warning(f"Failed to process item {idx}: {e}")
return idx, []
if max_workers > 1:
import concurrent.futures
with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor:
# Submit tasks
future_to_idx = {
executor.submit(process_item, idx, item): idx
for idx, item in enumerate(text)
}
for future in concurrent.futures.as_completed(future_to_idx):
idx, triplets = future.result()
results[idx] = triplets
total_triplets_count += len(triplets)
processed_count += 1
if processed_count % update_interval == 0 or processed_count == total_items:
remaining = total_items - processed_count
self.progress_tracker.update_progress(
tracking_id,
processed=processed_count,
total=total_items,
message=f"Processing... {processed_count}/{total_items} (remaining: {remaining}) - Extracted {total_triplets_count} triplets"
)
else:
# Sequential processing
for idx, item in enumerate(text):
_, triplets = process_item(idx, item)
results[idx] = triplets
total_triplets_count += len(triplets)
processed_count += 1
if processed_count % update_interval == 0 or processed_count == total_items:
remaining = total_items - processed_count
self.progress_tracker.update_progress(
tracking_id,
processed=processed_count,
total=total_items,
message=f"Processing... {processed_count}/{total_items} (remaining: {remaining}) - Extracted {total_triplets_count} triplets"
)
self.progress_tracker.stop_tracking(
tracking_id,
status="completed",
@@ -269,11 +318,12 @@ class TripletExtractor:
def extract_triplets(
self,
text: str,
entities: Optional[List[Entity]] = None,
relations: Optional[List[Relation]] = None,
text: Union[str, List[str], List[Dict[str, Any]]],
entities: Optional[Union[List[Entity], List[List[Entity]]]] = None,
relations: Optional[Union[List[Relation], List[List[Relation]]]] = None,
pipeline_id: Optional[str] = None,
**options,
) -> List[Triplet]:
) -> Union[List[Triplet], List[List[Triplet]]]:
"""
Extract RDF triplets from text.
@@ -281,11 +331,29 @@ class TripletExtractor:
text: Input text
entities: Pre-extracted entities (optional)
relations: Pre-extracted relations (optional)
pipeline_id: Optional pipeline ID for progress tracking (batch mode)
**options: Extraction options
Returns:
list: List of extracted triplets
"""
if isinstance(text, list):
entities_batch = entities
if entities is not None and isinstance(entities, list) and (not entities or all(isinstance(e, Entity) for e in entities)):
entities_batch = [entities for _ in range(len(text))] if entities else [[] for _ in range(len(text))]
relations_batch = relations
if relations is not None and isinstance(relations, list) and (not relations or all(isinstance(r, Relation) for r in relations)):
relations_batch = [relations for _ in range(len(text))] if relations else [[] for _ in range(len(text))]
return self.extract(
text,
entities=entities_batch,
relations=relations_batch,
pipeline_id=pipeline_id,
**options,
)
from .methods import get_triplet_method
tracking_id = self.progress_tracker.start_tracking(
@@ -303,16 +371,38 @@ class TripletExtractor:
self.progress_tracker.update_tracking(
tracking_id, message="Extracting entities..."
)
ner = NERExtractor(**self.config.get("ner", {}))
entities = ner.extract_entities(text)
if self._ner_extractor is None:
ner_config = self.config.get("ner", {})
if "ner_method" in self.config:
ner_config = {**ner_config, "method": self.config["ner_method"]}
self._ner_extractor = NERExtractor(
**ner_config,
**{
k: v
for k, v in self.config.items()
if k not in ["ner", "relation", "validator", "serializer", "quality"]
},
)
entities = self._ner_extractor.extract_entities(text)
# Extract relations if not provided
if relations is None:
self.progress_tracker.update_tracking(
tracking_id, message="Extracting relations..."
)
rel_extractor = RelationExtractor(**self.config.get("relation", {}))
relations = rel_extractor.extract_relations(text, entities)
if self._relation_extractor is None:
rel_config = self.config.get("relation", {})
if "relation_method" in self.config:
rel_config = {**rel_config, "method": self.config["relation_method"]}
self._relation_extractor = RelationExtractor(
**rel_config,
**{
k: v
for k, v in self.config.items()
if k not in ["ner", "relation", "validator", "serializer", "quality"]
},
)
relations = self._relation_extractor.extract_relations(text, entities)
# Use method-based extraction
methods = options.get("method", self.method)
View File
@@ -0,0 +1,125 @@
import statistics
import time
import os
import sys
from typing import Dict, List
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../..")))
from semantica.semantic_extract.event_detector import EventDetector
from semantica.semantic_extract.ner_extractor import NERExtractor
from semantica.semantic_extract.relation_extractor import RelationExtractor
from semantica.semantic_extract.semantic_analyzer import SemanticAnalyzer
from semantica.semantic_extract.semantic_network_extractor import SemanticNetworkExtractor
from semantica.semantic_extract.triplet_extractor import TripletExtractor
from semantica.utils.progress_tracker import get_progress_tracker
def _make_documents(n: int) -> List[Dict[str, str]]:
base = (
"Apple Inc. was founded by Steve Jobs in 1976 and is headquartered in Cupertino, California. "
"Microsoft Corporation was founded by Bill Gates and Paul Allen in 1975. "
"In 2014, Apple acquired Beats Electronics for $3 billion. "
"In 2023, Google announced a partnership with OpenAI to improve search experiences."
)
return [{"id": f"doc_{i}", "content": f"{base} Document number {i}."} for i in range(n)]
def _median_seconds(fn, repeats: int = 3) -> float:
times = []
for _ in range(repeats):
start = time.perf_counter()
fn()
times.append(time.perf_counter() - start)
return statistics.median(times)
def _bench(label: str, fn, repeats: int = 3) -> dict:
fn()
seconds = _median_seconds(fn, repeats=repeats)
return {"label": label, "seconds": seconds}
def main():
progress = get_progress_tracker()
progress.displays = []
docs = _make_documents(80)
texts = [d["content"] for d in docs]
ner = NERExtractor(method="pattern")
rel = RelationExtractor(method="pattern")
trip = TripletExtractor(method="pattern")
events = EventDetector(method="pattern")
analyzer = SemanticAnalyzer()
net = SemanticNetworkExtractor(ner_method="pattern", relation_method="pattern")
results = []
def ner_parallel():
ner.extract(texts)
def ner_seq():
ner.extract(texts, max_workers=1)
results.append(_bench("NER batch (default workers)", ner_parallel))
results.append(_bench("NER batch (max_workers=1)", ner_seq))
entities_batch = ner.extract(texts)
def rel_parallel():
rel.extract(texts, entities_batch)
def rel_seq():
rel.extract(texts, entities_batch, max_workers=1)
results.append(_bench("Relation batch (default workers)", rel_parallel))
results.append(_bench("Relation batch (max_workers=1)", rel_seq))
def trip_parallel():
trip.extract(texts)
def trip_seq():
trip.extract(texts, max_workers=1)
results.append(_bench("Triplet pipeline (default workers)", trip_parallel))
results.append(_bench("Triplet pipeline (max_workers=1)", trip_seq))
def ev_parallel():
events.detect_events(texts)
def ev_seq():
events.detect_events(texts, max_workers=1)
results.append(_bench("Event detection (default workers)", ev_parallel))
results.append(_bench("Event detection (max_workers=1)", ev_seq))
def analyzer_parallel():
analyzer.analyze(texts)
def analyzer_seq():
analyzer.analyze(texts, max_workers=1)
results.append(_bench("Semantic analysis (default workers)", analyzer_parallel))
results.append(_bench("Semantic analysis (max_workers=1)", analyzer_seq))
def net_parallel():
net.extract_network(texts)
def net_seq():
net.extract_network(texts, max_workers=1)
results.append(_bench("Semantic network (default workers)", net_parallel))
results.append(_bench("Semantic network (max_workers=1)", net_seq))
per_doc = []
for row in results:
per_doc.append({**row, "ms_per_doc": (row["seconds"] / len(texts)) * 1000.0})
print(f"Documents: {len(texts)}")
for row in per_doc:
print(f"{row['label']}: {row['seconds']:.3f}s ({row['ms_per_doc']:.2f} ms/doc)")
if __name__ == "__main__":
main()
+84
View File
@@ -7,8 +7,12 @@ import os
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../..")))
from semantica.semantic_extract.ner_extractor import NERExtractor
from semantica.semantic_extract.ner_extractor import Entity as NEREntity
from semantica.semantic_extract.relation_extractor import RelationExtractor
from semantica.semantic_extract.triplet_extractor import TripletExtractor
from semantica.semantic_extract.event_detector import EventDetector
from semantica.semantic_extract.semantic_analyzer import SemanticAnalyzer
from semantica.semantic_extract.semantic_network_extractor import SemanticNetworkExtractor
from semantica.semantic_extract.named_entity_recognizer import Entity
from semantica.semantic_extract.relation_extractor import Relation
@@ -51,6 +55,21 @@ class TestExtractors(unittest.TestCase):
self.assertIsInstance(entities, list)
mock_get_method.assert_called()
@patch("semantica.semantic_extract.methods.get_entity_method")
def test_ner_extraction_batch_via_extract_entities(self, mock_get_method):
mock_method = MagicMock()
mock_method.extract_entities.return_value = []
mock_get_method.return_value = mock_method
extractor = NERExtractor(method="pattern")
results = extractor.extract_entities(
["Test text 1", "Test text 2"],
)
self.assertIsInstance(results, list)
self.assertEqual(len(results), 2)
self.assertTrue(all(isinstance(r, list) for r in results))
@patch("semantica.semantic_extract.methods.get_relation_method")
def test_relation_extraction(self, mock_get_method):
"""Test relation extraction call"""
@@ -65,6 +84,21 @@ class TestExtractors(unittest.TestCase):
self.assertIsInstance(relations, list)
mock_get_method.assert_called()
@patch("semantica.semantic_extract.methods.get_relation_method")
def test_relation_extraction_batch_via_extract_relations(self, mock_get_method):
mock_method = MagicMock()
mock_method.extract_relations.return_value = []
mock_get_method.return_value = mock_method
extractor = RelationExtractor(method="pattern")
texts = ["A knows B", "A knows B"]
entities = [NEREntity(text="A", label="PERSON", start_char=0, end_char=1, confidence=1.0)]
results = extractor.extract_relations(texts, entities)
self.assertIsInstance(results, list)
self.assertEqual(len(results), 2)
self.assertTrue(all(isinstance(r, list) for r in results))
@patch("semantica.semantic_extract.methods.get_triplet_method")
def test_triplet_extraction(self, mock_get_method):
"""Test triplet extraction call"""
@@ -81,5 +115,55 @@ class TestExtractors(unittest.TestCase):
self.assertIsInstance(triplets, list)
mock_get_method.assert_called()
@patch("semantica.semantic_extract.methods.get_triplet_method")
def test_triplet_extraction_batch_via_extract_triplets(self, mock_get_method):
mock_method = MagicMock()
mock_method.extract_triplets.return_value = []
mock_get_method.return_value = mock_method
extractor = TripletExtractor(method="pattern")
texts = ["A knows A", "A knows A"]
entities_batch = [[NEREntity(text="A", label="PERSON", start_char=0, end_char=1, confidence=1.0)] for _ in texts]
relations_batch = [[] for _ in texts]
results = extractor.extract_triplets(
texts,
entities=entities_batch,
relations=relations_batch,
)
self.assertIsInstance(results, list)
self.assertEqual(len(results), 2)
self.assertTrue(all(isinstance(r, list) for r in results))
def test_event_detector_batch_via_detect_events(self):
detector = EventDetector()
texts = ["Apple acquired Beats in 2014.", "Google announced a partnership in 2023."]
results = detector.detect_events(texts)
self.assertIsInstance(results, list)
self.assertEqual(len(results), 2)
self.assertTrue(all(isinstance(r, list) for r in results))
def test_semantic_analyzer_batch_parallel(self):
analyzer = SemanticAnalyzer()
texts = ["A short sentence.", "Another short sentence."]
results = analyzer.analyze(texts)
self.assertIsInstance(results, list)
self.assertEqual(len(results), 2)
self.assertTrue(all(isinstance(r, dict) for r in results))
def test_semantic_network_batch_via_extract_network(self):
extractor = SemanticNetworkExtractor()
texts = ["A knows B.", "C knows D."]
entities_batch = [[] for _ in texts]
relations_batch = [[] for _ in texts]
results = extractor.extract_network(
texts,
entities=entities_batch,
relations=relations_batch,
)
self.assertIsInstance(results, list)
self.assertEqual(len(results), 2)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,378 @@
import unittest
import time
import os
from dotenv import load_dotenv
load_dotenv()
from semantica.semantic_extract.ner_extractor import NERExtractor
from semantica.semantic_extract.relation_extractor import RelationExtractor
from semantica.semantic_extract.triplet_extractor import TripletExtractor
from semantica.semantic_extract.event_detector import EventDetector
from semantica.semantic_extract.semantic_network_extractor import SemanticNetworkExtractor
from semantica.semantic_extract.methods import _result_cache
class TestGroqRealWorldPerformance(unittest.TestCase):
"""
Real-world performance test suite using Groq LLM.
Tests parallel processing, caching, and correctness.
"""
@classmethod
def setUpClass(cls):
cls.api_key = os.getenv("GROQ_API_KEY")
if not cls.api_key:
raise unittest.SkipTest("GROQ_API_KEY is not set")
cls.metrics_file = os.path.join(os.getcwd(), "groq_metrics.txt")
# Real-world sample texts (mix of tech, business, and general)
cls.sample_texts = [
"""Apple Inc. is planning to launch a new AI-powered iPhone in late 2024.
CEO Tim Cook announced that the device will feature a neural engine capable of
processing 50 trillion operations per second. The company's stock rose 5% following the news.""",
"""Microsoft Corporation has acquired Activision Blizzard for $68.7 billion.
Satya Nadella, Microsoft's Chairman and CEO, stated that this acquisition will
accelerate growth in Microsoft's gaming business across mobile, PC, console, and cloud.""",
"""Elon Musk's SpaceX successfully launched the Starship rocket from Boca Chica, Texas.
The mission aims to test new heat shield technology essential for future Mars missions.
NASA Administrator Bill Nelson congratulated the team on the achievement.""",
"""Google DeepMind introduced Gemini, a new multimodal AI model.
Sundar Pichai emphasized that Gemini represents a significant leap forward in
AI capabilities, outperforming GPT-4 on several benchmarks including MMLU.""",
"""Amazon Web Services (AWS) announced a partnership with Anthropic to develop
reliable and high-performance foundation models. Amazon is investing up to $4 billion
in the AI safety startup founded by Dario Amodei."""
]
# Warm up: Ensure modules are loaded
print("\n[Setup] Initializing extractors...")
cls.extractor = NERExtractor(
method="llm",
provider="groq",
llm_model="llama-3.3-70b-versatile",
api_key=cls.api_key,
)
cls.relation_extractor = RelationExtractor(
method="llm",
provider="groq",
llm_model="llama-3.3-70b-versatile",
api_key=cls.api_key,
)
cls.triplet_extractor = TripletExtractor(
method="llm",
provider="groq",
llm_model="llama-3.3-70b-versatile",
api_key=cls.api_key,
)
cls.event_detector = EventDetector(
method="llm",
provider="groq",
llm_model="llama-3.3-70b-versatile",
api_key=cls.api_key,
)
cls.network_extractor = SemanticNetworkExtractor(
method="llm",
provider="groq",
llm_model="llama-3.3-70b-versatile",
api_key=cls.api_key,
)
def setUp(self):
# Clear cache before specific performance tests to ensure fair comparison
# (Unless testing cache specifically)
if _result_cache:
_result_cache._caches["entities"].clear()
_result_cache._caches["relations"].clear()
def log_metrics(self, message):
print(message)
with open(self.metrics_file, "a") as f:
f.write(message + "\n")
f.flush()
def test_01_parallel_vs_sequential_performance(self):
"""Compare sequential vs parallel extraction speed."""
try:
self.log_metrics("\n" + "="*60)
self.log_metrics("TEST 1: Sequential vs Parallel Processing Performance")
self.log_metrics("="*60)
extractor = NERExtractor(method="llm", provider="groq", api_key=self.api_key, model="llama-3.3-70b-versatile")
# 1. Sequential Run (Max workers = 1)
self.log_metrics("\nStarting Sequential Extraction (5 documents)...")
start_time = time.time()
seq_results = extractor.extract(self.sample_texts, max_workers=1)
seq_time = time.time() - start_time
self.log_metrics(f"Sequential Time: {seq_time:.4f}s")
self.log_metrics(f"Average Latency: {seq_time/len(self.sample_texts):.4f}s per doc")
# Clear cache to force re-extraction for parallel test
_result_cache._caches["entities"].clear()
# 2. Parallel Run (Max workers = 5)
self.log_metrics("\nStarting Parallel Extraction (5 documents, 5 workers)...")
start_time = time.time()
par_results = extractor.extract(self.sample_texts, max_workers=5)
par_time = time.time() - start_time
self.log_metrics(f"Parallel Time: {par_time:.4f}s")
self.log_metrics(f"Average Latency: {par_time/len(self.sample_texts):.4f}s per doc")
# Analysis
speedup = seq_time / par_time if par_time > 0 else 0
self.log_metrics(f"\n>>> Performance Gain: {speedup:.2f}x speedup")
self.log_metrics(f">>> Latency Reduction: {(seq_time - par_time):.4f}s total time saved")
self.assertLess(par_time, seq_time * 1.35, "Parallel processing should not be significantly slower")
self.assertEqual(len(seq_results), len(self.sample_texts))
self.assertEqual(len(par_results), len(self.sample_texts))
except Exception as e:
self.log_metrics(f"ERROR in Test 1: {e}")
raise
def test_02_caching_latency_reduction(self):
"""Measure latency reduction from caching."""
try:
self.log_metrics("\n" + "="*60)
self.log_metrics("TEST 2: Caching Performance & Latency Reduction")
self.log_metrics("="*60)
extractor = NERExtractor(method="llm", provider="groq", api_key=self.api_key, model="llama-3.3-70b-versatile")
text = [self.sample_texts[0]]
# 1. Cold Cache
_result_cache._caches["entities"].clear()
self.log_metrics("\nCold Cache Request...")
start_time = time.time()
extractor.extract(text)
cold_time = time.time() - start_time
self.log_metrics(f"Cold Cache Time: {cold_time:.4f}s")
cache_size_after_cold = _result_cache.get_stats()["entities"]["size"]
# 2. Warm Cache
self.log_metrics("\nWarm Cache Request (Identical Query)...")
start_time = time.time()
extractor.extract(text)
warm_time = time.time() - start_time
self.log_metrics(f"Warm Cache Time: {warm_time:.6f}s")
cache_size_after_warm = _result_cache.get_stats()["entities"]["size"]
# Analysis
reduction = (cold_time - warm_time) / cold_time * 100
self.log_metrics(f"\n>>> Latency Reduction: {reduction:.2f}%")
self.assertLess(warm_time, 1.0, "Warm cache response should be fast (<1.0s)")
# self.assertGreater(reduction, 50, "Caching should reduce latency by >50%")
if reduction < 30:
self.log_metrics(f"WARNING: Caching reduction is low ({reduction:.2f}%)")
self.assertGreater(reduction, 20, "Caching should reduce latency by >20%")
self.assertGreater(cache_size_after_cold, 0, "Cache should store entity results")
self.assertEqual(cache_size_after_warm, cache_size_after_cold, "Warm request should hit the cache")
except Exception as e:
self.log_metrics(f"ERROR in Test 2: {e}")
raise
def test_03_correctness_and_entity_matching(self):
"""Verify extraction correctness and data quality."""
try:
self.log_metrics("\n" + "="*60)
self.log_metrics("TEST 3: Extraction Correctness & Data Quality")
self.log_metrics("="*60)
# Use a specific text with clear entities
text = "Satya Nadella is the CEO of Microsoft."
extractor = NERExtractor(method="llm", provider="groq", api_key=self.api_key, model="llama-3.3-70b-versatile")
entities = extractor.extract([text])[0] # List of lists
self.log_metrics(f"\nInput: {text}")
self.log_metrics(f"Extracted Entities: {[e.text + '(' + e.label + ')' for e in entities]}")
# Validation
found_person = any(e.label == "PERSON" and "Satya" in e.text for e in entities)
found_org = any(e.label == "ORG" and "Microsoft" in e.text for e in entities)
if not found_org:
self.log_metrics("FAILURE: Did not find Microsoft as ORG. Found entities:")
for e in entities:
self.log_metrics(f" - {e.text}: {e.label}")
self.assertTrue(found_person, "Failed to extract Satya Nadella as PERSON")
self.assertTrue(found_org, "Failed to extract Microsoft as ORG")
self.log_metrics("\n>>> Correctness Verification: PASS")
self.log_metrics(" - Identified PERSON entity")
self.log_metrics(" - Identified ORG entity")
self.log_metrics(" - Pydantic models validated successfully")
except Exception as e:
self.log_metrics(f"ERROR in Test 3: {e}")
raise
def test_4_relation_extraction(self):
"""Test Relation Extraction capabilities"""
print("\n" + "="*60)
print("TEST 4: Relation Extraction")
print("="*60)
text = self.sample_texts[1] # Microsoft acquisition text
print(f"\nInput: {text[:100]}...")
# First extract entities
entities = self.__class__.extractor.extract_entities(text)
self.assertTrue(len(entities) > 0, "Should extract entities first")
# Extract relations
start_time = time.time()
relations = self.__class__.relation_extractor.extract_relations(text, entities)
duration = time.time() - start_time
print(f"Extracted {len(relations)} relations in {duration:.4f}s")
for r in relations:
print(f" - {r.subject.text} -> {r.predicate} -> {r.object.text}")
self.assertTrue(len(relations) > 0, "Should extract relations")
# Verify specific relation (Microsoft -> acquired -> Activision Blizzard)
found_acquisition = False
for r in relations:
if "Microsoft" in r.subject.text and "Activision" in r.object.text:
found_acquisition = True
break
if not found_acquisition:
# Fallback check - sometimes subject/object might be swapped or different wording
for r in relations:
if "Activision" in r.subject.text and "Microsoft" in r.object.text:
found_acquisition = True
break
self.assertTrue(found_acquisition, "Should find acquisition relation between Microsoft and Activision")
def test_5_triplet_extraction(self):
"""Test RDF Triplet Extraction capabilities"""
print("\n" + "="*60)
print("TEST 5: Triplet Extraction")
print("="*60)
text = self.sample_texts[0] # Apple text
print(f"\nInput: {text[:100]}...")
# Pipeline: Entities -> Relations -> Triplets
entities = self.__class__.extractor.extract_entities(text)
relations = self.__class__.relation_extractor.extract_relations(text, entities)
start_time = time.time()
triplets = self.__class__.triplet_extractor.extract_triplets(text, entities, relations)
duration = time.time() - start_time
print(f"Extracted {len(triplets)} triplets in {duration:.4f}s")
for t in triplets:
print(f" - <{t.subject}> <{t.predicate}> <{t.object}>")
self.assertTrue(len(triplets) > 0, "Should extract triplets")
# Check for Apple related triplet
found_apple = False
for t in triplets:
if "Apple" in t.subject or "Apple" in t.object:
found_apple = True
break
self.assertTrue(found_apple, "Should find Apple-related triplet")
def test_6_event_detection(self):
"""Test Event Detection capabilities"""
print("\n" + "="*60)
print("TEST 6: Event Detection")
print("="*60)
text = self.sample_texts[2] # SpaceX launch text
print(f"\nInput: {text[:100]}...")
start_time = time.time()
events = self.__class__.event_detector.detect_events(text)
duration = time.time() - start_time
print(f"Detected {len(events)} events in {duration:.4f}s")
for e in events:
print(f" - [{e.event_type}] {e.text} (Participants: {e.participants})")
self.assertTrue(len(events) > 0, "Should detect events")
# Verify launch event
found_launch = False
for e in events:
if "launch" in e.event_type.lower() or "launch" in e.text.lower():
found_launch = True
break
self.assertTrue(found_launch, "Should detect launch event")
def test_7_semantic_network(self):
"""Test Semantic Network Extraction capabilities"""
print("\n" + "="*60)
print("TEST 7: Semantic Network Extraction")
print("="*60)
text = self.sample_texts[3] # Google DeepMind text
print(f"\nInput: {text[:100]}...")
# Extract base components first
entities = self.__class__.extractor.extract_entities(text)
relations = self.__class__.relation_extractor.extract_relations(text, entities)
start_time = time.time()
network = self.__class__.network_extractor.extract_network(text, entities=entities, relations=relations)
duration = time.time() - start_time
print(f"Extracted Network in {duration:.4f}s")
print(f" - Nodes: {len(network.nodes)}")
print(f" - Edges: {len(network.edges)}")
self.assertTrue(len(network.nodes) > 0, "Should have nodes")
self.assertTrue(len(network.edges) > 0, "Should have edges")
# Verify Google/DeepMind/Gemini nodes exist
node_labels = [n.label for n in network.nodes]
print(f" - Node Labels: {node_labels}")
self.assertTrue(any("Gemini" in l for l in node_labels), "Should contain Gemini node")
def test_8_parallel_event_detection(self):
"""Test Parallel Event Detection capabilities"""
print("\n" + "="*60)
print("TEST 8: Parallel Event Detection")
print("="*60)
# Create a larger batch by duplicating sample texts
batch_texts = self.sample_texts * 2 # 10 documents
# 1. Sequential Run
print("\nStarting Sequential Event Detection (10 documents)...")
start_time = time.time()
seq_results = self.__class__.event_detector.extract(batch_texts, max_workers=1)
seq_time = time.time() - start_time
print(f"Sequential Time: {seq_time:.4f}s")
# 2. Parallel Run
print("\nStarting Parallel Event Detection (10 documents, 5 workers)...")
start_time = time.time()
par_results = self.__class__.event_detector.extract(batch_texts, max_workers=5)
par_time = time.time() - start_time
print(f"Parallel Time: {par_time:.4f}s")
# Analysis
speedup = seq_time / par_time if par_time > 0 else 0
print(f"\n>>> Performance Gain: {speedup:.2f}x speedup")
self.assertEqual(len(seq_results), len(batch_texts))
self.assertEqual(len(par_results), len(batch_texts))
# Verify results match (order should be preserved)
for i in range(len(batch_texts)):
self.assertEqual(len(seq_results[i]), len(par_results[i]), f"Result count mismatch at index {i}")
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,63 @@
import os
import pytest
try:
from dotenv import load_dotenv
load_dotenv()
except Exception:
pass
pytest.importorskip("groq")
from semantica.semantic_extract.ner_extractor import NERExtractor
from semantica.semantic_extract.relation_extractor import RelationExtractor
from semantica.semantic_extract.triplet_extractor import TripletExtractor
def test_groq_llm_smoke_entities_relations_triplets():
if not os.getenv("GROQ_API_KEY"):
pytest.skip("GROQ_API_KEY is not set")
text = (
"Apple acquired Beats in 2014 for $3 billion. "
"Steve Jobs founded Apple. "
"Beats is based in California."
)
model = "llama-3.3-70b-versatile"
entities = NERExtractor(method="llm").extract(
text,
provider="groq",
model=model,
temperature=0.0,
max_tokens=250,
)
assert isinstance(entities, list)
assert len(entities) > 0
assert len(entities) <= 30
relations = RelationExtractor(method="llm").extract(
text,
entities=entities,
provider="groq",
model=model,
temperature=0.0,
max_tokens=350,
max_entities_prompt=12,
)
assert isinstance(relations, list)
assert len(relations) <= 30
triplets = TripletExtractor(method="llm").extract(
text,
entities=entities,
relations=relations,
provider="groq",
model=model,
temperature=0.0,
max_tokens=350,
)
assert isinstance(triplets, list)
assert len(triplets) <= 40
+286
View File
@@ -0,0 +1,286 @@
import time
import unittest
print("Starting tests module...")
from unittest.mock import MagicMock, patch
from semantica.semantic_extract.providers import create_provider, ProviderPool, _provider_pool
from semantica.semantic_extract.ner_extractor import NERExtractor
from semantica.semantic_extract.relation_extractor import RelationExtractor
from semantica.semantic_extract.triplet_extractor import TripletExtractor, Triplet
from semantica.semantic_extract.methods import _result_cache, extract_entities_llm, extract_relations_llm, extract_triplets_llm, match_entity
from semantica.semantic_extract.ner_extractor import Entity
class TestSemanticExtractImprovements(unittest.TestCase):
def setUp(self):
_provider_pool.clear()
# Clear cache before each test
if _result_cache:
_result_cache._caches["entities"].clear()
_result_cache._caches["relations"].clear()
_result_cache._caches["triplets"].clear()
def test_entity_matching(self):
print("\nTesting Entity Matching...")
entities = [
Entity(text="Apple Inc.", label="ORG", start_char=0, end_char=10, confidence=1.0),
Entity(text="Steve Jobs", label="PERSON", start_char=0, end_char=10, confidence=1.0)
]
# Exact match
m1 = match_entity("Apple Inc.", entities)
self.assertIsNotNone(m1)
self.assertEqual(m1.text, "Apple Inc.")
# Case insensitive
m2 = match_entity("apple inc.", entities)
self.assertIsNotNone(m2)
self.assertEqual(m2.text, "Apple Inc.")
# Substring/Partial match (should work via calculate_similarity)
# "Apple" is contained in "Apple Inc."
# calculate_similarity gives a boost for containment
m3 = match_entity("Apple", entities)
if m3:
self.assertEqual(m3.text, "Apple Inc.")
print(" Partial match 'Apple' -> 'Apple Inc.' successful.")
else:
print(" Partial match 'Apple' -> 'Apple Inc.' failed (score too low).")
# No match
m4 = match_entity("Microsoft", entities)
self.assertIsNone(m4)
print(" No match verified.")
# Synonym match
# We need entities that match the synonym keys in methods.py (e.g. "acquired" -> "bought")
# Let's create an entity "bought"
rel_entities = [Entity(text="bought", label="RELATION", start_char=0, end_char=6, confidence=1.0)]
m5 = match_entity("acquired", rel_entities)
self.assertIsNotNone(m5)
self.assertEqual(m5.text, "bought")
print(" Synonym match 'acquired' -> 'bought' verified.")
# Empty input
m6 = match_entity("", entities)
self.assertIsNone(m6)
print(" Empty input handled.")
def test_caching(self):
print("\nTesting Caching...")
text = "Apple Inc. was founded in 1976."
# Mock provider
mock_provider = MagicMock()
mock_provider.is_available.return_value = True
# Setup mock response for entities
mock_entities_response = MagicMock()
mock_entities_response.entities = [
MagicMock(text="Apple Inc.", label="ORG", confidence=0.9),
MagicMock(text="1976", label="DATE", confidence=0.9)
]
mock_provider.generate_typed.return_value = mock_entities_response
with patch('semantica.semantic_extract.methods.create_provider', return_value=mock_provider) as mock_create:
# First call - should hit provider
print(" First call (cache miss)...")
results1 = extract_entities_llm(text, provider="openai", model="gpt-4", api_key="test")
self.assertEqual(len(results1), 2)
self.assertEqual(mock_provider.generate_typed.call_count, 1)
# Check cache state
print(f" Cache size: {len(_result_cache._caches['entities'])}")
# Second call - should hit cache
print(" Second call (cache hit)...")
results2 = extract_entities_llm(text, provider="openai", model="gpt-4", api_key="test")
self.assertEqual(len(results2), 2)
# Provider should NOT be called again
self.assertEqual(mock_provider.generate_typed.call_count, 1)
print(" Cache hit verified for entities.")
def test_secure_caching(self):
"""Test that sensitive parameters are excluded from cache keys."""
print("\nTesting Secure Caching...")
text = "Security test."
# Mock provider
mock_provider = MagicMock()
mock_provider.is_available.return_value = True
mock_entities_response = MagicMock()
mock_entities_response.entities = [MagicMock(text="Test", label="TEST", confidence=1.0)]
mock_provider.generate_typed.return_value = mock_entities_response
with patch('semantica.semantic_extract.methods.create_provider', return_value=mock_provider):
# First call with one API key
extract_entities_llm(text, provider="openai", model="gpt-4", api_key="secret_key_1")
# Second call with DIFFERENT API key
# If secure caching is working, this should be a CACHE HIT because api_key is ignored
extract_entities_llm(text, provider="openai", model="gpt-4", api_key="secret_key_2")
# Provider should have been called ONLY ONCE
self.assertEqual(mock_provider.generate_typed.call_count, 1)
print(" Secure caching verified: Changing API key did not trigger new extraction.")
# Verify cache content
self.assertIn("entities", _result_cache._caches)
self.assertTrue(len(_result_cache._caches["entities"]) > 0)
def test_provider_pool(self):
print("\nTesting Provider Pool...")
# Create provider twice with same args
# We need to mock the actual provider init to avoid API keys requirement if not present
with patch('semantica.semantic_extract.providers.OpenAIProvider') as MockProvider:
MockProvider.side_effect = lambda *args, **kwargs: MagicMock()
p1 = create_provider("openai", api_key="test", model_name="gpt-4")
p2 = create_provider("openai", api_key="test", model_name="gpt-4")
# Should be same instance
self.assertIs(p1, p2)
print(" Provider reuse verified.")
# Different args
p3 = create_provider("openai", api_key="test", model_name="gpt-3.5")
self.assertIsNot(p1, p3)
print(" Different args create new instance verified.")
# Explicitly not using pool
p4 = create_provider("openai", use_pool=False, api_key="test", model_name="gpt-4")
self.assertIsNot(p1, p4)
print(" Opt-out of pool verified.")
def test_ner_parallel_processing(self):
print("\nTesting NER Parallel Processing...")
extractor = NERExtractor(method="pattern") # Use pattern which is fast/local
# Mock extract_entities to simulate work and track thread execution
original_extract = extractor.extract_entities
def mock_extract(text, **kwargs):
time.sleep(0.1) # Simulate delay
return original_extract(text, **kwargs)
extractor.extract_entities = mock_extract
texts = ["Text 1", "Text 2", "Text 3", "Text 4"]
start_time = time.time()
results = extractor.extract(texts)
end_time = time.time()
duration = end_time - start_time
print(f" Parallel NER (default workers) took {duration:.4f}s")
self.assertEqual(len(results), 4)
# Verify sequential fallback
start_time_seq = time.time()
extractor.extract(texts, max_workers=1)
end_time_seq = time.time()
duration_seq = end_time_seq - start_time_seq
print(f" Sequential NER took {duration_seq:.4f}s")
# Check if parallel was indeed parallel (faster)
# With 0.1s sleep * 4 items:
# Sequential ~ 0.4s
# Parallel (2 workers) ~ 0.2s + overhead
self.assertLess(duration, duration_seq * 0.8)
print(" Parallel execution speedup verified.")
def test_relation_parallel_processing(self):
print("\nTesting Relation Parallel Processing...")
extractor = RelationExtractor(method="pattern")
# Mock extract_relations
original_extract = extractor.extract_relations
def mock_extract(text, entities, **kwargs):
time.sleep(0.1)
return original_extract(text, entities, **kwargs)
extractor.extract_relations = mock_extract
texts = ["Text 1", "Text 2", "Text 3", "Text 4"]
entities = [[], [], [], []]
start_time = time.time()
results = extractor.extract(texts, entities)
end_time = time.time()
duration = end_time - start_time
print(f" Parallel RE (default workers) took {duration:.4f}s")
self.assertEqual(len(results), 4)
# Sequential
start_time_seq = time.time()
extractor.extract(texts, entities, max_workers=1)
end_time_seq = time.time()
duration_seq = end_time_seq - start_time_seq
print(f" Sequential RE took {duration_seq:.4f}s")
self.assertLess(duration, duration_seq * 0.8)
print(" Parallel execution speedup verified.")
def test_relation_extraction_fuzzy_matching(self):
print("\nTesting Relation Extraction Fuzzy Matching...")
extractor = RelationExtractor(method="pattern")
# Entities have formal names
entities = [
Entity(text="Apple Inc.", label="ORG", start_char=0, end_char=10, confidence=1.0),
Entity(text="Steve Jobs", label="PERSON", start_char=21, end_char=31, confidence=1.0)
]
# Text uses informal name "Apple"
text = "Apple was founded by Steve Jobs."
relations = extractor.extract(text, entities)
found = False
for rel in relations:
# Check if subject matches "Apple Inc." even though text said "Apple"
if rel.subject.text == "Apple Inc." and rel.object.text == "Steve Jobs" and rel.predicate == "founded_by":
found = True
print(" Successfully matched 'Apple' -> 'Apple Inc.' in relation extraction.")
break
self.assertTrue(found, "Failed to extract relation with fuzzy entity matching")
def test_triplet_parallel_processing(self):
print("\nTesting Triplet Parallel Processing...")
extractor = TripletExtractor(method="pattern")
# Mock extract_triplets
original_extract = extractor.extract_triplets
def mock_extract(text, **kwargs):
time.sleep(0.1)
# Return dummy triplets to avoid actual extraction overhead
return [Triplet(subject="s", predicate="p", object="o")]
extractor.extract_triplets = mock_extract
texts = ["Text 1", "Text 2", "Text 3", "Text 4"]
start_time = time.time()
results = extractor.extract(texts)
end_time = time.time()
duration = end_time - start_time
print(f" Parallel TE (default workers) took {duration:.4f}s")
self.assertEqual(len(results), 4)
# Sequential
start_time_seq = time.time()
extractor.extract(texts, max_workers=1)
end_time_seq = time.time()
duration_seq = end_time_seq - start_time_seq
print(f" Sequential TE took {duration_seq:.4f}s")
self.assertLess(duration, duration_seq * 0.8)
print(" Parallel execution speedup verified.")
if __name__ == "__main__":
suite = unittest.TestLoader().loadTestsFromTestCase(TestSemanticExtractImprovements)
unittest.TextTestRunner(verbosity=2).run(suite)
@@ -0,0 +1,123 @@
import multiprocessing
import time
from unittest.mock import MagicMock, patch
import pytest
from semantica.semantic_extract.config import resolve_max_workers
from semantica.semantic_extract.ner_extractor import Entity, NERExtractor
from semantica.semantic_extract.relation_extractor import RelationExtractor
from semantica.semantic_extract.triplet_extractor import TripletExtractor
from semantica.semantic_extract.semantic_network_extractor import SemanticNetworkExtractor
from semantica.semantic_extract.methods import filter_entities_for_text
from semantica.semantic_extract.schemas import RelationsResponse, RelationOut
def test_resolve_max_workers_defaults_and_clamps():
cpu_count = multiprocessing.cpu_count() or 1
assert resolve_max_workers(explicit=0) == 1
assert resolve_max_workers(explicit=-10) == 1
assert resolve_max_workers(explicit=1) == 1
assert resolve_max_workers(explicit=10**9) == min(cpu_count, 32)
assert resolve_max_workers(explicit=None, methods=["ml"]) == 1
def test_filter_entities_for_text_keeps_short_tokens():
text = "US AI lab in NY"
entities = [
Entity(text="US", label="GPE", start_char=0, end_char=2, confidence=1.0),
Entity(text="AI", label="TECH", start_char=3, end_char=5, confidence=1.0),
Entity(text="NY", label="GPE", start_char=13, end_char=15, confidence=1.0),
]
kept = filter_entities_for_text(text, entities, max_keep=2)
kept_texts = {e.text for e in kept}
assert "US" in kept_texts or "AI" in kept_texts or "NY" in kept_texts
def test_pattern_batch_defaults_to_single_worker_low_latency():
extractor = NERExtractor(method="pattern")
texts = [f"Text {i}" for i in range(8)]
extractor.extract(texts)
def test_relation_llm_prompt_filter_does_not_break_mapping():
entities = [Entity(text=f"VeryLongEntityName{i}", label="ORG", start_char=0, end_char=1, confidence=1.0) for i in range(120)]
ghost = Entity(text="Ghost", label="ORG", start_char=0, end_char=1, confidence=1.0)
entities.append(ghost)
captured = {}
class FakeLLM:
def is_available(self):
return True
def generate_typed(self, prompt, schema, **kwargs):
captured["prompt"] = prompt
return RelationsResponse(
relations=[
RelationOut(subject="Ghost", predicate="related_to", object="VeryLongEntityName0", confidence=0.9)
]
)
with patch("semantica.semantic_extract.methods.create_provider", return_value=FakeLLM()):
from semantica.semantic_extract.methods import extract_relations_llm
relations = extract_relations_llm(
"Short text mentioning VeryLongEntityName0 only.",
entities=entities,
provider="openai",
model="gpt-4",
max_entities_prompt=20,
)
assert "Ghost" not in captured["prompt"]
assert len(relations) == 1
assert relations[0].subject.text == "Ghost"
def test_triplet_extractor_reuses_sub_extractors():
ner_instance = MagicMock()
ner_instance.extract_entities.return_value = [
Entity(text="A", label="PERSON", start_char=0, end_char=1, confidence=1.0)
]
rel_instance = MagicMock()
rel_instance.extract_relations.return_value = []
ner_ctor = MagicMock(return_value=ner_instance)
rel_ctor = MagicMock(return_value=rel_instance)
with patch("semantica.semantic_extract.ner_extractor.NERExtractor", ner_ctor), patch(
"semantica.semantic_extract.relation_extractor.RelationExtractor", rel_ctor
), patch("semantica.semantic_extract.methods.get_triplet_method", return_value=lambda *args, **kwargs: []):
extractor = TripletExtractor(method="pattern")
extractor.extract_triplets("A text.")
extractor.extract_triplets("A text again.")
assert ner_ctor.call_count == 1
assert rel_ctor.call_count == 1
def test_semantic_network_extractor_reuses_sub_extractors():
ner_instance = MagicMock()
ner_instance.extract_entities.return_value = [
Entity(text="A", label="PERSON", start_char=0, end_char=1, confidence=1.0)
]
rel_instance = MagicMock()
rel_instance.extract_relations.return_value = []
ner_ctor = MagicMock(return_value=ner_instance)
rel_ctor = MagicMock(return_value=rel_instance)
with patch("semantica.semantic_extract.ner_extractor.NERExtractor", ner_ctor), patch(
"semantica.semantic_extract.relation_extractor.RelationExtractor", rel_ctor
):
extractor = SemanticNetworkExtractor(method="pattern")
extractor.extract_network("A text.")
extractor.extract_network("A text again.")
assert ner_ctor.call_count == 1
assert rel_ctor.call_count == 1