# ============================================================================== # Author: Simon-Pierre Boucher # File: creaka/dedup.py # Desc: Déduplication des créateurs — fusion prudente multi-sources # (CLAUDE.md §12.2 : jamais de fusion sur le seul nom — homonymes !) # ============================================================================== """Déduplication : un créateur = UNE fiche canonique. Règle de fusion (§12.2) : deux fiches ne fusionnent QUE si elles partagent au moins un compte (plateforme+handle) à confiance élevée des deux côtés. Le nom seul ne suffit JAMAIS — une erreur de fusion mélange deux personnes réelles. """ from __future__ import annotations from .identity import STRONG_THRESHOLD, merge_accounts from .normalize import strip_accents from .schema import Creator def name_key(name: str) -> str: """Clé de comparaison de nom (accents/casse neutralisés) — INDICATIVE seulement.""" return " ".join(strip_accents(name or "").lower().split()) def shared_strong_account(a: Creator, b: Creator) -> bool: """Vrai si a et b partagent ≥1 compte à confiance ≥ STRONG_THRESHOLD des 2 côtés.""" strong_a = {acc.key for acc in a.platforms if (acc.confidence or 0) >= STRONG_THRESHOLD} for acc in b.platforms: if acc.key in strong_a and (acc.confidence or 0) >= STRONG_THRESHOLD: return True return False def should_merge(a: Creator, b: Creator) -> bool: """Décision de fusion : compte fort partagé requis. Homonymes ≠ fusion.""" return shared_strong_account(a, b) def merge_creators(canon: Creator, other: Creator) -> Creator: """Fusionne `other` dans `canon` : union des comptes, meilleure valeur par champ. On ne perd aucun lien vérifié ; les champs descriptifs prennent la valeur la plus riche (bio la plus longue, champ non nul). """ canon.platforms = merge_accounts(canon.platforms, other.platforms) canon.source_ids = sorted(set(canon.source_ids) | set(other.source_ids)) # nom d'affichage : un VRAI nom public remplace un simple pseudo-handle # (ex. « misslavoie » → « Jade Lavoie ») ; jamais l'inverse handles = {a.handle for a in canon.platforms} if (other.display_name and other.display_name != canon.display_name and canon.display_name.lower() in handles and other.display_name.lower() not in handles): canon.display_name = other.display_name if len(other.bio or "") > len(canon.bio or ""): canon.bio = other.bio if len(other.notes or "") > len(canon.notes or ""): canon.notes = other.notes canon.niches = sorted(set(canon.niches) | set(other.niches)) or ["autre"] canon.languages = sorted(set(canon.languages) | set(other.languages)) or ["fr"] for field_ in ("region", "city", "legal_name", "business_contact", "agency", "link_in_bio_url", "avatar_url"): if getattr(canon, field_) in (None, "") and getattr(other, field_): setattr(canon, field_, getattr(other, field_)) # prudence maximale : si UNE source signale un mineur, le régime s'applique canon.is_minor = canon.is_minor or other.is_minor # opt-out prime sur tout (§15) ; sinon l'observation la plus FRAÎCHE # (`other`) fixe le statut — un balado dormant passe inactive, une fiche # archivée (§13) redevient active dès qu'une source la revoit if "opted_out" in (canon.status, other.status): canon.status = "opted_out" elif other.status: canon.status = other.status # recalculer les agrégats (tier, portée, plateforme principale) canon.audience_tier = "" canon.primary_platform = "" canon.total_reach = None return canon.finalize() def dedupe(creators: list[Creator]) -> list[Creator]: """Déduplique une liste de fiches (intra-lot). Utilisé par l'ingestion. Index par clé de compte : deux fiches partageant un compte fort fusionnent. """ canon_by_acc: dict[str, Creator] = {} result: list[Creator] = [] for cr in creators: target = None for acc in cr.platforms: if (acc.confidence or 0) >= STRONG_THRESHOLD and acc.key in canon_by_acc: cand = canon_by_acc[acc.key] if should_merge(cand, cr): target = cand break if target is None: result.append(cr) target = cr else: merge_creators(target, cr) for acc in target.platforms: if (acc.confidence or 0) >= STRONG_THRESHOLD: canon_by_acc[acc.key] = target return result