Créa·Ka — annuaire public cross-plateforme des créateurs de contenu québécois (crea-ka.com)
Python 73.6%
HTML 13.2%
TypeScript 6%
JavaScript 4.5%
CSS 1.7%
Dockerfile 0.6%
1# ==============================================================================2# Author: Simon-Pierre Boucher <contact@spboucher.ai>3# File: creaka/dedup.py4# Desc: Déduplication des créateurs — fusion prudente multi-sources5# (CLAUDE.md §12.2 : jamais de fusion sur le seul nom — homonymes !)6# ==============================================================================7"""Déduplication : un créateur = UNE fiche canonique.89Règle de fusion (§12.2) : deux fiches ne fusionnent QUE si elles partagent au10moins un compte (plateforme+handle) à confiance élevée des deux côtés. Le nom11seul ne suffit JAMAIS — une erreur de fusion mélange deux personnes réelles.12"""13from __future__ import annotations1415from .identity import STRONG_THRESHOLD, merge_accounts16from .normalize import strip_accents17from .schema import Creator181920def name_key(name: str) -> str:21 """Clé de comparaison de nom (accents/casse neutralisés) — INDICATIVE seulement."""22 return " ".join(strip_accents(name or "").lower().split())232425def shared_strong_account(a: Creator, b: Creator) -> bool:26 """Vrai si a et b partagent ≥1 compte à confiance ≥ STRONG_THRESHOLD des 2 côtés."""27 strong_a = {acc.key for acc in a.platforms28 if (acc.confidence or 0) >= STRONG_THRESHOLD}29 for acc in b.platforms:30 if acc.key in strong_a and (acc.confidence or 0) >= STRONG_THRESHOLD:31 return True32 return False333435def should_merge(a: Creator, b: Creator) -> bool:36 """Décision de fusion : compte fort partagé requis. Homonymes ≠ fusion."""37 return shared_strong_account(a, b)383940def merge_creators(canon: Creator, other: Creator) -> Creator:41 """Fusionne `other` dans `canon` : union des comptes, meilleure valeur par champ.4243 On ne perd aucun lien vérifié ; les champs descriptifs prennent la valeur44 la plus riche (bio la plus longue, champ non nul).45 """46 canon.platforms = merge_accounts(canon.platforms, other.platforms)47 canon.source_ids = sorted(set(canon.source_ids) | set(other.source_ids))48 # nom d'affichage : un VRAI nom public remplace un simple pseudo-handle49 # (ex. « misslavoie » → « Jade Lavoie ») ; jamais l'inverse50 handles = {a.handle for a in canon.platforms}51 if (other.display_name and other.display_name != canon.display_name52 and canon.display_name.lower() in handles53 and other.display_name.lower() not in handles):54 canon.display_name = other.display_name55 if len(other.bio or "") > len(canon.bio or ""):56 canon.bio = other.bio57 if len(other.notes or "") > len(canon.notes or ""):58 canon.notes = other.notes59 canon.niches = sorted(set(canon.niches) | set(other.niches)) or ["autre"]60 canon.languages = sorted(set(canon.languages) | set(other.languages)) or ["fr"]61 for field_ in ("region", "city", "legal_name", "business_contact",62 "agency", "link_in_bio_url", "avatar_url"):63 if getattr(canon, field_) in (None, "") and getattr(other, field_):64 setattr(canon, field_, getattr(other, field_))65 # prudence maximale : si UNE source signale un mineur, le régime s'applique66 canon.is_minor = canon.is_minor or other.is_minor67 # opt-out prime sur tout (§15) ; sinon l'observation la plus FRAÎCHE68 # (`other`) fixe le statut — un balado dormant passe inactive, une fiche69 # archivée (§13) redevient active dès qu'une source la revoit70 if "opted_out" in (canon.status, other.status):71 canon.status = "opted_out"72 elif other.status:73 canon.status = other.status74 # recalculer les agrégats (tier, portée, plateforme principale)75 canon.audience_tier = ""76 canon.primary_platform = ""77 canon.total_reach = None78 return canon.finalize()798081def dedupe(creators: list[Creator]) -> list[Creator]:82 """Déduplique une liste de fiches (intra-lot). Utilisé par l'ingestion.8384 Index par clé de compte : deux fiches partageant un compte fort fusionnent.85 """86 canon_by_acc: dict[str, Creator] = {}87 result: list[Creator] = []88 for cr in creators:89 target = None90 for acc in cr.platforms:91 if (acc.confidence or 0) >= STRONG_THRESHOLD and acc.key in canon_by_acc:92 cand = canon_by_acc[acc.key]93 if should_merge(cand, cr):94 target = cand95 break96 if target is None:97 result.append(cr)98 target = cr99 else:100 merge_creators(target, cr)101 for acc in target.platforms:102 if (acc.confidence or 0) >= STRONG_THRESHOLD:103 canon_by_acc[acc.key] = target104 return result105