# SERP Semantics UI & Prompt (S4+S5) Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Rendre le moteur sémantique visible et actionnable : bouton « Analyser la SERP » avec curation d'URLs et polling dans l'Overview mot-clé, section profil, 6ᵉ item de readiness, section « GUIDE SÉMANTIQUE » dans le générateur de prompt, diffusion dans dispatch et content sheet. **Fin de ce plan = point de QA live.**

**Architecture:** Design D5 (`docs/features/analyse-semantique-serp/design-technique-phase-b.md`) — aucun nouveau canal : bundle étendu, `computeReadiness` étendu, `build-prompt` étendu, 3 emplacements UI existants. Le pipeline S3 (branche `ytg-like/serp-pipeline`) fournit `startSerpSemanticsAction`, `getSemanticProfileAction`, le polling `getAnalyticsJob`, et le profil dans `keywords.semantic[country]`.

**Tech Stack:** React 19 / Next App Router, shadcn (composants existants du repo), types `src/lib/semantic/{types,profile-types}.ts`.

## Global Constraints

- Branche **`ytg-like/serp-ui`** (empilée sur `ytg-like/serp-pipeline`). AUCUN merge dans `dev`.
- Le résumé d'une analyse terminée se lit dans **`keywords.semantic`** (via `getSemanticProfileAction`), JAMAIS dans `analytics_jobs.totals` (vidé par `completeJob` — revue S3).
- Le polling job utilise **`getAnalyticsJob`** existant (`src/lib/analytics/actions.ts`) ; le progress `{phase, done, total}` arrive dans `totals` (cast local — le type `JobTotals` ne le connaît pas, revue S3 minor 6).
- UI : suivre les patterns existants du fichier touché (composants shadcn du repo, `toast` sonner, `cn`). Pas de nouvelle dépendance. Textes UI en **français**.
- Rappels de pièges UI du repo (mémoire projet) : largeur explicite `w-*` sur `DropdownMenuContent` si dropdown ; `position="popper"` sur `SelectContent` si Select.
- `medianWeighted` (échelle brute) ne se compare JAMAIS à `score` (renormalisé) — n'affiche pas les deux sur une même échelle visuelle.
- `intent.curve` est en français (`"transactionnelle"`), les clés `structure.archetypes` en anglais — mapper les libellés côté UI.
- Conventional Commits en français. Gates récurrentes : `bun run test:all`, `bunx tsc --noEmit`, `bun run lint` sur les fichiers touchés.

---

### Task 1: Actions — mapping 409 + garde urls vides (reliquat revue S3)

**Files:**
- Modify: `src/lib/semantic/actions.ts`
- Modify: `src/lib/semantic/actions.test.ts`

**Interfaces:**
- Produces: `startSerpSemanticsAction` retourne désormais aussi `{ ok: false; error: "already_running" }` quand le service répond 409 (aujourd'hui : throw « Analytics service rejected the job (409 Conflict) »), et `{ ok: false; error: "no_serp" }` quand `buildSerpSemanticsPayload` produit 0 URL après clamp (aujourd'hui : 400 zod → throw générique). Le type de retour devient `{ ok: true; jobId: string } | { ok: false; error: "no_serp" | "already_running" | "enqueue_failed" }`.

- [ ] **Step 1: Tests** — la fonction pure : payload sans aucune URL valide (toutes positions > 10) → l'action répond `no_serp` (test via le harnais existant) ; le mapping 409 : selon comment `enqueueAnalyticsJob` signale le 409 (lis `src/lib/analytics/enqueue.ts` — s'il throw avec le status dans le message, extrais un helper testable `mapEnqueueError`), teste `mapEnqueueError` en pur.
- [ ] **Step 2: Fail.** — `bun run test src/lib/semantic/actions.test.ts`
- [ ] **Step 3: Implémenter** — `if (payload.urls.length === 0) return { ok: false, error: "no_serp" }` avant l'enqueue ; try/catch autour de l'enqueue : 409 → `already_running`, autre → `enqueue_failed` (log console.error). Si `enqueueAnalyticsJob` ne distingue pas le status, améliore SA levée d'erreur (champ `status` sur l'Error) — changement minimal, sans toucher les autres appelants.
- [ ] **Step 4: Pass + gates** — tests fichier, `bunx tsc --noEmit`.
- [ ] **Step 5: Commit** — `git commit -m "fix(semantic): startSerpSemanticsAction — 409 mappé en already_running, garde urls vides"`

---

### Task 2: Bundle + readiness (6ᵉ item « Profil sémantique »)

**Files:**
- Modify: `src/lib/keywords/prompt/types.ts` (bundle + readiness key)
- Modify: `src/lib/keywords/prompt/actions.ts` (`getKeywordPromptBundle` lit `row.semantic[country]`)
- Modify: `src/lib/keywords/prompt/compute-readiness.ts` (6ᵉ item)
- Modify: `src/lib/keywords/prompt/compute-readiness.test.ts` (ou le fichier de test existant du module — trouve-le)
- Modify: `src/components/keywords/planning/readiness-dot.tsx` SEULEMENT si un mapping de libellé y est nécessaire (regarde `READY_LABEL`)

**Interfaces:**
- Consumes: `StoredSemanticProfile` (`src/lib/semantic/profile-types.ts`).
- Produces:
  - `KeywordPromptBundle.semanticProfile: StoredSemanticProfile | null` ;
  - `ReadinessItem.key` union étendue avec `"semantic"` ;
  - item readiness : `key: "semantic"`, `label: "Profil sémantique"`, `state: bundle.semanticProfile ? "ready" : "missing"`, `detail` : si prêt `"${terms.length} termes · ${intent.curve}"` + date courte, sinon `"Aucune analyse"`. Poids dans le % de complétude : même mécanique que les 5 items existants (lis `compute-readiness.ts` et suis-la).

- [ ] **Step 1: Tests** — `computeReadiness` : bundle avec `semanticProfile: null` → item missing + % inchangé par rapport à la formule à 6 items ; avec profil → ready. Suis le style des tests existants du module.
- [ ] **Step 2: Fail.**
- [ ] **Step 3: Implémenter.** Dans `getKeywordPromptBundle` : la ligne `keywords` est déjà chargée — ajoute la lecture `row.semantic?.[row.country] ?? null` (attention : `semantic` est exclu de `KeywordReadRow` via Omit — regarde comment `serp` est re-sélectionné dans ce fichier et fais pareil).
- [ ] **Step 4: Pass + gates** — tests module prompt, `bunx tsc --noEmit`.
- [ ] **Step 5: Commit** — `git commit -m "feat(semantic): semanticProfile dans le bundle + 6e item de readiness"`

---

### Task 3: UI Overview — bouton « Analyser la SERP », curation, polling, section profil

**Files:**
- Create: `src/components/keywords/planning/semantic-analysis-section.tsx` (section + bouton + dialog curation + polling — composant client autonome)
- Modify: `src/components/keywords/planning/keyword-overview-modal.tsx` (insertion de la section entre « Vérification des sources » et `KeywordPromptCard` ; action « Analyser » sur l'item readiness `semantic`)

**Interfaces:**
- Consumes: `startSerpSemanticsAction`/`getSemanticProfileAction` (`src/lib/semantic/actions.ts`), `getAnalyticsJob` (`src/lib/analytics/actions.ts`), `getSerpForKeywordAction` (`src/lib/keywords/serp-actions.ts` — pour lister les concurrents à curer), `StoredSemanticProfile`.
- Produces: `<SemanticAnalysisSection keywordId={string} profile={StoredSemanticProfile | null} onAnalyzed={() => void} />`.

Comportement imposé :
1. **Sans profil** : carte « Profil sémantique SERP — aucune analyse » + bouton « Analyser la SERP ». Sans snapshot SERP (l'action répondra `no_serp`) : message qui renvoie vers l'import SERP (la ligne SERP de la vérification des sources).
2. **Clic Analyser** → dialog de **curation** : liste des concurrents du snapshot (position, domaine, URL tronquée), checkbox par ligne (tout coché par défaut), bouton « Lancer l'analyse (N URLs) ». Décoché → `excludedPositions`. (Pattern dialog : mirror des dialogs existants du dossier.)
3. **Lancement** : `startSerpSemanticsAction(keywordId, { excludedPositions })` — `already_running` → toast info « Analyse déjà en cours » et passe en mode polling ; `no_serp` → message import ; `enqueue_failed` → toast erreur.
4. **Polling** : `getAnalyticsJob(jobId)` toutes les 2 s (pattern des dialogs d'import existants — regarde comment `ImportKeywordsDialog` polle). Affiche la phase (`fetch/extract/score/persist` → libellés FR). `status === "done"` → recharge le profil via `getSemanticProfileAction` + `onAnalyzed()` (le parent rafraîchit le bundle). `failed` → toast avec `error`.
5. **Avec profil** : résumé compact — intention (badge, libellé FR déjà fourni par `intent.curve` + mode), couverture (`analyzed/total`, mention des exclus/manquants), date, top 10 termes (terme + score en barre relative — PAS de comparaison score/medianWeighted), archétypes dominants (mapper EN→FR : transactional→transactionnel, editorial→éditorial, unknown→indéterminé), flags (`mixed_intent`, `empty_corpus`, `lang_mismatch:*` → libellés FR lisibles). Bouton « Relancer » (même dialog curation).
6. La section reste **sobre** : c'est un résumé, le détail viendra plus tard — pas de tableaux géants dans le modal.

- [ ] **Step 1: Le composant est essentiellement de l'orchestration UI — pas de test unitaire imposé** (le repo ne teste pas ses composants clients) ; les helpers purs éventuels (mapping libellés FR des phases/flags/archétypes) vont dans un fichier séparé `semantic-labels.ts` AVEC test (`semantic-labels.test.ts`).
- [ ] **Step 2: Implémenter labels + tests** (fail→pass).
- [ ] **Step 3: Implémenter la section + l'insertion dans le modal.** L'item readiness `semantic` du modal reçoit une action « Analyser » qui scrolle/ouvre le dialog de curation (mirror du câblage `onImport` existant — attention au bug historique documenté dans `docs/bugs/2026-07-21-planning-serp-import.md` : chaque ligne doit ouvrir SON action, pas celle d'une autre).
- [ ] **Step 4: Gates** — `bunx tsc --noEmit`, `bun run lint` sur les fichiers touchés, `bun run test:all`.
- [ ] **Step 5: Commit** — `git commit -m "feat(semantic): section profil sémantique + analyse avec curation et polling dans l'Overview"`

---

### Task 4: Prompt — section « GUIDE SÉMANTIQUE »

**Files:**
- Modify: `src/lib/keywords/prompt/build-prompt.ts`
- Modify: le fichier de test existant de `build-prompt` (trouve-le — snapshots/assertions de sections)

**Interfaces:**
- Consumes: `KeywordPromptBundle.semanticProfile` (Task 2).
- Produces: nouvelle section du prompt, APRÈS la section YTG et AVANT l'analyse concurrentielle (ordre logique : données → guide), rendue SEULEMENT si `semanticProfile` non null :

```
== GUIDE SÉMANTIQUE (profil pondéré de la SERP) ==
Intention détectée : transactionnelle (inférée, 8/10 concurrents analysés)
Termes à employer (fourchettes d'occurrences observées dans le Top 3) :
- « bijou acier » : 4–12 occurrences (zone clé : body) — score 0.83
- …(top 15 termes, format stable)
Expressions à placer telles quelles : « fabricant bijoux acier », …(top 8 bigrammes/trigrammes)
Questions à traiter (issues des Hn concurrents) : …(max 8)
Objectifs structurels : ~1 850 mots (médiane SERP), 7 H2, 3 listes, 1 tableau
```

- Règles : fourchettes = `top3.min–top3.max` (si `{0,0,0}` — terme absent du top 3 — affiche « présent hors top 3 » à la place de la fourchette) ; zone clé = zone au max de `zones` ; nombres FR (espace insécable fine acceptée en espace simple) ; troncatures (15 termes / 8 n-grammes / 8 questions) codées en constantes nommées ; si `gap` non null, sous-section « Ajustements (gap analysis) » listant les 10 premiers deltas.

- [ ] **Step 1: Tests** — bundle sans profil → section absente ; avec profil → section présente avec les valeurs attendues (fixture profil minimal construite à la main) ; terme `{0,0,0}` → « présent hors top 3 » ; avec gap → sous-section. Suis le style de test existant du fichier.
- [ ] **Step 2: Fail.** — [ ] **Step 3: Implémenter.** — [ ] **Step 4: Pass + `bun run test src/lib/keywords/prompt`.**
- [ ] **Step 5: Commit** — `git commit -m "feat(semantic): section GUIDE SÉMANTIQUE dans le générateur de prompt"`

---

### Task 5: Diffusion — dispatch-view + content-card-sheet + gates finales

**Files:**
- Modify: `src/components/keywords/dispatch/dispatch-view.tsx` (panneau `SemanticAnalysisSection` sous `SerpPanel`)
- Modify: `src/components/content/content-card-sheet.tsx` (idem, à côté du `KeywordPromptCard` « Rédaction »)

**Interfaces:**
- Consumes: `SemanticAnalysisSection` (Task 3), `getSemanticProfileAction`.
- Les deux emplacements chargent le profil au montage via l'action (pattern de chargement des données du fichier hôte — mirror comment `content-card-sheet` charge déjà SERP/briefs). ⚠️ Mémoire projet « Backlink nav deadlock » : ne JAMAIS re-fetcher au mount une donnée déjà fournie par le RSC parent sans garde — suis le pattern de chargement du fichier hôte, et si le fichier hôte fournit déjà les données côté serveur, fais pareil plutôt qu'un fetch client.

- [ ] **Step 1: Implémenter les deux insertions** (le composant Task 3 est réutilisé tel quel ; l'`onAnalyzed` recharge ce que le fichier hôte recharge d'habitude — `router.refresh()` ou re-fetch local).
- [ ] **Step 2: Gates finales du plan**

Run: `bun run test:all` → aucune régression.
Run: `bunx tsc --noEmit` → clean.
Run: `bun run lint` sur les fichiers touchés → clean.

- [ ] **Step 3: Commit + push (PAS de merge dev)**

```bash
git add -A && git commit -m "feat(semantic): profil sémantique dans dispatch et content sheet"
git push -u origin ytg-like/serp-ui
```

**Fin de plan = POINT DE QA LIVE.** Checklist QA (pour l'utilisateur, org eleva-seo, mot-clé avec snapshot SERP) : Overview → Analyser la SERP → curation (décocher 1 URL) → polling visible → profil affiché (intention/termes/couverture) → readiness passe à 6/6 → prompt contient GUIDE SÉMANTIQUE → dispatch et content sheet affichent la section → relance = `already_running` si double-clic.

---

## Self-review (fait à l'écriture)

- Couverture design D5 : bundle ✔ (T2), readiness ✔ (T2), prompt ✔ (T4), UI n°1 curation+polling ✔ (T3), n°2/n°3 ✔ (T5). Reliquats revue S3 intégrés : 409→already_running + urls vides ✔ (T1), résumé lu dans keywords.semantic ✔ (contrainte globale), cast totals ✔ (T3 polling).
- Hors périmètre assumé : garde SSRF (avant merge dev, tracée au ledger), gap analysis UI complète (S6), headless (S7), co-occurrences (crate, S5+).
- Types cohérents : `SemanticAnalysisSection` défini T3, consommé T5 ; union d'erreurs définie T1, consommée T3.
