# seoinsight v2 — Multi-sources sans hiérarchie et cloisonnement par pays à l'import

Date : 2026-09-01
Statut : **validé par l'utilisateur le 2026-09-02** (relecture + test navigateur + débat design du réservoir — voir décisions A5/A6 ajoutées ce jour).
S'appuie sur : module Imports (`2026-08-31-module-imports-design.md`, §1.7, §3.3, §5, §6), data grande échelle (`2026-08-28-data-grande-echelle-design.md` §7), architecture (`2026-08-26-…` §8).
**Amende** la décision n°3 du module Imports (priorités de consolidation par métrique) et le choix documenté dans `api/app/keywords/sources/mangools.py:34-46` (colonne `Location` volontairement ignorée).

## 0. But et périmètre

Deux chantiers indépendants, nés du même constat de terrain (§1) :

- **A — Consolidation multi-sources sans hiérarchie.** Un mot-clé porte les observations de *toutes* ses sources, côte à côte. Plus aucune source n'est élue au détriment d'une autre.
- **B — Cloisonnement par pays à l'import.** Un fichier qui porte un pays ne verse dans une cible que les lignes de *ce* pays ; les autres pays trouvés sont proposés à la création comme nouvelles cibles.

**Dans le périmètre** : fonction SQL de consolidation, contrat `/keywords` (Pydantic + Zod), filtres du réservoir, adaptateurs, `POST /imports/preview`, pipeline worker, rapport d'import.

**Hors périmètre** : le dessin des écrans. L'utilisateur a tranché le 2026-09-01 — « on fera le design UI/UX plus tard, on gère les fonctions pour l'instant ». La spec fixe donc *ce que rend l'api* et *ce que le web reçoit*, pas la mise en page qui l'affiche. Hors périmètre également : la Matrice (module 5), l'hydratation GSC (module 3), toute reprise rétroactive des données déjà en base (§7).

## 1. Constat

Vérifié en base de développement le 2026-09-01, sur la cible `en-US` du site de test.

**A.** Le mot-clé `jewelry manufacturer` porte bien deux observations :

| source | volume | KD | CPC | `fetched_at` |
|---|---|---|---|---|
| `mangools` | 2200 | 14 | 3,05 | 2026-09-01 |
| `semrush_magic` | 720 | 46 | 2,26 | 2026-09-01 |

L'écran n'en montre qu'une, Semrush, et ce n'est pas un défaut d'import : `keyword_metrics_latest` ordonne par `(source = preferred) DESC, fetched_at DESC, <priorité>, source, id DESC`. Les deux imports datant du même jour, `fetched_at` ne départage rien et c'est la priorité codée dans les `CASE` qui tranche — `semrush_magic` vaut 2, `mangools` tombe dans le `ELSE 50`. **Semrush gagne systématiquement dès qu'il a une valeur.** Le libellé affiché (« la valeur la plus récente l'emporte, métrique par métrique ») décrit donc une règle qui n'est vraie que hors égalité de date, c'est-à-dire rarement.

Corollaire mesuré : le filtre `volume_min` applique un double test — une observation brute dans l'intervalle **et** la valeur élue dans l'intervalle (`api/app/keywords/service.py:349-357`). `jewelry manufacturer` est donc **invisible** à « volume ≥ 1000 » alors que Mangools annonce 2200.

**B.** Répartition de la colonne `Location` dans les exports KWFinder de l'utilisateur :

| fichier | pays |
|---|---|
| `kwfinder_ADVMFR_USA_*.csv` (4 fichiers) | 100 % `United States` |
| `kwfinder_Top_Advmfr_export.csv` (226 lignes) | 74 US, 55 GB, 32 AU, 13 CA, 12 SG, 11 HK, 10 DE, 8 NZ, 8 ES, 2 IL, 1 JP |

Importé tel quel sur la cible `en-US`, ce dernier fichier écrirait 152 mots-clés de dix autres pays avec `locale = 'en-US'` — une pollution silencieuse et irréversible sans purge manuelle. La base est aujourd'hui saine : seul un fichier 100 % US est passé.

L'export **Semrush Keyword Magic ne porte aucune colonne pays** (en-tête : `Keyword, Intent, Relevance, Volume, Trend, Keyword Difficulty, CPC (USD), Competitive Density, Number of Results`). Le cloisonnement ne peut donc valoir que pour les sources qui déclarent le pays.

## 2. Décisions actées (utilisateur, 2026-09-01)

| # | Sujet | Décision |
|---|---|---|
| A1 | Hiérarchie de sources | **Supprimée.** « Personne ne prend le dessus sur personne. » Aucune priorité de source nulle part. |
| A2 | Forme de la ligne `/keywords` | **Par métrique → liste de sources.** Chaque métrique devient un tableau, une entrée par source qui a une valeur pour ce champ. |
| A3 | Profondeur | La **dernière observation de chaque source**, pas tout l'historique — celui-ci reste dans `GET /keywords/{id}/metrics` (popover, §1.7 module Imports). |
| A4 | Approche technique | **Remplacer la fonction SQL**, pas la doubler d'une seconde requête : une seule règle, en un seul endroit. |
| B1 | Filtrage par pays | **Inconditionnel** dès qu'un fichier porte un pays. Pas d'option « importer quand même tout ». |
| B2 | Autres pays du fichier | **Proposés à la création** comme nouvelles cibles, à l'étape du wizard. |
| B3 | Langue des cibles créées | **Choisie par l'utilisateur**, pré-remplie avec la langue officielle majoritaire du pays. Jamais héritée de la cible courante ni devinée. |
| B4 | Rétroactivité | **Aucune.** Le filtrage vaut pour les imports à venir ; rien n'est purgé (§7). |
| A5 | Valeur d'affichage (débat design 2026-09-02) | **Demi-élection assumée, à l'affichage seulement** : dans la table du réservoir, chaque cellule métrique montre la valeur de la **première source de l'ordre d'affichage** (la `preferred_source` de l'org en tête) avec **badge de provenance**, les autres observations au **survol** (et en détail dans le Sheet/popover). Aucune valeur élue n'est stockée ni calculée côté données — A1/A2 restent intacts, c'est une règle de rendu. Corollaire : pas de code couleur unique inter-sources sur le KD (les échelles diffèrent par fournisseur) — la couleur éventuelle se calibre par source. |
| A6 | Filtre Pays/Langue sur la page réservoir (débat design 2026-09-02) | **Gardé.** Le cloisonnement par pays reste la loi À L'IMPORT (B1 intact) ; à l'AFFICHAGE, la page réservoir peut agréger les cibles du site et se filtrer par pays/langue (facettes multi-sélection) — le sélecteur de cible actuel devient un cas particulier de ce filtre. |

## 3. Chantier A — consolidation multi-sources

### 3.1 La fonction SQL

`keyword_metrics_latest(preferred_source text)` change de contrat : chaque latéral passe de « la meilleure ligne » à « la dernière ligne **par source** », agrégée en JSON.

```sql
LEFT JOIN LATERAL (
    SELECT jsonb_agg(jsonb_build_object(
               'value', x.volume, 'source', x.source, 'fetched_at', x.fetched_at)
           ORDER BY x.fetched_at DESC, x.source)
    FROM (SELECT DISTINCT ON (m.source) m.source, m.volume, m.fetched_at
          FROM keyword_metrics m
          WHERE m.keyword_id = b.keyword_id AND m.volume IS NOT NULL
          ORDER BY m.source, m.fetched_at DESC) x
) volume_pick ON true
```

Points de conception :

- **Aucun index à ajouter.** Le `DISTINCT ON (m.source) … ORDER BY m.source, m.fetched_at DESC` se sert du btree de `uq_keyword_metrics_keyword_source_fetched (keyword_id, source, fetched_at)`, dont `keyword_id` est la colonne de tête — exactement l'accès que la spec Imports §3.3 avait déjà prévu (« égalités sur keyword_id et source, puis parcours arrière sur fetched_at »).
- **La forme reste inlinable.** Un `SELECT` unique, sans CTE ni `DISTINCT` au niveau supérieur, six `LEFT JOIN LATERAL` : le planificateur continue de pousser le filtre sur `keyword_id` dans chaque latéral, ce qui est ce qui rend la fonction tenable sur une page de 50 mots-clés dans un réservoir de 500 000.
- **`NULL` reste `NULL`.** Une source sans valeur pour un champ n'apparaît pas dans le tableau de ce champ ; un mot-clé sans aucune observation de CPC rend un tableau vide, jamais `null` — le web n'a qu'un cas à traiter.
- **`preferred_source` ne filtre plus, elle ordonne** (§3.4).
- **Les trois `CASE` de priorité disparaissent** (`_PRIORITY_VOLUME_CPC`, `_PRIORITY_KD`, `_PRIORITY_QUALITATIVE`), et avec eux `CONSOLIDATION_SOURCES`, `FALLBACK_SOURCES` et `KNOWN_SOURCES` (`api/app/keywords/models.py:54-76`). Ces trois ensembles n'existaient que pour verrouiller le nommage des adaptateurs **contre les `CASE`** ; sans `CASE`, une source inconnue du SQL n'est plus traitée différemment d'une source connue, et le verrou n'a plus d'objet. Les tests qui les confrontent au corps de la fonction (`tests/test_keyword_metrics_latest.py:272-330`, `tests/test_import_adapters.py:60-82`) tombent avec eux.
- **`last_updated`** reste `max(fetched_at)` toutes sources : inchangé.

`RETURNS TABLE` change de type sur douze colonnes : la migration fait `DROP VIEW` → `DROP FUNCTION` → `CREATE FUNCTION` → `CREATE VIEW`, dans cet ordre, et l'inverse au `downgrade` (§5).

### 3.2 Contrat `/keywords`

`Provenance` cesse d'être une classe de base pour devenir l'entrée d'une liste (`api/app/keywords/schemas.py:47-72`) :

```python
class Observation[T](BaseModel):
    """Ce qu'une source dit d'un champ, à sa date. Jamais élue, jamais arbitrée."""
    value: T
    source: str
    fetched_at: datetime
```

`KeywordRow` (`schemas.py:84-99`) devient :

```python
volume: list[Observation[int]]
kd: list[Observation[float]]
cpc: list[Observation[float]]
intent: list[Observation[str]]
serp_features: list[Observation[Any]]
trend: list[Observation[list[int]]]
```

`source` et `fetched_at` cessent d'être nullables : une entrée n'existe que parce qu'une source a produit une valeur à une date. L'absence se dit par la liste vide, pas par un triplet de `None` — le `{"value": None, "source": None, "fetched_at": None}` que `service.py:510-517` fabrique aujourd'hui disparaît.

Ordre des entrées, contractuel : `fetched_at DESC`, puis nom de source, puis la préférée en tête si elle existe (§3.4). Le web n'a rien à retrier.

**En vue par source** (`?view=mangools`), chaque liste contient zéro ou une entrée. La forme du contrat est donc la même dans les deux vues — une seule branche à écrire côté web, au lieu du couple `_CONSOLIDATED_COLUMNS` / `_SOURCE_COLUMNS` de `service.py:93-147` qui rendait deux formes distinctes.

Côté web, en miroir : `keywordRowSchema` dans `web/lib/keywords.ts`, `MetricCell` (`web/components/keywords/metric-popover.tsx:31-36`) qui passe de un à N, et `cell()` / `toRow()` dans `web/app/(app)/sites/[siteId]/keywords/page.tsx:48-75` qui formatent désormais une liste. `contract-keeper` passe sur ce point avant merge.

### 3.3 Filtres du réservoir

`volume_min`, `volume_max` et `intent` font aujourd'hui deux tests : un `EXISTS` sur les observations brutes, puis un re-test sur la valeur élue (`service.py:344-357`). Le second perd son objet et **disparaît** : le filtre devient « **au moins une source** satisfait la condition ».

C'est ce qui répare le cas mesuré au §1 : `jewelry manufacturer` réapparaît dans « volume ≥ 1000 » parce que Mangools annonce 2200, et l'affichage montrera les deux valeurs — donc pourquoi il est là. `_metric_exists` (`service.py:277-289`) reste inchangé et devient l'unique filtre ; en vue par source, il se restreint déjà à la source demandée, ce qui reste juste.

Conséquence à assumer et à écrire dans l'UI plus tard : un mot-clé peut satisfaire un filtre par une source et pas par une autre. C'est la contrepartie directe de A1 — sans hiérarchie, il n'existe aucune valeur « officielle » sur laquelle filtrer sans en privilégier une.

Les autres filtres (`list_id`, `tag_id`, `source`, `updated_since`, `attach_status`) sont inchangés. `source` reste « le mot-clé a au moins une observation de cette source », ce qui était déjà son sens.

**Le tri n'est pas concerné** : le réservoir n'offre aucun tri utilisateur (l'ordre serveur est `k.id DESC`, ou la pertinence en recherche — `service.py:305-320`), et `DataTable` désactive explicitement le tri en mode serveur (`web/components/data-table/data-table.tsx:198`). Un tri par volume multi-sources poserait la même question que le filtre ; elle ne se pose pas aujourd'hui et cette spec ne l'ouvre pas.

### 3.4 `preferred_source`

La colonne `org_settings.preferred_source` et le `PATCH` qui l'écrit sont **conservés**, avec un sens réduit : elle **ordonne** les entrées d'une liste (la préférée en tête) et n'en masque jamais aucune. Aucune migration, aucun changement de contrat côté écriture, et la validation contre le registre d'adaptateurs (`service.py:1016-1022`) reste telle quelle.

Motif : « personne ne prend le dessus » interdit d'élire une valeur, pas d'ordonner une lecture. Supprimer la colonne coûterait une migration pour reprendre exactement la même le jour où l'utilisateur veut voir Mangools en premier.

Le commentaire de `models.py:369-376` (« elle gagne dès qu'elle a une valeur pour un champ, **même plus ancienne** ») devient faux et doit être réécrit dans le même commit que la fonction SQL.

### 3.5 `/keywords/suggest`

`KeywordSuggestion.volume` est aujourd'hui « le volume **consolidé** — la même valeur que la colonne du réservoir » (`schemas.py:120-133`), lu via `keyword_metrics_latest` (`service.py:598`). Il n'y a plus de valeur consolidée.

Décision : la suggestion rend le **volume maximum toutes sources**, et le champ est documenté comme tel. Une complétion au clavier a besoin d'un repère de taille pour se départager, pas d'une vérité ; empiler deux chiffres dans un menu déroulant le rendrait illisible, et choisir « la plus récente » réintroduirait un arbitrage silencieux au moment même où on le supprime ailleurs. `None` reste normal (mot-clé sans observation).

### 3.6 Ce qui ne change pas

- **La vue par source** (`?view=<nom>`) : elle rend déjà les valeurs brutes d'une seule source, elle reste le moyen d'isoler un fournisseur.
- **Le popover d'historique** (`GET /keywords/{id}/metrics`, `MetricPoint`) : il montre déjà toutes les sources et toutes les dates. Rien à changer côté api ; l'affichage suivra le design UI ultérieur.
- **Le stockage.** `keyword_metrics` empile déjà une observation par `(keyword_id, source, fetched_at)` sans jamais écraser (`models.py:164-176`). **Ni le parseur ni le mapper ne changent au titre du chantier A** : la structure était déjà neutre vis-à-vis des fournisseurs, seule la lecture élisait.

## 4. Chantier B — cloisonnement par pays

### 4.1 Le pays devient une capacité déclarée par l'adaptateur

`_detected_country` (`api/app/imports/service.py:126-145`) devine aujourd'hui à l'aveugle : il balaie les cinq premières lignes (`_COUNTRY_SCAN_ROWS = 5`) à la recherche d'un en-tête parmi `("database", "base de données", "base", "location", "pays", "country")` et n'accepte la valeur que si elle fait déjà deux lettres alphabétiques. D'où le silence sur Mangools, dont `Location` vaut `United States`.

Il est remplacé par une capacité portée par le contrat `Adapter` (`api/app/keywords/sources/base.py`) : quelle colonne porte le pays, et comment en tirer un code ISO 3166-1 alpha-2.

| adaptateur | colonne | forme lue | résolution |
|---|---|---|---|
| `mangools` | `Location` | nom anglais (`United States`, `Hong Kong`) | nom → code ; une **localité** (`New York`) ne résout pas |
| `semrush_positions` | `Database` | code (`us`, `uk`) | direct, via la table d'alias |
| `semrush_magic` | — | — | pas de colonne pays : la cible fait autorité, comme aujourd'hui |
| `generic_csv` | colonne mappable, optionnelle | au choix | même résolution que `mangools` |

**Résolution des noms : `pycountry`** (`sfw uv add pycountry`), pas une liste écrite à la main. Les données ISO officielles y sont maintenues ; une liste de 200 pays recopiée dans le dépôt se périme sans que personne ne s'en aperçoive. Par-dessus, une **table d'alias explicite et courte** dans le module de résolution, pour les divergences connues des exports (`uk` → `GB`, et ce que KWFinder nomme autrement que l'ISO). Un nom non résolu n'est jamais deviné : il est rapporté tel quel (§4.5).

Cette résolution vit dans un module dédié (`api/app/keywords/countries.py`) et non dans chaque adaptateur : trois adaptateurs en dépendent, et c'est exactement le genre d'unité qu'on veut pouvoir tester seule.

### 4.2 Recensement complet au `preview`

`POST /imports/preview` rend aujourd'hui `country: str | None`, lu sur cinq lignes. Il rendra un recensement lu sur **tout** le fichier — un fichier de 226 lignes qui commence par 74 lignes `United States` ne révèle ses dix autres pays qu'après un scan complet.

Le comptage se fait en une passe **DuckDB**, déjà présent dans le pipeline d'import CSV (`api/app/keywords/sources/csvfile.py:170`) et cantonné à lui par la règle projet : un `GROUP BY` sur une seule colonne, négligeable même sur 50 000 lignes.

`PreviewOut` (`api/app/imports/schemas.py:79`) : le champ `country` est remplacé par

```json
"countries": [
  {"raw": "United States",  "code": "US", "rows": 74, "target_id": "01a05698-…"},
  {"raw": "United Kingdom", "code": "GB", "rows": 55, "target_id": null},
  {"raw": "Germany",        "code": "DE", "rows": 10, "target_id": null},
  {"raw": "New York",       "code": null, "rows":  3, "target_id": null}
]
```

`target_id` est renseigné quand le site possède déjà une cible sur ce pays — c'est ce qui distingue « à cocher pour créer » de « déjà là ». Liste vide = le fichier ne porte pas de pays, et le wizard se comporte comme aujourd'hui.

L'avertissement `country_mismatch` (`service.py:350-358`) est **supprimé** : il disait « le fichier annonce X, la cible est Y » à partir du même champ, en moins précis, et il était de toute façon inerte sur Mangools. Le recensement le remplace et le dépasse.

### 4.3 Filtrage au pipeline

Le worker ne conserve que les lignes dont le pays résolu vaut `site_targets.country` de la cible de l'import. Les autres ne sont **pas** des erreurs : elles alimentent `rows_ignored`, qui existe déjà dans le rapport, ventilé en deux motifs distincts (§4.5). Une ligne dont le pays ne résout pas (localité, nom inconnu) est ignorée elle aussi, comptée à part — la confondre avec « mauvais pays » masquerait un défaut de table d'alias.

`locale` reste dérivé de la cible (`_target_locale`, `service.py:85`) : le filtrage ne change pas la règle, il la rend enfin vraie.

**Cas du fichier sans aucune ligne du pays de la cible** : le job produirait 0 ligne. Le wizard le signale comme avertissement **bloquant** à la confirmation, sur la foi du recensement du `preview` — lancer un job qu'on sait vide n'apprend rien à personne.

### 4.4 Créer les autres cibles : N imports, jamais un import multi-cibles

Le modèle l'impose : `imports.site_target_id` désigne **une** cible (`models.py:414-418`), et une `keyword_list` appartient à une cible (`models.py:191-195`). Un import multi-cibles demanderait de casser les deux.

Le geste validé se traduit donc ainsi : l'utilisateur coche les pays à importer et choisit la langue de chacun ; le web crée les `site_targets` manquantes (module Sites, `UNIQUE (site_id, country, language)` — une cible déjà présente est réutilisée, jamais dupliquée), puis lance **un job par cible sur le même fichier déjà uploadé**. L'upload étant séparé (`POST /imports/files`, le fichier est identifié par son `file_sha256`), il n'y a **aucun re-transfert** : N imports, N listes, N rapports, un seul fichier sur le disque.

Chaque import est une ligne `imports` de plein droit, avec son propre filtrage. L'onglet Imports les montre comme N entrées — ce qu'elles sont.

La langue proposée par défaut pour un pays est sa langue officielle majoritaire (`pycountry` la donne), **modifiable**, jamais imposée : `Germany` propose `de`, `Japan` propose `ja`, et l'utilisateur qui veut cibler l'anglais en Allemagne le dit.

### 4.5 Rapport d'import

`imports.report` (JSONB, aucune migration) gagne la ventilation :

```
1 934 lignes lues · 74 importées (United States)
    152 ignorées — hors cible : United Kingdom 55, Australia 32, Canada 13, Singapore 12, …
      3 ignorées — pays non reconnu : New York 3
```

`imports.detected_country` (colonne existante, aujourd'hui toujours nulle pour Mangools) est enfin alimentée : le pays **de la cible**, c'est-à-dire celui qui a été retenu — un champ d'audit qui dit ce qui a été gardé, pas ce que le fichier annonçait, puisque le fichier peut en annoncer onze.

### 4.6 Un défaut connexe, hors périmètre mais à ne pas perdre

L'import `01a05b86-…` du 2026-09-01 12:52 a échoué sur `ValueError: adaptateur inconnu : mangools` (`api/app/keywords/pipeline.py:498`) — un worker resté sur du code antérieur au merge de l'adaptateur (`0ddbb4a`), deux minutes après que le même fichier soit passé. Le code courant connaît `mangools` ; l'artefact d'environnement est résolu.

Ce qui ne l'est pas : **l'échec n'a rien affiché**. Un import `failed` reste muet dans l'UI, `report` à `NULL`, `error` visible seulement en base. Cela mérite son propre correctif et n'entre pas dans cette spec.

## 5. Migrations

**Base DATA — une seule révision Alembic**, portant les deux chantiers :

1. `DROP VIEW keyword_metrics_latest`
2. `DROP FUNCTION keyword_metrics_latest(text)`
3. `CREATE FUNCTION keyword_metrics_latest(text)` — nouvelle signature de retour (§3.1)
4. `CREATE VIEW keyword_metrics_latest AS SELECT * FROM keyword_metrics_latest(NULL::text)`

`downgrade` rejoue l'inverse en restaurant textuellement l'ancien corps. **Aucun DDL sur une table**, aucune donnée touchée, aucun `DROP TABLE`, aucune colonne perdue : la révision est réversible sans perte, ce que `migration-warden` doit confirmer avant application.

**Base AUTH — aucune migration.** Les deux chantiers ne touchent que DATA.

`tests/test_migrations.py` (`alembic check` en sous-process) doit rester vert dans les deux sens ; les modèles ne changent pas, donc l'autogénération ne doit rien produire — la révision est écrite à la main, comme la révision `333652ce4b5e` qui a créé la fonction.

## 6. Tests

**A — consolidation** (`tests/test_keyword_metrics_latest.py`, largement réécrit) :

- deux sources, même `fetched_at`, valeurs différentes → **les deux** entrées sont rendues, dans l'ordre contractuel. C'est le test qui échoue aujourd'hui et qui définit le chantier.
- une source trouée sur un champ → absente du tableau de ce champ, présente sur les autres.
- deux observations de la même source à deux dates → **une seule** entrée, la plus récente.
- aucune observation → tableau vide, jamais `null`.
- `preferred_source` renseignée → cette source en tête, **aucune autre masquée** ; `preferred_source` inconnue de ce mot-clé → ordre par date, aucune erreur.
- les tests de priorité par métrique (`test_…_priority_…`) et les deux tests de nommage (`tests/test_import_adapters.py:60-82`) sont **supprimés** avec les ensembles qu'ils gardaient (§3.1).

**A — filtres** (`tests/` du réservoir) : `volume_min` retient un mot-clé dont **une seule** source est dans l'intervalle — le cas `jewelry manufacturer` du §1, en régression explicite.

**B — résolution des pays** (`tests/test_countries.py`, nouveau) : `United States` → `US` ; `uk` → `GB` ; `Hong Kong` → `HK` ; `New York` → `None` ; casse et espaces indifférents.

**B — recensement** (`tests/test_import_preview.py`) : sur une fixture dérivée de `kwfinder_Top_Advmfr_export.csv`, le `preview` rend les onze pays avec leurs décomptes exacts, y compris ceux qui n'apparaissent qu'après la centième ligne — c'est ce qui prouve que le scan est complet et non tronqué à cinq lignes.

**B — filtrage** (`tests/test_import_pipeline.py`) : import de cette fixture sur une cible `en-US` → 74 lignes écrites, 152 ignorées « hors cible », 0 erreur ; aucun mot-clé de la base ne porte `locale = 'en-US'` sans être dans les 74. Puis le même fichier sur une cible `en-GB` → 55 lignes, sans toucher aux 74 précédentes.

**B — Semrush Magic** (fichier sans colonne pays) : `countries` vide, comportement inchangé, aucune ligne ignorée. C'est le test qui empêche le cloisonnement de se retourner contre les sources qui ne déclarent pas de pays.

Toute la suite tourne avec `uv run pytest -q -W error` sur `data_test`, chaîne de migrations rejouée de zéro.

## 7. Ce que cette spec ne fait pas

- **Aucune reprise rétroactive.** Les 50 015 observations en base restent telles quelles. La base est saine (§1), et le seul fichier problématique n'a jamais été importé — le rejouer après B suffira.
- **Aucun dessin d'écran.** L'affichage des valeurs empilées, les badges, le sort des onglets de vue et la densité de la table sont un chantier UI distinct, à ouvrir quand les fonctions seront en place. La contrainte projet « la fenêtre ne scrolle jamais horizontalement » s'y appliquera pleinement et n'est pas triviale avec N valeurs par cellule : c'est la première question de ce chantier-là.
- **Aucun tri par métrique** (§3.3).
- **Aucun correctif de l'import `failed` silencieux** (§4.6).
- **Aucun changement de parseur ni de mapper au titre de A** (§3.6). Seul B en touche un, et pour lire une colonne qui existait déjà.

## 8. Ordre d'exécution proposé

A et B sont indépendants — ils ne partagent qu'une révision Alembic, que B n'utilise pas. Ils peuvent donc être menés en parallèle sur deux branches, à condition que la révision soit portée par A seul.

1. **A1** — module `countries.py` + tests (aucune dépendance, sert B).
2. **A2** — fonction SQL + révision Alembic + tests de consolidation.
3. **A3** — contrat api (`schemas.py`, `service.py`), filtres, `suggest`.
4. **A4** — contrat web (Zod, `toRow`, `MetricCell`) — affichage minimal, sans design.
5. **B1** — capacité pays des adaptateurs + recensement `preview`.
6. **B2** — filtrage pipeline + ventilation du rapport.
7. **B3** — création de cibles et N jobs au wizard.

Gardiens : `migration-warden` sur A2, `contract-keeper` sur A3/A4 et B1, tous deux **après** que `pytest`, `tsc` et les linters soient verts.
