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 | # File: creaka/connectors/snowball.py | 3 | # File: creaka/connectors/snowball.py | ||
| 4 | # Desc: Connecteur DÉCOUVERTE par boule de neige — exploite les profils | 4 | # Desc: Connecteur DÉCOUVERTE par boule de neige — exploite les profils | ||
| 5 | # reliés (`related_profiles`) captés par l'enrichissement Instagram | 5 | # reliés (`related_profiles`) captés par l'enrichissement Instagram | ||
| 6 | −# (instagram-apify) pour créer de nouvelles fiches. C'est le levier | 6 | +# (instagram-apify) pour créer de nouvelles fiches. FILTRE QC | ||
| 7 | −# d'échelle : chaque profil IG suggère ~10-20 comptes similaires → | 7 | +# OBLIGATOIRE (marqueur québécois dans le nom/handle) : sans lui, la | ||
| 8 | −# parcours en largeur qui fait croître le bassin de dizaines de milliers | 8 | +# proximité algorithmique d'Instagram ramène les vedettes mondiales | ||
| 9 | −# au fil des vagues quotidiennes. Aucune requête réseau (lit la base). | 9 | +# (constaté 2026-08-20 : cristiano, selenagomez, ferrari, nasa…). | ||
| 10 | +# Aucune requête réseau (lit la base). | ||||
| 10 | # ============================================================================== | 11 | # ============================================================================== | ||
| 11 | """Découverte par boule de neige sur les profils reliés Instagram. | 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 | RETOURNE de NOUVELLES fiches (le pipeline sync_source les ajoute par uid). On | 15 | RETOURNE de NOUVELLES fiches (le pipeline sync_source les ajoute par uid). On | ||
| 15 | lit `account.metrics["related_profiles"]` (posé par instagram-apify), on écarte | 16 | lit `account.metrics["related_profiles"]` (posé par instagram-apify), on écarte | ||
| 16 | les handles déjà connus, et on crée une graine par nouveau compte suggéré. | 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 | + | ||
| 18 | −l'enrichissement — sous le seuil de publication tant que non vérifié (§12.1). | 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 | from __future__ import annotations | 27 | from __future__ import annotations | ||
| 21 | 28 | ||||
| 29 | +import re | ||||
| 30 | + | ||||
| 22 | from ..identity import account | 31 | from ..identity import account | ||
| 23 | −from ..normalize import canonical_url | 32 | +from ..normalize import canonical_url, strip_accents | ||
| 24 | from ..schema import Creator | 33 | from ..schema import Creator | ||
| 25 | from .base import BaseConnector | 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 | class SnowballConnector(BaseConnector): | 53 | class SnowballConnector(BaseConnector): | ||
@@ -52,6 +77,15 @@ class SnowballConnector(BaseConnector): | |||||
| 52 | continue | 77 | continue | ||
| 53 | if handle in known or handle in emitted: | 78 | if handle in known or handle in emitted: | ||
| 54 | continue | 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 | emitted.add(handle) | 89 | emitted.add(handle) | ||
| 56 | try: | 90 | try: | ||
| 57 | acc = account("instagram", handle, "mention", | 91 | acc = account("instagram", handle, "mention", | ||
@@ -61,12 +95,10 @@ class SnowballConnector(BaseConnector): | |||||
| 61 | else None)).finalize() | 95 | else None)).finalize() | ||
| 62 | except Exception: | 96 | except Exception: | ||
| 63 | continue | 97 | continue | ||
| 64 | − name = (rel.get("full_name") if isinstance(rel, dict) | ||||
| 65 | − else None) or handle | ||||
| 66 | new.append(Creator( | 98 | new.append(Creator( | ||
| 67 | source=self.source_id, | 99 | source=self.source_id, | ||
| 68 | external_id=f"instagram:{handle}", | 100 | external_id=f"instagram:{handle}", | ||
| 69 | − display_name=name, | 101 | + display_name=name or handle, | ||
| 70 | platforms=[acc], | 102 | platforms=[acc], | ||
| 71 | notes="découvert par boule de neige (profils reliés IG)", | 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 | # marqueurs québécois (comparés SANS accents, en minuscules) | 36 | # marqueurs québécois (comparés SANS accents, en minuscules) | ||
| 37 | # ⚠ frontières de mot sur les toponymes courts/piégeux : « levis » matchait | 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 | _QC_MARKERS = re.compile( | 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 | r"abitibi|\blevis\b|sherbrooke|trois-rivieres|gatineau|\blaval\b|longueuil|" | 45 | r"abitibi|\blevis\b|sherbrooke|trois-rivieres|gatineau|\blaval\b|longueuil|" | ||
| 42 | r"rimouski|chicoutimi|drummondville|\bgranby\b|\bbeauce\b|charlevoix|" | 46 | r"rimouski|chicoutimi|drummondville|\bgranby\b|\bbeauce\b|charlevoix|" | ||
| 43 | r"lanaudiere|laurentides|monteregie|temiscamingue|\bjoliette\b|" | 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 | from pathlib import Path | 27 | from pathlib import Path | ||
| 28 | 28 | ||||
| 29 | from . import ethics | 29 | from . import ethics | ||
| 30 | −from .dedup import merge_creators, should_merge | 30 | +from .dedup import merge_creators | ||
| 31 | −from .identity import STRONG_THRESHOLD, needs_review | 31 | +from .identity import needs_review | ||
| 32 | from .normalize import slugify | 32 | from .normalize import slugify | ||
| 33 | from .schema import Creator, PlatformAccount, now_iso | 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 | def _find_existing(con: sqlite3.Connection, cr: Creator) -> str | None: | 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 | for acc in cr.platforms: | 198 | for acc in cr.platforms: | ||
| 188 | − if (acc.confidence or 0) < STRONG_THRESHOLD: | ||||
| 189 | − continue | ||||
| 190 | row = con.execute( | 199 | row = con.execute( | ||
| 191 | − "SELECT creator_id FROM accounts WHERE platform=? AND handle=? " | 200 | + "SELECT creator_id FROM accounts WHERE platform=? AND handle=?", | ||
| 192 | − "AND confidence>=?", (acc.platform, acc.handle, STRONG_THRESHOLD) | 201 | + (acc.platform, acc.handle)).fetchone() | ||
| 193 | − ).fetchone() | ||||
| 194 | if row: | 202 | if row: | ||
| 195 | return row["creator_id"] | 203 | return row["creator_id"] | ||
| 196 | return None | 204 | return None | ||
@@ -226,10 +234,12 @@ def sync_source(con: sqlite3.Connection, source_id: str, | |||||
| 226 | current = creator_from_row(row) | 234 | current = creator_from_row(row) | ||
| 227 | if current.status == "opted_out": | 235 | if current.status == "opted_out": | ||
| 228 | continue # masqué : on ne ré-agrège pas | 236 | continue # masqué : on ne ré-agrège pas | ||
| 229 | − if should_merge(current, cr): | 237 | + # _find_existing garantit un compte IDENTIQUE (plateforme+handle) | ||
| 230 | − merged = merge_creators(current, cr) | 238 | + # partagé : c'est le même compte, la fusion est toujours sûre — | ||
| 231 | − _write_creator(con, existing_id, merged, row["first_seen"], ts) | 239 | + # pas une fusion sur le nom (§12.2) | ||
| 232 | − updated += 1 | 240 | + merged = merge_creators(current, cr) | ||
| 241 | + _write_creator(con, existing_id, merged, row["first_seen"], ts) | ||||
| 242 | + updated += 1 | ||||
| 233 | continue | 243 | continue | ||
| 234 | cid = _unique_id(con, slugify(cr.display_name)) | 244 | cid = _unique_id(con, slugify(cr.display_name)) | ||
| 235 | _write_creator(con, cid, cr, ts, ts) | 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 | |||||