mirror of
https://github.com/semantica-agi/semantica.git
synced 2026-08-29 04:26:20 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c6316ba4bd | ||
|
|
b6d630fc74 | ||
|
|
3f2cb49e50 | ||
|
|
c7814616a9 | ||
|
|
531014fbda | ||
|
|
1cf9b34e3e | ||
|
|
2e81c86489 | ||
|
|
1690fec3f7 | ||
|
|
72a6ddb48f | ||
|
|
a5da533d55 | ||
|
|
be8856cfcf | ||
|
|
d2e599bcb0 | ||
|
|
05d0bbf86c | ||
|
|
dd7fcd3ddb | ||
|
|
43f55e1028 | ||
|
|
e20c522c62 | ||
|
|
fd9f0b2526 | ||
|
|
d8e04c29e9 |
@@ -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**:
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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
@@ -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
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
@@ -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"
|
||||
]
|
||||
|
||||
@@ -10,7 +10,7 @@ Main exports:
|
||||
- Config: Configuration management
|
||||
"""
|
||||
|
||||
__version__ = "0.2.1"
|
||||
__version__ = "0.2.2"
|
||||
__author__ = "Semantica Contributors"
|
||||
__license__ = "MIT"
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user