vgtc-api / src /hermes /tools /sanctions.py
vora-sonnet's picture
Upload folder using huggingface_hub (part 2)
b9f94e1 verified
Raw
History Blame Contribute Delete
55.5 kB
"""
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 ───────────────────────────────────────────────────────
@dataclass(frozen=False, slots=True)
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 ""
@staticmethod
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")
@staticmethod
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()
@dataclass(frozen=False, slots=True)
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
@dataclass(frozen=False, slots=True)
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)
@property
def has_matches(self) -> bool:
"""Whether any sanctions matches were found."""
return len(self.matches) > 0
@property
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",
}
@staticmethod
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()
@staticmethod
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
@staticmethod
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]
@staticmethod
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"
@property
def entity_count(self) -> int:
"""Number of loaded sanctions entities."""
return len(self._entities)
@property
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)