feat(provider-grades): ✨ Introduce ProviderGrades schema, rubric logic, and seed data with API integration in server.ts
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
52e214972e
commit
c3c2331f4c
11 changed files with 789 additions and 0 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
||||
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)`;
|
||||
},
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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<void> {
|
||||
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)`;
|
||||
},
|
||||
},
|
||||
];
|
||||
|
|
|
|||
26
codebase/@features/api/src/entities/provider-grades/index.ts
Normal file
26
codebase/@features/api/src/entities/provider-grades/index.ts
Normal file
|
|
@ -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';
|
||||
133
codebase/@features/api/src/entities/provider-grades/repo.ts
Normal file
133
codebase/@features/api/src/entities/provider-grades/repo.ts
Normal file
|
|
@ -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<readonly ProviderMarketGrade[]> {
|
||||
try {
|
||||
const rows = providerSlug
|
||||
? await sql<Row[]>`SELECT * FROM provider_market_grades WHERE provider_slug = ${providerSlug} ORDER BY category`
|
||||
: await sql<Row[]>`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<ProviderMarketGrade | null> {
|
||||
try {
|
||||
const rows = await sql<Row[]>`
|
||||
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<ProviderMarketGrade> {
|
||||
try {
|
||||
const grade = validateGrade(draft.grade);
|
||||
const rows = await sql<Row[]>`
|
||||
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<ProviderMarketGrade> {
|
||||
try {
|
||||
const grade = patch.grade !== undefined ? validateGrade(patch.grade) : null;
|
||||
const rows = await sql<Row[]>`
|
||||
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<void> {
|
||||
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)}`);
|
||||
}
|
||||
}
|
||||
255
codebase/@features/api/src/entities/provider-grades/rubric.ts
Normal file
255
codebase/@features/api/src/entities/provider-grades/rubric.ts
Normal file
|
|
@ -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<Grade, number> = 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<Record<Grade, RubricTier>> = {
|
||||
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<ProviderCategory | string, Grade>,
|
||||
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<Record<string, string>>,
|
||||
category: ProviderCategory | string,
|
||||
): Grade | null {
|
||||
const direct = marketMinGrades[category];
|
||||
if (isGrade(direct)) return direct;
|
||||
const fallback = marketMinGrades['general'];
|
||||
return isGrade(fallback) ? fallback : null;
|
||||
}
|
||||
|
|
@ -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<void> {
|
||||
// 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)`;
|
||||
},
|
||||
},
|
||||
];
|
||||
|
|
@ -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."
|
||||
}
|
||||
]
|
||||
}
|
||||
18
codebase/@features/api/src/entities/provider-grades/types.ts
Normal file
18
codebase/@features/api/src/entities/provider-grades/types.ts
Normal file
|
|
@ -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<Omit<ProviderMarketGradeDraft, 'providerSlug' | 'category'>>;
|
||||
183
docs/grading-rubric.md
Normal file
183
docs/grading-rubric.md
Normal file
|
|
@ -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.
|
||||
Loading…
Add table
Reference in a new issue