| import os |
| import logging |
| import spacy |
| from typing import Dict, Any, List |
|
|
| |
| BASE_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) |
| MODEL_DIR = os.path.join(BASE_DIR, "model") |
|
|
| |
| model_path1 = os.path.join(MODEL_DIR, "model-best") |
| model_path2 = os.path.join(MODEL_DIR, "2") |
| model_path3 = os.path.join(MODEL_DIR, "3") |
|
|
| |
| _spacy_models_cache: Dict[int, Any] = {} |
| _transformer_pipeline = None |
| import time |
|
|
| |
| FALLBACK_TRANSFORMER_MODEL = "dslim/distilbert-NER" |
|
|
| def get_target_transformer_model(): |
| """ |
| Check for custom trained Transformer model paths or environment variables. |
| Returns the path to the custom model if found, otherwise returns FALLBACK_TRANSFORMER_MODEL. |
| """ |
| env_model = os.environ.get("NER_TRANSFORMER_MODEL") |
| if env_model: |
| return env_model |
| |
| |
| possible_paths = [ |
| os.path.join(BASE_DIR, "training", "roberta_ner_model"), |
| os.path.join(MODEL_DIR, "roberta_ner_model"), |
| os.path.join(MODEL_DIR, "roberta"), |
| os.path.join(BASE_DIR, "roberta_ner_model") |
| ] |
| for path in possible_paths: |
| if os.path.exists(path): |
| logging.info(f"Auto-detected custom trained Transformer model at: {path}") |
| return path |
| |
| return FALLBACK_TRANSFORMER_MODEL |
|
|
| _transformer_model_name = get_target_transformer_model() |
|
|
| def get_spacy_model(level: int = 1): |
| """ |
| Lazy load spaCy models into memory cache with graceful fallback to standard en_core_web models. |
| """ |
| if level in _spacy_models_cache: |
| return _spacy_models_cache[level] |
| |
| path = model_path1 if level == 1 else (model_path2 if level == 2 else model_path3) |
| fallback = "en_core_web_sm" if level == 1 else ("en_core_web_md" if level == 2 else "en_core_web_lg") |
| |
| try: |
| if not os.path.exists(path): |
| spacy_repo = os.environ.get("NER_SPACY_REPO") |
| if spacy_repo: |
| try: |
| from huggingface_hub import snapshot_download |
| logging.info(f"Downloading custom spaCy models from HuggingFace repo {spacy_repo}...") |
| snapshot_download(repo_id=spacy_repo, local_dir=MODEL_DIR) |
| except Exception as e: |
| logging.warning(f"Failed to download spaCy repo {spacy_repo}: {e}") |
|
|
| if os.path.exists(path): |
| logging.info(f"Lazy loading spaCy model from {path}...") |
| _spacy_models_cache[level] = spacy.load(path) |
| else: |
| logging.info(f"Custom model not found at {path}. Loading fallback '{fallback}'...") |
| try: |
| _spacy_models_cache[level] = spacy.load(fallback) |
| except OSError: |
| logging.warning(f"Fallback {fallback} not installed. Loading blank en model...") |
| _spacy_models_cache[level] = spacy.blank("en") |
| except Exception as e: |
| logging.error(f"Error loading spaCy model level {level}: {e}. Falling back to blank model.") |
| _spacy_models_cache[level] = spacy.blank("en") |
| |
| return _spacy_models_cache[level] |
|
|
| def get_transformer_pipeline(): |
| """ |
| Lazy load HuggingFace Transformer NER pipeline. |
| Tries to load the custom trained model up to 3 times before falling back to the downloaded cloud model. |
| """ |
| global _transformer_pipeline |
| if _transformer_pipeline is None: |
| try: |
| from transformers import pipeline |
| |
| target_model = get_target_transformer_model() |
| models_to_try = [] |
| |
| |
| if target_model != FALLBACK_TRANSFORMER_MODEL: |
| for attempt_num in range(1, 4): |
| models_to_try.append((target_model, attempt_num, True)) |
| |
| |
| models_to_try.append((FALLBACK_TRANSFORMER_MODEL, 1, False)) |
| |
| for model_path, attempt, is_custom in models_to_try: |
| try: |
| if is_custom: |
| logging.info(f"Attempting to load custom trained Transformer model from '{model_path}' (Attempt {attempt} of 3)...") |
| else: |
| logging.info(f"Loading fallback downloaded cloud model '{model_path}'...") |
| |
| try: |
| _transformer_pipeline = pipeline("ner", model=model_path, aggregation_strategy="simple") |
| except TypeError: |
| _transformer_pipeline = pipeline("ner", model=model_path, grouped_entities=True) |
| |
| logging.info(f"Transformer NER pipeline successfully loaded using: {model_path}") |
| break |
| except Exception as e: |
| if is_custom: |
| logging.warning(f"Failed to load custom model on Attempt {attempt}: {e}") |
| if attempt < 3: |
| time.sleep(1) |
| else: |
| logging.warning(f"Custom trained model failed after 3 attempts! Switching to downloaded fallback model '{FALLBACK_TRANSFORMER_MODEL}'.") |
| else: |
| logging.error(f"Error loading fallback transformer model '{model_path}': {e}. Falling back to spaCy NER.") |
| return None |
| |
| except ImportError: |
| logging.warning("Transformers library not installed. Transformer NER will fallback to spaCy.") |
| return None |
| except Exception as e: |
| logging.error(f"Unexpected error initializing Transformer pipeline: {e}. Falling back to spaCy NER.") |
| return None |
| return _transformer_pipeline |
|
|
| def redact_entities_spacy(text: str, level: int = 1) -> str: |
| """ |
| Unified spaCy NER redaction function. |
| Uses character-span slice replacement (O(n log n)) sorted descending to prevent O(n²) str.replace() clobbering. |
| """ |
| if not text or not isinstance(text, str): |
| return text |
| |
| doc = get_spacy_model(level)(text) |
| entities = [] |
| for ent in doc.ents: |
| if ent.text and ent.text.strip(): |
| entities.append({ |
| "start": ent.start_char, |
| "end": ent.end_char, |
| "text": ent.text |
| }) |
| |
| |
| entities_sorted = sorted(entities, key=lambda x: x["start"], reverse=True) |
| |
| redacted_text = text |
| for ent in entities_sorted: |
| start, end = ent["start"], ent["end"] |
| if start is not None and end is not None and 0 <= start < len(redacted_text) and end <= len(redacted_text): |
| mask = 'x' * (end - start) |
| redacted_text = redacted_text[:start] + mask + redacted_text[end:] |
| |
| return redacted_text |
|
|
| def redact_entities_transformer(text: str, mask_char: str = "x") -> str: |
| """ |
| Redact named entities using deep learning Transformer model (DistilBERT/RoBERTa). |
| Falls back to spaCy Level 1 if transformers cannot be loaded. |
| """ |
| if not text or not isinstance(text, str): |
| return text |
|
|
| pipe = get_transformer_pipeline() |
| if pipe is None: |
| return redact_entities_spacy(text, level=1) |
|
|
| try: |
| entities = pipe(text) |
| entities_sorted = sorted(entities, key=lambda x: x.get('start', 0), reverse=True) |
| redacted_text = text |
| for ent in entities_sorted: |
| word = ent.get('word', '') |
| score = ent.get('score', 0.0) |
| if score > 0.40 and word and len(word.strip()) > 1: |
| start, end = ent.get('start'), ent.get('end') |
| if start is not None and end is not None and 0 <= start < len(redacted_text) and end <= len(redacted_text): |
| mask = mask_char * (end - start) |
| redacted_text = redacted_text[:start] + mask + redacted_text[end:] |
| return redacted_text |
| except Exception as e: |
| logging.error(f"Error in transformer redaction: {e}") |
| return redact_entities_spacy(text, level=1) |
|
|
| def preload_fallback_model(): |
| """ |
| Pre-download / cache the fallback cloud model at startup so there is zero download wait time during fallback. |
| """ |
| try: |
| from transformers import pipeline |
| logging.info(f"Pre-downloading/caching fallback Transformer model '{FALLBACK_TRANSFORMER_MODEL}' at startup...") |
| try: |
| pipeline("ner", model=FALLBACK_TRANSFORMER_MODEL, aggregation_strategy="simple") |
| except TypeError: |
| pipeline("ner", model=FALLBACK_TRANSFORMER_MODEL, grouped_entities=True) |
| logging.info("Fallback Transformer model successfully pre-cached at startup!") |
| except Exception as e: |
| logging.warning(f"Could not pre-cache fallback transformer model at startup: {e}") |
|
|