SPB Git

spb/crea-ka

Public

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

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>
Simon-Pierre Boucher committed (Aug 20, 2026) parent 9b0a685 Browse files

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
Menu

Theme