import asyncio import atexit import json import os import re import tempfile import unicodedata from functools import lru_cache from pathlib import Path from typing import Dict, List, Optional, Tuple def _close_event_loop() -> None: try: loop = asyncio.get_event_loop() if not loop.is_closed(): loop.close() except Exception: pass atexit.register(_close_event_loop) import gradio as gr try: from huggingface_hub import login as hf_login except Exception: hf_login = None try: import torch except Exception: torch = None from gliner2 import GLiNER2 from rule_detector import DEFAULT_RULEBASE_ALLOWED_ENTITY_TYPES, detect_by_rules DEFAULT_MODEL_ID = os.getenv("MODEL_ID", "AITeamUIT/gliner2-multi-v1-e3-25-6") DEFAULT_THRESHOLD = float(os.getenv("THRESHOLD", "0.5")) DEFAULT_CHUNK_CHARS = int(os.getenv("CHUNK_CHARS", "1000")) DEFAULT_CHUNK_TOKENS = int(os.getenv("CHUNK_TOKENS", "768")) CHUNK_SAFETY_MARGIN = int(os.getenv("CHUNK_SAFETY_MARGIN", "20")) MAX_CHARS_PER_CHUNK = int(os.getenv("MAX_CHARS_PER_CHUNK", "8000")) MAX_WIDTH = int(os.getenv("MAX_WIDTH", "30")) PII_LABEL_DESCRIPTIONS: Dict[str, str] = { "PERSON": "Person names", "DATE": "Calendar dates", "JOB_TITLE": "Job titles", "TIME": "Time values", "LOCATION": "General locations", "PREFIX": "Titles before names", "ORGANIZATION": "Organization names", "EMAIL": "Email addresses", "PHONE": "Phone numbers", "ADDRESS": "Street addresses", "GENDER": "Sex or gender", "COORDINATE": "Geographic coordinates", "MONEY": "Monetary amounts", "IP": "IP addresses", "ZIP_CODE": "Postal or ZIP codes", "NATIONALITY": "Legal nationality", "AGE": "Age values", "USERNAME": "Usernames or handles", "URL": "Web links or URLs", "MARITAL": "Marital status", "TRADE_UNION": "Trade union membership", "BIRTHDATE": "Date of birth", "CARD_ISSUER": "Card brands or issuers", "PIN": "Authentication PINs", "PASSPORT": "Passport numbers", "MEDICAL_INFO": "Health or medical information", "IBAN": "International bank account numbers", "PASSWORD": "Account passwords", "ETHNICITY": "Ethnic or racial group", "RELIGION": "Religious affiliation", "EMPLOYEE_ID": "Employee identifiers", "INSURANCE_ID": "Insurance identifiers", "TIN": "Tax identification numbers", "SWIFT": "Bank SWIFT or BIC codes", "CARD_NUMBER": "Payment card numbers", "ACCOUNT_ID": "Account identifiers", "WALLET": "Crypto wallet addresses", "NATIONAL_ID": "National ID numbers", "CVV": "Card security codes", "BANK_ACCOUNT": "Bank account numbers", "LICENSE": "Driver license numbers", "TICKET_ID": "Support or case IDs", "PLATE": "Vehicle plate numbers", "API_KEY": "API keys or tokens", } LABELS = list(PII_LABEL_DESCRIPTIONS.keys()) RULE_LABELS = set(DEFAULT_RULEBASE_ALLOWED_ENTITY_TYPES) LABEL_PRIORITY: Dict[str, int] = { "WALLET": 10, "IBAN": 10, "CARD_NUMBER": 10, "NATIONAL_ID": 10, "IP": 10, "URL": 10, "EMAIL": 10, "MONEY": 10, "AGE": 10, } STRIP_CHARS = frozenset(["\u00ad", "\u200b", "\u200c", "\u200d", "\u2060", "\ufeff", "\ufffd"]) C1_RE = re.compile(r"[\x80-\x9f]") C0_RE = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]") MULTI_SPACE_RE = re.compile(r"[^\S\n]+") NUMERIC_MASK_LABELS = { "CARD_NUMBER", "PHONE", "PIN", "CVV", "TIN", "BANK_ACCOUNT", "IBAN", "SWIFT", "WALLET", "IP", "ZIP_CODE", } try: from gliner2.processor import WhitespaceTokenSplitter IMPROVED_PATTERN = re.compile( r""" # Scheme / www URL (?:(?:https?|ftp)://|www\.)[^\s<>"'`{}|\\^]*[^\s<>"'`{}|\\^.,;:!?()\[\]] # Email address |[a-z0-9.!#$%&'*+/=?^_`{|}~-]+@(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63} # Social/user handle |@[a-z0-9_](?:[a-z0-9_.-]{0,28}[a-z0-9_])? # IPv4 address. Allows sentence punctuation after the IP, but avoids # matching inside a longer dotted numeric sequence. |(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(?:\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}(?=$|[^\w])(?!\.\d) # IPv6 address, including compressed forms and optional zone IDs. |(?: (?:[0-9a-f]{1,4}:){7}[0-9a-f]{1,4} |[0-9a-f]{1,4}:(?:(?::[0-9a-f]{1,4}){1,6}) |(?:[0-9a-f]{1,4}:){1,2}(?::[0-9a-f]{1,4}){1,5} |(?:[0-9a-f]{1,4}:){1,3}(?::[0-9a-f]{1,4}){1,4} |(?:[0-9a-f]{1,4}:){1,4}(?::[0-9a-f]{1,4}){1,3} |(?:[0-9a-f]{1,4}:){1,5}(?::[0-9a-f]{1,4}){1,2} |(?:[0-9a-f]{1,4}:){1,6}:[0-9a-f]{1,4} |:(?:(?::[0-9a-f]{1,4}){1,7}|:) |(?:[0-9a-f]{1,4}:){1,7}: )(?:%[0-9a-z_.-]+)?(?![0-9a-f:]) # Strict JWT-like token |[a-z0-9_-]{10,}\.[a-z0-9_-]{10,}\.[a-z0-9_-]{10,} # Shorter dot-separated token-like string, e.g. sk-abc.def.ghi |(?=[a-z0-9_.-]*[-_\d])[a-z0-9_-]{2,}(?:\.[a-z0-9_-]{2,}){2,} # Bare domain / URL-like candidate. Validation belongs downstream; this # tokenizer only keeps plausible URL spans from being split into pieces. |(?"'`{}|\\^]*[^\s<>"'`{}|\\^.,;:!?()\[\]])?)? # Letter-containing word token: must have at least one letter or # underscore (the [^\W\d] anchor). Only underscores join tokens; # hyphens always split (except digit-digit handled below). |(?:\d*[^\W\d]\w*)(?:_\w+)* # Pure digit sequence: hyphens/underscores only join other digit groups. # "555-1234" stays atomic; "13-tuổi" → "13" / "-" / "tuổi" so the # numeric entity (AGE, DATE, …) is not fused with the surrounding word. |\d+(?:[-_]\d+)* # Fallback: any single non-space character. |\S """, re.VERBOSE | re.IGNORECASE, ) WhitespaceTokenSplitter._PATTERN = IMPROVED_PATTERN except Exception: pass @lru_cache(maxsize=2) def load_model(model_id: str): hf_token = os.getenv("HF_TOKEN") or os.getenv("HUGGINGFACEHUB_API_TOKEN") if hf_token and hf_login is not None: hf_login(token=hf_token) model = GLiNER2.from_pretrained(model_id) model.config.max_width = MAX_WIDTH model.max_width = MAX_WIDTH if hasattr(model, "span_rep") and hasattr(model.span_rep, "span_rep_layer"): model.span_rep.span_rep_layer.max_width = MAX_WIDTH if torch is not None: device = "cuda" if torch.cuda.is_available() else "cpu" model = model.to(device) model.eval() return model def build_schema(model, use_descriptions: bool): schema = model.create_schema() if use_descriptions: schema.entities(PII_LABEL_DESCRIPTIONS) else: schema.entities(LABELS) return schema def manual_chunks(text: str, max_chars: int) -> List[Tuple[str, int]]: if len(text) <= max_chars: return [(text, 0)] tokens = list(re.finditer(r"\S+", text)) if not tokens: return [(text, 0)] chunks: List[Tuple[str, int]] = [] chunk_start = 0 for index in range(1, len(tokens)): seg_start = tokens[chunk_start].start() seg_end = tokens[index].end() if seg_end - seg_start > max_chars: prev_end = tokens[index - 1].end() chunks.append((text[seg_start:prev_end], seg_start)) chunk_start = index seg_start = tokens[chunk_start].start() chunks.append((text[seg_start:tokens[-1].end()], seg_start)) return chunks def word_token_count(text: str) -> int: """Count word tokens the way the (patched) model tokenizer splits them.""" try: from gliner2.processor import WhitespaceTokenSplitter splitter = WhitespaceTokenSplitter() return sum(1 for _ in splitter(text, lower=False)) except Exception: return len(text.split()) class RecursiveWordChunker: """Label-aware recursive chunker built on chonkie.RecursiveChunker. The chunk-size budget is measured in the model's own word-token space and already subtracts the entity-schema label tokens, so each emitted chunk plus the labels fits the encoder. Recursion falls back paragraph -> sentence -> whitespace. ``chunk()`` returns ``(chunk_text, start_index)`` tuples. """ def __init__(self, budget_words: int, max_chars: int = MAX_CHARS_PER_CHUNK, min_chars_per_chunk: int = 24): # chonkie is a pip dependency (not a cross-folder import). from chonkie.chunker.recursive import RecursiveChunker from chonkie.types import RecursiveLevel, RecursiveRules self.budget = budget_words self.max_chars = max_chars self._chunker = RecursiveChunker( tokenizer=word_token_count, chunk_size=budget_words, rules=RecursiveRules( levels=[ RecursiveLevel(delimiters=["\n\n", "\r\n", "\n", "\r"]), RecursiveLevel(delimiters=[". ", "! ", "? ", ".\n", "!\n", "?\n"]), RecursiveLevel(whitespace=True), ] ), min_characters_per_chunk=min_chars_per_chunk, ) def chunk(self, text: str) -> List[Tuple[str, int]]: return [(c.text, int(c.start_index)) for c in self._chunker.chunk(text)] @lru_cache(maxsize=4) def build_word_chunker( chunk_tokens: int = DEFAULT_CHUNK_TOKENS, safety_margin: int = CHUNK_SAFETY_MARGIN, max_chars: int = MAX_CHARS_PER_CHUNK, ) -> RecursiveWordChunker: """Build a :class:`RecursiveWordChunker` with a label-aware token budget. The label tokens (``"