---
name: mailforge-ai-pipeline
description: LLM-Pipeline, System-Prompts und Hero-Bild-Generation für MailForge. Nutze diesen Skill bei Änderungen an `src/lib/ai/pipeline.ts`, `suggest-briefing.ts`, `draft-brief.ts`, `suggest-section.ts`, `hero-image.ts`, am `produce_email`-Tool-Schema, an den Default-System-Prompts in `src/lib/db/defaults.ts`, oder beim Editieren der User-Prompts in `app_settings.system_prompt_*`. Triggert auch bei "LLM gibt kein JSON", "trailing comma", "JSON-Parse-Error aus dem Modell", "max_tokens erreicht", "Output abgeschnitten", "rewrite vs preserve verstehen", "Hero-Bild generiert nicht", "fal.ai schlägt fehl", "image_brief schwach", "warum nutzt das Modell `~latest` nicht mehr", "System-Prompt-Default fehlt". Nicht für: was passiert NACH dem LLM-Call (→ mailforge-send-pipeline + mailforge-section-authoring).
---

# MailForge AI-Pipeline

Wie MailForge mit dem LLM spricht: ein Tool-Call, deterministische Pinning-Strategie, robust gegen typische Sonnet-Output-Macken (trailing comma, abgeschnittene Strings). Und wie Hero-Bilder via fal.ai entstehen.

## Architektur in einem Bild

```
User-Briefing (UI)
   ↓
suggest-briefing.ts   ← optional: aus User-Prompt → strukturierter Brief
draft-brief.ts        ← optional: aus Brand+Prompt → Prose-Brief
   ↓
pipeline.ts::generate
   - System-Prompt aus app_settings.system_prompt_generate (Fallback: defaults.ts)
   - User-Message + Mode (rewrite/preserve)
   - OpenRouter API + openai-SDK + Tool-Choice 'produce_email'
   ↓
   Tool-Call response → jsonrepair → Zod-Validierung
   ↓
   { strategy: {...}, mjml: "<mjml>..." }
   ↓
   image_brief separat → hero-image.ts → fal.ai (flux/schnell)
   ↓
   Strategy gespeichert; MJML weiter zu render-template + inject-sections (→ mailforge-send-pipeline)
```

## Single-Roundtrip-Pattern

Ein einziger Tool-Call `produce_email` liefert **alles**: Strategy + komplettes MJML. Keine Multi-Step-Chains, keine separaten Calls für Subject/Body/Image-Brief.

**Warum so:**
- Strategy und MJML müssen kohärent sein (subject_variants müssen zum Body passen).
- Sonnet-4.6 hat 64k Output-Budget → 32k reichen mit Reserve für eine vollständige Mail.
- Tool-Choice `{ type: 'function', function: { name: 'produce_email' } }` zwingt das Model in strukturiertes JSON.
- Kein retry-Loop für Format-Korrektur nötig.

**Wenn du das Pattern aufbrichst:** sehr genau überlegen. Die Kohärenz zwischen Strategy.subject_variants und MJML-Body bricht in zwei-Step-Chains regelmäßig, und der Token-Overhead ist hoch.

## Model-Pinning

```typescript
// src/lib/ai/pipeline.ts:15
const DEFAULT_MODEL = 'anthropic/claude-sonnet-4.6';
```

- **Nicht** `~anthropic/claude-sonnet-latest` — der Tilde-Pointer routet bei neuen Releases automatisch um und ändert das Tool-Use-Verhalten.
- Pin wird auch in `suggest-briefing.ts`, `draft-brief.ts`, `suggest-section.ts` gleich gehalten.
- Wenn du einen Model-Wechsel testest: in **einem** File ändern, A/B-Vergleich mit echtem Briefing fahren, erst dann global rollouten.
- Token-Limits sind modell-spezifisch — bei neuem Modell `MAX_TOKENS` neu kalibrieren.

## `MAX_TOKENS` & Output-Trunkierung

| File | `MAX_TOKENS` | Warum |
|---|---|---|
| `pipeline.ts` (produce_email) | **32000** | Volles MJML kann lang werden. 8k war zu knapp → Unterminated-String-Fehler |
| `suggest-briefing.ts` | 2000 | Strukturierte Felder, kurz |
| `draft-brief.ts` | 4000 | Prose, mittellang |

Wenn `finish_reason === 'length'` (Output abgeschnitten) → `pipeline.ts` wirft eine sprechende Fehlermeldung (`Model-Output abgeschnitten (max_tokens=...) — Bitte Briefing kürzen oder MAX_TOKENS in pipeline.ts erhöhen.`). Niemals stillschweigend retryen mit höherem Limit — das versteckt das eigentliche Problem (zu kompakter MJML-Style oder zu großer User-Brief).

## JSON-Repair vor Zod

```typescript
import { jsonrepair } from 'jsonrepair';

const raw = toolCall.function.arguments;
const repaired = jsonrepair(raw);  // fix trailing commas, unquoted keys, etc.
const parsed = ProduceEmailSchema.parse(JSON.parse(repaired));
```

Sonnet-4.6 liefert gelegentlich:
- Trailing-Comma in Arrays / Objects
- Single-Quotes statt Double-Quotes
- Unterminated Strings am Ende (bei Max-Tokens-Hit) — das fängt `jsonrepair` **nicht** ab, das ist der Trunkierungs-Fall

**Reihenfolge ist wichtig:** erst `jsonrepair` (string → string), dann `JSON.parse`, dann Zod. Wenn du Zod weglässt: Schema-Drift im Tool-Output bleibt unentdeckt bis ein Edge-Case in Production kracht.

## `produce_email` — das Tool-Schema

Required Output-Felder (siehe `pipeline.ts:130`):

```
subject_variants (string[])
preheader (string)
hero_headline (string)
intro (string)
pain_point (string)
solution (string)
benefits (string[])
cta_primary_text (string)
ps_line (string)
signature (string)
image_brief (string)     ← Lifestyle-Brief für fal.ai, kein Produktshot, kein On-Image-Text
```

Plus: `mjml` (string) — das komplette MJML mit allen Platzhaltern (`{{HERO_URL}}`, `{{CTA_PRIMARY_URL}}`, `{{FIRST_NAME}}`, `{{UNSUBSCRIBE_URL}}` etc.).

### Wenn du das Schema erweitern willst

1. `ProduceEmailSchema` in `pipeline.ts` ergänzen (Zod)
2. JSON-Schema im `tool.function.parameters` synchron halten — Zod und JSON-Schema sind **nicht** automatisch verbunden
3. System-Prompt updaten: das Modell muss wissen, was das neue Feld bedeutet
4. Downstream-Konsument prüfen: wer liest `strategy.<neuesFeld>`?
5. DB-Schema prüfen — wenn das Feld persistiert werden soll, neuer Column in `campaignVariants` (→ mailforge-data-layer)

## `rewrite` vs `preserve` Modes

Zwei strikte Modi, vom UI per Toggle gesetzt (`src/lib/ai/pipeline.ts:111`):

### `rewrite`
- LLM formuliert Body komplett selbst aus dem Briefing.
- Volle kreative Kontrolle, volles Brand-Voice-Respect.
- Default für die meisten Use-Cases.

### `preserve`
- User hat fertigen Brief geliefert (`briefHtml` im Input).
- LLM übernimmt **wortwörtlich** in `<mj-text>`-Blöcke. Korrigiert nur Layout (Bullets, Absätze, Headings).
- Ergänzt **nur** was im Brief fehlt: Anrede mit `{{FIRST_NAME}}`, P.S.-Zeile (falls Deadline gesetzt), Footer.
- Subject-Varianten, Preheader, Hero-Headline, `image_brief` werden **trotzdem** selbst generiert (der User-Brief deckt nur den Body).

Wer den Mode-Switch im Code anfasst: `i.mode === 'preserve' ? '...' : '...'` Ternary in `pipeline.ts:492` und Mode-Hint in System-Prompt-Sektion `pipeline.ts:111`.

## System-Prompts

```
app_settings.system_prompt_generate   ← Main Generate-Prompt
app_settings.system_prompt_suggest    ← Suggest-Briefing (extraction)
app_settings.system_prompt_image      ← Image-Brief-Refinement
```

- **Settings-UI ist die Wahrheit.** User kann pflegen.
- **`defaults.ts` ist Bootstrap.** Wird beim ersten Seed übernommen, danach **nicht** mehr (es sei denn, self-heal — siehe mailforge-data-layer).
- Wenn du in `defaults.ts` einen Prompt änderst und der Server hat schon eine DB: User-Wert in `app_settings` bleibt stehen. Bewusste Migration nötig.

### Wann welcher Prompt

| Prompt | File / Funktion | Use |
|---|---|---|
| `system_prompt_generate` | `pipeline.ts::generate` | Komplette Mail produzieren |
| `system_prompt_suggest` | `suggest-briefing.ts` | User-Rohtext → strukturierter Brief |
| `system_prompt_image` | `hero-image.ts` (Refinement-Step) | `image_brief` schärfen vor fal.ai-Call |

Suggest-Section (`suggest-section.ts`) baut seinen Prompt inline aus Brand-Kontext + Section-Hint, **kein** Settings-Override (kann man später hinzufügen, wenn User-Customization gewünscht).

## Was das LLM **nicht** generieren darf

Cross-Reference zu `mailforge-send-pipeline` — diese Platzhalter müssen als Literal-Tokens im MJML stehen bleiben:

```
{{FIRST_NAME}} {{RECIPIENT_ID}} {{CRM_ID}} {{EMAIL}}
{{HERO_URL}} {{CTA_PRIMARY_URL}} {{UNSUBSCRIBE_URL}}
{{BROWSER_URL}} {{IMPRINT_URL}} {{PRIVACY_URL}}
{{LOGO_URL}} {{BRAND_NAME}} {{LOGIN_LINK}}
```

Diese Liste **muss** im `system_prompt_generate` als "do not substitute"-Hinweis stehen. Wenn ein Output mit ersetztem Platzhalter durchschlüpft, ist der Prompt zu schwach — verbessern, nicht im Post-Processing fixen.

## Hero-Image-Pipeline (fal.ai)

```
strategy.image_brief (vom Haupt-Call generiert)
   ↓
optional: /api/suggest-hero refinement (system_prompt_image)
   ↓
hero-image.ts → fal.ai (Default: 'flux/schnell')
   ↓
PNG-URL → in campaign-state speichern → in MJML {{HERO_URL}} ersetzen
```

- **`image_brief` ist Teil des Haupt-Tool-Outputs** — kein separater Generate-Call.
- Refinement-Step ist optional (User-UX: "Bild generieren" Button kann den Brief vorher schärfen).
- fal.ai liefert PNG-URL — die URL ist **kurzlebig** (CDN-TTL), also frühzeitig in Brand-eigenes Storage kopieren falls Persistenz nötig (für E-Mail-Bilder reicht meist die TTL, weil sie zur Send-Zeit auf MailForge-Server gespiegelt werden).
- **Stil:** Lifestyle, kein Produktshot, kein On-Image-Text — das ist in `image_brief`-Description im Zod-Schema festgelegt (`pipeline.ts:45`). Wenn der User Produktshots will, im System-Prompt das Constraint lockern.

## Häufige Symptome → Fix

| Symptom | Ursache | Fix |
|---|---|---|
| `Model … did not call produce_email` | LLM hat freien Text statt Tool-Call produziert | `tool_choice` prüfen (muss forced sein), System-Prompt verschärfen ("Niemals freier Text, niemals JSON in Markdown-Fences") |
| `Model-Output abgeschnitten` | `max_tokens=32000` reicht nicht | Briefing kürzen ODER `MAX_TOKENS` hochsetzen (Vorsicht: Sonnet-Hard-Limit) ODER MJML kompakter (System-Prompt anpassen) |
| `JSON.parse` Fehler trotz jsonrepair | Unterminated String → Trunkierung | siehe oben: max-tokens-Hit |
| LLM generiert `{{FIRST_NAME}}` als "Hallo Max" | Platzhalter-Liste fehlt / unklar im System-Prompt | "do not substitute"-Hinweis prominenter machen |
| Hero-Bild zeigt Text-Artefakte | fal.ai macht das gelegentlich | `image_brief` mit "no text in image" verschärfen, oder höheres Model (`flux/pro`) |
| Image-Brief generisch / langweilig | LLM bekommt zu wenig Brand-Kontext | `brandDesignStyle` in den Input geben (`pipeline.ts:519`) |
| `preserve`-Mode formuliert trotzdem um | System-Prompt nicht streng genug, oder Mode-Hint fehlt im User-Message | beide prüfen, User-Brief im Prompt klar markieren |

## OpenRouter-Eigenheiten

- Wir nutzen das **openai-SDK** mit `baseURL='https://openrouter.ai/api/v1'`. Daher die ganze OpenAI-Tool-Call-API verfügbar.
- Model-IDs auf OpenRouter haben Format `<provider>/<model>` → `anthropic/claude-sonnet-4.6`.
- **Pricing & Rate-Limits** sind OpenRouter-account-gebunden, nicht Anthropic-direkt.
- Fehler-Response: OpenRouter wrapped Anthropic-Errors — manchmal mit zusätzlichem `metadata.provider_error`. Bei 429 ist es meist OpenRouter, nicht Anthropic.

## Cross-References

- Was nach dem Call passiert (MJML → Brand-Tags → Section-Inject → Send): `mailforge-send-pipeline`.
- Wo System-Prompts gespeichert sind und wie `defaults.ts` Self-Heal funktioniert: `mailforge-data-layer`.
- Wie Suggest-Section-Output mit `bodyMode='tiptap'` zusammenspielt: `mailforge-section-authoring`.

## Kritische Files

| Datei | Verantwortung |
|---|---|
| `src/lib/ai/pipeline.ts` | Main Generate-Call mit `produce_email`-Tool, Zod-Schema, jsonrepair |
| `src/lib/ai/suggest-briefing.ts` | User-Rohtext → strukturierter Brief (Extraction) |
| `src/lib/ai/draft-brief.ts` | Brand + Prompt → Prose-Brief |
| `src/lib/ai/suggest-section.ts` | Section-Content-Vorschlag (TipTap-HTML) |
| `src/lib/ai/hero-image.ts` | fal.ai-Call für Hero-Bild, Refinement-Logik |
| `src/lib/ai/sections-to-tiptap.ts` | TipTap-Doc Serialisierung (kein LLM-Call, aber AI-adjacent) |
| `src/lib/db/defaults.ts` | `DEFAULT_SYSTEM_PROMPT_GENERATE / _SUGGEST / _IMAGE` |
| `src/lib/db/repositories.ts` | Typed Prompt-Getter / -Setter (`getSystemPromptGenerate` etc.) |
| `src/app/api/generate/route.ts` | HTTP-Endpoint zum Main-Generate |
| `src/app/api/suggest-hero/route.ts` | Image-Brief-Refinement-Endpoint |
