Spaces:
Paused
Paused
| """ | |
| Sanctions screening engine with multi-layer phonetic matching. | |
| Provides comprehensive OFAC/EU/UK/UN sanctions screening using: | |
| - OFAC SDN List (US Government - Public Domain) | |
| - EU Consolidated Sanctions List (EU Open Data) | |
| - UK HMT OFSI List (Open Government License) | |
| - UN Security Council Sanctions (UN Open Data) | |
| Matching pipeline: | |
| 1. Normalization (lowercase, remove punctuation) | |
| 2. Exact match (O(1) lookup) | |
| 3. Jaro-Winkler similarity (typos, minor spelling) | |
| 4. Soundex/Metaphone phonetic matching (transliterations) | |
| 5. Levenshtein distance (edit distance) | |
| 6. Token-based matching (name reordering) | |
| All data sources are FREE for commercial use. | |
| Typing conventions: | |
| All public APIs use explicit type hints. Literal types enforce valid | |
| risk levels and match types. Final constants prevent mutation. | |
| """ | |
| from __future__ import annotations | |
| import csv | |
| import io | |
| import logging | |
| import os | |
| import re | |
| import threading | |
| import time | |
| import unicodedata | |
| from dataclasses import dataclass, field | |
| from pathlib import Path | |
| from typing import Annotated, Any, Final, Literal, Optional | |
| import httpx | |
| import jellyfish | |
| logger: Final = logging.getLogger(__name__) | |
| # ββ Domain Exceptions βββββββββββββββββββββββββββββββββββββββββββββββββ | |
| class SanctionsError(Exception): | |
| """Base exception for all sanctions screening errors.""" | |
| class SanctionsDataCorruptionError(SanctionsError): | |
| """Raised when downloaded sanctions data cannot be parsed. | |
| Attributes: | |
| source: The sanctions list source (e.g., 'OFAC_SDN', 'EU_FSF'). | |
| detail: Human-readable description of the corruption. | |
| """ | |
| def __init__(self, source: str, detail: str) -> None: | |
| self.source = source | |
| self.detail = detail | |
| super().__init__(f"Data corruption in {source}: {detail}") | |
| class SanctionsDataDownloadError(SanctionsError): | |
| """Raised when a sanctions list download fails. | |
| Attributes: | |
| url: The URL that failed. | |
| status_code: HTTP status code, if available. | |
| """ | |
| def __init__( | |
| self, url: str, status_code: Optional[int] = None, detail: str = "" | |
| ) -> None: | |
| self.url = url | |
| self.status_code = status_code | |
| msg = f"Download failed for {url}" | |
| if status_code is not None: | |
| msg += f" (HTTP {status_code})" | |
| if detail: | |
| msg += f": {detail}" | |
| super().__init__(msg) | |
| class SanctionsCacheStaleError(SanctionsError): | |
| """Raised when no fresh cache and download fails β caller decides fallback.""" | |
| # ββ Constants βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # OFAC SDN List (CSV format) | |
| OFAC_SDN_URL: Final = ( | |
| "https://sanctionslistservice.ofac.treas.gov/api/PublicationPreview/exports/SDN.CSV" | |
| ) | |
| OFAC_ADD_URL: Final = ( | |
| "https://sanctionslistservice.ofac.treas.gov/api/PublicationPreview/exports/ADD.CSV" | |
| ) | |
| OFAC_ALT_URL: Final = ( | |
| "https://sanctionslistservice.ofac.treas.gov/api/PublicationPreview/exports/ALT.CSV" | |
| ) | |
| # EU Consolidated Sanctions List | |
| EU_SANCTIONS_URL: Final = ( | |
| "https://webgate.ec.europa.eu/fsd/fsf/public/files/csvFullSanctionsList/" | |
| "content?token=dG9rZW4tMjAxNw" | |
| ) | |
| # UK HMT OFSI List | |
| UK_HMT_URL: Final = ( | |
| "https://sanctionslist.fcdo.gov.uk/docs/UK-Sanctions-List.csv" | |
| ) | |
| # UN Security Council Consolidated List | |
| UNSC_URL: Final = ( | |
| "https://scsanctions.un.org/resources/xml/en/consolidated.xml" | |
| ) | |
| # US Consolidated Screening List (CSL) β includes BIS DPL, Entity List, OFAC, etc. | |
| US_CSL_URL: Final = ( | |
| "https://data.trade.gov/downloadable_consolidated_screening_list/v1/consolidated.csv" | |
| ) | |
| # Cache directory (use HERMES_CACHE_DIR env var for HF Spaces /tmp compatibility) | |
| CACHE_DIR: Final = Path(os.environ.get("HERMES_CACHE_DIR", str(Path.home() / ".cache" / "hermes" / "sanctions"))) | |
| CACHE_TTL_HOURS: Final[int] = 24 | |
| # Matching thresholds | |
| EXACT_MATCH_THRESHOLD: Final[float] = 1.0 | |
| JARO_WINKLER_HIGH_THRESHOLD: Final[float] = 0.92 | |
| JARO_WINKLER_MEDIUM_THRESHOLD: Final[float] = 0.85 | |
| LEVENSHTEIN_MAX_DISTANCE: Final[int] = 2 | |
| PHONETIC_BASE_CONFIDENCE: Final[float] = 0.70 | |
| # Type aliases | |
| EntityType = Literal["individual", "entity", "vessel", "aircraft", "unknown"] | |
| RiskLevel = Literal["clear", "low", "medium", "high", "blocked"] | |
| MatchType = Literal["exact", "fuzzy", "phonetic", "token", "partial"] | |
| ListName = Literal["OFAC_SDN", "EU_FSF", "UK_HMT", "UNSC", "unknown"] | |
| # Name prefixes to strip during normalization | |
| _NAME_PREFIXES: Final[tuple[str, ...]] = ( | |
| "mr.", "mrs.", "ms.", "dr.", "prof.", "sir", "lord", | |
| ) | |
| # Stop words in entity names | |
| _STOP_WORDS: Final[frozenset[str]] = frozenset({ | |
| "the", "of", "and", "or", "for", "inc", "ltd", "llc", "corp", "co", | |
| }) | |
| # ββ Data Models βββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| class SanctionedEntity: | |
| """Represents a sanctioned party from any sanctions list. | |
| Attributes: | |
| name: Primary name as listed on the sanctions list. | |
| aliases: Alternate names / transliterations for this entity. | |
| entity_type: Classification of the entity (individual, vessel, etc.). | |
| programs: Sanctions programs this entity is listed under. | |
| list_name: Source sanctions list (OFAC_SDN, EU_FSF, etc.). | |
| country: Country associated with this entity. | |
| remarks: Additional remarks from the sanctions list. | |
| soundex_code: Pre-computed Soundex code for phonetic matching. | |
| metaphone_code: Pre-computed Metaphone code for phonetic matching. | |
| ent_no: OFAC entity number for alias lookup from ALT.CSV. | |
| """ | |
| name: str | |
| aliases: list[str] = field(default_factory=list) | |
| entity_type: EntityType = "unknown" | |
| programs: list[str] = field(default_factory=list) | |
| list_name: ListName = "unknown" | |
| country: str = "" | |
| remarks: str = "" | |
| soundex_code: str = "" | |
| metaphone_code: str = "" | |
| ent_no: str = "" | |
| def __post_init__(self) -> None: | |
| """Pre-compute phonetic codes for fast lookup. | |
| Transliterates Unicode to ASCII before computing codes to ensure | |
| consistent matching across diacritics (e.g., MΓLLER β MULLER). | |
| """ | |
| ascii_name: str = self._transliterate_to_ascii(self.name) | |
| normalized: str = self._normalize(ascii_name) | |
| self.soundex_code = jellyfish.soundex(normalized) if normalized else "" | |
| self.metaphone_code = jellyfish.metaphone(normalized) if normalized else "" | |
| def _transliterate_to_ascii(name: str) -> str: | |
| """Transliterate Unicode to ASCII, stripping diacritics. | |
| Args: | |
| name: The Unicode name to transliterate. | |
| Returns: | |
| ASCII-only string. Non-Latin scripts (Arabic, Chinese) are stripped. | |
| """ | |
| nfkd: str = unicodedata.normalize("NFKD", name) | |
| ascii_bytes: bytes = nfkd.encode("ascii", "ignore") | |
| return ascii_bytes.decode("ascii") | |
| def _normalize(name: str) -> str: | |
| """Normalize name for comparison (lowercase, strip punctuation). | |
| Args: | |
| name: The name to normalize. | |
| Returns: | |
| Normalized lowercase string with punctuation removed. | |
| """ | |
| name = name.lower().strip() | |
| name = re.sub(r"[^\w\s]", "", name) | |
| name = re.sub(r"\s+", " ", name) | |
| return name.strip() | |
| class ScreeningMatch: | |
| """Represents a potential sanctions match with confidence score. | |
| Attributes: | |
| entity: The matched sanctions entity. | |
| confidence: Match confidence from 0.0 (no match) to 1.0 (exact). | |
| match_type: How the match was found (exact, fuzzy, phonetic, etc.). | |
| matched_name: The specific name/alias that matched. | |
| source: Which sanctions list this match came from. | |
| """ | |
| entity: SanctionedEntity | |
| confidence: float | |
| match_type: MatchType | |
| matched_name: str | |
| source: str | |
| class ScreeningResult: | |
| """Complete screening result for a single name query. | |
| Attributes: | |
| query: The original name that was screened. | |
| matches: All matches found, sorted by confidence descending. | |
| risk_level: Aggregated risk assessment. | |
| screened_at: UTC timestamp when screening was performed. | |
| sources_checked: List of sanctions sources consulted. | |
| """ | |
| query: str | |
| matches: list[ScreeningMatch] | |
| risk_level: RiskLevel | |
| screened_at: float = field(default_factory=time.time) | |
| sources_checked: list[str] = field(default_factory=list) | |
| def has_matches(self) -> bool: | |
| """Whether any sanctions matches were found.""" | |
| return len(self.matches) > 0 | |
| def is_blocked(self) -> bool: | |
| """Whether the result is a definite sanctions hit (risk_level == 'blocked').""" | |
| return self.risk_level == "blocked" | |
| # ββ Data Downloaders ββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| class SanctionsDataDownloader: | |
| """Downloads and caches sanctions lists from official government sources. | |
| All data sources are FREE for commercial use. Files are cached locally | |
| and refreshed according to CACHE_TTL_HOURS. | |
| Attributes: | |
| cache_dir: Local directory for cached sanctions files. | |
| """ | |
| def __init__(self, cache_dir: Optional[Path] = None) -> None: | |
| """Initialize the downloader. | |
| Args: | |
| cache_dir: Override default cache directory. | |
| """ | |
| self.cache_dir: Path = cache_dir or CACHE_DIR | |
| self.cache_dir.mkdir(parents=True, exist_ok=True) | |
| def _is_cache_valid(self, filepath: Path) -> bool: | |
| """Check if cache file exists and is within TTL. | |
| Args: | |
| filepath: Path to the cached file. | |
| Returns: | |
| True if the file exists and is newer than CACHE_TTL_HOURS. | |
| """ | |
| if not filepath.exists(): | |
| return False | |
| mtime: float = filepath.stat().st_mtime | |
| age_hours: float = (time.time() - mtime) / 3600 | |
| return age_hours < CACHE_TTL_HOURS | |
| def _download_with_cache( | |
| self, url: str, cache_name: str, source_label: str | |
| ) -> Path: | |
| """Download a file with cache-first strategy. | |
| Args: | |
| url: The URL to download from. | |
| cache_name: Filename for the local cache. | |
| source_label: Human-readable label for logging. | |
| Returns: | |
| Path to the cached or freshly downloaded file. | |
| Raises: | |
| SanctionsDataDownloadError: If download fails and no cache exists. | |
| """ | |
| filepath: Path = self.cache_dir / cache_name | |
| if self._is_cache_valid(filepath): | |
| logger.debug("Using cached %s data", source_label) | |
| return filepath | |
| logger.info("Downloading %s...", source_label) | |
| try: | |
| response = httpx.get(url, timeout=60.0, follow_redirects=True) | |
| response.raise_for_status() | |
| filepath.write_bytes(response.content) | |
| logger.info( | |
| "Downloaded %s: %d bytes", source_label, len(response.content) | |
| ) | |
| except httpx.HTTPStatusError as exc: | |
| if filepath.exists(): | |
| logger.warning("Using stale cache for %s", source_label) | |
| return filepath | |
| raise SanctionsDataDownloadError( | |
| url=url, status_code=exc.response.status_code, detail=str(exc) | |
| ) from exc | |
| except httpx.RequestError as exc: | |
| if filepath.exists(): | |
| logger.warning("Using stale cache for %s", source_label) | |
| return filepath | |
| raise SanctionsDataDownloadError( | |
| url=url, detail=str(exc) | |
| ) from exc | |
| return filepath | |
| def download_ofac_sdn(self) -> Path: | |
| """Download OFAC SDN List (CSV format). | |
| Returns: | |
| Path to cached or freshly downloaded SDN CSV file. | |
| """ | |
| return self._download_with_cache(OFAC_SDN_URL, "ofac_sdn.csv", "OFAC SDN") | |
| def download_ofac_alt(self) -> Path: | |
| """Download OFAC Alternate Names (CSV format). | |
| Returns: | |
| Path to cached or freshly downloaded ALT CSV file. | |
| """ | |
| return self._download_with_cache(OFAC_ALT_URL, "ofac_alt.csv", "OFAC ALT") | |
| def download_eu_sanctions(self) -> Path: | |
| """Download EU Consolidated Sanctions List. | |
| Returns: | |
| Path to cached or freshly downloaded EU sanctions CSV. | |
| """ | |
| return self._download_with_cache( | |
| EU_SANCTIONS_URL, "eu_sanctions.csv", "EU Sanctions" | |
| ) | |
| def download_uk_sanctions(self) -> Path: | |
| """Download UK FCDO Sanctions List (CSV format). | |
| Returns: | |
| Path to cached or freshly downloaded UK sanctions CSV. | |
| """ | |
| return self._download_with_cache( | |
| UK_HMT_URL, "uk_sanctions.csv", "UK FCDO Sanctions" | |
| ) | |
| def download_unsc(self) -> Path: | |
| """Download UN Security Council Consolidated List (XML format). | |
| Returns: | |
| Path to cached or freshly downloaded UNSC XML file. | |
| """ | |
| return self._download_with_cache( | |
| UNSC_URL, "unsc_sanctions.xml", "UNSC Sanctions" | |
| ) | |
| def download_us_csl(self) -> Path: | |
| """Download US Consolidated Screening List (CSV format). | |
| Includes BIS Denied Persons List, Entity List, Unverified List, | |
| OFAC SDN, and other US screening lists. | |
| Returns: | |
| Path to cached or freshly downloaded US CSL CSV file. | |
| """ | |
| return self._download_with_cache( | |
| US_CSL_URL, "us_csl.csv", "US Consolidated Screening List" | |
| ) | |
| # ββ Data Parsers ββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| class SanctionsDataParser: | |
| """Parses sanctions data files into SanctionedEntity objects. | |
| Supports OFAC SDN CSV, OFAC ALT CSV, EU Consolidated CSV, | |
| UK FCDO CSV, and UN SC XML formats. | |
| """ | |
| def parse_ofac_sdn(self, filepath: Path) -> list[SanctionedEntity]: | |
| """Parse OFAC SDN CSV file into SanctionedEntity objects. | |
| Args: | |
| filepath: Path to the SDN CSV file. | |
| Returns: | |
| List of parsed SanctionedEntity objects. | |
| Raises: | |
| SanctionsDataCorruptionError: If file cannot be parsed or contains | |
| zero valid entities (caller must handle to avoid false-negative | |
| screening). | |
| """ | |
| entities: list[SanctionedEntity] = [] | |
| try: | |
| content: str = filepath.read_text(encoding="utf-8", errors="replace") | |
| reader = csv.reader(io.StringIO(content)) | |
| for row in reader: | |
| if len(row) < 3: | |
| continue | |
| ent_no: str = row[0].strip() | |
| name: str = row[1].strip() | |
| entity_type: str = row[2].strip() if len(row) > 2 else "individual" | |
| if not name or not ent_no: | |
| continue | |
| program: str = row[3].strip() if len(row) > 3 else "" | |
| entity = SanctionedEntity( | |
| name=name, | |
| entity_type=entity_type.lower(), # type: ignore[arg-type] | |
| programs=[program] if program else [], | |
| list_name="OFAC_SDN", | |
| ent_no=ent_no, | |
| ) | |
| entities.append(entity) | |
| except csv.Error as exc: | |
| raise SanctionsDataCorruptionError( | |
| source="OFAC_SDN", | |
| detail=f"CSV parse error at line {exc.lineno}: {exc.msg}", | |
| ) from exc | |
| except OSError as exc: | |
| raise SanctionsDataCorruptionError( | |
| source="OFAC_SDN", | |
| detail=f"File read error: {exc}", | |
| ) from exc | |
| logger.info("Parsed %d OFAC SDN entities", len(entities)) | |
| if not entities: | |
| raise SanctionsDataCorruptionError( | |
| source="OFAC_SDN", | |
| detail="Parse returned 0 entities β possible format change", | |
| ) | |
| return entities | |
| def parse_ofac_alt( | |
| self, filepath: Path, sdn_entities: list[SanctionedEntity] | |
| ) -> dict[str, list[str]]: | |
| """Parse OFAC Alternate Names CSV and return alias map. | |
| Args: | |
| filepath: Path to the ALT CSV file. | |
| sdn_entities: Previously parsed SDN entities (for validation). | |
| Returns: | |
| Dict mapping OFAC entity numbers to lists of alternate names. | |
| """ | |
| aliases: dict[str, list[str]] = {} | |
| try: | |
| content: str = filepath.read_text(encoding="utf-8", errors="replace") | |
| reader = csv.reader(io.StringIO(content)) | |
| for row in reader: | |
| if len(row) < 4: | |
| continue | |
| ent_no: str = row[0].strip() | |
| alt_name: str = row[3].strip() | |
| if ent_no and alt_name: | |
| if ent_no not in aliases: | |
| aliases[ent_no] = [] | |
| aliases[ent_no].append(alt_name) | |
| except (csv.Error, OSError) as exc: | |
| logger.error("Failed to parse OFAC ALT: %s", exc) | |
| logger.info("Parsed %d OFAC ALT entries", len(aliases)) | |
| return aliases | |
| def parse_eu_sanctions(self, filepath: Path) -> list[SanctionedEntity]: | |
| """Parse EU Consolidated Sanctions List (semicolon-delimited CSV). | |
| Args: | |
| filepath: Path to the EU sanctions CSV file. | |
| Returns: | |
| List of parsed SanctionedEntity objects from the EU list. | |
| """ | |
| entities: list[SanctionedEntity] = [] | |
| try: | |
| content: str = filepath.read_text(encoding="utf-8", errors="replace") | |
| reader = csv.reader(io.StringIO(content), delimiter=";") | |
| header_skipped: bool = False | |
| for row in reader: | |
| if not header_skipped: | |
| header_skipped = True | |
| continue | |
| if len(row) < 5: | |
| continue | |
| name: str = row[0].strip() if len(row) > 0 else "" | |
| entity_type: str = row[1].strip() if len(row) > 1 else "entity" | |
| program: str = row[2].strip() if len(row) > 2 else "" | |
| country: str = row[3].strip() if len(row) > 3 else "" | |
| if not name: | |
| continue | |
| entity = SanctionedEntity( | |
| name=name, | |
| entity_type=entity_type.lower(), # type: ignore[arg-type] | |
| programs=[program] if program else [], | |
| list_name="EU_FSF", | |
| country=country, | |
| ) | |
| entities.append(entity) | |
| except (csv.Error, OSError) as exc: | |
| logger.error("Failed to parse EU Sanctions: %s", exc) | |
| logger.info("Parsed %d EU sanctions entities", len(entities)) | |
| return entities | |
| def parse_uk_sanctions(self, filepath: Path) -> list[SanctionedEntity]: | |
| """Parse UK FCDO Sanctions List (CSV format). | |
| Args: | |
| filepath: Path to the UK sanctions CSV file. | |
| Returns: | |
| List of parsed SanctionedEntity objects from the UK list. | |
| """ | |
| entities: list[SanctionedEntity] = [] | |
| try: | |
| content: str = filepath.read_text(encoding="utf-8", errors="replace") | |
| reader = csv.DictReader(io.StringIO(content)) | |
| for row in reader: | |
| name: str = row.get("Name", "").strip() | |
| if not name: | |
| continue | |
| raw_type: str = row.get("Type", "Unknown").strip() | |
| entity_type: EntityType = "individual" if "Individual" in raw_type else "entity" # type: ignore[assignment] | |
| regime: str = row.get("Regime", "").strip() | |
| country: str = row.get("Country", "").strip() | |
| entity = SanctionedEntity( | |
| name=name, | |
| entity_type=entity_type, | |
| programs=[regime] if regime else [], | |
| list_name="UK_HMT", | |
| country=country, | |
| ) | |
| entities.append(entity) | |
| except (csv.Error, OSError) as exc: | |
| logger.error("Failed to parse UK FCDO Sanctions: %s", exc) | |
| logger.info("Parsed %d UK FCDO sanctions entities", len(entities)) | |
| return entities | |
| def parse_unsc(self, filepath: Path) -> list[SanctionedEntity]: | |
| """Parse UN Security Council Consolidated List (XML format). | |
| Args: | |
| filepath: Path to the UNSC XML file. | |
| Returns: | |
| List of parsed SanctionedEntity objects from the UNSC list. | |
| """ | |
| import xml.etree.ElementTree as ET | |
| entities: list[SanctionedEntity] = [] | |
| try: | |
| tree = ET.parse(filepath) | |
| root = tree.getroot() | |
| for entry in root.findall(".//INDIVIDUAL"): | |
| name1 = (entry.findtext("NAME1") or "").strip() | |
| name2 = (entry.findtext("NAME2") or "").strip() | |
| full_name = f"{name1} {name2}".strip() if name2 else name1 | |
| if not full_name: | |
| continue | |
| ref_number = (entry.findtext("REFERENCE_NUMBER") or "").strip() | |
| comments = (entry.findtext("COMMENTS1") or "").strip() | |
| programs = [comments] if comments else [] | |
| entity = SanctionedEntity( | |
| name=full_name, | |
| entity_type="individual", | |
| programs=programs, | |
| list_name="UNSC", | |
| remarks=ref_number, | |
| ) | |
| entities.append(entity) | |
| for entry in root.findall(".//ENTITY"): | |
| name1 = (entry.findtext("NAME1") or "").strip() | |
| name2 = (entry.findtext("NAME2") or "").strip() | |
| full_name = f"{name1} {name2}".strip() if name2 else name1 | |
| if not full_name: | |
| continue | |
| ref_number = (entry.findtext("REFERENCE_NUMBER") or "").strip() | |
| comments = (entry.findtext("COMMENTS1") or "").strip() | |
| programs = [comments] if comments else [] | |
| entity = SanctionedEntity( | |
| name=full_name, | |
| entity_type="entity", | |
| programs=programs, | |
| list_name="UNSC", | |
| remarks=ref_number, | |
| ) | |
| entities.append(entity) | |
| except ET.ParseError as exc: | |
| logger.error("Failed to parse UNSC XML: %s", exc) | |
| except OSError as exc: | |
| logger.error("Failed to read UNSC file: %s", exc) | |
| logger.info("Parsed %d UNSC sanctions entities", len(entities)) | |
| return entities | |
| def parse_us_csl(self, filepath: Path) -> list[SanctionedEntity]: | |
| """Parse US Consolidated Screening List (CSV format). | |
| The CSL includes multiple US screening lists: | |
| - BIS Denied Persons List | |
| - BIS Entity List | |
| - BIS Unverified List | |
| - OFAC SDN List | |
| - OFAC Consolidated List | |
| - State Department Debarred List | |
| Args: | |
| filepath: Path to the US CSL CSV file. | |
| Returns: | |
| List of parsed SanctionedEntity objects from the US CSL. | |
| """ | |
| entities: list[SanctionedEntity] = [] | |
| try: | |
| content: str = filepath.read_text(encoding="utf-8", errors="replace") | |
| reader = csv.DictReader(io.StringIO(content)) | |
| for row in reader: | |
| name: str = row.get("name", "").strip() | |
| if not name: | |
| continue | |
| # Determine entity type from type column | |
| raw_type: str = row.get("type", "").strip().lower() | |
| if "individual" in raw_type or "person" in raw_type: | |
| entity_type: EntityType = "individual" | |
| elif "vessel" in raw_type or "ship" in raw_type: | |
| entity_type = "vessel" | |
| else: | |
| entity_type = "entity" | |
| # Get source list and programs | |
| source_list: str = row.get("source_list", "").strip() | |
| programs: list[str] = [source_list] if source_list else [] | |
| # Get country | |
| country: str = row.get("country", "").strip() | |
| # Get remarks/programs | |
| remarks: str = row.get("remarks", "").strip() | |
| if remarks and remarks not in programs: | |
| programs.append(remarks) | |
| entity = SanctionedEntity( | |
| name=name, | |
| entity_type=entity_type, | |
| programs=programs, | |
| list_name="OFAC_SDN", # Map to OFAC_SDN for consistency | |
| country=country, | |
| remarks=remarks, | |
| ) | |
| entities.append(entity) | |
| except (csv.Error, OSError) as exc: | |
| logger.error("Failed to parse US CSL: %s", exc) | |
| logger.info("Parsed %d US CSL entities", len(entities)) | |
| return entities | |
| # ββ Name Normalizer βββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| class NameNormalizer: | |
| """Normalizes names for consistent comparison across transliterations. | |
| Provides three normalization modes: | |
| - Standard: lowercase, strip punctuation, remove prefixes | |
| - Phonetic: Unicode transliteration + phonetic mapping | |
| - Token: extract significant words, remove stop words | |
| """ | |
| TRANSLITERATION_MAP: Final[dict[str, str]] = { | |
| "q": "g", | |
| "kh": "h", | |
| "ph": "f", | |
| "tz": "z", | |
| "ks": "x", | |
| "ck": "k", | |
| "sch": "sh", | |
| "ch": "h", | |
| "oe": "o", | |
| "ue": "u", | |
| "ae": "a", | |
| "ij": "y", | |
| } | |
| def normalize(name: str) -> str: | |
| """Full normalization for comparison. | |
| Strips prefixes, lowercases, replaces punctuation with spaces, | |
| and collapses whitespace. | |
| Args: | |
| name: The name to normalize. | |
| Returns: | |
| Normalized lowercase string. | |
| """ | |
| name = name.lower().strip() | |
| for prefix in _NAME_PREFIXES: | |
| if name.startswith(prefix): | |
| name = name[len(prefix) :].strip() | |
| name = re.sub(r"[^\w\s]", " ", name) | |
| name = re.sub(r"\s+", " ", name) | |
| return name.strip() | |
| def normalize_for_phonetic(name: str) -> str: | |
| """Normalization optimized for phonetic matching. | |
| Applies UnicodeβASCII transliteration before standard normalization | |
| and phonetic mapping rules. | |
| Args: | |
| name: The name to normalize phonetically. | |
| Returns: | |
| Phonetically normalized string. | |
| """ | |
| nfkd: str = unicodedata.normalize("NFKD", name) | |
| name = nfkd.encode("ascii", "ignore").decode("ascii") | |
| name = NameNormalizer.normalize(name) | |
| for old, new in NameNormalizer.TRANSLITERATION_MAP.items(): | |
| name = name.replace(old, new) | |
| return name | |
| def extract_tokens(name: str) -> set[str]: | |
| """Extract significant tokens from a name. | |
| Args: | |
| name: The name to tokenize. | |
| Returns: | |
| Set of significant words (stop words removed). | |
| """ | |
| normalized: str = NameNormalizer.normalize(name) | |
| tokens: set[str] = set(normalized.split()) | |
| return tokens - _STOP_WORDS | |
| # ββ Matching Engine βββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| class SanctionsMatcher: | |
| """Multi-layer sanctions matching engine. | |
| Implements 8 matching layers in order of specificity: | |
| 1. Exact match (normalized) | |
| 2. Jaro-Winkler fuzzy match (typos) | |
| 3. Soundex phonetic match (transliterations) | |
| 4. Metaphone phonetic match (alternative phonetics) | |
| 5. Levenshtein edit distance (character-level) | |
| 6. Token-based matching (name reordering) | |
| 7. Partial name matching (substring) | |
| 8. Token-level fuzzy/phonetic matching | |
| """ | |
| def __init__(self) -> None: | |
| """Initialize the matcher with a NameNormalizer.""" | |
| self.normalizer: NameNormalizer = NameNormalizer() | |
| def match( | |
| self, | |
| query: str, | |
| entities: list[SanctionedEntity], | |
| threshold: float = JARO_WINKLER_MEDIUM_THRESHOLD, | |
| ) -> list[ScreeningMatch]: | |
| """Run multi-layer matching against a list of entities. | |
| Args: | |
| query: The name to screen. | |
| entities: List of sanctioned entities to match against. | |
| threshold: Minimum confidence for fuzzy matches. | |
| Returns: | |
| List of ScreeningMatch objects sorted by confidence descending. | |
| """ | |
| matches: list[ScreeningMatch] = [] | |
| query_normalized: str = self.normalizer.normalize(query) | |
| query_phonetic: str = self.normalizer.normalize_for_phonetic(query) | |
| query_tokens: set[str] = self.normalizer.extract_tokens(query) | |
| query_soundex: str = ( | |
| jellyfish.soundex(query_phonetic) if query_phonetic else "" | |
| ) | |
| query_metaphone: str = ( | |
| jellyfish.metaphone(query_phonetic) if query_phonetic else "" | |
| ) | |
| for entity in entities: | |
| match = self._match_single( | |
| query_normalized=query_normalized, | |
| query_soundex=query_soundex, | |
| query_metaphone=query_metaphone, | |
| query_tokens=query_tokens, | |
| entity=entity, | |
| threshold=threshold, | |
| ) | |
| if match is not None: | |
| matches.append(match) | |
| # Deduplicate by entity name, keep highest confidence | |
| seen: dict[str, ScreeningMatch] = {} | |
| for match in matches: | |
| key: str = match.entity.name.upper() | |
| if key not in seen or match.confidence > seen[key].confidence: | |
| seen[key] = match | |
| return sorted(seen.values(), key=lambda m: m.confidence, reverse=True) | |
| def _match_single( | |
| self, | |
| query_normalized: str, | |
| query_soundex: str, | |
| query_metaphone: str, | |
| query_tokens: set[str], | |
| entity: SanctionedEntity, | |
| threshold: float, | |
| ) -> Optional[ScreeningMatch]: | |
| """Apply all matching layers against a single entity. | |
| Args: | |
| query_normalized: Pre-computed normalized query. | |
| query_soundex: Pre-computed Soundex code. | |
| query_metaphone: Pre-computed Metaphone code. | |
| query_tokens: Pre-computed token set. | |
| entity: The entity to match against. | |
| threshold: Minimum confidence threshold. | |
| Returns: | |
| ScreeningMatch if any layer matched, None otherwise. | |
| """ | |
| entity_normalized: str = self.normalizer.normalize(entity.name) | |
| # Layer 1: Exact match | |
| if query_normalized == entity_normalized: | |
| return ScreeningMatch( | |
| entity=entity, | |
| confidence=EXACT_MATCH_THRESHOLD, | |
| match_type="exact", | |
| matched_name=entity.name, | |
| source=entity.list_name, | |
| ) | |
| # Check aliases for exact match | |
| for alias in entity.aliases: | |
| alias_normalized: str = self.normalizer.normalize(alias) | |
| if query_normalized == alias_normalized: | |
| return ScreeningMatch( | |
| entity=entity, | |
| confidence=EXACT_MATCH_THRESHOLD, | |
| match_type="exact", | |
| matched_name=alias, | |
| source=entity.list_name, | |
| ) | |
| # Layer 2: Jaro-Winkler fuzzy match | |
| jw_score: float = jellyfish.jaro_winkler_similarity( | |
| query_normalized, entity_normalized | |
| ) | |
| if jw_score >= JARO_WINKLER_HIGH_THRESHOLD: | |
| return ScreeningMatch( | |
| entity=entity, | |
| confidence=jw_score, | |
| match_type="fuzzy", | |
| matched_name=entity.name, | |
| source=entity.list_name, | |
| ) | |
| # Check aliases with Jaro-Winkler | |
| for alias in entity.aliases: | |
| alias_normalized = self.normalizer.normalize(alias) | |
| alias_jw: float = jellyfish.jaro_winkler_similarity( | |
| query_normalized, alias_normalized | |
| ) | |
| if alias_jw >= JARO_WINKLER_HIGH_THRESHOLD: | |
| return ScreeningMatch( | |
| entity=entity, | |
| confidence=alias_jw, | |
| match_type="fuzzy", | |
| matched_name=alias, | |
| source=entity.list_name, | |
| ) | |
| # Layer 3: Phonetic match (Soundex) | |
| if query_soundex == entity.soundex_code: | |
| return self._phonetic_match( | |
| entity, query_normalized, entity_normalized | |
| ) | |
| # Layer 3b: Phonetic match (Metaphone) | |
| if query_metaphone == entity.metaphone_code and query_metaphone != "": | |
| return self._phonetic_match( | |
| entity, query_normalized, entity_normalized | |
| ) | |
| # Layer 4: Levenshtein distance | |
| lev_match = self._levenshtein_match( | |
| query_normalized, entity_normalized, entity, threshold | |
| ) | |
| if lev_match is not None: | |
| return lev_match | |
| # Layer 5: Token-based matching | |
| entity_tokens: set[str] = self.normalizer.extract_tokens(entity.name) | |
| token_match = self._token_match( | |
| query_tokens, entity_tokens, entity | |
| ) | |
| if token_match is not None: | |
| return token_match | |
| # Layer 6: Partial name matching | |
| partial_match = self._partial_match( | |
| query_normalized, entity_normalized, entity_tokens, entity | |
| ) | |
| if partial_match is not None: | |
| return partial_match | |
| # Layer 6b: Token-level fuzzy/phonetic matching | |
| token_level_match = self._token_level_match( | |
| query_normalized, query_soundex, query_metaphone, | |
| entity_tokens, entity, threshold, | |
| ) | |
| if token_level_match is not None: | |
| return token_level_match | |
| # Layer 7: Entity name starts with query | |
| entity_first_token: str = ( | |
| next(iter(entity_tokens), "") if entity_tokens else "" | |
| ) | |
| if entity_first_token and query_normalized.startswith(entity_first_token): | |
| return ScreeningMatch( | |
| entity=entity, | |
| confidence=0.9, | |
| match_type="partial", | |
| matched_name=entity.name, | |
| source=entity.list_name, | |
| ) | |
| # Layer 8: Query starts with entity first token | |
| query_first_token: str = ( | |
| next(iter(query_tokens), "") if query_tokens else "" | |
| ) | |
| if query_first_token and entity_first_token.startswith(query_first_token): | |
| return ScreeningMatch( | |
| entity=entity, | |
| confidence=JARO_WINKLER_MEDIUM_THRESHOLD, | |
| match_type="partial", | |
| matched_name=entity.name, | |
| source=entity.list_name, | |
| ) | |
| return None | |
| def _phonetic_match( | |
| self, | |
| entity: SanctionedEntity, | |
| query_normalized: str, | |
| entity_normalized: str, | |
| ) -> ScreeningMatch: | |
| """Create a phonetic match with Levenshtein-based confidence boost. | |
| Args: | |
| entity: The matched entity. | |
| query_normalized: Normalized query string. | |
| entity_normalized: Normalized entity name. | |
| Returns: | |
| ScreeningMatch with phonetic match type. | |
| """ | |
| lev_distance: int = jellyfish.levenshtein_distance( | |
| query_normalized, entity_normalized | |
| ) | |
| max_len: int = max(len(query_normalized), len(entity_normalized)) | |
| lev_similarity: float = 1.0 - (lev_distance / max_len) if max_len > 0 else 0.0 | |
| confidence: float = max(PHONETIC_BASE_CONFIDENCE, lev_similarity) | |
| return ScreeningMatch( | |
| entity=entity, | |
| confidence=confidence, | |
| match_type="phonetic", | |
| matched_name=entity.name, | |
| source=entity.list_name, | |
| ) | |
| def _levenshtein_match( | |
| self, | |
| query_normalized: str, | |
| entity_normalized: str, | |
| entity: SanctionedEntity, | |
| threshold: float, | |
| ) -> Optional[ScreeningMatch]: | |
| """Check for Levenshtein edit distance match. | |
| Args: | |
| query_normalized: Normalized query string. | |
| entity_normalized: Normalized entity name. | |
| entity: The entity to check. | |
| threshold: Minimum confidence threshold. | |
| Returns: | |
| ScreeningMatch if within edit distance, None otherwise. | |
| """ | |
| lev_distance: int = jellyfish.levenshtein_distance( | |
| query_normalized, entity_normalized | |
| ) | |
| if lev_distance <= LEVENSHTEIN_MAX_DISTANCE and lev_distance > 0: | |
| max_len: int = max(len(query_normalized), len(entity_normalized)) | |
| confidence: float = ( | |
| 1.0 - (lev_distance / max_len) if max_len > 0 else 0.0 | |
| ) | |
| effective_threshold: float = ( | |
| 0.70 if max_len <= 8 else threshold | |
| ) | |
| if confidence >= effective_threshold: | |
| return ScreeningMatch( | |
| entity=entity, | |
| confidence=confidence, | |
| match_type="fuzzy", | |
| matched_name=entity.name, | |
| source=entity.list_name, | |
| ) | |
| return None | |
| def _token_match( | |
| self, | |
| query_tokens: set[str], | |
| entity_tokens: set[str], | |
| entity: SanctionedEntity, | |
| ) -> Optional[ScreeningMatch]: | |
| """Check for token overlap match. | |
| Args: | |
| query_tokens: Token set from query. | |
| entity_tokens: Token set from entity name. | |
| entity: The entity to check. | |
| Returns: | |
| ScreeningMatch if sufficient token overlap, None otherwise. | |
| """ | |
| if not query_tokens or not entity_tokens: | |
| return None | |
| common_tokens: set[str] = query_tokens & entity_tokens | |
| if len(common_tokens) >= 2: | |
| token_score: float = len(common_tokens) / max( | |
| len(query_tokens), len(entity_tokens) | |
| ) | |
| if token_score >= 0.5: | |
| return ScreeningMatch( | |
| entity=entity, | |
| confidence=token_score, | |
| match_type="token", | |
| matched_name=entity.name, | |
| source=entity.list_name, | |
| ) | |
| return None | |
| def _partial_match( | |
| self, | |
| query_normalized: str, | |
| entity_normalized: str, | |
| entity_tokens: set[str], | |
| entity: SanctionedEntity, | |
| ) -> Optional[ScreeningMatch]: | |
| """Check for partial name matching (substring). | |
| Args: | |
| query_normalized: Normalized query string. | |
| entity_normalized: Normalized entity name. | |
| entity_tokens: Token set from entity name. | |
| entity: The entity to check. | |
| Returns: | |
| ScreeningMatch if query is a significant substring, None otherwise. | |
| """ | |
| if query_normalized not in entity_normalized: | |
| return None | |
| entity_first_word: str = ( | |
| entity_normalized.split()[0] if entity_normalized else "" | |
| ) | |
| if query_normalized == entity_first_word: | |
| return ScreeningMatch( | |
| entity=entity, | |
| confidence=0.95, | |
| match_type="partial", | |
| matched_name=entity.name, | |
| source=entity.list_name, | |
| ) | |
| confidence: float = len(query_normalized) / len(entity_normalized) | |
| if confidence >= 0.3: | |
| return ScreeningMatch( | |
| entity=entity, | |
| confidence=max(confidence, JARO_WINKLER_MEDIUM_THRESHOLD), | |
| match_type="partial", | |
| matched_name=entity.name, | |
| source=entity.list_name, | |
| ) | |
| return None | |
| def _token_level_match( | |
| self, | |
| query_normalized: str, | |
| query_soundex: str, | |
| query_metaphone: str, | |
| entity_tokens: set[str], | |
| entity: SanctionedEntity, | |
| threshold: float, | |
| ) -> Optional[ScreeningMatch]: | |
| """Check for match against individual entity tokens. | |
| Args: | |
| query_normalized: Normalized query string. | |
| query_soundex: Pre-computed Soundex code. | |
| query_metaphone: Pre-computed Metaphone code. | |
| entity_tokens: Token set from entity name. | |
| entity: The entity to check. | |
| threshold: Minimum confidence threshold. | |
| Returns: | |
| ScreeningMatch if any token matches, None otherwise. | |
| """ | |
| for token in entity_tokens: | |
| # Jaro-Winkler on individual tokens | |
| token_jw: float = jellyfish.jaro_winkler_similarity( | |
| query_normalized, token | |
| ) | |
| if token_jw >= JARO_WINKLER_HIGH_THRESHOLD: | |
| return ScreeningMatch( | |
| entity=entity, | |
| confidence=token_jw, | |
| match_type="fuzzy", | |
| matched_name=entity.name, | |
| source=entity.list_name, | |
| ) | |
| # Levenshtein on individual tokens | |
| token_lev: int = jellyfish.levenshtein_distance( | |
| query_normalized, token | |
| ) | |
| token_max: int = max(len(query_normalized), len(token)) | |
| if token_lev <= LEVENSHTEIN_MAX_DISTANCE and token_lev > 0: | |
| token_conf: float = ( | |
| 1.0 - (token_lev / token_max) if token_max > 0 else 0.0 | |
| ) | |
| effective_threshold: float = ( | |
| 0.70 if token_max <= 8 else threshold | |
| ) | |
| if token_conf >= effective_threshold: | |
| return ScreeningMatch( | |
| entity=entity, | |
| confidence=token_conf, | |
| match_type="fuzzy", | |
| matched_name=entity.name, | |
| source=entity.list_name, | |
| ) | |
| # Phonetic on individual tokens | |
| token_soundex: str = jellyfish.soundex(token) | |
| token_metaphone: str = jellyfish.metaphone(token) | |
| if query_soundex == token_soundex or ( | |
| query_metaphone == token_metaphone and query_metaphone != "" | |
| ): | |
| return ScreeningMatch( | |
| entity=entity, | |
| confidence=PHONETIC_BASE_CONFIDENCE, | |
| match_type="phonetic", | |
| matched_name=entity.name, | |
| source=entity.list_name, | |
| ) | |
| return None | |
| # ββ Main Screening Engine βββββββββββββββββββββββββββββββββββββββββββββ | |
| class SanctionsScreeningEngine: | |
| """Main sanctions screening interface. | |
| Coordinates data loading, parsing, and matching across multiple | |
| sanctions lists. Thread-safe for concurrent screening requests. | |
| Attributes: | |
| downloader: Handles downloading and caching of sanctions data. | |
| parser: Parses downloaded CSV files into entity objects. | |
| matcher: Multi-layer matching engine. | |
| """ | |
| def __init__(self) -> None: | |
| """Initialize the screening engine with default components.""" | |
| self.downloader: SanctionsDataDownloader = SanctionsDataDownloader() | |
| self.parser: SanctionsDataParser = SanctionsDataParser() | |
| self.matcher: SanctionsMatcher = SanctionsMatcher() | |
| self._entities: list[SanctionedEntity] = [] | |
| self._loaded: bool = False | |
| def load_data(self, force_refresh: bool = False, timeout_seconds: float = 60.0) -> None: | |
| """Load all sanctions data from downloaded/cached files. | |
| Downloads OFAC SDN + ALT and EU sanctions lists, parses them, | |
| and attaches aliases to entities. | |
| Args: | |
| force_refresh: If True, reload even if already loaded. | |
| timeout_seconds: Maximum time to wait for downloads (default: 60s). | |
| Raises: | |
| SanctionsDataCorruptionError: If critical data files are corrupted. | |
| SanctionsDataDownloadError: If downloads fail with no cache. | |
| """ | |
| if self._loaded and not force_refresh: | |
| return | |
| logger.info("Loading sanctions data (timeout: %.0fs)...", timeout_seconds) | |
| start_time: float = time.time() | |
| try: | |
| # Download and parse OFAC SDN | |
| sdn_path: Path = self.downloader.download_ofac_sdn() | |
| sdn_entities: list[SanctionedEntity] = self.parser.parse_ofac_sdn( | |
| sdn_path | |
| ) | |
| # Check timeout after OFAC SDN | |
| if time.time() - start_time > timeout_seconds: | |
| logger.warning("Sanctions loading timeout after OFAC SDN download, proceeding with partial data") | |
| self._entities.extend(sdn_entities) | |
| self._loaded = True | |
| return | |
| # Download and parse OFAC Alternate Names | |
| alt_path: Path = self.downloader.download_ofac_alt() | |
| aliases: dict[str, list[str]] = self.parser.parse_ofac_alt( | |
| alt_path, sdn_entities | |
| ) | |
| # Attach aliases to entities (keyed by ent_no from ALT.CSV) | |
| for entity in sdn_entities: | |
| entity.aliases = aliases.get(entity.ent_no, []) | |
| self._entities.extend(sdn_entities) | |
| # Check timeout | |
| if time.time() - start_time > timeout_seconds: | |
| logger.warning("Sanctions loading timeout after OFAC ALT, proceeding with partial data") | |
| self._loaded = True | |
| return | |
| # Download and parse EU sanctions (non-critical) | |
| try: | |
| eu_path: Path = self.downloader.download_eu_sanctions() | |
| eu_entities: list[SanctionedEntity] = ( | |
| self.parser.parse_eu_sanctions(eu_path) | |
| ) | |
| self._entities.extend(eu_entities) | |
| except SanctionsError as exc: | |
| logger.warning("EU sanctions data unavailable: %s", exc) | |
| # Check timeout | |
| if time.time() - start_time > timeout_seconds: | |
| logger.warning("Sanctions loading timeout after EU, proceeding with partial data") | |
| self._loaded = True | |
| return | |
| # Download and parse UK FCDO sanctions (non-critical) | |
| try: | |
| uk_path: Path = self.downloader.download_uk_sanctions() | |
| uk_entities: list[SanctionedEntity] = ( | |
| self.parser.parse_uk_sanctions(uk_path) | |
| ) | |
| self._entities.extend(uk_entities) | |
| logger.info("UK FCDO sanctions loaded: %d entities", len(uk_entities)) | |
| except SanctionsError as exc: | |
| logger.warning("UK FCDO sanctions data unavailable: %s", exc) | |
| # Check timeout | |
| if time.time() - start_time > timeout_seconds: | |
| logger.warning("Sanctions loading timeout after UK, proceeding with partial data") | |
| self._loaded = True | |
| return | |
| # Download and parse US Consolidated Screening List (non-critical) | |
| try: | |
| us_csl_path: Path = self.downloader.download_us_csl() | |
| us_csl_entities: list[SanctionedEntity] = ( | |
| self.parser.parse_us_csl(us_csl_path) | |
| ) | |
| self._entities.extend(us_csl_entities) | |
| logger.info("US CSL loaded: %d entities", len(us_csl_entities)) | |
| except SanctionsError as exc: | |
| logger.warning("US CSL data unavailable: %s", exc) | |
| # Check timeout | |
| if time.time() - start_time > timeout_seconds: | |
| logger.warning("Sanctions loading timeout after US CSL, proceeding with partial data") | |
| self._loaded = True | |
| return | |
| # Download and parse UN Security Council sanctions (non-critical) | |
| try: | |
| unsc_path: Path = self.downloader.download_unsc() | |
| unsc_entities: list[SanctionedEntity] = ( | |
| self.parser.parse_unsc(unsc_path) | |
| ) | |
| self._entities.extend(unsc_entities) | |
| logger.info("UNSC sanctions loaded: %d entities", len(unsc_entities)) | |
| except SanctionsError as exc: | |
| logger.warning("UNSC sanctions data unavailable: %s", exc) | |
| self._loaded = True | |
| elapsed: float = time.time() - start_time | |
| logger.info( | |
| "Loaded %d sanctions entities in %.2fs", | |
| len(self._entities), | |
| elapsed, | |
| ) | |
| except SanctionsError: | |
| raise | |
| except Exception as exc: | |
| logger.error("Unexpected error loading sanctions data: %s", exc) | |
| raise SanctionsError(f"Failed to load sanctions data: {exc}") from exc | |
| def screen( | |
| self, name: str, entity_type: EntityType = "unknown" | |
| ) -> ScreeningResult: | |
| """Screen a name against all loaded sanctions lists. | |
| Args: | |
| name: The name to screen. | |
| entity_type: Type of entity being screened. | |
| Returns: | |
| ScreeningResult with matches and risk assessment. | |
| """ | |
| if not self._loaded: | |
| self.load_data() | |
| # If still not loaded after attempt, return safe default | |
| if not self._loaded: | |
| return ScreeningResult( | |
| query=name, | |
| matches=[], | |
| risk_level="unknown", | |
| sources_checked=[], | |
| ) | |
| matches: list[ScreeningMatch] = self.matcher.match(name, self._entities) | |
| risk_level: RiskLevel = self._assess_risk(matches) | |
| sources: list[str] = list({m.source for m in matches}) | |
| return ScreeningResult( | |
| query=name, | |
| matches=matches, | |
| risk_level=risk_level, | |
| sources_checked=sources, | |
| ) | |
| def screen_batch(self, names: list[str]) -> list[ScreeningResult]: | |
| """Screen multiple names against all loaded sanctions lists. | |
| Args: | |
| names: List of names to screen. | |
| Returns: | |
| List of ScreeningResult objects, one per name. | |
| """ | |
| return [self.screen(name) for name in names] | |
| def _assess_risk(matches: list[ScreeningMatch]) -> RiskLevel: | |
| """Determine aggregate risk level from matches. | |
| Args: | |
| matches: List of ScreeningMatch objects. | |
| Returns: | |
| Risk level string: 'clear', 'low', 'medium', 'high', or 'blocked'. | |
| """ | |
| if not matches: | |
| return "clear" | |
| if any(m.confidence >= 0.95 for m in matches): | |
| return "blocked" | |
| if any(m.confidence >= 0.85 for m in matches): | |
| return "high" | |
| if any(m.confidence >= 0.70 for m in matches): | |
| return "medium" | |
| return "low" | |
| def entity_count(self) -> int: | |
| """Number of loaded sanctions entities.""" | |
| return len(self._entities) | |
| def is_loaded(self) -> bool: | |
| """Whether sanctions data has been loaded.""" | |
| return self._loaded | |
| # ββ Convenience Functions βββββββββββββββββββββββββββββββββββββββββββββ | |
| _engine: Optional[SanctionsScreeningEngine] = None | |
| _engine_lock: threading.Lock = threading.Lock() | |
| def get_engine() -> SanctionsScreeningEngine: | |
| """Get or create the global sanctions screening engine (thread-safe). | |
| Returns: | |
| The singleton SanctionsScreeningEngine instance. | |
| """ | |
| global _engine | |
| if _engine is None: | |
| with _engine_lock: | |
| if _engine is None: | |
| _engine = SanctionsScreeningEngine() | |
| return _engine | |
| def screen_name(name: str) -> ScreeningResult: | |
| """Screen a single name against all sanctions lists. | |
| Args: | |
| name: The name to screen. | |
| Returns: | |
| ScreeningResult with matches and risk assessment. | |
| """ | |
| engine: SanctionsScreeningEngine = get_engine() | |
| return engine.screen(name) | |
| def screen_batch(names: list[str]) -> list[ScreeningResult]: | |
| """Screen multiple names against all sanctions lists. | |
| Args: | |
| names: List of names to screen. | |
| Returns: | |
| List of ScreeningResult objects. | |
| """ | |
| engine: SanctionsScreeningEngine = get_engine() | |
| return engine.screen_batch(names) | |