Périmètre QC : garde-fous snowball-ig + purge 4 605 fiches hors-Québec
La boule de neige IG (profils reliés) ramenait les vedettes mondiales (cristiano, selenagomez, bellahadid…) et _find_existing exigeait une confiance forte des deux côtés, créant des doublons orphelins. Correctifs : - snowball : filtre is_quebec obligatoire + blocklist organismes/marques/ médias/partis QC (villes, tourisme, ministères… ne sont pas des créateurs) - _QC_MARKERS : « queb » nu matchait « QUEBra » (pt) et « jacQUEBrazil » → « quebec » complet ou « queb » en fin de mot (vague 4) - db : rapprochement par compte identique (plateforme+handle) sans condition de confiance — fini les secondes fiches qui volaient le compte (691 cas) - purge base : 4 439 snowball hors-QC, 95 organismes QC non-créateurs, 1 182 orphelines/doublons → 12 445 créateurs propres - tests/test_perimetre_qc.py : régressions vedettes, organismes, faux positifs de marqueur, anti-doublons Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
4 changed files +168 −24
modified
creaka/connectors/snowball.py
+42 −10
@@ -3,10 +3,11 @@ | ||
| 3 | 3 | # File: creaka/connectors/snowball.py |
| 4 | 4 | # Desc: Connecteur DÉCOUVERTE par boule de neige — exploite les profils |
| 5 | 5 | # reliés (`related_profiles`) captés par l'enrichissement Instagram |
| 6 | −# (instagram-apify) pour créer de nouvelles fiches. C'est le levier | |
| 7 | −# d'échelle : chaque profil IG suggère ~10-20 comptes similaires → | |
| 8 | −# parcours en largeur qui fait croître le bassin de dizaines de milliers | |
| 9 | −# au fil des vagues quotidiennes. Aucune requête réseau (lit la base). | |
| 6 | +# (instagram-apify) pour créer de nouvelles fiches. FILTRE QC | |
| 7 | +# OBLIGATOIRE (marqueur québécois dans le nom/handle) : sans lui, la | |
| 8 | +# proximité algorithmique d'Instagram ramène les vedettes mondiales | |
| 9 | +# (constaté 2026-08-20 : cristiano, selenagomez, ferrari, nasa…). | |
| 10 | +# Aucune requête réseau (lit la base). | |
| 10 | 11 | # ============================================================================== |
| 11 | 12 | """Découverte par boule de neige sur les profils reliés Instagram. |
| 12 | 13 | |
@@ -14,15 +15,39 @@ Déclaré `enrichment` pour recevoir la liste des créateurs existants, mais il | ||
| 14 | 15 | RETOURNE de NOUVELLES fiches (le pipeline sync_source les ajoute par uid). On |
| 15 | 16 | lit `account.metrics["related_profiles"]` (posé par instagram-apify), on écarte |
| 16 | 17 | les handles déjà connus, et on crée une graine par nouveau compte suggéré. |
| 17 | −Signal `mention` (0.60) : proximité algorithmique, à confirmer par | |
| 18 | −l'enrichissement — sous le seuil de publication tant que non vérifié (§12.1). | |
| 18 | + | |
| 19 | +GARDE-FOU QC (2026-08-20) : les suggestions d'Instagram débordent largement du | |
| 20 | +Québec (stars mondiales, marques, comptes US/BR) — 3 767 fiches hors-périmètre | |
| 21 | +ajoutées en un passage avant ce filtre. On n'émet donc QUE si le nom public ou | |
| 22 | +le handle porte un marqueur québécois (`is_quebec`). Périmètre (§1-2) : Créa-Ka | |
| 23 | +n'agrège que des créateurs QUÉBÉCOIS — mieux vaut manquer un profil QC sans | |
| 24 | +marqueur que d'inonder l'annuaire de vedettes américaines (§3 travail de | |
| 25 | +moine). Signal `mention` (0.60) : à confirmer par l'enrichissement (§12.1). | |
| 19 | 26 | """ |
| 20 | 27 | from __future__ import annotations |
| 21 | 28 | |
| 29 | +import re | |
| 30 | + | |
| 22 | 31 | from ..identity import account |
| 23 | −from ..normalize import canonical_url | |
| 32 | +from ..normalize import canonical_url, strip_accents | |
| 24 | 33 | from ..schema import Creator |
| 25 | 34 | from .base import BaseConnector |
| 35 | +from .youtube_recherche import is_quebec | |
| 36 | + | |
| 37 | +# Comptes QC qui ne sont PAS des créateurs (revue manuelle du 2026-08-20 : | |
| 38 | +# 95 des 101 survivantes du filtre QC étaient des villes, offices de tourisme, | |
| 39 | +# ministères, partis, commerces, festivals, médias) — on les bloque à la | |
| 40 | +# source pour ne pas re-polluer l'annuaire à chaque vague. | |
| 41 | +_NON_CREATEUR = re.compile( | |
| 42 | + r"ville|tourisme|musee|festival|fondation|ymca|universite|uqam|" | |
| 43 | + r"umontreal|ulaval|cegep|bibliothe|librairie|boutique|clinique|" | |
| 44 | + r"auberge|hotel|ministere|gouv|transport|elections|loto|\bparti\b|" | |
| 45 | + r"liberal|solidaire|conservateur|justepourrire|telequebec|magazine|" | |
| 46 | + r"societe|association|federation") | |
| 47 | + | |
| 48 | + | |
| 49 | +def _est_organisation(text: str) -> bool: | |
| 50 | + return bool(_NON_CREATEUR.search(strip_accents(text or "").lower())) | |
| 26 | 51 | |
| 27 | 52 | |
| 28 | 53 | class SnowballConnector(BaseConnector): |
@@ -52,6 +77,15 @@ class SnowballConnector(BaseConnector): | ||
| 52 | 77 | continue |
| 53 | 78 | if handle in known or handle in emitted: |
| 54 | 79 | continue |
| 80 | + name = (rel.get("full_name") if isinstance(rel, dict) | |
| 81 | + else None) or "" | |
| 82 | + # périmètre QC obligatoire : marqueur québécois dans le nom | |
| 83 | + # public ou le handle, sinon on n'émet PAS (§1-2) ; et on | |
| 84 | + # écarte les organismes/marques/médias QC (pas des créateurs) | |
| 85 | + if not is_quebec(f"{name} {handle}"): | |
| 86 | + continue | |
| 87 | + if _est_organisation(f"{name} {handle}"): | |
| 88 | + continue | |
| 55 | 89 | emitted.add(handle) |
| 56 | 90 | try: |
| 57 | 91 | acc = account("instagram", handle, "mention", |
@@ -61,12 +95,10 @@ class SnowballConnector(BaseConnector): | ||
| 61 | 95 | else None)).finalize() |
| 62 | 96 | except Exception: |
| 63 | 97 | continue |
| 64 | − name = (rel.get("full_name") if isinstance(rel, dict) | |
| 65 | − else None) or handle | |
| 66 | 98 | new.append(Creator( |
| 67 | 99 | source=self.source_id, |
| 68 | 100 | external_id=f"instagram:{handle}", |
| 69 | − display_name=name, | |
| 101 | + display_name=name or handle, | |
| 70 | 102 | platforms=[acc], |
| 71 | 103 | notes="découvert par boule de neige (profils reliés IG)", |
| 72 | 104 | )) |
modified
creaka/connectors/youtube_recherche.py
+6 −2
@@ -35,9 +35,13 @@ _VER_RE = re.compile(r'"INNERTUBE_CONTEXT_CLIENT_VERSION":"([^"]+)"') | ||
| 35 | 35 | |
| 36 | 36 | # marqueurs québécois (comparés SANS accents, en minuscules) |
| 37 | 37 | # ⚠ frontières de mot sur les toponymes courts/piégeux : « levis » matchait |
| 38 | −# dans « teLEVISion », « estrie » dans « tapESTRIEs » (corrigé vague 3) | |
| 38 | +# dans « teLEVISion », « estrie » dans « tapESTRIEs » (corrigé vague 3) ; | |
| 39 | +# « queb » nu matchait « QUEBra » (portugais, tatiQUEBrabarraco) et | |
| 40 | +# « jacQUEBrazil » (corrigé vague 4) — « quebec » complet n'importe où, ou | |
| 41 | +# « queb » en fin de mot (onlyqueb, grainsduquebec restent couverts) | |
| 39 | 42 | _QC_MARKERS = re.compile( |
| 40 | − r"queb|montreal|\bmtl\b|saguenay|gaspesie|mauricie|\bestrie\b|outaouais|" | |
| 43 | + r"quebec|queb(?![a-z])|montreal|\bmtl\b|saguenay|gaspesie|mauricie|" | |
| 44 | + r"\bestrie\b|outaouais|" | |
| 41 | 45 | r"abitibi|\blevis\b|sherbrooke|trois-rivieres|gatineau|\blaval\b|longueuil|" |
| 42 | 46 | r"rimouski|chicoutimi|drummondville|\bgranby\b|\bbeauce\b|charlevoix|" |
| 43 | 47 | r"lanaudiere|laurentides|monteregie|temiscamingue|\bjoliette\b|" |
modified
creaka/db.py
+22 −12
@@ -27,8 +27,8 @@ from datetime import datetime, timedelta, timezone | ||
| 27 | 27 | from pathlib import Path |
| 28 | 28 | |
| 29 | 29 | from . import ethics |
| 30 | −from .dedup import merge_creators, should_merge | |
| 31 | −from .identity import STRONG_THRESHOLD, needs_review | |
| 30 | +from .dedup import merge_creators | |
| 31 | +from .identity import needs_review | |
| 32 | 32 | from .normalize import slugify |
| 33 | 33 | from .schema import Creator, PlatformAccount, now_iso |
| 34 | 34 | |
@@ -183,14 +183,22 @@ def _write_creator(con: sqlite3.Connection, cid: str, cr: Creator, | ||
| 183 | 183 | |
| 184 | 184 | |
| 185 | 185 | def _find_existing(con: sqlite3.Connection, cr: Creator) -> str | None: |
| 186 | − """Fiche existante partageant un compte FORT avec la fiche entrante (§12.2).""" | |
| 186 | + """Fiche existante possédant déjà un des comptes entrants. | |
| 187 | + | |
| 188 | + Même plateforme + même handle = MÊME compte : la table accounts n'admet | |
| 189 | + qu'un propriétaire par compte (UNIQUE platform,handle). Le rapprochement se | |
| 190 | + fait donc sur l'identité exacte du compte, sans condition de confiance — | |
| 191 | + exiger `confidence >= STRONG_THRESHOLD` des deux côtés (version d'avant le | |
| 192 | + 2026-08-20) faisait rater les fiches à signal faible (mention 0.60, ex. | |
| 193 | + snowball-ig) : sync_source créait alors une SECONDE fiche et l'upsert des | |
| 194 | + comptes lui réassignait le compte, laissant l'originale orpheline | |
| 195 | + (691 doublons constatés). Ce n'est PAS une fusion sur le nom (§12.2) : | |
| 196 | + l'égalité stricte plateforme+handle identifie le compte lui-même. | |
| 197 | + """ | |
| 187 | 198 | for acc in cr.platforms: |
| 188 | − if (acc.confidence or 0) < STRONG_THRESHOLD: | |
| 189 | − continue | |
| 190 | 199 | row = con.execute( |
| 191 | − "SELECT creator_id FROM accounts WHERE platform=? AND handle=? " | |
| 192 | − "AND confidence>=?", (acc.platform, acc.handle, STRONG_THRESHOLD) | |
| 193 | − ).fetchone() | |
| 200 | + "SELECT creator_id FROM accounts WHERE platform=? AND handle=?", | |
| 201 | + (acc.platform, acc.handle)).fetchone() | |
| 194 | 202 | if row: |
| 195 | 203 | return row["creator_id"] |
| 196 | 204 | return None |
@@ -226,10 +234,12 @@ def sync_source(con: sqlite3.Connection, source_id: str, | ||
| 226 | 234 | current = creator_from_row(row) |
| 227 | 235 | if current.status == "opted_out": |
| 228 | 236 | continue # masqué : on ne ré-agrège pas |
| 229 | − if should_merge(current, cr): | |
| 230 | − merged = merge_creators(current, cr) | |
| 231 | − _write_creator(con, existing_id, merged, row["first_seen"], ts) | |
| 232 | − updated += 1 | |
| 237 | + # _find_existing garantit un compte IDENTIQUE (plateforme+handle) | |
| 238 | + # partagé : c'est le même compte, la fusion est toujours sûre — | |
| 239 | + # pas une fusion sur le nom (§12.2) | |
| 240 | + merged = merge_creators(current, cr) | |
| 241 | + _write_creator(con, existing_id, merged, row["first_seen"], ts) | |
| 242 | + updated += 1 | |
| 233 | 243 | continue |
| 234 | 244 | cid = _unique_id(con, slugify(cr.display_name)) |
| 235 | 245 | _write_creator(con, cid, cr, ts, ts) |
added
tests/test_perimetre_qc.py
+98 −0
@@ -0,0 +1,98 @@ | ||
| 1 | +# ============================================================================== | |
| 2 | +# Author: Simon-Pierre Boucher <contact@spboucher.ai> | |
| 3 | +# File: tests/test_perimetre_qc.py | |
| 4 | +# Desc: Périmètre québécois (§1-2) — garde QC de la boule de neige (les | |
| 5 | +# profils reliés IG ramènent des vedettes mondiales sans filtre) et | |
| 6 | +# rapprochement par compte identique dans sync_source (anti-doublons) | |
| 7 | +# ============================================================================== | |
| 8 | +"""Régressions du 2026-08-20 : vedettes américaines + fiches en double. | |
| 9 | + | |
| 10 | +1. snowball-ig émettait TOUS les profils reliés Instagram (cristiano, | |
| 11 | + selenagomez, ferrari, nasa… — 3 767 fiches hors-périmètre en un passage). | |
| 12 | +2. `_find_existing` exigeait une confiance forte des DEUX côtés : une fiche à | |
| 13 | + signal `mention` (0.60) n'était jamais retrouvée → sync_source créait une | |
| 14 | + seconde fiche et l'upsert des comptes lui réassignait le compte, laissant | |
| 15 | + l'originale orpheline (691 doublons). | |
| 16 | +""" | |
| 17 | +from creaka import db | |
| 18 | +from creaka.connectors.snowball import SnowballConnector | |
| 19 | +from creaka.identity import account | |
| 20 | +from creaka.schema import Creator | |
| 21 | + | |
| 22 | + | |
| 23 | +def _creator(name, accounts, **kw): | |
| 24 | + return Creator(source="test", external_id="", display_name=name, | |
| 25 | + platforms=accounts, **kw).finalize() | |
| 26 | + | |
| 27 | + | |
| 28 | +def _source_avec_relies(related): | |
| 29 | + acc = account("instagram", "createur.qc", "profil_source").finalize() | |
| 30 | + acc.metrics["related_profiles"] = related | |
| 31 | + return _creator("Créateur QC", [acc]) | |
| 32 | + | |
| 33 | + | |
| 34 | +def test_snowball_ecarte_les_vedettes_mondiales(): | |
| 35 | + src = _source_avec_relies([ | |
| 36 | + {"username": "cristiano", "full_name": "Cristiano Ronaldo"}, | |
| 37 | + {"username": "selenagomez", "full_name": "Selena Gomez"}, | |
| 38 | + {"username": "ferrari", "full_name": "Ferrari"}, | |
| 39 | + {"username": "nasa", "full_name": "NASA"}, | |
| 40 | + ]) | |
| 41 | + assert SnowballConnector().enrich([src]) == [] | |
| 42 | + | |
| 43 | + | |
| 44 | +def test_marqueur_queb_exige_fin_de_mot(): | |
| 45 | + # faux positifs constatés le 2026-08-20 : « queb » nu matchait dans | |
| 46 | + # « quebra » (portugais) et « jacquebrazil » | |
| 47 | + from creaka.connectors.youtube_recherche import is_quebec | |
| 48 | + assert not is_quebec("tatiquebrabarraco Tati Quebra Barraco") | |
| 49 | + assert not is_quebec("jacquebrazil") | |
| 50 | + assert is_quebec("onlyqueb") # queb en fin de mot | |
| 51 | + assert is_quebec("grainsduquebec") # quebec collé dans un handle | |
| 52 | + assert is_quebec("Créatrice québécoise à Montréal") | |
| 53 | + | |
| 54 | + | |
| 55 | +def test_snowball_ecarte_les_organismes_qc(): | |
| 56 | + # revue du 2026-08-20 : villes, tourisme, ministères, partis, festivals… | |
| 57 | + # portent des marqueurs QC mais ne sont pas des créateurs | |
| 58 | + src = _source_avec_relies([ | |
| 59 | + {"username": "villequebec", "full_name": "Ville de Québec"}, | |
| 60 | + {"username": "tourismecharlevoix", "full_name": "Tourisme Charlevoix"}, | |
| 61 | + {"username": "transports_quebec", "full_name": "Transports Québec"}, | |
| 62 | + {"username": "justepourrirequebec", "full_name": "Juste pour rire"}, | |
| 63 | + {"username": "humoriste.mtl", "full_name": "Humoriste MTL"}, | |
| 64 | + ]) | |
| 65 | + handles = {c.platforms[0].handle | |
| 66 | + for c in SnowballConnector().enrich([src])} | |
| 67 | + assert handles == {"humoriste.mtl"} | |
| 68 | + | |
| 69 | + | |
| 70 | +def test_snowball_garde_les_profils_marques_qc(): | |
| 71 | + src = _source_avec_relies([ | |
| 72 | + {"username": "humoriste.mtl", "full_name": "Humoriste MTL"}, | |
| 73 | + {"username": "chroniques.quebec", "full_name": "Chroniques"}, | |
| 74 | + {"username": "katyperry", "full_name": "Katy Perry"}, | |
| 75 | + ]) | |
| 76 | + handles = {c.platforms[0].handle | |
| 77 | + for c in SnowballConnector().enrich([src])} | |
| 78 | + assert handles == {"humoriste.mtl", "chroniques.quebec"} | |
| 79 | + | |
| 80 | + | |
| 81 | +def test_compte_identique_fusionne_jamais_de_doublon(tmp_path): | |
| 82 | + con = db.connect(tmp_path / "t.db") | |
| 83 | + faible = _creator("misslavoie", | |
| 84 | + [account("instagram", "misslavoie", "mention")]) | |
| 85 | + s1 = db.sync_source(con, "snowball-ig", [faible]) | |
| 86 | + assert s1["added"] == 1 | |
| 87 | + # ré-observation du MÊME compte avec signal fort (ex. enrichissement) : | |
| 88 | + # la fiche existante est réutilisée, jamais de seconde fiche | |
| 89 | + fort = _creator("Jade Lavoie", | |
| 90 | + [account("instagram", "misslavoie", "profil_source")]) | |
| 91 | + s2 = db.sync_source(con, "autre-source", [fort]) | |
| 92 | + assert s2["added"] == 0 and s2["updated"] == 1 | |
| 93 | + assert con.execute("SELECT count(*) FROM creators").fetchone()[0] == 1 | |
| 94 | + # aucun orphelin : chaque fiche possède encore son compte | |
| 95 | + orphans = con.execute( | |
| 96 | + "SELECT count(*) FROM creators WHERE id NOT IN " | |
| 97 | + "(SELECT creator_id FROM accounts)").fetchone()[0] | |
| 98 | + assert orphans == 0 | |
| 99 | ||