# Rex Resume Roast — Shared Scorecard and Output Logic

**Scope:** Current production Resume Roast flow for both Good Rex and Bad Rex.

This document is a source-derived reference for the deterministic shared score, score-related prompt contract, structured-output schema, API validations, and storefront rendering. It describes the implementation in source; where production prompt text requests behavior that the server does not mechanically parse, that distinction is explicit.

## Source Map

| Responsibility | Source |
| --- | --- |
| Deterministic scoring, request validation, OpenAI request, JSON schema, response validation, cleanup | `src/app/api/resume-review/route.ts` |
| Prompt rubric, score lock, immutable guardrails, persona directives, input wrapper | `src/lib/resume-roast-prompts.ts` |
| Accepted internal review types | `src/lib/resume-roast.ts` |
| User request construction and result rendering | `src/components/resume-roast.tsx` |
| Persona/config loading and default content | `src/lib/rex-persona-config.ts` |
| Runtime checks for both personas | `scripts/verify-rex-personas.mjs` |

## Execution Flow

1. The client submits `resumeText`, `mode`, optional `targetRole`, optional `heat`, optional `outputMode`, and (for Bad Rex) `badRexAcknowledged` to `POST /api/resume-review`.
2. The endpoint validates request shape and size, normalizes runtime values, and calculates the shared score with `calculateSharedScore(resumeText, targetRole)`.
3. The endpoint builds a persona-specific prompt with the **same** computed score for both modes and requests an OpenAI response.
4. JSON requests use a strict schema. The endpoint maps snake_case model fields to the camelCase application type.
5. The endpoint rejects invalid shape, a score that differs from the computed shared score, or a persona headline that lacks the exact expected marker. Accepted text is sanitized/truncated before response.
6. The client renders the structured fields directly. It does not derive or display individual dimension scores.

## Request Inputs and Preconditions

### Accepted Runtime Values

| Field | Production handling | Source |
| --- | --- | --- |
| `resumeText` | Must be a non-empty string after `trim()`. Maximum 50,000 characters; longer requests return HTTP 413. | `POST`, `MAX_RESUME_CHARACTERS` |
| `mode` | Must be exactly `"good"` or `"bad"`; otherwise HTTP 400. | `isReviewMode` / `POST` |
| `targetRole` | If a string: trimmed and truncated to 200 characters. Otherwise `""`. | `POST` |
| `heat` | Accepted only as `"mild"`, `"medium"`, or `"nuclear"`; any other/missing value becomes `"medium"`. | `isRoastHeat` / `POST` |
| `outputMode` | If explicitly supplied, must be `"json"` or `"markdown"`; omitted or non-`markdown` valid handling resolves to `"json"`. | `POST` |
| `badRexAcknowledged` | Required to be exactly `true` when `mode` is `"bad"`; otherwise HTTP 400. No equivalent gate exists for Good Rex. | `POST` |

There is no separate production API input for per-dimension scores, score overrides, or a requested final score.

## Deterministic Shared Score

### Scale, Aggregation, and Persona Invariance

`calculateSharedScore` returns the arithmetic sum of five dimensions. Each dimension is an integer from 0 to 2, so the total is an integer from **0 to 10**:

```text
sharedScore = sixSecondTest + impact + signalToNoise + atsSurvivability + positioning
```

The API calculates this value before building the model instruction and again after parsing a JSON model response. Both Good Rex and Bad Rex call the same function with the same `resumeText` and `targetRole`; persona, heat, and output packaging do not change the implementation score.

The prompt documents these interpretation bands, but the server does not use them for branching or rendering:

| Total | Prompt interpretation |
| --- | --- |
| 0–3 | structural fire; rebuild, don't edit |
| 4–5 | below the bar; major surgery |
| 6–7 | good bones, bad wallpaper; strong content sabotaged by presentation |
| 8–9 | interview-ready; polish only |
| 10 | stop reading this and go apply; almost never awarded |

### Exact Dimension Calculations

The following is the complete source logic in `calculateSharedScore`.

#### Precomputed Signals

- `normalizedText`: `resumeText.toLowerCase()`.
- `lines`: line-break split, each line trimmed, then empty lines removed.
- `bullets`: `lines` whose beginning matches `^(?:[-•*]|\d+[.)])\s+`.
- `hasSummary`: normalized text contains one of the whole words `summary`, `profile`, `objective`, or `about`.
- `hasExperience`: normalized text contains one of the whole phrases/words `experience`, `employment`, `work history`, or `professional experience`.
- `hasSkills`: normalized text contains one of the whole words `skills`, `competencies`, `tools`, or `technologies`.
- `quantifiedOutcomes`: count of individual non-empty lines matching at least one of: a dollar amount (`$` followed by digits/punctuation), a percentage, or a count tied to one of `customers`, `clients`, `users`, `leads`, `sales`, `revenue`, `projects`, `team members`, `staff`, `employees`, `hours`, or `days`.
- `targetTerms`: lowercased `targetRole`, split on non-alphanumeric characters, with terms of length 2 or less discarded. Terms are not deduplicated.
- `targetMatches`: number of retained `targetTerms` that occur anywhere in normalized resume text.
- `repeatedWeakBullets`: count of detected bullet lines containing `responsible for`, `helped with`, `worked with`, `various`, or `duties included` as whole-word phrases, case-insensitively.
- `hasRoleSignal`: whether any of the first eight non-empty lines contains one of `manager`, `specialist`, `analyst`, `engineer`, `director`, `designer`, `coordinator`, `consultant`, `intern`, `assistant`, or `associate` as a whole word.

#### Dimension Table

| Prompt dimension | Server signal and exact points | Notes |
| --- | --- | --- |
| **The six-second test** | **2** if `hasRoleSignal` and (`hasSummary` or `targetMatches > 0`); **1** if `hasRoleSignal` only; otherwise **0**. | The prompt describes recruiter clarity; production detects role signals in the first eight non-empty lines plus a summary-like heading or target-term occurrence. |
| **Evidence of impact** | **2** if `quantifiedOutcomes >= 3`; **1** if `quantifiedOutcomes >= 1`; otherwise **0**. | Count is matching **lines**, not distinct metrics. |
| **Signal-to-noise** | **2** if `3 <= bullets.length <= 30` and `repeatedWeakBullets <= max(1, floor(bullets.length / 3))`; **1** if `bullets.length >= 1`; otherwise **0**. | The prompt describes length/repetition/relevance; the implementation uses detected bullets and five weak-phrase patterns. |
| **Formatting & ATS survivability** | **2** if `hasExperience` and (`hasSkills` or `bullets.length >= 3`); **1** if `hasExperience` or `bullets.length >= 2`; otherwise **0**. | No file-layout, font, column, or parser simulation is performed by this score function. |
| **Positioning coherence** | If `targetRole` is non-empty: **2** if `targetMatches >= min(2, targetTerms.length)`; **1** if `targetMatches > 0`; else **0**. If `targetRole` is empty: **1** if `hasRoleSignal`, else **0**. | With an empty target role, this dimension cannot receive 2. With a non-empty target role that yields zero retained terms, `min(2, 0)` is 0, so the condition `targetMatches >= 0` produces 2. This follows the current source exactly. |

### Scoring Constraints Applied to the Model

`sharedScoreAddendum(score)` injects this trusted instruction after editable persona content:

```text
## SHARED SCORE LOCK

A shared implementation of the five-dimension scoring rubric calculated this resume at **${score}/10**. Use exactly **${score}/10** in the Roast Headline and Final Verdict. Do not recalculate, inflate, or deflate this score for persona, heat, or a punchline.
```

The immutable guardrails additionally require preservation of the shared five-dimension score. For JSON responses, the server enforces equality: `review.score !== calculateSharedScore(resumeText, targetRole)` returns HTTP 502 with an inconsistent-score error.

## Evidence, Safety, and Content Requirements

These are prompt-level constraints; the endpoint validates output structure/score/marker but does not independently verify every citation or section-count requirement.

- Every joke must point to verifiable content from the submitted resume: quoted phrase, real date, or actual formatting choice.
- Never invent resume facts, metrics, quotes, or flaws. Rewrites must use `[X]` placeholders and identify what to measure when source data is missing.
- Roast choices on the page, not the candidate. Protected traits, sensitive circumstances, health, gaps, hardship, identity, and the person are prohibited targets.
- The canonical prompt says sensitive content may be addressed respectfully only in fix sections.
- The model must read the whole resume before writing; select the issues that cost interviews rather than the easiest jokes.
- Output must be only the requested review. Non-resume sources should trigger the redirect edge case, which requests the actual resume and has no score.
- Heat can alter jokes but is instructed never to alter score or practical value. The prompt instructs an automatic downshift to `mild` for vulnerable situations; the API does not independently detect/enforce that downshift.
- Good/Bad voice differences are applied after the shared score lock. Both retain the same evidence, score, output, and safety requirements.

## Required Review Content

### Markdown Contract Requested by the Canonical Prompt

For `outputMode: "markdown"`, the canonical prompt requests this exact order:

1. **Roast Headline:** `**X/10 — "[Epithet]"** — [one-line diagnosis of the single biggest issue]`.
2. **The Roast:** H2 title; 250–400 words; exactly four damaging issues in descending damage order; direct address; first name maximum three uses; bold only quoted evidence; maximum one emoji per paragraph.
3. **The Turn:** 60–100 words; two or three specific strengths; completely sincere.
4. **The Charges:** exactly five items in descending damage order, each with crime, evidence, and sentence.
5. **The Rewrite:** exactly three weakest bullets or sections; verbatim `Before` and impact-first `After` with `[X]` placeholders/data-measurement note where needed.
6. **The 48-Hour Fix Plan:** maximum five actions, impact-per-minute order, verb first, time estimate each, total planned work at most three hours.
7. **Final Verdict:** restates score, physical/visual metaphor, at least one callback, and a sincere final sentence tied to a real strength.

The API returns markdown as a single `markdown` string after only a non-empty check. It does not parse or mechanically enforce the requested heading/word/count constraints in markdown mode.

### JSON Structured Output

For `outputMode: "json"`, OpenAI receives strict JSON Schema and the API then validates the mapped TypeScript object.

#### Wire Schema

```json
{
  "score": 0,
  "headline": "string",
  "roast_md": "string",
  "turn_md": "string",
  "charges": [
    { "crime": "string", "evidence": "string", "sentence": "string" }
  ],
  "rewrites": [
    { "before": "string", "after": "string" }
  ],
  "fix_plan": ["string"],
  "verdict_md": "string"
}
```

The strict schema has `additionalProperties: false` at the top level and on nested charge/rewrite objects. All listed top-level properties are required.

| Field | Schema / runtime requirement | Cleanup maximum |
| --- | --- | --- |
| `score` | integer, 0–10; must equal deterministic shared score after parsing | not text-cleaned |
| `headline` | string, schema maximum 320; must include expected persona marker | 320 characters |
| `roast_md` | string, schema maximum 5,000 | 5,000 characters |
| `turn_md` | string, schema maximum 1,500 | 1,500 characters |
| `charges` | array of exactly 5; each item requires `crime`, `evidence`, `sentence` strings | crime 300; evidence/sentence 800 |
| `rewrites` | array of exactly 3; each item requires `before`, `after` strings | before 1,200; after 1,500 |
| `fix_plan` | array of 1–5 strings in OpenAI schema; endpoint acceptance allows zero through five strings because it checks only `length <= 5` | each 500 |
| `verdict_md` | string, schema maximum 1,500 | 1,500 characters |

`cleanText` removes NUL characters, trims, then truncates strings. It applies only after successful structural validation; it does not parse or sanitize Markdown.

### Derivable Example Shape

The following uses only the production schema shape and does not claim example content is a valid review:

```json
{
  "score": 7,
  "headline": "7/10 — \"GOOD REX: COACH'S CALL — [epithet]\" — [diagnosis]",
  "roast_md": "...",
  "turn_md": "...",
  "charges": [
    { "crime": "...", "evidence": "...", "sentence": "..." },
    { "crime": "...", "evidence": "...", "sentence": "..." },
    { "crime": "...", "evidence": "...", "sentence": "..." },
    { "crime": "...", "evidence": "...", "sentence": "..." },
    { "crime": "...", "evidence": "...", "sentence": "..." }
  ],
  "rewrites": [
    { "before": "...", "after": "..." },
    { "before": "...", "after": "..." },
    { "before": "...", "after": "..." }
  ],
  "fix_plan": ["..."],
  "verdict_md": "..."
}
```

For Bad Rex, the required headline marker is `BAD REX: CURTAIN-UP SALVO` instead of `GOOD REX: COACH'S CALL`.

## Validation and Failure Behavior

### Model Output Validation

1. **Structured-output constraint:** JSON requests use OpenAI `text.format` with `type: "json_schema"`, `strict: true`, schema name `roast_my_resume_review`, and `max_output_tokens: 4_800`.
2. **Parsing/mapping:** `parseResumeRoast` maps `roast_md`, `turn_md`, `fix_plan`, and `verdict_md` into camelCase `ResumeRoast` fields.
3. **Runtime shape:** `isResumeRoast` accepts only integer score 0–10, strings for textual fields, exactly five charge objects, exactly three rewrite objects, at most five string fix-plan steps, and string verdict. It does not require non-empty values or at least one fix-plan step.
4. **Cleaning:** `cleanReview` strips NUL, trims, and truncates each textual field to the tabled limits.
5. **Score equality:** JSON response score must equal a second calculation of `calculateSharedScore` from the original request values.
6. **Persona marker:** `headline` must include the exact marker for the selected mode.
7. **No fallback:** errors from OpenAI, empty markdown, invalid JSON, invalid structure, inconsistent score, or wrong marker become a response error; the route does not return a fabricated review.

## Mode-Specific Differences

| Concern | Good Rex | Bad Rex | Shared behavior |
| --- | --- | --- | --- |
| Marker required in `headline` | `GOOD REX: COACH'S CALL` | `BAD REX: CURTAIN-UP SALVO` | Server validates mode-specific marker. |
| Voice | Constructive recruiter-coach; dry wit; avoids stage directions/combat/grandiose villain language. | Savage-but-safe theatrical headliner; at least two theatrical devices across four roast paragraphs, no consecutive repetition. | Voice is the last prompt layer and cannot change shared safety/evidence/score/schema requirements. |
| Submission gate | No acknowledgement requirement. | Requires `badRexAcknowledged === true`; UI presents adult-only acknowledgement. | `mode` must be explicitly valid. |
| Score | Same deterministic function and score lock. | Same deterministic function and score lock. | Persona/heat/punchlines cannot legitimately change score. |
| JSON / renderer | Same schema and same mapping. | Same schema and same mapping. | Only marker and requested voice vary. |

`scripts/verify-rex-personas.mjs` requests both modes with the same resume/target/heat and asserts equal scores, distinct headlines/roast bodies, correct markers, five charges, three rewrites, and one to five fix-plan entries. That script is an external behavior check; it is stricter about fix-plan minimum than `isResumeRoast` currently is.

## UI Mapping and Rendering

The client component expects the JSON envelope `{ review, provider, persona }` and stores `review` as `ResumeRoast`.

| API review field | User-facing rendering in `src/components/resume-roast.tsx` |
| --- | --- |
| `score` | Large `X / 10` score display; passed into the audio script as `Score: X out of 10.`; included in share text. |
| `headline` | Rendered as a `ReactMarkdown` headline under the score; included in audio/share text. |
| `roastMd` | Rendered with `ReactMarkdown` in **The Roast**; included in audio/share text. |
| `turnMd` | Rendered with `ReactMarkdown` in **The Turn**; included in audio/share text. |
| `charges` | Rendered as numbered cards in **The Charges**, with labeled crime, evidence, and sentence; included in audio/share text. |
| `rewrites` | Rendered as three **The Rewrite** cards with before/after labels; included in audio/share text. |
| `fixPlan` | Rendered as an ordered list in **The 48-Hour Fix Plan**; included in audio/share text. |
| `verdictMd` | Rendered with `ReactMarkdown` in **Final verdict**; included in audio/share text. |

The UI does not display the five internal dimension scores, the scoring-band label, raw matching signals, target terms, or score calculation trace. It renders only the total and model-authored sections.

## Prompt-Only Requirements Versus Enforced Checks

| Requirement | Prompt asks for it | Server mechanically checks it |
| --- | --- | --- |
| Total equals sum of five dimensions | Yes | Yes, for JSON response score equality |
| Correct persona marker in headline | Yes | Yes, for JSON |
| Exact 5 charges / exact 3 rewrites | Yes | Yes, for JSON schema and runtime validator |
| One to five fix-plan steps | Yes | Strict OpenAI JSON Schema requires 1–5; post-parse runtime validator permits 0–5 |
| Every joke/evidence tied to resume | Yes | No semantic source-evidence verification |
| No invented facts/metrics/quotes/flaws | Yes | No semantic verification |
| Word counts, four roast paragraphs, charge ordering, plan time total | Yes | Not parsed/enforced by server |
| Markdown-only requested response | Yes | Markdown path only checks non-empty model output |
| Bad Rex acknowledgement | N/A to prompt | Yes, request validation |

## Published Persona Configuration Note

The canonical prompt above is the default source. Production may use a published encrypted persona configuration retrieved by `getPublishedRexPersonaConfig(mode)`. The configuration can replace `systemPrompt` and add editable context, but source-controlled score lock, immutable guardrails, and final persona directive are still appended afterwards. The documentation therefore records the exact source-controlled production layers and their precedence; it cannot reconstruct a database-resident published override from repository files alone.
