# PRP — Autopilot v2: Content Diversity, Human Review & Hardening

**Date:** 2026-07-06 · **Owner:** Islam Baraka · **Reviewer:** Ahmed Fawzy (content)
**Scope:** `server/src/autopilot/*`, `server/src/gemini/decideTopic-related`, publish flow, autopilot routes.

---

## 1 · Problem statement (observed in production)

After ~27 automated posts (July 3–6), the blog shows severe **mode collapse**:

| Symptom | Evidence |
|---|---|
| ~90% of titles are "How to …" | 24 of 27 published titles |
| Almost every post targets "3PLs" | "…for Egyptian 3PLs", "…Saudi 3PLs", "GCC 3PLs"… |
| Zero direct Shiprex marketing posts | No feature spotlights, announcements, comparisons |
| Zero persona/mentor-voice posts | All posts are the same authoritative how-to voice |
| Same author on every post | `SANITY_DEFAULT_AUTHOR_ID` always used |
| No timely/news content | Pipeline has no news input at all |

### Root causes (from code review)

1. **Prompt bias** — `decideTopic.js` frames the audience around "3PL decision-makers" and pushes "high-commercial-intent … rank and sell" → the LLM's attractor is the archetypal SEO post: *"How to X for Y 3PLs"*. Same skew in `keywords.js`.
2. **Stateless runs** — past posts are fed as titles/keywords only; the tone/contentType/titleStyle used before is never recorded or fed back. Nothing tells the model "vary the format".
3. **Self-reinforcing loop** — each run sees a published list full of 3PL how-tos and anchors on the pattern (avoids duplicate *topics*, clones the *style*).
4. **LLM free-picks from the preset menu** — given 7 tones × 8 content types × 8 title styles with no distribution constraint, it picks the safe default (`authoritative/operations/standard`) nearly every time. The fallbacks default to that same trio.
5. **No product-marketing / persona / news modes exist** (only `new | refresh | seo-marketing`).
6. **Direct publish** — `publishPost()` creates a live document at stage 5; no human ever reviews.

---

## 2 · Core design decision

> **The LLM no longer chooses the angle. Code does.**

A **weighted angle sampler** (plain JS + SQLite history) picks
`mode / tone / contentType / titleStyle / audience / author` with a *guaranteed*
distribution and hard anti-repeat rules. The LLM's only creative job becomes:
*"given THIS fixed angle, pick the best topic."* Code guarantees variety; the
LLM supplies creativity.

### Target content mix (configurable in `settings` table, key `content_mix`)

| Mode | Weight | What it produces |
|---|---|---|
| `education` | 28% | Teaching/how-to/checklist content (the current archetype, now capped) |
| `product-marketing` | 20% | Direct Shiprex feature spotlights, announcements, "why Shiprex" |
| `persona` | 14% | Mentor/founder-voice stories ("what I learned running a fleet…") |
| `problem-solution` | 14% | Pain → agitate → solve |
| `comparison` | 10% | "X vs Y", spreadsheet vs system, manual vs automated |
| `news` | 10% | **Newsjacking** — Google-Search-grounded recent MENA logistics news |
| `refresh` | 4% | Re-angle an old post (recorded to avoid re-refreshing the same slug) |

### Audience rotation (7 segments — 3PL becomes 1 of 7, ~14%)

owner/founder · operations manager · fleet manager · finance/accounting lead ·
e-commerce merchant · courier-startup founder · 3PL decision-maker.

### Anti-repeat hard rules (enforced in code, not prompt)

- Never the same `titleStyle` twice in a row; a style used ≥2× in the last 5 posts is excluded.
- Never the same `audience` as the previous 2 posts; never the same `tone` twice in a row.
- A mode over its target share in the last 12 posts gets its weight collapsed.
- Author is picked least-recently-used from Sanity's author list.

---

## 3 · Newsjacking (`news` mode)

New `scanIndustryNews()` uses Gemini's built-in `googleSearch` grounding tool to
fetch recent (≤30 days) MENA logistics/e-commerce/regulation news (ZATCA
deadlines, customs changes, fuel prices, White Friday/Ramadan season, carrier
news). The summary is injected into the topic decision as `newsContext`; the
resulting post reacts to a real, current event.

---

## 4 · Human-in-the-loop (chosen flow: **Sanity draft + email approve links**)

1. Stage `publish` creates a **Sanity draft** (`drafts.post-auto-<uuid>`) instead of a live doc — still a durable checkpoint, still patchable by every enhancement stage, **invisible on the site**.
2. After `save`, new stage **`review`**: a **Brevo transactional email** goes to the reviewers (`REVIEW_NOTIFY_EMAILS` — Islam + Ahmed) containing: EN/AR titles, excerpt, cover image, the sampled angle, the model's rationale, a Studio link, and signed **Approve / Reject** buttons.
3. `GET /api/autopilot/runs/:id/approve?token=…` → promotes the draft to a published document (sets `publishedAt` to approval time), deletes the draft.
   `GET …/reject?token=…` → deletes the draft; the angle stays in history marked rejected (the sampler still counts it, so rejection ≠ repetition).
4. Tokens are `HMAC-SHA256(runId:action, REVIEW_TOKEN_SECRET)` — stateless, no DB table, safe in email links.
5. If Brevo is not configured (`BREVO_API_KEY` empty) the pipeline behaves exactly as today (direct live publish) — nothing breaks on a bare install. `REVIEW_MODE=live|draft` overrides explicitly.

> ⚠️ Secrets live only in `server/.env` (gitignored). The Brevo key that was shared in chat should be **rotated** in the Brevo dashboard after setup.

---

## 5 · Storage: SQLite via `node:sqlite`

Node 22 is confirmed available (local + cPanel), so we use the **built-in**
`node:sqlite` (`DatabaseSync`) — zero native compilation, nothing to build on
shared hosting. DB file: `server/.autopilot/autopilot.db` (gitignored).

| Table | Purpose |
|---|---|
| `runs` | Full run records (JSON column) — replaces the per-run `.json` files; legacy files still readable as a hydrate fallback |
| `angle_history` | One row per produced post: mode/tone/contentType/titleStyle/audience/author/category/keyword/title + approved flag — the sampler's memory |
| `settings` | Key/value config: `content_mix` weights, run lock, future knobs |

The single-run lock moves into the DB (`settings.active_run` with expiry) so it
also holds across **multiple Passenger processes** on cPanel.

---

## 6 · Hardening

| Item | Fix |
|---|---|
| 🔴 Unauthenticated trigger | `AUTOPILOT_API_KEY` env; all `/api/autopilot/*` routes require `x-api-key` header **except** approve/reject (they carry their own HMAC token). Unset key = localhost-style open (dev). |
| 🟠 Posting cadence (7/day looks spammy) | `MAX_POSTS_PER_DAY` (default **2**) checked against `angle_history` before a run starts; `{ "force": true }` bypasses. |
| 🟡 Orphaned "running" runs | Startup sweep marks stale `running` rows as errored. |
| 🟡 No cross-links to our own blog posts | Sanity post slugs (EN + AR) are merged into the internal-linking page list, so new posts link to related existing posts even before the sitemap rebuilds. |
| 🟡 `refresh` never used its target | `refreshOfSlug` is recorded in history; the sampler avoids re-refreshing the same slug; the prompt demands a clearly different angle/audience. |

---

## 7 · New environment variables

```ini
# --- Autopilot v2 ---
AUTOPILOT_API_KEY=            # required in production; sent as x-api-key
MAX_POSTS_PER_DAY=2
BLOG_BASE_URL=https://www.shiprexnow.com

# --- Human review (Brevo) ---
BREVO_API_KEY=                # xkeysib-…  (keep ONLY in .env; rotate the one shared in chat)
BREVO_SENDER_NAME=Shiprex Team
BREVO_SENDER_EMAIL=no-reply@notifications.shiprexnow.com
REVIEW_NOTIFY_EMAILS=islam.baraka.90@gmail.com,ahmed83016@gmail.com
REVIEW_MODE=draft             # draft | live  (default: draft when Brevo configured)
REVIEW_TOKEN_SECRET=          # any long random string; falls back to a derived secret
APP_BASE_URL=                 # public URL of this API (for approve/reject links in email)
```

---

## 8 · Out of scope (future)

- Performance feedback loop (GSC/Analytics data steering topic choice).
- Old-post canonical/update handling for `refresh` (needs Sanity schema fields).
- Review UI in the web app (email links cover the workflow for now).
- Multi-language posting cadence windows / publish-time scheduling.

## 9 · Acceptance criteria

1. 10 consecutive dry runs produce ≥4 distinct modes, ≥4 distinct title styles, ≥4 distinct audiences, and no immediate titleStyle repeats.
2. With Brevo configured: a run ends with a **draft** in Studio + a review email; Approve publishes it; Reject deletes it; both update run + history.
3. A third `POST /run` within a day (default cap 2) is refused with 429.
4. Requests without `x-api-key` are refused with 401 when the key is set.
5. Server still boots and behaves identically when none of the new env vars are set (live publish, no email).
