---
name: ai-theme-analysis
description: |
  Build the "AI theme analysis — what competitors are getting credit for"
  section of an AI Mention Gap report. The section runs an LLM over every
  AI-search response where a tracked competitor was named but the target brand
  was not, and renders 5–8 themed cards. Each card shows praised competitor
  brands, a 2-3 sentence summary, an actionable implication, and 2–4 verbatim
  evidence quotes (question + AI quote + surface tag). Trigger when the user
  asks for "AI mention gap themes", "what AI assistants credit competitors
  for", "competitor-only AI response analysis", or wants a thematic layer on
  top of a gap-queries list.
version: 1.0
inputs:
  - Ahrefs Brand Radar report UUID (target brand + 1–N competitors + tracked
    AI surfaces). The report MUST be configured before running.
  - LLM access (default: Claude Sonnet 4.6 via an OpenAI-compatible endpoint).
  - Optional upstream priority classification (HIGH/MEDIUM/LOW) per question.
outputs:
  - A JSON file `ai_gap_competitor_themes.json` with shape:
    {themes: [...], overall_summary: str, _meta: {model, items_analyzed,
    items_available}}.
  - Server-rendered Jinja section with one collapsible card per theme,
    sorted by `item_count` descending. Section heading carries an info
    button that opens a methodology modal.
---

# AI Theme Analysis — competitor-mentioned AI responses

This skill builds the **"AI theme analysis — what competitors are getting
credit for"** section of an AI Mention Gap report. It is the LLM-driven
narrative layer that sits between the Summary KPIs and the per-priority
HIGH/MEDIUM gap tables.

The skill is **not** a generic LLM theming exercise. It is tightly coupled to
the **Ahrefs Brand Radar** data model:

- Brand Radar runs a library of ~14k SEO/marketing prompts per surface per
  snapshot against ChatGPT, Google AI Overviews and Google AI Mode (and
  optionally Perplexity / Gemini / Copilot / Grok).
- For each response Brand Radar records which tracked brands were mentioned.
- A "gap response" = a response where one or more **competitor** brands were
  matched AND the **target** brand (e.g. Ahrefs) was NOT.

The job of this section is to take those gap responses and tell the user *why*
they keep losing — what topics, framings, and use-cases the AI keeps crediting
the competitors for.

---

## 1. Data inputs — Ahrefs endpoints used

Three Brand Radar endpoints feed this section. They run in a precompute
pipeline that saves intermediate JSON to disk; the Flask report reads only the
final cached `ai_gap_competitor_themes.json`.

### A. `ahrefs_brand_radar.get_report`

Single-call lookup that resolves a saved Brand Radar report by UUID and
returns its full configuration:

- `target` / `brands` cluster (e.g. Ahrefs).
- `competitors` cluster (e.g. Semrush, BrightEdge, Similarweb).
- `niche` (optional broader-context entities).
- `models` — which AI surfaces are tracked (`chatgpt`, `google_ai_overviews`,
  `google_ai_mode`, `gemini`, `perplexity`, `copilot`, `grok`).
- `countries` (ISO codes — default `["us"]`).
- Any saved tags / text filters / custom-query overrides.
- `attached_project` (links Brand Radar to an Ahrefs project for cross-tool
  joins).

**Args:** `{report_id: "<UUID>"}` — case-insensitive.

**Why we call it:** to feed the methodology modal's "tracked brand /
competitors / surfaces" line, and to know which `models` to iterate over in
the next step.

### B. `ahrefs_brand_radar.platforms`

Returns per-surface, per-tracked-entity breakdown counts. The shape (per
distribution row) is:

```json
{
  "mentions": {
    "total": 14802,
    "only_target_brand": 1642,
    "only_competitors_brands": 7050,
    "target_and_competitors_brands": 6110,
    "no_brands": 0
  },
  "citations":   { "total": ..., "only_target_brand": ..., ... },
  "impressions": { "total": ..., "only_target_brand": ..., ... }
}
```

**`only_competitors_brands` on the target's row IS the size of the gap pool**
for that surface — the responses where competitors got airtime and the target
didn't.

**Args:**
```json
{
  "report_id": "<UUID>",
  "models": ["chatgpt"],          // ONE surface per call (see AIO-mix rule)
  "countries": ["us"],
  "queries_dataset": "public_only" // safest default; custom variants 500 on
                                   // workspaces with no custom queries
}
```

**Critical rule — AIO mixing:** Ahrefs splits AI models into two non-mixable
buckets. **Brand Radar AIO** (`google_ai_overviews`, `google_ai_mode`) and
**Chatbot Tracker** (`chatgpt`, `gemini`, `perplexity`, `copilot`, `grok`,
plus the `*_chatbot_tracker` variants) cannot share a single call. If you
need both, fire two calls. Mixing them returns HTTP 400 "Mixed (BR AIO and
chatbot tracker) models provided".

**Save the raw response to disk as `ai_gap_baseline.json`** — one row per
brand per surface. The target brand's `only_competitors_brands` per surface
is the seed count for the KPI tile and the candidate pool size.

### C. `ahrefs_brand_radar.ai_responses_results`

The workhorse. Returns the actual question → response pairs for any filtered
slice of Brand Radar's AI-response corpus. Each row carries:

- `question` — the SEO/marketing prompt asked.
- `response` — the AI's full markdown answer (sometimes thousands of chars).
- `matched_brands` — list of tracked-brand names found in the response.
- `surface` — the AI platform (`chatgpt`, `google_ai_overviews`, etc.).
- Cited sitelinks, search_volume hints, tags, date.

**Args (per surface):**
```json
{
  "report_id": "<UUID>",
  "filters": {
    "country": ["us"],
    "models": ["chatgpt"],
    "date": "latest"
  },
  "report": {
    "brands": [{"keywords": ["ahrefs"], "urls": ["ahrefs.com"]}],
    "competitors": [
      {"keywords": ["semrush"],     "urls": ["semrush.com"]},
      {"keywords": ["brightedge"],  "urls": ["brightedge.com"]},
      {"keywords": ["similarweb"],  "urls": ["similarweb.com"]}
    ],
    "niche": []
  },
  "pagination": {"limit": 1000, "offset": 0},
  "sort_by": "relevance"
}
```

To get **only gap responses**, post-filter the rows: keep where
`target NOT IN matched_brands AND any competitor IN matched_brands`. The API
itself does not have a single "gap" filter — the filtering is client-side
against `matched_brands`. (You could approximate this via Brand Radar's
`text_filter` DSL with `not_contains_partial "Ahrefs"`, but it's brittle
for spelling variants — post-filter on `matched_brands` is canonical.)

**Pagination:** `limit` is 0–1000; `limit=0` returns just the total count.
Paginate via `offset` until you exhaust the dataset (typically 100–500 gap
responses per surface for a single snapshot).

**Save as `ai_gap_responses.json`** — `{surface: [...gap_rows]}` keyed by
surface, one list of gap rows per surface.

### D. (Optional) `ahrefs_keywords_explorer.metrics_by_keywords`

Joined upstream to attach `search_volume` per question. Used to rank the gap
pool before sending to the LLM (top 80 by volume).

**Args:**
```json
{"keywords": ["..."], "country": "us"}
```

Returns per-keyword `{search_volume, keyword_difficulty, cpc, parent_topic,
...}`. Limited to ~700 keywords per call.

**Why:** Without volume, the LLM might pick low-traffic edge-case themes.
Volume-weighted prioritisation surfaces commercially relevant gaps.

### E. (Optional) Upstream priority classifier — pure LLM, no Ahrefs call

A separate precompute step classifies each unique question as HIGH / MEDIUM /
LOW priority via an LLM (Claude Opus 4 in the reference build) against an
SEO-relevance rubric. Saved as `ai_gap_classified.json` with shape:
`[{question, priority, ...}]`. This skill **drops LOW** before LLM theming so
the model isn't drowned in noise.

If you don't have a priority classifier, fall back to the SEO regex filter
(see Step 2 below) — it removes the worst noise and is sufficient for v1.

---

## 2. Metrics extracted

| Field | Source | Used for |
|---|---|---|
| `question` (verbatim) | `ai_responses_results.question` | Theme card evidence quotes |
| `response` (verbatim, truncated) | `ai_responses_results.response` | LLM input — the body of text Claude themes from |
| `matched_brands` | `ai_responses_results.matched_brands` | Per-row gap classification; LLM input ("competitor X was mentioned") |
| `surface` | response key in saved JSON | Evidence tag (AIO / ChatGPT / AI Mode) |
| `search_volume` | `keywords_explorer.metrics_by_keywords` | Sort key for the top-80 LLM input |
| `priority` | upstream classifier | Drop LOW before LLM call |
| Derived: `theme_item_count` | LLM output | Card sort order (desc), badge |
| Derived: `competitor_brands` per theme | LLM output | Brand chips on each card |
| Derived: `ahrefs_implication` | LLM output | "What to do next" callout per card |
| Derived: `evidence` (2–4 quotes) | LLM output, verbatim from input | Quote list under each card |

---

## 3. Steps — precompute pipeline

Run as a one-shot script (`analyze_competitor_themes.py` in the reference
build). Idempotent — rerunning regenerates the cache.

1. **Load gap responses.** Read `ai_gap_responses.json` — a dict of surface
   → list of gap-response rows. Each row must have `question`, `response`,
   `matched_brands`, optionally `search_volume`.
2. **Load priority map** from `ai_gap_classified.json` (if available):
   `{question: "HIGH" | "MEDIUM" | "LOW"}`.
3. **Apply SEO-relevance regex filter.** Keep only rows whose `question` OR
   `response` text matches the SEO/marketing pattern bank (see Section 4). A
   single Brand Radar snapshot contains a long tail of off-topic prompts
   (NSFW, pirate sites, totally unrelated queries) — drop them.
4. **Drop LOW-priority items** using the priority map. If a question is
   missing from the map, default to LOW (conservative — better to drop than
   contaminate).
5. **Deduplicate by question.** A question can appear in multiple surfaces.
   Collapse into one entry: `{question, surfaces: [list], responses: [list],
   search_volume, priority}` — preserve all responses (LLM sees richer
   context) but only one entry per question.
6. **Sort by `search_volume` desc.** This is the only volume-weighted step;
   the LLM doesn't see volume directly, but its top-N input is volume-ranked.
7. **Cap to 80 items.** Beyond ~80 the prompt becomes too long for a single
   Claude call (cost + context-window pressure). 80 is the reference cap;
   200K-context models can handle more, but quality plateaus past ~100.
8. **Truncate each response to 1,800 chars** + ellipsis. Pick the FIRST
   response in `responses` (which is the highest-relevance surface — `sort_by:
   relevance` in step C). Whitespace-collapse before truncation.
9. **Build the LLM prompt body.** One block per item with `--- ITEM N ---`
   delimiter, `Question:`, `Search volume:`, `Surfaces:`, `Priority:`,
   `AI response (competitors mentioned, Ahrefs not):`.
10. **Call the LLM** with the strict system prompt from Section 5,
    `temperature=0.2`, JSON response forced (no markdown wrapper). Strip code
    fences defensively before `json.loads`.
11. **Validate the JSON.** If parsing fails, save the raw output to a sibling
    `.raw` file for debugging; **never** silently fall back to partial data.
12. **Inject `_meta`:** `{model, items_analyzed, items_available}`.
13. **Save to `ai_gap_competitor_themes.json`.** This is what the Flask view
    reads — no live LLM call at render time.

---

## 4. SEO-relevance regex filter

Pattern list, case-insensitive, OR'd together. Tune for the vertical — this
list is for SEO tooling competitor gaps:

```python
SEO_PATTERNS = [
    r"\bseo\b", r"\bsearch engine\b", r"\bkeyword", r"\bbacklink",
    r"\branking", r"\bsemrush", r"\bahrefs", r"\bmoz\b",
    r"\bsimilarweb", r"\bbrightedge",
    r"\bcontent marketing", r"\bsearch console", r"\bgsc\b", r"\bserp",
    r"\borganic search", r"\bgoogle ads", r"\bppc\b",
    r"\bcompetitor", r"\bdomain authority", r"\bda\b", r"\bcrawl",
    r"\bdigital marketing", r"\baffiliate", r"\binbound", r"\boutreach",
    r"\blink build", r"\bon[- ]page", r"\boff[- ]page",
    r"\btraffic analysis", r"\bweb analytics", r"\bsite audit",
    r"\bsiterank",
]
```

Apply via `re.search(combined_pattern, text, re.I)` against `question OR
response`. Test against your specific gap dataset before shipping — if you're
in a different vertical (e-commerce, fintech, etc.) replace the bank entirely.

---

## 5. LLM system prompt (verbatim — DO NOT paraphrase in production)

The model is `anthropic/claude-sonnet-4.6` in the reference build. GPT-4o or
Claude Opus 4 work as drop-ins with the same prompt; smaller models (Haiku,
GPT-4o-mini) tend to fabricate quotes — verify before substituting.

```text
You are an SEO competitive intelligence analyst.

You will receive AI-generated responses to SEO/marketing questions where a
competitor brand (Semrush, BrightEdge, or Similarweb) was mentioned but
Ahrefs was NOT.

Your job: synthesise the dominant THEMES across these responses. A theme is
a recurring topic, use case, or framing where competitors are getting credit
and Ahrefs is being left out.

Output STRICT JSON only, no markdown. Schema:

{
  "themes": [
    {
      "title": "Short theme name (5-7 words)",
      "summary": "2-3 sentence summary of what AI keeps saying in this theme, framed as the gap for Ahrefs.",
      "competitor_brands": ["Semrush", ...],
      "ahrefs_implication": "1-2 sentence recommendation for what Ahrefs should do to close the gap.",
      "evidence": [
         {
           "question": "The exact question text from the source item.",
           "quote": "A direct quote (verbatim, 15-40 words) from the AI response that captures the theme. Include the competitor brand name where mentioned.",
           "surface": "google_ai_overviews | chatgpt | google_ai_mode"
         }
      ],
      "item_count": 4
    }
  ],
  "overall_summary": "3-5 sentence executive summary describing the cross-theme pattern."
}

Rules:
- 5 to 8 themes total. Order by item_count descending (most-supported first).
- Quotes must be VERBATIM from the responses you receive. Do not paraphrase.
  If you cannot find a verbatim quote, drop that evidence row rather than
  invent one.
- Themes should be MUTUALLY DISTINCT. Don't repeat the same idea under
  different names.
- Skip non-SEO noise. If a theme would be mostly off-topic, drop it.
- Ahrefs implication should be concrete (e.g. "Refresh the Best Keyword
  Research Tools article with stronger entity signals so Ahrefs is named
  in AI Overviews").
```

When adapting to another brand: replace `Ahrefs` and the competitor list in
both the prompt body and the `ahrefs_implication` field name (rename to
`brand_implication`).

---

## 6. Rendering — Jinja template + CSS

The section sits in the report's main HTML between Summary KPIs and the
priority gap tables. It is wrapped in a section header with the standard info
button + methodology modal pattern.

### 6.1 Section header

```jinja
{% if competitor_themes and competitor_themes.themes %}
<div class="amg-section-header" style="border-bottom-color: hsl(285, 35%, 30%);">
  <h2 style="color: hsl(285, 55%, 80%);">
    AI theme analysis — what competitors are getting credit for
    <button class="amg-info-btn" data-method="themes"
            title="How was this calculated?"
            aria-label="How was this calculated?">i</button>
  </h2>
  <p>{LLM_MODEL_NAME} analysed every AI response where competitors were
     named but {BRAND_NAME} wasn't. Themes are sorted by the number of gap
     responses that support each one, with direct quotes as evidence.</p>
</div>
```

The `data-method="themes"` attribute opens a modal populated from
`METHODOLOGY["themes"]` (see Section 7).

### 6.2 Theme card — one per theme

```jinja
{% for theme in competitor_themes.themes %}
<div class="amg-theme amg-theme-collapsible">
  <div class="amg-theme-head"
       onclick="this.closest('.amg-theme-collapsible').classList.toggle('open')">
    <div class="amg-theme-head-left">
      <div class="amg-theme-title">
        <svg class="amg-theme-chev" viewBox="0 0 24 24" fill="none"
             stroke="currentColor" stroke-width="2">
          <path d="M9 18l6-6-6-6"/>
        </svg>
        <span class="amg-theme-rank">#{{ loop.index }}</span>
        <h3>{{ theme.title }}</h3>
      </div>
      {% if theme.competitor_brands %}
      <div class="amg-theme-brands">
        {% for b in theme.competitor_brands %}
          <span class="amg-theme-brand">{{ b }}</span>
        {% endfor %}
      </div>
      {% endif %}
    </div>
    <div class="amg-theme-count">
      <div class="num">{{ theme.item_count }}</div>
      <div class="lbl">items</div>
    </div>
  </div>
  <div class="amg-theme-body">
    <p class="amg-theme-summary">{{ theme.summary }}</p>

    {% if theme.ahrefs_implication %}
    <div class="amg-theme-implication">
      <span class="lbl">Ahrefs implication</span>
      {{ theme.ahrefs_implication }}
    </div>
    {% endif %}

    {% if theme.evidence %}
    <div class="amg-theme-evidence">
      <p class="lbl">Direct quotes — evidence</p>
      {% for ev in theme.evidence %}
      <div class="amg-quote">
        <div class="q-head">
          <span class="q-q">Q: {{ ev.question }}</span>
          {% if ev.surface == 'google_ai_overviews' %}
            <span class="q-surface aio">AIO</span>
          {% elif ev.surface == 'chatgpt' %}
            <span class="q-surface chatgpt">ChatGPT</span>
          {% elif ev.surface == 'google_ai_mode' %}
            <span class="q-surface aim">AI Mode</span>
          {% endif %}
        </div>
        <p class="q-text">{{ ev.quote }}</p>
      </div>
      {% endfor %}
    </div>
    {% endif %}
  </div>
</div>
{% endfor %}

<p class="amg-theme-meta">
  Analysis cached &middot;
  {% if competitor_themes._meta %}
    {{ competitor_themes._meta.items_analyzed }} of
    {{ competitor_themes._meta.items_available }} gap responses analysed by
    {{ competitor_themes._meta.model }}
  {% endif %}
</p>
{% endif %}
```

### 6.3 CSS — minimum needed

Match the design tokens of the parent page. Reference build uses warm-purple
hues for this section (`hsl(285, 55%, 80%)` for headings).

```css
.amg-theme { background: var(--amg-card); border: 1px solid var(--amg-border);
             border-radius: 8px; margin-bottom: 0.75rem; }
.amg-theme-head { display: flex; align-items: flex-start;
                  justify-content: space-between;
                  padding: 0.85rem 1rem;
                  border-bottom: 1px solid var(--amg-border); gap: 1rem; }
.amg-theme-collapsible .amg-theme-head { cursor: pointer; }
.amg-theme-collapsible .amg-theme-head:hover { background: rgba(255,255,255,0.02); }
.amg-theme-collapsible:not(.open) .amg-theme-head { border-bottom-color: transparent; }
.amg-theme-collapsible:not(.open) .amg-theme-body { display: none; }
.amg-theme-chev { width: 0.85rem; height: 0.85rem;
                  transition: transform 0.15s; color: var(--amg-fg-dim);
                  flex-shrink: 0; }
.amg-theme-collapsible.open .amg-theme-chev { transform: rotate(90deg); }

.amg-theme-title { display: flex; align-items: center; gap: 0.55rem; }
.amg-theme-title h3 { margin: 0; font-size: 0.95rem; font-weight: 700;
                      color: var(--amg-fg); }
.amg-theme-rank { color: var(--amg-fg-dim); font-size: 0.72rem;
                  font-weight: 700; }

.amg-theme-brands { margin-top: 0.5rem; display: flex; flex-wrap: wrap;
                    gap: 0.35rem; }
.amg-theme-brand { font-size: 0.7rem; font-weight: 600;
                   color: hsl(285, 55%, 80%);
                   background: hsl(285, 35%, 22%);
                   padding: 0.15rem 0.5rem; border-radius: 4px; }

.amg-theme-count { text-align: right; flex-shrink: 0; }
.amg-theme-count .num { color: var(--amg-fg); font-weight: 800;
                        font-size: 1.4rem; line-height: 1; }
.amg-theme-count .lbl { color: var(--amg-fg-dim); font-size: 0.62rem;
                        font-weight: 800; letter-spacing: 0.06em;
                        text-transform: uppercase; margin-top: 0.2rem; }

.amg-theme-body { padding: 1rem 1.2rem 1.1rem; }
.amg-theme-summary { color: var(--amg-fg); margin: 0 0 0.85rem;
                     font-size: 0.86rem; line-height: 1.55; }

.amg-theme-implication { background: rgba(96,165,250,0.05);
                         border-left: 3px solid var(--amg-accent);
                         padding: 0.6rem 0.85rem;
                         border-radius: 4px; margin-bottom: 0.9rem;
                         font-size: 0.83rem; }
.amg-theme-implication .lbl { display: block; color: var(--amg-accent);
                              font-weight: 700; font-size: 0.7rem;
                              letter-spacing: 0.06em;
                              text-transform: uppercase;
                              margin-bottom: 0.25rem; }

.amg-theme-evidence .lbl { color: var(--amg-fg-dim); font-size: 0.7rem;
                           font-weight: 700; letter-spacing: 0.06em;
                           text-transform: uppercase;
                           margin: 0 0 0.45rem; }
.amg-quote { background: var(--amg-card-2);
             border: 1px solid var(--amg-border);
             border-radius: 4px; padding: 0.6rem 0.85rem;
             margin-bottom: 0.5rem; }
.q-head { display: flex; align-items: flex-start; justify-content: space-between;
          gap: 0.7rem; margin-bottom: 0.3rem; }
.q-q { color: var(--amg-fg-dim); font-size: 0.74rem; font-weight: 600;
       flex: 1; min-width: 0; }
.q-surface { font-size: 0.62rem; font-weight: 800; letter-spacing: 0.06em;
             text-transform: uppercase; padding: 0.1rem 0.4rem;
             border-radius: 3px; flex-shrink: 0; }
.q-surface.aio { background: hsl(220, 45%, 20%); color: hsl(220, 70%, 80%); }
.q-surface.chatgpt { background: hsl(150, 40%, 18%); color: hsl(150, 60%, 75%); }
.q-surface.aim { background: hsl(285, 40%, 22%); color: hsl(285, 60%, 80%); }
.q-text { margin: 0; color: var(--amg-fg); font-size: 0.85rem;
          line-height: 1.5; font-style: italic; }

.amg-theme-meta { color: var(--amg-fg-dim); font-size: 0.75rem;
                  text-align: center; margin-top: 0.8rem; }
```

### 6.4 No JS needed for the cards themselves

The collapse/expand is a one-line `onclick` toggling `.open`. Methodology
modal JS is shared with other sections (see Section 7).

---

## 7. Methodology modal — schema for the `themes` key

The info button next to the `<h2>` opens a modal populated from a JS const
`METHODOLOGY` injected as `{{ methodology | tojson }}` in the Jinja template.
The shared modal markup + JS is described in the sibling skill
`ai-crossref-commentary` — reuse it. The only thing this skill provides is
the `themes` entry:

```python
METHODOLOGY["themes"] = {
  "title": "AI theme analysis (competitor-mentioned responses)",
  "intro": "This section runs an LLM analysis (Claude Sonnet 4.6) over every "
           "gap response — i.e. AI responses where Semrush / BrightEdge / "
           "Similarweb were mentioned but Ahrefs was not. The goal is to "
           "surface the recurring themes (not just keywords) where Ahrefs is "
           "being left out, with direct quotes as evidence.",
  "endpoints": [
    ("ahrefs_brand_radar.get_report",
     "Resolves the Brand Radar report config — target brand, competitors, "
     "tracked AI surfaces, country, and any saved text filter / tags."),
    ("ahrefs_brand_radar.platforms",
     "Per-surface, per-tracked-entity counts (`mentions.only_target_brand`, "
     "`only_competitors_brands`, `target_and_competitors_brands`, "
     "`no_brands`). The target row's `only_competitors_brands` is the size "
     "of the gap pool per surface."),
    ("ahrefs_brand_radar.ai_responses_results",
     "The source of every analysed response — question, response text, and "
     "`matched_brands` per AI surface. Paginated up to 1000 per call."),
    ("ahrefs_keywords_explorer.metrics_by_keywords",
     "Used upstream to attach `search_volume` to each question for sorting."),
    ("(LLM analysis)",
     "Claude Sonnet 4.6 receives the cleaned gap responses and returns a "
     "structured JSON of themes, each with a summary, the praised competitor "
     "brands, an Ahrefs implication, and 2–4 verbatim direct quotes."),
  ],
  "metrics": [
    "`question` + `response` + `matched_brands` from every gap row",
    "`search_volume` (Keywords Explorer) used to rank/select the most "
    "consequential responses",
    "`priority` from the upstream classifier — only HIGH/MEDIUM gaps are "
    "sent (LOW is noise-filtered)",
  ],
  "steps": [
    "Load `ai_gap_responses.json` — per-surface gap responses pulled from "
    "`ai_responses_results`.",
    "Filter to questions whose question OR response text matches an "
    "SEO/marketing regex (skip NSFW/pirate/totally off-topic noise).",
    "Drop LOW-priority items per `ai_gap_classified.json` so the LLM only "
    "analyses commercially relevant gaps.",
    "Deduplicate by question (a question can appear in multiple surfaces — "
    "keep all responses but only one entry per question).",
    "Sort by `search_volume` descending, take the top 80, truncate each "
    "response to 1,800 chars to fit context.",
    "Send a single batched prompt to Claude Sonnet 4.6 with a strict JSON "
    "schema demanding: 5–8 distinct themes, each with title / summary / "
    "competitor_brands / ahrefs_implication / 2–4 verbatim quotes / "
    "item_count.",
    "Render the returned themes in priority order (item_count desc). Each "
    "card shows praised brands, theme summary, the recommended Ahrefs "
    "response, and the quoted evidence (question + verbatim AI quote + "
    "surface tag).",
  ],
  "notes": "Quotes are LLM-attributed but were generated under a 'verbatim "
           "from supplied responses' constraint — they should match the "
           "source text. The analysis is cached on disk "
           "(`ai_gap_competitor_themes.json`); re-running the precompute "
           "script regenerates it.",
}
```

---

## 8. Gotchas (don't skip)

- **AIO + Chatbot can't mix in one `platforms` / `ai_responses_results` call.**
  Two separate calls (or two surfaces in a loop). HTTP 400 otherwise.
- **`queries_dataset: "custom_only"` (or `effective_custom`) returns HTTP 500
  on workspaces without custom Brand Radar queries.** Use `public_only`
  unless you've set custom queries up.
- **Gap classification is client-side.** Brand Radar doesn't have a single
  "Ahrefs missing but competitors present" filter. You filter on the
  `matched_brands` array after fetching. Don't trust a `text_filter` with
  `not_contains_partial "Ahrefs"` — it misses spelling variants and
  capitalisation edge cases.
- **A single non-SEO prompt can absorb 10–20% of the gap pool.** Always
  apply the SEO-relevance regex BEFORE picking top-N by volume.
- **LLM hallucinated quotes are the #1 failure mode.** The system prompt
  explicitly says "verbatim, drop if you can't find one". Verify the first 2
  themes' quotes by full-text searching the source `response` field after
  the first run — if more than ~10% of quotes are paraphrased, swap to a
  larger model (Opus / GPT-4o) and re-run.
- **Truncate at 1,800 chars per response, not per question.** A single
  question can have several attached responses (one per surface); only the
  first (highest-relevance) goes into the LLM input.
- **Cap at 80 items.** Beyond ~80, theme quality plateaus and prompt cost
  doubles. The reference build's input is ~92 items available, 80 sent.
- **Order matters.** `item_count` desc on render. Don't accept the LLM's
  random output order — even with the rule, models sometimes return out of
  order. Sort server-side before `render_template`.
- **JSON parse defensively.** Strip ```` ```json ```` code fences before
  `json.loads`. Save raw on parse failure to `.raw` for debugging.
- **Cache the result.** No live LLM call at request time. The Flask view
  reads the JSON from disk; the precompute is run on demand (or via a job).

---

## 9. Ahrefs MCP — fallback / alternative when this connector layer isn't available

The agent-platform connector layer used here is a typed wrapper around
**Ahrefs API v3**. If you're rebuilding this elsewhere (Claude Desktop /
Cursor / ChatGPT with MCP, or any agent that supports MCP servers) using the
**official Ahrefs MCP server** (`@ahrefs/mcp` or the remote MCP at
`https://mcp.ahrefs.com`), here's the mapping.

### Available via Ahrefs MCP (drop-in)

Ahrefs' official remote MCP exposes the v3 REST surface as ~112 MCP tools.
**Brand Radar IS on MCP** — the same endpoints used here are accessible:

| What this skill needs | Ahrefs MCP / API v3 tool name |
|---|---|
| Resolve a saved Brand Radar report | `brand-radar_get-report` (v3 path: `/v3/brand-radar/get-report`) |
| Per-surface gap counts (only_target / only_competitors / both / none) | `brand-radar_platforms` (`/v3/brand-radar/platforms`) |
| Raw AI responses with matched brands | `brand-radar_ai-responses-results` (`/v3/brand-radar/ai-responses-results`) — paginate via `pagination.limit` (0–1000) + `pagination.offset` |
| Share of Voice cluster ranking | `brand-radar_share-of-voice` |
| Search volume per question | `keywords-explorer_metrics-by-keywords` (`/v3/keywords-explorer/keywords-metrics-by-keywords`) |
| Cited domains / cited pages cross-reference | `brand-radar_cited-domains` / `brand-radar_cited-pages` |
| Overview stats for the AI corpus | `brand-radar_get-overview-stats` |

Tool names use kebab-case in the MCP server but the underlying API v3 paths
are identical. Pass arguments as a JSON object exactly matching the v3
request body shape.

### Pre-requisites for using Brand Radar via MCP

1. **Ahrefs subscription with Brand Radar add-on** — Brand Radar is sold per
   AI platform ($199/mo single, $699/mo all-platforms) on top of any paid
   Ahrefs plan. Without the add-on, `brand-radar_*` tools return 403.
2. **A saved Brand Radar report** — created in the Ahrefs UI with the target
   brand, competitors, niche, surfaces, country and (optional) custom queries
   pre-configured. The skill is driven by `report_id`; you cannot build a
   report from scratch via MCP in one shot.
3. **Enterprise plan API access** or the included Integration API units
   (Lite 25k/mo → Enterprise 2M/mo). Each MCP call costs ≥50 units; this
   skill burns ~10–30 calls per refresh (one `get_report`, one `platforms`
   per surface, one or more paginated `ai_responses_results` per surface).

### Not directly on MCP

- **The LLM analysis itself** — the Ahrefs MCP returns *data*, not themes.
  The theming step is your own LLM call (Claude / GPT / etc.) using the data
  pulled via MCP. Plug your model of choice into the system prompt in
  Section 5.
- **The priority classification step** — same: your own LLM call against an
  SEO-relevance rubric. The MCP doesn't classify questions for you.

### Suggested MCP-only setup

In Claude Desktop / Claude Code / Cursor `mcp.json`:

```json
{
  "mcpServers": {
    "ahrefs": {
      "url": "https://mcp.ahrefs.com",
      "headers": {"Authorization": "Bearer <API_KEY>"}
    }
  }
}
```

Local Node install (alternative):

```bash
npm install --prefix=~/.global-node-modules @ahrefs/mcp -g
```

```json
{
  "mcpServers": {
    "ahrefs": {
      "command": "npx",
      "args": ["--prefix=~/.global-node-modules", "@ahrefs/mcp"],
      "env": {"API_KEY": "<API_KEY>"}
    }
  }
}
```

### If you do NOT have Brand Radar (no add-on, no API access)

There is **no clean substitute** — Brand Radar is the only large-scale
managed prompt-and-response corpus available right now. Workarounds:

- **Build your own prompt-response corpus.** Maintain a list of 500–2k
  SEO/marketing prompts (informed by Keywords Explorer's "Questions" report
  or GSC top queries). Hit each AI platform's API (ChatGPT API,
  Gemini API, Perplexity API, Claude API) on a schedule, store responses,
  scan for tracked brand mentions yourself. This is the lowest-fidelity path
  — you lose Google AI Overviews coverage entirely (no public AIO API) and
  you have to build the brand-mention extractor from scratch.
- **Use Semrush AI Toolkit / Profound / OmniSEO / Otterly.ai** — competing
  AI-visibility products. None expose an MCP server publicly; you'd need to
  scrape their UI or export CSV. The data model is close enough that the
  theming step (Steps 1–13) ports directly.
- **Hybrid: GA4 referrer-classification + manual prompt sampling.** Identify
  the topics where you're losing AI traffic by looking at GA4's
  `session_source` matched against AI host regex (`chatgpt.com`,
  `claude.ai`, `perplexity.ai`, `gemini.google.com`,
  `copilot.microsoft.com`). Then *manually* sample 50 prompts per topic and
  run the LLM theming step over those. Loses scale; preserves the
  methodology.

### Degraded methodology when Brand Radar isn't available

- The "only_competitors_brands" KPI is computed from your own brand-matcher
  over your home-grown corpus. Be conservative about brand-name variants.
- The "surfaces" tag in evidence still works but is limited to the platforms
  you have API access to (no AIO).
- `search_volume` join via `keywords_explorer.metrics_by_keywords` still
  works on its own — Keywords Explorer is on MCP and doesn't require Brand
  Radar.
- The LLM prompt is unchanged.

---

## 10. Wiring checklist

1. **Confirm Brand Radar report exists** and note its UUID.
2. **Configure connector / MCP secret** — Ahrefs API key with Integration
   API units available.
3. **Run precompute script** (`analyze_competitor_themes.py` or equivalent):
   - Step 0: pull `get_report` to confirm config matches expectations.
   - Step 1: per surface, pull `platforms` to size the gap pool. Save as
     `ai_gap_baseline.json`.
   - Step 2: per surface, paginate `ai_responses_results`, client-side filter
     to gap rows. Save as `ai_gap_responses.json`.
   - Step 3: extract unique questions, call
     `keywords_explorer.metrics_by_keywords` for `search_volume`.
   - Step 4: (optional) run upstream priority classifier; save
     `ai_gap_classified.json`.
   - Step 5: run the theming LLM call described in Section 5. Save to
     `ai_gap_competitor_themes.json`.
4. **Add the `themes` entry** to your Flask report's `METHODOLOGY` dict.
   Pass `methodology=METHODOLOGY` to `render_template`.
5. **Embed the Jinja section** (Section 6.1 + 6.2) between Summary KPIs and
   the priority gap tables.
6. **Add the CSS** (Section 6.3) — adapt design tokens to your colour scheme.
7. **Confirm the methodology modal JS** is already wired (from sibling skill
   `ai-crossref-commentary`). If not, copy the JS pattern: a single
   delegated `click` handler on `[data-method]` that opens an overlay
   populated from `METHODOLOGY[key]`.
8. **(Optional) schedule a refresh job** — Brand Radar updates daily on most
   surfaces; rerunning the precompute weekly is typically enough.

---

## 11. Acceptance test

- ✓ The section appears between Summary KPIs and HIGH-priority gaps.
- ✓ Section header has a visible `i` info button.
- ✓ Clicking the button opens a modal listing every endpoint, every metric,
  every step, plus the notes callout.
- ✓ At least 5 theme cards render, sorted by `item_count` desc.
- ✓ Each card has: rank badge (#1, #2, …), title, optional brand chips,
  item-count number, summary paragraph, optional implication callout,
  evidence list with question + quote + surface tag.
- ✓ Clicking a card head toggles `.open` on the parent — chevron rotates,
  body shows/hides.
- ✓ All evidence quotes can be found verbatim in the source `responses` in
  `ai_gap_responses.json` (spot-check 3 quotes per theme).
- ✓ Footer line shows `{items_analyzed} of {items_available} … analysed by
  {model}`.
- ✓ No live LLM call fires when the page is rendered — purely cached JSON.
- ✓ Re-running the precompute script regenerates the cache without changing
  the schema.
- ✓ Other report sections continue to render unchanged.
