from decimal import Decimal from hashlib import sha256 from unicodedata import normalize from uuid import uuid4 from datetime import datetime, timezone from rapidfuzz import fuzz, process def run_vendor_validation( *, document, extracted_fields, vendors, platform_configs, route_contexts=None, actor_id=None, ): """ Runs Cat 2 vendor validation using RapidFuzz instead of the platform name-match algorithm. Expected document fields: document.id document.tenant_id Expected extracted_fields fields: sender_name sender_name_canonical sender_tax_id_canonical bank_iban_canonical Expected vendor fields: id tenant_id legal_name vendor_name legal_name_canonical billing_country billing_address vendor_status deleted_at Returns a dict containing: resolved_vendor_id resolved_sender_name resolved_sender_addr risk_flags advisory_flags skipped_steps audit_row match_result """ route_contexts = set(route_contexts or []) require_high_tier_contexts = set( platform_configs.get( "validation.vendor_match_require_high_tier_contexts", ["contract_match", "bank_validation", "approval_revalidation"], ) ) algorithm_version = platform_configs.get( "validation.name_match_algorithm_version", "rapidfuzz.local.v1", ) high_threshold = Decimal(str(platform_configs.get("validation.rapidfuzz_high_threshold", "0.90"))) medium_threshold = Decimal(str(platform_configs.get("validation.rapidfuzz_medium_threshold", "0.75"))) suggestion_threshold = Decimal(str(platform_configs.get("validation.rapidfuzz_suggestion_threshold", "0.60"))) def canonicalize(value): if not value: return "" return normalize("NFKC", str(value)).lower().strip() def hash_prefix(value, length=8): if not value: return None return sha256(str(value).encode("utf-8")).hexdigest()[:length] def get_vendor_names(vendor): names = [] for name in ( getattr(vendor, "legal_name", None), getattr(vendor, "vendor_name", None), ): canonical = canonicalize(name) if canonical and canonical not in names: names.append(canonical) return names def build_candidate_pool(): pool = [] for vendor in vendors: if getattr(vendor, "tenant_id", None) != document.tenant_id: continue if getattr(vendor, "vendor_status", None) == "deleted": continue if getattr(vendor, "deleted_at", None) is not None: continue names = get_vendor_names(vendor) if not names: continue pool.append( { "record_id": vendor.id, "vendor": vendor, "names": names, "auxiliary": { "country": getattr(vendor, "billing_country", None), "tax_id_hash": hash_prefix( getattr(extracted_fields, "sender_tax_id_canonical", None) ), "iban_hash": hash_prefix( getattr(extracted_fields, "bank_iban_canonical", None) ), }, } ) return pool def rapidfuzz_name_match(query_name, candidate_pool): choices = [] for candidate in candidate_pool: for name in candidate["names"]: choices.append( { "name": name, "record_id": candidate["record_id"], "vendor": candidate["vendor"], } ) if not query_name or not choices: return { "decision": "unmatched", "matched_record_id": None, "matched_vendor": None, "runner_up_record_id": None, "runner_up_vendor": None, "score": Decimal("0"), "score_tier": "low", "algorithm_version": algorithm_version, "trace_id": str(uuid4()), } raw_matches = process.extract( query_name, [choice["name"] for choice in choices], scorer=fuzz.WRatio, limit=5, ) ranked = [] seen_record_ids = set() for matched_name, score, choice_index in raw_matches: choice = choices[choice_index] record_id = choice["record_id"] if record_id in seen_record_ids: continue seen_record_ids.add(record_id) ranked.append( { "record_id": record_id, "vendor": choice["vendor"], "matched_name": matched_name, "score": Decimal(str(score / 100)).quantize(Decimal("0.0001")), } ) if not ranked: best = None runner_up = None score = Decimal("0") else: best = ranked[0] runner_up = ranked[1] if len(ranked) > 1 else None score = best["score"] if score >= high_threshold: decision = "matched" score_tier = "high" matched_record_id = best["record_id"] matched_vendor = best["vendor"] elif score >= medium_threshold: decision = "matched" score_tier = "medium" matched_record_id = best["record_id"] matched_vendor = best["vendor"] elif score >= suggestion_threshold: decision = "suggestion" score_tier = "low" matched_record_id = None matched_vendor = None else: decision = "unmatched" score_tier = "low" matched_record_id = None matched_vendor = None return { "decision": decision, "matched_record_id": matched_record_id, "matched_vendor": matched_vendor, "runner_up_record_id": ( best["record_id"] if decision == "suggestion" and best else (runner_up["record_id"] if runner_up else None) ), "runner_up_vendor": ( best["vendor"] if decision == "suggestion" and best else (runner_up["vendor"] if runner_up else None) ), "score": score, "score_tier": score_tier, "algorithm_version": algorithm_version, "trace_id": str(uuid4()), } risk_flags = [] advisory_flags = [] skipped_steps = [] resolved_vendor_id = None resolved_sender_name = None resolved_sender_addr = None query_name = canonicalize( getattr(extracted_fields, "sender_name_canonical", None) or getattr(extracted_fields, "sender_name", None) ) candidate_pool = build_candidate_pool() match_result = rapidfuzz_name_match( query_name=query_name, candidate_pool=candidate_pool, ) decision = match_result["decision"] score_tier = match_result["score_tier"] control_context_requires_high = bool(route_contexts & require_high_tier_contexts) if decision == "matched" and score_tier == "high": resolved_vendor_id = match_result["matched_record_id"] elif decision == "matched" and score_tier == "medium": if control_context_requires_high: match_result["decision"] = "suggestion" advisory_flags.append( { "code": "vendor_suggestion", "severity": "advisory", "record_id": match_result["matched_record_id"], "reason": "medium_tier_match_downgraded_for_control_context", } ) skipped_steps.extend( [ {"step": "vendor_tax_validation", "skip_reason": "vendor_unresolved"}, {"step": "vendor_bank_validation", "skip_reason": "vendor_unresolved"}, {"step": "vendor_status_validation", "skip_reason": "vendor_unresolved"}, ] ) else: resolved_vendor_id = match_result["matched_record_id"] risk_flags.append( { "code": "low_confidence_vendor_resolution", "severity": "medium", "record_id": resolved_vendor_id, "score_tier": score_tier, } ) elif decision == "suggestion": advisory_flags.append( { "code": "vendor_suggestion", "severity": "advisory", "record_id": match_result["runner_up_record_id"], "reason": "possible_vendor_match", } ) skipped_steps.extend( [ {"step": "sender_field_override", "skip_reason": "vendor_unresolved"}, {"step": "vendor_tax_validation", "skip_reason": "vendor_unresolved"}, {"step": "vendor_bank_validation", "skip_reason": "vendor_unresolved"}, {"step": "vendor_status_validation", "skip_reason": "vendor_unresolved"}, ] ) else: risk_flags.append( { "code": "unknown_vendor", "severity": "high", "reason": "no_vendor_match_found", } ) skipped_steps.extend( [ {"step": "sender_field_override", "skip_reason": "vendor_unresolved"}, {"step": "vendor_tax_validation", "skip_reason": "vendor_unresolved"}, {"step": "vendor_bank_validation", "skip_reason": "vendor_unresolved"}, {"step": "vendor_status_validation", "skip_reason": "vendor_unresolved"}, ] ) blocked_vendor = None if match_result["decision"] in ("matched", "suggestion"): if match_result.get("matched_vendor") and match_result["matched_vendor"].vendor_status == "blocked": blocked_vendor = match_result["matched_vendor"] if match_result.get("runner_up_vendor") and match_result["runner_up_vendor"].vendor_status == "blocked": blocked_vendor = match_result["runner_up_vendor"] if blocked_vendor: risk_flags.append( { "code": "vendor_blocked", "severity": "high", "record_id": blocked_vendor.id, "reason": "matched_or_suggested_vendor_is_blocked", } ) if resolved_vendor_id: resolved_vendor = match_result["matched_vendor"] resolved_sender_name = ( getattr(resolved_vendor, "legal_name", None) or getattr(resolved_vendor, "vendor_name", None) ) billing_address = getattr(resolved_vendor, "billing_address", None) if billing_address is not None: resolved_sender_addr = billing_address audit_row = { "document_id": document.id, "query_name_hash_prefix_8": hash_prefix(query_name, length=8), "algorithm_version": algorithm_version, "decision": match_result["decision"], "matched_record_id": match_result["matched_record_id"], "runner_up_record_id": match_result["runner_up_record_id"], "score_tier": match_result["score_tier"], "score": str(match_result["score"]), "trace_id": match_result["trace_id"], "called_at": datetime.now(timezone.utc), "actor_id": actor_id, } return { "resolved_vendor_id": resolved_vendor_id, "resolved_sender_name": resolved_sender_name, "resolved_sender_addr": resolved_sender_addr, "risk_flags": risk_flags, "advisory_flags": advisory_flags, "skipped_steps": skipped_steps, "audit_row": audit_row, "match_result": { "decision": match_result["decision"], "matched_record_id": resolved_vendor_id, "runner_up_record_id": match_result["runner_up_record_id"], "score": match_result["score"], "score_tier": match_result["score_tier"], "algorithm_version": match_result["algorithm_version"], "trace_id": match_result["trace_id"], }, }