SPB Git forge

spb/crea-ka

Public

Créa·Ka — annuaire public cross-plateforme des créateurs de contenu québécois (crea-ka.com)

52commits 1branches 0releases
11.3 MBsize
maindefault branch
19 days agolast push
Python 73.6% HTML 13.2% TypeScript 6% JavaScript 4.5% CSS 1.7% Dockerfile 0.6%
4.6 KB · 105 lines python
Raw Blame History
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