guvi-document-api / app /modules /entity_extractor.py
shruthivihub
Initial commit: Intelligent Document Processing API
de76d9d
Raw History Blame Contribute Delete
7.35 kB
"""
Named Entity Recognition (NER) Module
Extracts and categorizes entities: persons, organizations, dates,
monetary values, and locations.
Uses spaCy (en_core_web_trf or en_core_web_lg) for high accuracy NER.
Falls back to en_core_web_sm if transformer model is unavailable.
Combines regex patterns for money/date extraction to improve recall.
"""
import logging
import re
from collections import defaultdict
from typing import Dict, List
logger = logging.getLogger(__name__)
class EntityExtractor:
"""
High-precision NER using spaCy with regex augmentation.
Entity categories:
- persons β†’ PERSON
- organizations β†’ ORG
- dates β†’ DATE
- amounts β†’ MONEY
- locations β†’ GPE, LOC, FAC
"""
# spaCy label β†’ our category
LABEL_MAP = {
"PERSON": "persons",
"ORG": "organizations",
"DATE": "dates",
"MONEY": "amounts",
"GPE": "locations",
"LOC": "locations",
"FAC": "locations",
}
# Regex patterns for augmenting spaCy (higher recall for structured patterns)
MONEY_PATTERNS = [
r"\$\s*[\d,]+(?:\.\d{1,2})?(?:\s*(?:million|billion|trillion|thousand|M|B|K))?\b",
r"(?:USD|EUR|GBP|INR|JPY)\s*[\d,]+(?:\.\d{1,2})?(?:\s*(?:million|billion|trillion))?\b",
r"[\d,]+(?:\.\d{1,2})?\s*(?:dollars?|euros?|pounds?|rupees?|crore|lakh)\b",
]
DATE_PATTERNS = [
r"\b(?:January|February|March|April|May|June|July|August|September|October|November|December)"
r"\s+\d{1,2},?\s+\d{4}\b",
r"\b\d{1,2}[/-]\d{1,2}[/-]\d{2,4}\b",
r"\b\d{4}[/-]\d{1,2}[/-]\d{1,2}\b",
r"\b(?:Q[1-4]|FY|FQ)\s*\d{4}\b",
r"\b\d{4}\b(?=\s*(?:to|through|until|-)\s*\d{4})", # Year ranges
r"\b(?:Jan|Feb|Mar|Apr|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\.?\s+\d{1,2},?\s+\d{4}\b",
]
_nlp = None # Class-level singleton
def __init__(self):
self._load_nlp()
self._compile_patterns()
@classmethod
def _load_nlp(cls):
"""Load spaCy NLP model (tries best available model)."""
if cls._nlp is not None:
return
model_preference = [
"en_core_web_trf", # Transformer-based (best accuracy)
"en_core_web_lg", # Large (good accuracy)
"en_core_web_md", # Medium
"en_core_web_sm", # Small fallback
]
import spacy
for model_name in model_preference:
try:
cls._nlp = spacy.load(model_name)
logger.info(f"Loaded spaCy model: {model_name}")
return
except OSError:
continue
# Last resort: download small model
logger.warning("No spaCy model found. Downloading en_core_web_sm...")
try:
import subprocess, sys
subprocess.run(
[sys.executable, "-m", "spacy", "download", "en_core_web_sm"],
check=True, capture_output=True
)
cls._nlp = spacy.load("en_core_web_sm")
except Exception as e:
logger.error(f"Failed to load any spaCy model: {e}")
cls._nlp = None
def _compile_patterns(self):
"""Pre-compile regex patterns for performance."""
self._money_re = [re.compile(p, re.IGNORECASE) for p in self.MONEY_PATTERNS]
self._date_re = [re.compile(p, re.IGNORECASE) for p in self.DATE_PATTERNS]
def extract(self, text: str) -> Dict[str, List[str]]:
"""
Extract named entities from text.
Returns a dict with keys: persons, organizations, dates, amounts, locations.
"""
result = defaultdict(set)
# ── spaCy NER ──────────────────────────────────────────────────────────
if self._nlp and text.strip():
spacy_entities = self._extract_spacy(text)
for category, entities in spacy_entities.items():
result[category].update(entities)
# ── Regex augmentation ─────────────────────────────────────────────────
regex_money = self._extract_regex(text, self._money_re)
regex_dates = self._extract_regex(text, self._date_re)
result["amounts"].update(regex_money)
result["dates"].update(regex_dates)
# ── Deduplicate and sort ───────────────────────────────────────────────
return {
"persons": sorted(self._dedupe(result["persons"])),
"organizations": sorted(self._dedupe(result["organizations"])),
"dates": sorted(self._dedupe(result["dates"])),
"amounts": sorted(self._dedupe(result["amounts"])),
"locations": sorted(self._dedupe(result["locations"])),
}
def _extract_spacy(self, text: str) -> Dict[str, List[str]]:
"""Run spaCy NER on text, handling long documents by chunking."""
result = defaultdict(list)
max_chars = 1_000_000 # spaCy default limit
# Process in chunks for very long documents
for chunk_start in range(0, len(text), max_chars - 100):
chunk = text[chunk_start: chunk_start + max_chars]
try:
doc = self._nlp(chunk)
for ent in doc.ents:
category = self.LABEL_MAP.get(ent.label_)
if category:
cleaned = self._clean_entity(ent.text)
if cleaned:
result[category].append(cleaned)
except Exception as e:
logger.warning(f"spaCy processing chunk failed: {e}")
return result
def _extract_regex(self, text: str, patterns: List[re.Pattern]) -> List[str]:
"""Extract entities using regex patterns."""
found = []
for pattern in patterns:
matches = pattern.findall(text)
found.extend(m.strip() for m in matches if m.strip())
return found
def _clean_entity(self, text: str) -> str:
"""
Clean an entity string:
- Strip whitespace and punctuation artifacts
- Normalize internal whitespace
- Skip single-character or pure-number entities for persons/orgs
"""
text = text.strip()
# Normalize internal whitespace
text = re.sub(r"\s+", " ", text)
# Remove leading/trailing punctuation
text = text.strip(".,;:\"'()-")
return text.strip()
def _dedupe(self, items) -> List[str]:
"""
Deduplicate entity list.
Also removes sub-strings that are already covered by longer matches.
"""
seen = list(dict.fromkeys(item for item in items if item))
# Remove items that are substrings of other items
result = []
for item in seen:
if not any(
item.lower() != other.lower() and item.lower() in other.lower()
for other in seen
):
result.append(item)
return result