From c3c2331f4cdea1f9bafd6d138e3e8d890a8e77a4 Mon Sep 17 00:00:00 2001 From: autocommit Date: Sun, 17 May 2026 07:47:50 -0700 Subject: [PATCH] =?UTF-8?q?feat(provider-grades):=20=E2=9C=A8=20Introduce?= =?UTF-8?q?=20ProviderGrades=20schema,=20rubric=20logic,=20and=20seed=20da?= =?UTF-8?q?ta=20with=20API=20integration=20in=20server.ts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- codebase/@features/api/src/app/server.ts | 2 + .../destination-performance/schema.ts | 23 ++ .../seed-quinn-experience.json | 76 ++++++ .../api/src/entities/destination/schema.ts | 17 ++ .../api/src/entities/provider-grades/index.ts | 26 ++ .../api/src/entities/provider-grades/repo.ts | 133 +++++++++ .../src/entities/provider-grades/rubric.ts | 255 ++++++++++++++++++ .../src/entities/provider-grades/schema.ts | 29 ++ .../entities/provider-grades/seed-quinn.json | 27 ++ .../api/src/entities/provider-grades/types.ts | 18 ++ docs/grading-rubric.md | 183 +++++++++++++ 11 files changed, 789 insertions(+) create mode 100644 codebase/@features/api/src/entities/destination-performance/seed-quinn-experience.json create mode 100644 codebase/@features/api/src/entities/provider-grades/index.ts create mode 100644 codebase/@features/api/src/entities/provider-grades/repo.ts create mode 100644 codebase/@features/api/src/entities/provider-grades/rubric.ts create mode 100644 codebase/@features/api/src/entities/provider-grades/schema.ts create mode 100644 codebase/@features/api/src/entities/provider-grades/seed-quinn.json create mode 100644 codebase/@features/api/src/entities/provider-grades/types.ts create mode 100644 docs/grading-rubric.md diff --git a/codebase/@features/api/src/app/server.ts b/codebase/@features/api/src/app/server.ts index a7d61530..10fd3859 100644 --- a/codebase/@features/api/src/app/server.ts +++ b/codebase/@features/api/src/app/server.ts @@ -27,6 +27,7 @@ import { macSyncStatusMigrations } from '@/entities/mac-sync-status'; import { contentPostMigrations } from '@/entities/content-post'; import { destinationMigrations } from '@/entities/destination'; import { destinationPerformanceMigrations } from '@/entities/destination-performance'; +import { providerGradesMigrations } from '@/entities/provider-grades'; import { financialRecordMigrations } from '@/entities/financial-record'; import { flightMigrations } from '@/entities/flight'; import { hotelStayMigrations } from '@/entities/hotel-stay'; @@ -157,6 +158,7 @@ await runMigrations(db, [ // Content / CMS entities ...destinationMigrations, ...destinationPerformanceMigrations, + ...providerGradesMigrations, ...galleryItemMigrations, ...loreSectionMigrations, ...providerProfileMigrations, diff --git a/codebase/@features/api/src/entities/destination-performance/schema.ts b/codebase/@features/api/src/entities/destination-performance/schema.ts index bcdafa73..4a0ad8b6 100644 --- a/codebase/@features/api/src/entities/destination-performance/schema.ts +++ b/codebase/@features/api/src/entities/destination-performance/schema.ts @@ -30,4 +30,27 @@ export const destinationPerformanceMigrations: readonly Migration[] = [ await sql`CREATE INDEX IF NOT EXISTS idx_destination_performance_fit ON destination_performance(personal_fit_score DESC)`; }, }, + { + // Letter-grade overlay (per docs/grading-rubric.md). Captures Quinn's + // lived intel beyond the coarse personal_fit_score: + // + // observed_market_grade — what THIS market actually demanded when she + // visited. Feeds back into destinations.market_min_grades + // when it conflicts with the public score. + // effective_personal_grade — her effective grade in THIS market specifically; + // overrides provider_market_grades when present. + // Useful where a niche reputation locally outperforms + // the global grade (e.g. Cincinnati's trans scene + // where she's effectively A- vs B+ globally). + // + // Grades are validated app-side against rubric.ts:GRADES — no CHECK + // constraint to avoid drift from the source of truth. + id: '2026-05-17_destination_performance_grades', + async up(sql: Sql): Promise { + await sql.unsafe(`ALTER TABLE destination_performance ADD COLUMN IF NOT EXISTS observed_market_grade TEXT`); + await sql.unsafe(`ALTER TABLE destination_performance ADD COLUMN IF NOT EXISTS effective_personal_grade TEXT`); + await sql.unsafe(`ALTER TABLE destination_performance ADD COLUMN IF NOT EXISTS provider_slug TEXT NOT NULL DEFAULT 'quinn'`); + await sql`CREATE INDEX IF NOT EXISTS idx_destination_performance_provider ON destination_performance(provider_slug)`; + }, + }, ]; diff --git a/codebase/@features/api/src/entities/destination-performance/seed-quinn-experience.json b/codebase/@features/api/src/entities/destination-performance/seed-quinn-experience.json new file mode 100644 index 00000000..017570a8 --- /dev/null +++ b/codebase/@features/api/src/entities/destination-performance/seed-quinn-experience.json @@ -0,0 +1,76 @@ +{ + "_meta": { + "description": "Quinn's lived experience overlay — markets she has a real read on. Seeded only where she has confirmed signal (Bay Area + Cincinnati + LA + Vegas). Other cities remain ungraded until visited.", + "provider": "quinn", + "updated": "2026-05-17" + }, + "performance": [ + { + "destinationSlug": "berkeley", + "providerSlug": "quinn", + "personalFitScore": 75, + "livedCompetitiveness": "Homebase. Established repeat-client base; mid-saturation Bay Area market. Premium clientele but plenty of elite cis competition for general-category bookings.", + "observedMarketGrade": "B", + "effectivePersonalGrade": "B", + "visitCount": 999, + "wouldReturn": true, + "notes": "Homebase — always 'in town' here." + }, + { + "destinationSlug": "san-francisco", + "providerSlug": "quinn", + "personalFitScore": 70, + "livedCompetitiveness": "Strong daytime / tech-week presence; off-peak softer. Trans niche meaningfully different from general — opportunity sits in trans-positive client segments, not general luxury market.", + "observedMarketGrade": "B", + "effectivePersonalGrade": "B+", + "visitCount": 50, + "wouldReturn": true, + "notes": "Tech-week weeks and Pride run hot; mid-summer / late August slower." + }, + { + "destinationSlug": "palo-alto", + "providerSlug": "quinn", + "personalFitScore": 72, + "livedCompetitiveness": "Peninsula tech wealth — clients quieter / more discrete than SF proper. Lower platform-saturation than SF. Repeat-driven.", + "observedMarketGrade": "B-", + "effectivePersonalGrade": "B", + "visitCount": 20, + "wouldReturn": true, + "notes": "Hotel-incall friendly. Best for repeats; cold prospecting harder than SF." + }, + { + "destinationSlug": "cincinnati", + "providerSlug": "quinn", + "personalFitScore": 92, + "livedCompetitiveness": "Crushed it. Big fish in small pond — trans niche locally is wide open and the wealth tier is real (Midwest old money + Procter & Gamble executive base). Repeat clients booked within 3 days. Best dollar-per-day market on her tour history.", + "observedMarketGrade": "C-", + "effectivePersonalGrade": "A-", + "visitCount": 4, + "wouldReturn": true, + "lastRevenuePerDayUsd": 4200, + "notes": "Canonical big-fish-small-pond example. Effective grade A- because she over-qualifies; the market demands C-. Trans niche essentially uncontested." + }, + { + "destinationSlug": "los-angeles", + "providerSlug": "quinn", + "personalFitScore": 25, + "livedCompetitiveness": "Crowded out. Too many elite cis providers competing for the same luxury-tier clients. Even with trans-niche advantage, the volume of high-grade providers in adjacent niches drowns out signal. Pricing pressure is real — clients comparison-shop aggressively.", + "observedMarketGrade": "A-", + "effectivePersonalGrade": "B-", + "visitCount": 6, + "wouldReturn": false, + "notes": "Not worth flying to. Market demands A-, she's effective B- here — under the bar. Skip unless paired with high-value events that justify the loss." + }, + { + "destinationSlug": "las-vegas", + "providerSlug": "quinn", + "personalFitScore": 25, + "livedCompetitiveness": "Same crowded-out dynamic as LA. Convention-driven; high-grade providers fly in for every major event. Trans niche slightly less saturated than LA but still A-tier difficulty.", + "observedMarketGrade": "A-", + "effectivePersonalGrade": "B", + "visitCount": 5, + "wouldReturn": false, + "notes": "Same under-the-bar dynamic. Skip unless event-driven with a guaranteed booking." + } + ] +} diff --git a/codebase/@features/api/src/entities/destination/schema.ts b/codebase/@features/api/src/entities/destination/schema.ts index 25f7f5a5..be3ae853 100644 --- a/codebase/@features/api/src/entities/destination/schema.ts +++ b/codebase/@features/api/src/entities/destination/schema.ts @@ -120,4 +120,21 @@ export const destinationMigrations: readonly Migration[] = [ `); }, }, + { + // Per-category market difficulty grade (letter grade, A+..F). Empty {} means + // ungraded. Categories: 'general', 'trans', 'bbw', 'mature', 'gfe', etc. + // Validated app-side against PROVIDER_CATEGORIES / GRADES in + // @/entities/provider-grades/rubric. Empty fallback uses 'general'. + // + // Example: Vegas → {"general": "A-", "trans": "B"} — high overall bar but + // proportionally easier in the trans niche. See docs/grading-rubric.md for + // the canonical reference and seeding guidance. + id: '2026-05-17_destination_market_difficulty', + async up(sql: Sql): Promise { + await sql.unsafe(`ALTER TABLE destinations ADD COLUMN IF NOT EXISTS market_min_grades JSONB NOT NULL DEFAULT '{}'::jsonb`); + // GIN index on the JSONB lets us filter by category presence efficiently + // (e.g. "all destinations with a trans-specific grade"). + await sql`CREATE INDEX IF NOT EXISTS idx_destinations_market_min_grades ON destinations USING GIN (market_min_grades)`; + }, + }, ]; diff --git a/codebase/@features/api/src/entities/provider-grades/index.ts b/codebase/@features/api/src/entities/provider-grades/index.ts new file mode 100644 index 00000000..9e9a5c68 --- /dev/null +++ b/codebase/@features/api/src/entities/provider-grades/index.ts @@ -0,0 +1,26 @@ +export { providerGradesMigrations } from './schema'; +export { + listProviderMarketGrades, + getProviderMarketGrade, + upsertProviderMarketGrade, + updateProviderMarketGrade, + deleteProviderMarketGrade, +} from './repo'; +export type { + ProviderMarketGrade, + ProviderMarketGradeDraft, + ProviderMarketGradePatch, +} from './types'; +export { + GRADES, + PROVIDER_RUBRIC, + PROVIDER_CATEGORIES, + isGrade, + isProviderCategory, + gradeIndex, + gradeMeets, + gradeStepDelta, + resolveProviderGrade, + resolveMarketGrade, +} from './rubric'; +export type { Grade, ProviderCategory, RubricTier } from './rubric'; diff --git a/codebase/@features/api/src/entities/provider-grades/repo.ts b/codebase/@features/api/src/entities/provider-grades/repo.ts new file mode 100644 index 00000000..c50c4664 --- /dev/null +++ b/codebase/@features/api/src/entities/provider-grades/repo.ts @@ -0,0 +1,133 @@ +import type { Sql } from '@/shared/db'; +import { HttpError, badRequest, notFound } from '@/shared/http/errors'; + +import { isGrade, type Grade } from './rubric'; +import type { + ProviderMarketGrade, + ProviderMarketGradeDraft, + ProviderMarketGradePatch, +} from './types'; + +interface Row { + provider_slug: string; + category: string; + grade: string; + rubric_notes: string | null; + updated_at: string; +} + +const hydrate = (r: Row): ProviderMarketGrade => { + if (!isGrade(r.grade)) { + throw new Error(`provider_market_grades row has invalid grade "${r.grade}" for ${r.provider_slug}/${r.category}`); + } + return { + providerSlug: r.provider_slug, + category: r.category, + grade: r.grade, + rubricNotes: r.rubric_notes, + updatedAt: r.updated_at, + }; +}; + +const validateGrade = (g: string): Grade => { + if (!isGrade(g)) throw badRequest('invalid_grade', `grade "${g}" is not in GRADES`); + return g; +}; + +export async function listProviderMarketGrades( + sql: Sql, + providerSlug?: string, +): Promise { + try { + const rows = providerSlug + ? await sql`SELECT * FROM provider_market_grades WHERE provider_slug = ${providerSlug} ORDER BY category` + : await sql`SELECT * FROM provider_market_grades ORDER BY provider_slug, category`; + return rows.map(hydrate); + } catch (err) { + throw new Error(`listProviderMarketGrades failed: ${String(err)}`); + } +} + +export async function getProviderMarketGrade( + sql: Sql, + providerSlug: string, + category: string, +): Promise { + try { + const rows = await sql` + SELECT * FROM provider_market_grades + WHERE provider_slug = ${providerSlug} AND category = ${category} + `; + return rows[0] ? hydrate(rows[0]) : null; + } catch (err) { + throw new Error(`getProviderMarketGrade(${providerSlug}, ${category}) failed: ${String(err)}`); + } +} + +export async function upsertProviderMarketGrade( + sql: Sql, + draft: ProviderMarketGradeDraft, +): Promise { + try { + const grade = validateGrade(draft.grade); + const rows = await sql` + INSERT INTO provider_market_grades (provider_slug, category, grade, rubric_notes, updated_at) + VALUES (${draft.providerSlug}, ${draft.category}, ${grade}, ${draft.rubricNotes ?? null}, now()) + ON CONFLICT (provider_slug, category) DO UPDATE SET + grade = EXCLUDED.grade, + rubric_notes = EXCLUDED.rubric_notes, + updated_at = now() + RETURNING * + `; + if (!rows[0]) throw new Error('upsert returned no row'); + return hydrate(rows[0]); + } catch (err) { + if (err instanceof HttpError) throw err; + throw new Error(`upsertProviderMarketGrade failed: ${String(err)}`); + } +} + +export async function updateProviderMarketGrade( + sql: Sql, + providerSlug: string, + category: string, + patch: ProviderMarketGradePatch, +): Promise { + try { + const grade = patch.grade !== undefined ? validateGrade(patch.grade) : null; + const rows = await sql` + UPDATE provider_market_grades SET + grade = COALESCE(${grade}, grade), + rubric_notes = COALESCE(${patch.rubricNotes ?? null}, rubric_notes), + updated_at = now() + WHERE provider_slug = ${providerSlug} AND category = ${category} + RETURNING * + `; + if (!rows[0]) { + throw notFound('provider_market_grade_not_found', `${providerSlug}/${category} not found`); + } + return hydrate(rows[0]); + } catch (err) { + if (err instanceof HttpError) throw err; + throw new Error(`updateProviderMarketGrade failed: ${String(err)}`); + } +} + +export async function deleteProviderMarketGrade( + sql: Sql, + providerSlug: string, + category: string, +): Promise { + try { + const result = await sql` + DELETE FROM provider_market_grades + WHERE provider_slug = ${providerSlug} AND category = ${category} + `; + if (result.count === 0) { + throw notFound('provider_market_grade_not_found', `${providerSlug}/${category} not found`); + } + } catch (err) { + if (err instanceof HttpError) throw err; + throw new Error(`deleteProviderMarketGrade failed: ${String(err)}`); + } +} diff --git a/codebase/@features/api/src/entities/provider-grades/rubric.ts b/codebase/@features/api/src/entities/provider-grades/rubric.ts new file mode 100644 index 00000000..b7adafc9 --- /dev/null +++ b/codebase/@features/api/src/entities/provider-grades/rubric.ts @@ -0,0 +1,255 @@ +/** + * Provider × market grading rubric (runtime mirror of docs/grading-rubric.md). + * + * If you change criteria, update docs/grading-rubric.md to match — the AI cites + * both, so they must agree. + */ + +export const GRADES = [ + 'F', + 'D-', + 'D', + 'D+', + 'C-', + 'C', + 'C+', + 'B-', + 'B', + 'B+', + 'A-', + 'A', + 'A+', + 'S', +] as const; + +export type Grade = (typeof GRADES)[number]; + +const GRADE_INDEX: ReadonlyMap = new Map(GRADES.map((g, i) => [g, i])); + +export function isGrade(value: unknown): value is Grade { + return typeof value === 'string' && GRADE_INDEX.has(value as Grade); +} + +export function gradeIndex(g: Grade): number { + const i = GRADE_INDEX.get(g); + if (i === undefined) throw new Error(`unknown grade: ${g}`); + return i; +} + +/** + * Strict clearance — provider grade is >= market difficulty grade. + * + * Use for the boolean "can this provider work this market" gate. + */ +export function gradeMeets(provider: Grade, market: Grade): boolean { + return gradeIndex(provider) >= gradeIndex(market); +} + +/** + * Step delta — positive = provider over-qualifies; negative = under-qualifies. + * + * Use for big-fish-small-pond ranking: gap >= 2 steps signals a market where + * the provider stands out, not just clears the bar. + */ +export function gradeStepDelta(provider: Grade, market: Grade): number { + return gradeIndex(provider) - gradeIndex(market); +} + +/** + * Standard provider categories. `general` is the default fallback when a + * market_min_grades JSONB doesn't specify the category. + */ +export const PROVIDER_CATEGORIES = [ + 'overall', + 'general', + 'trans', + 'bbw', + 'mature', + 'gfe', +] as const; +export type ProviderCategory = (typeof PROVIDER_CATEGORIES)[number]; + +export function isProviderCategory(value: unknown): value is ProviderCategory { + return typeof value === 'string' && PROVIDER_CATEGORIES.includes(value as ProviderCategory); +} + +export interface RubricTier { + readonly tier: string; + readonly criteria: readonly string[]; + readonly notes?: string; +} + +/** + * Per-grade criteria summary. Full prose lives in docs/grading-rubric.md. + * Keep this concise — it's loaded into AI context per query. + */ +export const PROVIDER_RUBRIC: Readonly> = { + S: { + tier: 'celebrity', + criteria: [ + 'household name within niche', + 'pricing ≥ $5k/hr without resistance', + 'multi-market tour sells out months ahead', + 'editorial-tier photography portfolio', + 'press / brand presence beyond industry', + ], + }, + 'A+': { + tier: 'elite', + criteria: [ + '50+ verified reviews across markets', + 'pricing $2.5k–$5k/hr commanded routinely', + 'P411 Trust / Tryst Diamond / Eros premium presence', + 'sustained quarterly multi-market tours', + '≥40% repeat-client share', + ], + notes: 'A+ in routine markets is rare — typically only in event-peak conditions.', + }, + A: { + tier: 'elite', + criteria: [ + '50+ verified reviews across markets', + 'pricing $2.5k–$5k/hr commanded routinely', + 'P411 Trust / Tryst Diamond / Eros premium presence', + 'sustained quarterly multi-market tours', + '≥40% repeat-client share', + ], + }, + 'A-': { + tier: 'elite', + criteria: [ + '50+ verified reviews across markets', + 'pricing $2.5k–$5k/hr commanded routinely', + 'P411 Trust / Tryst Diamond / Eros premium presence', + 'sustained quarterly multi-market tours', + '≥40% repeat-client share', + ], + }, + 'B+': { + tier: 'strong-pro', + criteria: [ + '15–50 verified reviews', + 'pricing $1k–$2k/hr sustainable', + 'active across 2–3 platforms with polished profiles', + '~25–40% repeat-client share', + 'dominates a clear niche (trans / BBW / mature / etc.)', + ], + notes: 'B+ specifically requires categorical niche distinctiveness — not just polish.', + }, + B: { + tier: 'strong-pro', + criteria: [ + '15–50 verified reviews', + 'pricing $1k–$2k/hr sustainable in home market', + 'active across 2–3 platforms', + '~25–40% repeat-client share', + 'polished photo portfolio with variety', + ], + }, + 'B-': { + tier: 'strong-pro', + criteria: [ + '10–25 verified reviews', + 'pricing $1k/hr sustainable', + 'active on 2+ platforms with complete profiles', + 'growing repeat-client base', + 'photography polished but not signature-tier', + ], + }, + 'C+': { + tier: 'working-pro', + criteria: [ + '5–15 reviews', + 'pricing $500–$1k/hr sustainable', + 'active on 1–2 platforms; profiles complete but not premium', + 'mixed pipeline — some repeat clients but not majority', + 'decent photos that could be elevated', + ], + }, + C: { + tier: 'working-pro', + criteria: [ + '5–15 reviews', + 'pricing $500–$1k/hr', + 'one primary platform with complete profile', + 'building repeat base', + 'photos exist but inconsistent in style / quality', + ], + }, + 'C-': { + tier: 'working-pro', + criteria: [ + '<10 reviews but established cadence', + 'pricing $400–$700/hr', + 'one primary platform', + 'low repeat rate', + 'self-shot or mixed-quality photo set', + ], + }, + 'D+': { + tier: 'entry', + criteria: [ + '<5 reviews / new to market', + 'pricing $200–$500/hr', + 'limited platform presence', + 'amateur / inconsistent photos', + 'portfolio-building stage', + ], + }, + D: { + tier: 'entry', + criteria: [ + '<5 reviews / new', + 'pricing $200–$400/hr', + 'one platform, partial profile', + 'self-shot photos', + 'works permissive / low-density markets', + ], + }, + 'D-': { + tier: 'entry', + criteria: [ + 'no verified reviews', + 'pricing $150–$300/hr', + 'minimal platform presence', + 'amateur photos only', + 'can book only in very permissive markets', + ], + }, + F: { + tier: 'not-market-ready', + criteria: [ + 'no reviews', + 'no professional photos', + 'no platform presence', + 'cannot reliably book', + ], + }, +}; + +/** + * Resolve the relevant provider grade for a clearance check. + * + * Falls back: requested category → `overall` → null. + */ +export function resolveProviderGrade( + grades: ReadonlyMap, + category: ProviderCategory | string, +): Grade | null { + return grades.get(category) ?? grades.get('overall') ?? null; +} + +/** + * Resolve the relevant market difficulty grade. + * + * Falls back: requested category → `general` → null. + */ +export function resolveMarketGrade( + marketMinGrades: Readonly>, + category: ProviderCategory | string, +): Grade | null { + const direct = marketMinGrades[category]; + if (isGrade(direct)) return direct; + const fallback = marketMinGrades['general']; + return isGrade(fallback) ? fallback : null; +} diff --git a/codebase/@features/api/src/entities/provider-grades/schema.ts b/codebase/@features/api/src/entities/provider-grades/schema.ts new file mode 100644 index 00000000..89f7bdce --- /dev/null +++ b/codebase/@features/api/src/entities/provider-grades/schema.ts @@ -0,0 +1,29 @@ +import type { Migration, Sql } from '@/shared/db'; + +export const providerGradesMigrations: readonly Migration[] = [ + { + id: '2026-05-17_provider_market_grades_initial', + async up(sql: Sql): Promise { + // Per-provider, per-category letter grade. The clearance test against a + // destination's market_min_grades JSONB drives opportunity ranking. + // + // category is open-ended (TS-validated against PROVIDER_CATEGORIES) so we + // can introduce new niches (bbw, mature, gfe, ...) without DB migrations. + // 'overall' is the headline; 'general' / 'trans' / niche grades drive math. + // + // grade is validated app-side against rubric.ts:GRADES — a CHECK constraint + // here would duplicate the source of truth and drift. + await sql` + CREATE TABLE IF NOT EXISTS provider_market_grades ( + provider_slug TEXT NOT NULL, + category TEXT NOT NULL, + grade TEXT NOT NULL, + rubric_notes TEXT, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (provider_slug, category) + ) + `; + await sql`CREATE INDEX IF NOT EXISTS idx_provider_market_grades_provider ON provider_market_grades(provider_slug)`; + }, + }, +]; diff --git a/codebase/@features/api/src/entities/provider-grades/seed-quinn.json b/codebase/@features/api/src/entities/provider-grades/seed-quinn.json new file mode 100644 index 00000000..dc6cd34e --- /dev/null +++ b/codebase/@features/api/src/entities/provider-grades/seed-quinn.json @@ -0,0 +1,27 @@ +{ + "_meta": { + "description": "Quinn's per-category market grades. Drives clearance check against destinations.market_min_grades.", + "rubric": "docs/grading-rubric.md", + "updated": "2026-05-17" + }, + "grades": [ + { + "providerSlug": "quinn", + "category": "overall", + "grade": "B-", + "rubricNotes": "Headline grade. C+ floor, B- ceiling. Strong photos + copy; review volume capped because she rotates markets rather than building one TER stronghold." + }, + { + "providerSlug": "quinn", + "category": "general", + "grade": "C+", + "rubricNotes": "Open category — competes against elite cis providers. Clears entry in mid-tier markets; crowded out in A-tier general markets." + }, + { + "providerSlug": "quinn", + "category": "trans", + "grade": "B+", + "rubricNotes": "Niche-strong. Small competitive pool, distinctive look (perfect perky E-cups implants), verified, repeat client base. This is where opportunity actually lives." + } + ] +} diff --git a/codebase/@features/api/src/entities/provider-grades/types.ts b/codebase/@features/api/src/entities/provider-grades/types.ts new file mode 100644 index 00000000..921c868e --- /dev/null +++ b/codebase/@features/api/src/entities/provider-grades/types.ts @@ -0,0 +1,18 @@ +import type { Grade, ProviderCategory } from './rubric'; + +export interface ProviderMarketGrade { + readonly providerSlug: string; + readonly category: ProviderCategory | string; + readonly grade: Grade; + readonly rubricNotes: string | null; + readonly updatedAt: string; +} + +export interface ProviderMarketGradeDraft { + readonly providerSlug: string; + readonly category: ProviderCategory | string; + readonly grade: Grade; + readonly rubricNotes?: string | null; +} + +export type ProviderMarketGradePatch = Partial>; diff --git a/docs/grading-rubric.md b/docs/grading-rubric.md new file mode 100644 index 00000000..2ae8abfc --- /dev/null +++ b/docs/grading-rubric.md @@ -0,0 +1,183 @@ +# Provider × Market Grading Rubric + +A reproducible system for grading both *providers* (escorts) and *markets* (cities) +on the same A-through-F scale, so the AI researcher can answer: +"Can this provider thrive in this market?" + +The clearance test is simple: `provider_grade(category) >= market_difficulty(category)`. +If the provider clears the bar, the market is viable. The over-qualification gap +hints at how dominant they'll be (big fish in small pond). + +This rubric is the canonical reference. The TypeScript const at +`codebase/@features/api/src/entities/provider-grades/rubric.ts` mirrors it for +runtime use. Update both together. + +--- + +## Grade scale + +``` +F D- D D+ C- C C+ B- B B+ A- A A+ S +``` + +`F` = unranked / not market-ready. `S` = celebrity tier, top ~1%. + +--- + +## Provider grade — what each letter requires + +Each axis is scored qualitatively. A provider's grade is the **lowest-axis ceiling** +modified by standout strengths; one weak axis caps the overall grade unless +distinctively offset. + +### Axes + +- **Review volume** — verified reviews on TER / P411 / Eros / Tryst across markets. +- **Photo tier** — production quality, recency, professional vs. selfie mix, variety. +- **Copy quality** — bio, screening notes, ad copy that signals taste / scarcity. +- **Marketing presence** — Tryst/P411/Eros tier, profile completeness, advertising spend. +- **Pricing power** — what the market will pay at her rate without resistance. +- **Repeat-client density** — % of bookings from prior clients (signals retention). +- **Niche distinctiveness** — is she categorically rare in her primary niche? + +### Tier definitions + +#### S — Celebrity (top ~1%) +- Household name within niche; clients fly to *her*. +- Pricing > $$$$ (≥$5k/hr) without resistance. +- Multi-market tour selling out months in advance. +- Editorial-tier photography portfolio. +- Heavy press / brand presence beyond the industry. + +#### A+ / A / A- — Elite (top 5–10%) +- 50+ verified reviews across multiple markets. +- Top-tier photography (professional, recent, signature look). +- Multi-market sustained presence — confirmed tours quarterly. +- Pricing $$$$ (~$2.5k–$5k/hr) commanded without resistance. +- Strong repeat-client base (≥40% repeat). +- Marketing presence: P411 Trust, Tryst Diamond, Eros premium. + +#### B+ / B / B- — Strong working pro (top 25%) +- 15–50 verified reviews. +- Polished photos with variety (multiple sets, recent). +- Pricing $$$ (~$1k–$2k/hr) sustainable in home market. +- Repeat-client base (~25–40% repeat). +- Marketing presence active across 2–3 platforms. +- **B+ specifically:** dominates a clear niche (e.g., trans, BBW, mature) — categorically distinctive in a smaller pool. + +#### C+ / C / C- — Solid working pro (top 50%) +- 5–15 reviews. +- Decent photos but not breakout (could be better lit / staged / shot). +- Pricing $$ (~$500–$1k/hr) sustainable. +- Mixed booking pipeline; some repeat clients but not majority. +- Active on 1–2 platforms; profiles complete but not premium-tier. + +#### D+ / D / D- — Entry tier +- <5 reviews or new to market. +- Photos exist but are amateur / inconsistent / self-shot. +- Pricing $ (~$200–$500/hr). +- Limited platform presence; profile may be incomplete. +- Pre-portfolio building stage; can work permissive markets. + +#### F — Not market-ready +- No reviews, no professional photos, no platform presence. +- Cannot reliably book even in entry markets. + +--- + +## Market difficulty grade — what each letter means + +The **minimum grade required** to thrive in a given market in a given category. +A market is harder when many high-grade providers compete for the same clients +(Vegas, LA, NYC) — entry providers get crowded out. + +### Tier definitions + +#### S markets — Only celebrity-tier providers thrive +- Monaco, Dubai during F1 week, Cannes during the festival. +- Below S = noise. Even A-tier providers struggle to differentiate. + +#### A markets (A- / A / A+) — Saturated luxury markets +- **A-:** Las Vegas, Los Angeles (general), Miami, New York. +- **A:** Aspen / Vail in season, Hamptons in summer, San Francisco / Bay Area during major tech weeks. +- **A+:** None routine — only ephemeral peaks (Super Bowl host city, Art Basel Miami). +- Below A = crowded out by sheer provider density. + +#### B markets (B- / B / B+) — Affluent but reachable +- **B-:** Amsterdam, Toronto, Chicago, Boston, DC, Seattle. +- **B:** Atlanta, Denver, Austin, Berkeley / SF (off-peak), San Diego. +- **B+:** Top European capitals during peak business travel (London, Paris). +- Strong-pro level (B-tier) thrives; mid-tier struggles unless niche. + +#### C markets (C- / C / C+) — Mid-tier wealth, mid-tier competition +- **C+:** Cincinnati, Indianapolis, Kansas City, Pittsburgh, Charlotte, Nashville. +- **C:** Columbus, Louisville, Cleveland, Minneapolis, St. Louis, Tampa. +- **C-:** Smaller secondary cities with established but not flooded provider scenes. +- *Big-fish-small-pond opportunity zone for B-tier providers.* + +#### D markets (D- / D / D+) — Permissive / emerging +- **D:** European general markets (most non-capital cities), South American capitals. +- **D-:** Cities with limited paid-companionship economy; entry providers can book. +- Pricing pressure is real but competition is sparse. + +### Niche overrides + +A market's difficulty is *per category*. Cities with thin niche scenes have lower +difficulty in that niche even if their `general` grade is high. + +- **Las Vegas:** general A-, trans B (smaller trans-specific competitor pool). +- **Amsterdam:** general B-, trans B- (proportional). +- **Cincinnati:** general C+, trans C- (very few trans providers — niche wide open). +- **Berlin / Tel Aviv:** general D, trans C (relatively sophisticated trans-aware market). + +Stored as JSONB on `destinations.market_min_grades`, e.g.: +```json +{"general": "A-", "trans": "B"} +``` + +Missing categories fall back to `general`. Empty `{}` means the market hasn't been +graded yet. + +--- + +## Quinn's current grades (provider_market_grades seed) + +| category | grade | notes | +|---|---|---| +| `overall` | B- | Mid-range overall — C+ floor, B- ceiling. Strong photos and copy; review volume capped because she rotates markets rather than building one TER stronghold. | +| `general` | C+ | Against the open category (cis + trans + all niches), she clears entry but elite cis providers dominate. | +| `trans` | B+ | Niche-strong — small competitive pool, distinctive look, verified-implant confirmed, repeat client base. Where her opportunity actually lives. | + +The `overall` grade is the headline; `category` grades drive market-clearance math. + +--- + +## How to update grades + +- **Provider grades:** quarterly review based on the axes above. Bump when sustained + evidence accumulates (review count crossed threshold; pricing held a tier increase; + niche dominance solidified). +- **Market difficulty grades:** ad-hoc as new intel arrives. After a tour stop, the + `destination_performance.observed_market_grade` captures *what the market actually + demanded* — feed that back into `destinations.market_min_grades` if it conflicts + with the stored value. +- **Rubric itself:** if you change criteria here, update `rubric.ts` to match — + the AI cites both, so they must agree. + +--- + +## How the AI uses this + +`listOpportunityRanked` in `destination/repo.ts` joins: + +1. `destinations.market_min_grades` (per-category market difficulty) +2. `provider_market_grades` (provider's grade per category) +3. `destination_performance.effective_personal_grade` (override when she has lived intel) + +It returns each destination ranked by `clears_bar AND region_value DESC`. Cities +where Quinn over-qualifies by 2+ grade steps are flagged as `big_fish_small_pond` +opportunities. + +The `/engine/opportunity-locations?category=trans` endpoint returns the trans-niche +view — markets where her B+ trans grade clears the bar that her C+ general grade +doesn't.