# Rattacher les keywords aux pages existantes — anti-cannibalisation

**Date** : 2026-07-15 · **Branche** : `feature/keyword-page-attach`
**Prérequis** : module Pages livré (`site_pages`, `keywords.targetPage*`, `parse_page_meta`, mapping textuel pur `textualMatch`).

## Contexte & objectif

Un keyword important trouvé en Recherche/Planning est souvent **déjà couvert par
une page existante** du site. Aujourd'hui, la promotion « Vers Contenu » crée
systématiquement une fiche neuve → risque de **double contenu / cannibalisation**
(deux URLs pour la même intention). Objectif : au moment de promouvoir, décider
explicitement **« optimiser la page existante »** vs **« créer du neuf »**, et
rendre la page cible visible EN AMONT (Recherche + Planning) pour prioriser en
connaissance de cause.

**Décisions verrouillées (brainstorm 2026-07-15)** :
1. Geste central : **bifurcation à la promotion** (dialog « Vers Contenu »).
2. « Optimiser » = **fusion** (jamais de 2ᵉ fiche pour une URL managée).
3. Statut : **dimension `kind` orthogonale et extensible** (pas de surcharge du
   statut linéaire) — l'utilisateur a insisté sur la scalabilité.
4. Visibilité **Planning + Recherche** (colonne « Page ciblée »).
5. Kw sans page : **suggestion live** (`textualMatch` en base) + picker.
6. Override « Créer quand même » : **trace de risque** sur la fiche.
7. Dialog par-kw avec **défaut intelligent** (page → Optimiser coché ; sinon
   Créer) + override ligne par ligne.
8. Sub-agents MAX ; modèle : Opus 4.8 par défaut, Fable si dispo pour la review
   finale.

## Modèle de données (1 migration légère)

Sur `content_items` :
- `kind text NOT NULL DEFAULT 'new'` — `$type<ContentKind>` = `"new" | "optimize"`.
  **Axe extensible** : la nature du travail, orthogonale au `status`
  (idea→brief→writing→…→published, inchangé). Ajouter `refresh`/`merge`/…
  plus tard = étendre `ContentKind`, zéro impact statut.
- `warned_page_url text NULL` — URL de la page que l'utilisateur a choisi
  d'ignorer en forçant « Créer quand même » (trace de cannibalisation potentielle).
- (Déjà présents : `url` = URL cible d'un item `optimize` ; `primary_keyword_id`.)

`ContentKind` (`src/lib/content/types.ts`) : type + `CONTENT_KIND_LABEL`
(`new`: « Nouveau contenu », `optimize`: « Optimisation »). Le kanban et les
facettes gagnent une dimension de filtre par kind (extensible).

## Résolution de page (pur, réutilisable)

`resolvePageForKeyword(kw, pages)` dans `src/lib/pages/attach.ts` — priorité :
1. `kw.targetPage` déjà mappée (crawl) → `{ pageId, url, source, confidence: "mapped" }`.
2. Sinon **suggestion live** : `textualMatch(term, pages)` (fonction pure déjà
   écrite ; les pages `site_pages` sont déjà en DB — coût nul) → si ≥ seuil,
   `{ pageId, url, source: "text", score, confidence: "suggested" }`.
3. Sinon `null` (vrai gap).

Une **server action** `resolvePagesForKeywordsAction(siteId, keywordIds)` charge
les kw (avec `targetPage`) + l'inventaire `site_pages` du site (une fois), applique
`resolvePageForKeyword` par kw, retourne `Map<keywordId, ResolvedPage | null>` +
le titre de page (pour l'affichage). Gate `assertSiteInActiveOrg`.

## Promotion — nouveau mode `optimize`

`promoteKeywordsToContentAction` gagne un 5ᵉ paramètre `decisions?:
Record<keywordId, { action: "optimize"; pageUrl: string; pageId: string } |
{ action: "create"; warnedPageUrl?: string }>`. Rétrocompatible : sans
`decisions`, comportement actuel (tout en `create`, `kind: "new"`).

Par kw :
- **`optimize`** : chercher un `content_items` du site avec `url ==
  normalizeUrl(pageUrl)` (le `normalizeUrl` de content/stats-logic, déjà le point
  de vérité du module Pages) :
  - **trouvé** (URL managée) → `content_keywords` INSERT (role secondary,
    onConflictDoNothing) + le content_item repasse `kind: "optimize"` et, s'il
    était `published`, statut → `writing` (« à retravailler ») ; sinon statut
    inchangé. **Jamais de 2ᵉ fiche.**
  - **non trouvé** → création d'un content_item `kind: "optimize"`,
    `url = normalizeUrl(pageUrl)`, `title` = titre `site_pages` (fallback: terme),
    kw en primary + `content_keywords`.
  - Le kw reçoit aussi `targetPage = manual` sur cette page (cohérence réservoir).
- **`create`** : content_item `kind: "new"` (flux actuel) ; si `warnedPageUrl`
  fourni → colonne `warned_page_url` posée (badge risque au kanban).

Idempotence : un kw déjà lié à l'item cible → skip compté. Tenancy : la page ET
le content_item doivent appartenir au `siteId` (vérifié).

## UI

### Dialog « Vers Contenu » (`promote-content-dialog.tsx`)
- À l'ouverture → `resolvePagesForKeywordsAction`.
- Rendu **par-kw** avec **défaut intelligent** : les kw avec page résolue →
  ligne « 🔄 Optimiser [path court] » (badge source/confiance) **pré-sélectionnée**
  Optimiser ; les kw sans page → « ➕ Créer ». Chaque ligne a un toggle
  Optimiser/Créer + un **picker** (rattacher manuellement à une autre page de
  l'inventaire) via `searchSitePagesAction` existant.
- Forcer « Créer » sur un kw qui avait une page → `warnedPageUrl` embarqué
  (trace de risque).
- Le mode historique « 1 page (groupe) » reste disponible (inchangé) ; la
  bifurcation optimiser/créer s'applique au mode par-keyword.
- Récap au submit : « X optimisés · Y créés · Z fusionnés dans un contenu existant ».

### Recherche
Démasquer la colonne « Page ciblée » (cachée au fix T7) en **câblant sa meta**
(`siteId` + `onTargetPageChanged`) → colonne fonctionnelle avec picker, comme au
réservoir. (Retirer le `targetPage: false` du columnVisibility recherche.)

### Planning (`planning-table.tsx`)
Colonne compacte « Page ciblée » (path court + badge source), avec picker
(réutilise `target-page-cell.tsx`). Le Planning est la salle de priorisation :
le badge « a déjà une page » y est le plus utile.

### Kanban Contenu
- Badge `kind` sur les cartes : « Optimisation » (distinct de « Nouveau »).
- Badge « ⚠️ Cannibalisation : [URL] » quand `warned_page_url` renseigné.
- Facette de filtre par `kind` (dimension extensible).

## Cas limites

| Cas | Comportement |
|---|---|
| Kw avec targetPage mais page supprimée depuis | resolve retombe sur suggestion live ou null. |
| Deux kw promus vers la MÊME page managée | 1er ajoute + bascule kind ; 2ᵉ ajoute son kw au même item (idempotent, pas de doublon). |
| URL optimize non managée, promue 2× | 2ᵉ promotion trouve désormais l'item (créé au 1er) → fusion, pas de doublon. |
| « Créer quand même » puis re-promotion « Optimiser » | La 2ᵉ fusionne dans le contenu existant ; le warned_page_url du 1er reste (trace historique). |
| Mode « 1 page (groupe) » | Inchangé (pas de bifurcation) — YAGNI. |
| Suggestion live sous le seuil | Pas proposée (null) — le kw part en Créer par défaut, picker manuel dispo. |

## Tests

- **Pur** : `resolvePageForKeyword` (mapped > suggested > null) ; sélection du
  défaut intelligent (page → optimize).
- **DB** : promote optimize sur URL managée (kw ajouté + kind bascule + statut
  published→writing) ; URL non managée (item optimize créé) ; idempotence 2 kw
  même page ; override → warned_page_url ; tenancy croisée (page/item d'un autre
  site refusés) ; rétrocompat (sans decisions = create/new).
- **UI** : dialog par-kw (défauts, toggle, picker), colonnes planning/recherche,
  badges kanban kind + cannibalisation.
- Gate : `bun run test:all`.

## Hors périmètre

Redirections automatiques ; audit rétroactif de cannibalisation entre contenus
existants ; suggestion d'angle différenciant (IA) ; merge de deux content_items ;
kinds futurs (`refresh`/`merge`) — l'axe est prêt, l'implémentation viendra au
besoin.
