# Design technique Phase B — Analyse Sémantique SERP Pondérée

> Implémente le **CDC v1.3** (`CDC_v1.3.md`, même dossier) dans SEO Insight.
> Statut : **proposé** — à valider avant plan d'implémentation.
> Fondé sur trois cartographies du codebase (stack Rust/WASM, service analytics, domaine keywords) — chemins vérifiés au 2026-07-21.

---

## 1. Vue d'ensemble

```
UI (Overview mot-clé)                    Next.js                       Service analytics (Bun/Hono, :8787)
┌──────────────────────┐   POST    ┌──────────────────────┐  enqueue  ┌──────────────────────────────────┐
│ « Analyser la SERP » │ ────────► │ route serp-semantics │ ────────► │ analytics_jobs (type nouveau)     │
│ + curation des URLs  │           │ (tenancy, quota)     │           │  worker : fetch UA mobile (pool)  │
└──────────┬───────────┘           └──────────────────────┘           │  → staging HTML sur R2 (TTL 6 h)  │
           │ poll getAnalyticsJob (DB)                                │  → wasm semantic-core (extraction)│
           ▼                                                          │  → wasm build_serp_profile (BM25F)│
┌──────────────────────┐   lecture  ┌─────────────────────┐  persist  │  → DuckDB COPY parquet → R2       │
│ Profil + guide + gap │ ◄──────── │ keywords.semantic    │ ◄──────── │  → UPDATE keywords.semantic jsonb │
└──────────────────────┘   bundle   │ (jsonb par-locale)  │           └──────────────────────────────────┘
                                    └─────────────────────┘
```

Le moteur de calcul est **Rust** (principe rust-maximal), le pipeline est **asynchrone** (pattern job existant), le stockage suit la doctrine **Postgres = vérité, R2 = fichiers, DuckDB = crunch éphémère**.

---

## 2. Décisions d'architecture

### D1 — Nouveau crate `semantic-core` (pas d'extension d'import-core)

Le moteur (zones, tokenisation, stemming, n-grammes, BM25F, archétypes) vit dans un **nouveau crate WASM `crates/semantic-core`**, membre du workspace racine.

- **Pourquoi pas étendre `import-core`** : le moteur tire des dépendances lourdes (`rust-stemmers`, dictionnaires stop-words multilingues) qui n'ont rien à faire dans le `.wasm` d'import (~590 Ko aujourd'hui, chargé par les routes d'import). Isolation = les deux évoluent indépendamment.
- **Réutilisation** : `semantic-core` dépend d'`import-core` en **rlib** (même workspace) pour les briques partagées : parse `tl` une-passe, `decode_entities`, garde `MAX_DEPTH 256` + fallback itératif, helpers de normalisation.
- **Conventions suivies à l'identique** (cartographie §5) : `crate-type = ["cdylib","rlib"]`, `wasm-pack --target nodejs`, out-dir gitignoré sous `src/lib/semantic/semantic-core/`, entrée `file:` dans `package.json`, ajout à `serverExternalPackages` (`next.config.ts`), étape dans `deploy/deploy.sh` **avant** `bun install` (⚠️ même gotcha que le crawler : wasm gitignoré, le deploy doit builder).
- **Pas d'I/O dans le crate** : fetch, R2, DB restent en TS. Le crate expose deux fonctions pures :
  - `extract_semantic_page(html: &str, config_json: &str) -> JsValue` → `PageSemantics` (zones exclusives par précédence, fréquences par zone, archétype, langue, n-grammes, structure) ;
  - `build_serp_profile(pages_json: &str, config_json: &str) -> JsValue` → `SemanticProfile` (BM25F, médianes pondérées, outliers, intention auto, guide d'écriture, gap si page cliente fournie).
- Tests : natifs Rust (`#[cfg(test)]`) pour toute la logique ; dérivés TS purs testés côté bun (pattern `markdown-analysis.ts`).

### D2 — Pipeline asynchrone via le service analytics (nouveau type de job)

Une analyse = jusqu'à 10 fetches (timeout 15 s), rendus headless éventuels, extraction, scoring. Trop long et trop fragile pour une server action (le précédent `crawlSerpForKeywordAction` en action est déjà limite, sans headless).

- Nouveau `type: "serp-semantics"` dans l'enum `analytics_jobs.type` (`src/lib/db/domain-schema.ts`) + une entrée dans `HANDLERS` (`services/analytics/src/worker.ts`) + un adaptateur mince façon `services/analytics/src/site-crawl.ts` câblant un **runner en DI** (`src/lib/semantic/analysis-runner.ts`) testable sur le vrai Postgres.
- Payload du job (colonne `totals` jsonb pour le progress `{phase, done, total}` comme site-crawl) :
  ```
  { keywordId, country, urls: [{position, url, included}], clientUrl?, configOverrides? }
  ```
- Déclenchement : **server action** `startSerpSemanticsAction(keywordId, curation?)` — pas de route dédiée nécessaire (payload minuscule : l'input est le snapshot `keywords.serp` déjà en base, pas un upload). Ouvre par `assertKeywordInActiveOrg`, enqueue via `enqueueAnalyticsJob` (`src/lib/analytics/enqueue.ts`).
- Suivi : polling du job **en DB** via `getAnalyticsJob` (`src/lib/analytics/actions.ts`), comme les imports lourds. Le service n'expose toujours aucun GET.
- Garde « une seule analyse active par mot-clé » (mirror du 409 site-crawl).

### D3 — Fetch mobile-first, staging R2 TTL 6 h, headless pluggable

- **Fetch** : réutilise le pattern `src/lib/keywords/onpage-fetch.ts` (timeout 15 s, cap 3 Mo, pool `mapPool`) avec **UA mobile type Googlebot Smartphone par défaut** (chaîne UA dans la config, CDC §3.0). Concurrence 4 (précédent serp-actions).
- **Staging des artefacts bruts** (HTML fetché, DOM rendu) : **R2**, préfixe `serp-semantics/${siteId}/${jobId}/pos-${n}.html.gz`, via `src/lib/analytics/r2-upload.ts`. Rétention : delete app-level en fin de job réussi (pattern `storage.ts deleteObject`) + **règle lifecycle R2 dédiée au préfixe (expiration 1 jour, granularité minimale R2)** en garde-fou. Le TTL « 6 h » du CDC est réalisé par le delete app-level immédiat ; la lifecycle couvre les jobs orphelins. Aucun dossier tmp local (`PrivateTmp=true` en prod de toute façon).
- **Headless** (fallback Top 5, CDC §3.0) : interface TS `RenderProvider` — `render(url, ua) -> { html, flags } | null` — injectée dans le runner.
  - v1 : implémentation **API de pre-rendering externe** (env `RENDER_API_URL`/`RENDER_API_KEY`), activée si configurée ;
  - non configurée → comportement CDC de dégradation documentée (flag, `unreliable` si Top 3 incomplet) ;
  - une impl locale (navigateur piloté) pourra s'ajouter sans toucher au runner. C'est l'« interchangeabilité » exigée par le CDC §3.0.

### D4 — Stockage : profil agrégé en jsonb, full-grain en Parquet sur R2

Réponse directe au « ne pas plomber la base » :

- **Postgres (source de vérité produit)** : nouvelle colonne `semantic` jsonb sur `keywords`, `Record<country, SemanticProfile>`, **merge SQL par-locale** (`semantic = coalesce(semantic,'{}') || {[country]: profile}`), pattern exact de `serp-store.ts`. Compression **lz4** comme `serp`/`briefs` (migration mirror de `0024`). Le profil est **capé** : top N termes (défaut 200) avec stats agrégées, profil structurel, guide d'écriture, gap, flags — tout ce que l'UI et le prompt consomment. Ordre de grandeur : quelques dizaines de Ko compressés par locale.
- **R2 (archivage analytique)** : les tables full-grain du CDC §4.4 (`pages` : 1 ligne/URL ; `terms` : 1 ligne/terme×page×zone, potentiellement 10-50 k lignes/analyse) sont écrites en **Parquet** par le worker — DuckDB `:memory:` fait `COPY ... TO 'r2://.../terms.parquet'` (httpfs + secret R2 déjà en place dans `processor.ts`). Clés : `serp-semantics-results/${siteId}/${keywordId}/${country}/${jobId}/{pages,terms}.parquet`, référencées dans le profil (`exports.pagesKey/termsKey`). Pas de lifecycle courte : ce sont des **résultats** (CDC §3.8), téléchargeables/requêtables ; on ne garde que la dernière analyse par (keyword, country) — le job supprime les exports de l'analyse précédente.
- **Pas de nouvelle table Postgres** en v1. Si un jour le dashboard doit requêter le grain `terms` en SQL live, c'est l'ouverture du read-path DuckDB (Phase 1 du premortem `2026-06-22`) — les Parquet seront déjà au bon format. On ne l'ouvre pas ici.

### D5 — Branchement produit (aucune duplication)

Points d'insertion identifiés par la cartographie, tous existants :

| Besoin | Fichier | Changement |
| --- | --- | --- |
| Bundle | `src/lib/keywords/prompt/actions.ts` + `types.ts` | `KeywordPromptBundle.semanticProfile` lu depuis `keywords.semantic[country]` |
| Readiness | `src/lib/keywords/prompt/compute-readiness.ts` | 6ᵉ item `key: "semantic"`, action « Analyser » |
| Prompt | `src/lib/keywords/prompt/build-prompt.ts` | nouvelle section « GUIDE SÉMANTIQUE » (termes pondérés + fourchettes + questions + objectifs structurels ; en mode gap : ajustements) |
| UI n°1 | `keyword-overview-modal.tsx` | section « Profil sémantique SERP » entre les sources et le `KeywordPromptCard` + bouton « Analyser la SERP » (avec **curation** : liste des concurrents du snapshot, checkboxes, positions conservées — CDC §6) |
| UI n°2 | `dispatch-view.tsx` | panneau profil sous `SerpPanel` |
| UI n°3 | `content-card-sheet.tsx` | guide d'écriture à côté du prompt Rédaction |

- **Input** : le snapshot `keywords.serp[country]` existant (positions + URLs, importé via SE-Ranking/SerpChecker) est l'entrée du job. Pas de nouveau canal d'import. Les features SERP (`serpFeatures`) déjà stockées alimentent la détection d'intention (CDC §2.1).
- Le crawl SERP existant (`crawlSerpForKeywordAction`, markdown capé 8 Ko, UA desktop) **reste en place** pour l'affichage `SerpPanel` ; le moteur sémantique fait son propre fetch (UA mobile, HTML complet) — les deux besoins sont différents (aperçu UI vs corpus d'analyse). Rationalisation éventuelle plus tard.

### D6 — Quotas / metering : différé, hook prévu

Aucun quota par action SERP n'existe aujourd'hui. L'analyse coûte du fetch + éventuellement du rendu headless payant. v1 : **pas de facturation**, mais le runner passe par un hook `assertCanAnalyze(orgId)` no-op, pour brancher plus tard soit une `QuotaResource`, soit le pattern credit-pack (`serankingSyncPurchases` en mirror). Décision produit à prendre quand le coût réel du headless sera mesuré.

---

## 3. Modèle de données

### `SemanticProfile` (jsonb `keywords.semantic[country]`, capé)

```
SemanticProfile {
  analyzedAt, jobId,
  intent:    { curve: "transactionnelle"|"informationnelle"|"standard"|..., mode: "inferred"|"manual"|"calibrated", mixedIntent: bool },
  coverage:  { analyzed: n, total: n, excludedByUser: [pos], missing: [pos], flags: [] },   // CDC §5
  terms:     [≤200] { term, stem, score, medianWeighted, isOutlier,
                      zones: { title, h1, h2h3, slug, alt, anchor, meta, body },   // freq brutes agrégées Top10
                      top3: { min, median, max } },                                 // fourchettes guide
  ngrams:    { bigrams: [...], trigrams: [...] },                                   // même shape, capé
  cooccurrences: [...],
  questions: [ { text, level, sourcePositions[] } ],
  structure: { wordCount: {p25, median, p75}, h2Count, lists, tables,
               schemaTypes: { type: pct }, archetypes: { archetype: pct }, top3VsTop10: {...} },
  guide:     { terms: [...], ngrams: [...], questions: [...], objectives: {...} },  // CDC §4.3
  gap:       null | { intentMismatch: bool, terms: [...], structure: {...} },       // CDC §4.2
  exports:   { pagesKey, termsKey },                                                // Parquet R2
  config:    { curveOverride?, zoneWeights?, ua }                                   // traçabilité
}
```

### Config du moteur (JSON passé au wasm, défauts = CDC v1.3)

Poids de zones (§2.2), courbe CTR/override, seuils (60 % intention, N termes, k₁ BM25F), UA, langue. Tout paramétrable, rien de hardcodé côté TS.

### Job & progress

`analytics_jobs` : `type = "serp-semantics"`, `site_id`, payload dans les colonnes existantes + `totals` jsonb pour `{phase: "fetch"|"render"|"extract"|"score"|"persist", done, total}`.

---

## 4. Découpage en slices (méthode projet : petit bout par petit bout)

| Slice | Contenu | Gate |
| --- | --- | --- |
| **S1 — crate extraction** | `crates/semantic-core`, `extract_semantic_page` : zones exclusives (précédence CDC §2.2), slug tokenisé, archétype (§3.0), stemming/stop-words par langue (rust-stemmers), n-grammes, structure. Build wasm + branchement package.json/next.config/deploy.sh | tests natifs Rust verts ; `bun run test:all` vert |
| **S2 — crate scoring** | `build_serp_profile` : BM25F, W_P (courbes + fallback log), médianes pondérées, outliers, intention auto (≥60 %/`mixed_intent`), guide d'écriture, renormalisation couverture | tests natifs sur corpus fixtures (SERP synthétique) |
| **S3 — pipeline** | Migration colonne `semantic` (+lz4), type de job, handler worker + runner DI (fetch mobile, staging R2, wasm, Parquet DuckDB, merge jsonb), action `startSerpSemanticsAction`, garde anti-doublon | test runner sur Postgres de test ; job de bout en bout en dev |
| **S4 — UI Overview** | Bouton « Analyser la SERP » + curation des URLs + polling + section profil dans `KeywordOverviewModal`, 6ᵉ item readiness | QA live sur mot-clé riche (org eleva-seo) |
| **S5 — prompt & rédaction** | Section « GUIDE SÉMANTIQUE » dans `build-prompt.ts`, guide dans `content-card-sheet` + `dispatch-view` | snapshot tests du prompt ; QA live |
| **S6 — Gap Analysis** | Input page cliente (URL → même fetch/extraction), `intent_mismatch`, deltas triés par manque à gagner, guide en mode ajustements | tests scoring gap + QA live |
| **S7 — headless** | Interface `RenderProvider` + impl API externe + fallback Top 5 + règles de fiabilité complètes (§5 CDC) | test avec provider mocké ; QA sur SERP à SPA connue |

S1→S3 livrables sans UI (testables par script). S7 volontairement en dernier : le pipeline dégrade proprement sans lui (flags CDC).

---

## 5. Risques & garde-fous

- **Déploiement wasm** : deuxième crate = deuxième étape `wasm-pack` dans `deploy/deploy.sh` avant `bun install` (le gotcha du crawler, documenté en mémoire projet, s'applique tel quel).
- **Taille du jsonb** : profil capé (200 termes) + lz4. Si une locale dépasse ~100 Ko compressés, réduire N — jamais de grain fin en Postgres.
- **VPS partagé** : le worker DuckDB est déjà borné (`memory_limit=1500MB`, `threads=2`) ; le fetch pool 4 + wasm par page s'inscrit dans la même enveloppe. Un seul job `serp-semantics` à la fois par mot-clé, la queue sérialise le reste.
- **Stemming multilingue** : rust-stemmers couvre les langues Snowball ; langue absente → repli sans stemming + flag (CDC §3.7), jamais de stemmer d'une autre langue.
- **Bun tests** : le service analytics tourne `--conditions=react-server` ; le runner en DI doit rester importable côté test (précédent `crawl-runner.ts` — même pattern, même split).
- **`analytics_jobs.type`** : enum text en pratique — vérifier qu'aucun CHECK constraint ne limite les valeurs avant d'ajouter le type (sinon migration).

## 6. Points ouverts (non bloquants pour S1-S3)

1. **Choix du provider headless** (S7) : API externe candidate à évaluer au moment de la slice (coût/latence).
2. **Facturation** (D6) : QuotaResource vs credit-pack — après mesure du coût réel.
3. **Rationalisation du crawl SERP existant** (UA desktop, markdown 8 Ko) vers le corpus du moteur — après S5, si duplication de fetch avérée en usage.

---

*Après validation : plan d'implémentation détaillé (slice par slice), S1 en premier.*
