feat(quinn-ai): Update opportunity context logic to support new context properties and improved handling

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
autocommit 2026-05-17 08:01:41 -07:00
parent 0150e0d629
commit ffd3ec918d
2 changed files with 36 additions and 12 deletions

View file

@ -2,17 +2,24 @@
* Opportunity-ranked locations fetcher for QuinnAI.
*
* Backed by the service-token-gated `/engine/opportunity-locations` endpoint
* on quinn.api. Returns destinations × destination_performance joined and
* ranked by a cross-table opportunity formula (wealth × (1 saturation) ×
* sex_positive × personal_fit). Used to pair upcoming events with
* area-opportunity context so QuinnAI can answer "is this worth flying to?"
* on quinn.api. Returns destinations × destination_performance × provider grades
* joined and ranked by region-value × climate × grade-clearance. Used to pair
* upcoming events with area-opportunity context so QuinnAI can answer
* "is this worth flying to?"
*
* Resilience: short timeout, in-process 5-minute cache, returns `[]` on any
* failure mode. Opportunity context is enrichment, never a draft blocker.
* Two-dimensional model:
* - Region value (wealth_score) how big is the prize?
* - Market difficulty (market_min_grades[category]) what grade clears the bar?
* These are independent: Vegas is high-value + high-difficulty (LA-tier crowd-out);
* Cincinnati is mid-value + low-difficulty (big-fish-small-pond).
*
* Pairing pattern (consumer-side): for each upcoming event with a resolved
* destination_slug, look up the matching `OpportunityLocation` here and
* surface `computedOpportunityScore` + `livedCompetitiveness` to the prompt.
* Per-category querying: trans-niche markets aren't the same as general markets.
* Quinn's B+ trans grade clears markets her C+ general grade doesn't (e.g.
* Amsterdam trans B- but general B-).
*
* Resilience: short timeout, in-process 5-minute cache keyed by query shape,
* returns `[]` on any failure mode. Opportunity context is enrichment, never a
* draft blocker.
*/
export interface OpportunityLocation {
@ -32,11 +39,20 @@ export interface OpportunityLocation {
readonly lastVisitAt: string | null;
readonly lastRevenuePerDayUsd: number | null;
readonly wouldReturn: boolean | null;
readonly marketMinGrades: Readonly<Record<string, string>>;
readonly marketGradeForCategory: string | null;
readonly providerGradeForCategory: string | null;
readonly observedMarketGrade: string | null;
readonly effectivePersonalGrade: string | null;
readonly clearsBar: boolean | null;
readonly gradeStepDelta: number | null;
readonly bigFishSmallPond: boolean;
readonly computedOpportunityScore: number;
}
interface EngineOpportunityResponse {
locations: readonly OpportunityLocation[];
readonly locations: readonly OpportunityLocation[];
readonly meta?: { readonly provider: string; readonly category: string };
}
export interface OpportunityContextDeps {
@ -45,8 +61,12 @@ export interface OpportunityContextDeps {
/** Service token. Defaults to env `QUINN_API_SERVICE_TOKEN`. */
readonly serviceToken?: string | null;
readonly providerSlug?: string;
/** Provider category — drives grade clearance. Defaults to `general`. */
readonly category?: string;
readonly minScore?: number;
readonly swLegalDeny?: readonly string[];
/** Drop destinations where provider's grade is under the market bar. */
readonly clearsBarOnly?: boolean;
readonly limit?: number;
readonly now?: Date;
}
@ -71,10 +91,12 @@ export async function fetchOpportunityLocations(
): Promise<readonly OpportunityLocation[]> {
const now = deps.now ?? new Date();
const provider = deps.providerSlug ?? 'quinn';
const category = deps.category ?? 'general';
const minScore = deps.minScore ?? 0;
const deny = deps.swLegalDeny ?? [];
const clearsBarOnly = deps.clearsBarOnly ?? false;
const limit = deps.limit ?? 200;
const cacheKey = `${provider}|${minScore}|${deny.join(',')}|${limit}`;
const cacheKey = `${provider}|${category}|${minScore}|${clearsBarOnly ? 1 : 0}|${deny.join(',')}|${limit}`;
if (cache && cache.key === cacheKey && cache.expiresAt > now.getTime()) {
return cache.value;
}
@ -88,9 +110,11 @@ export async function fetchOpportunityLocations(
const base = baseRaw.replace(/\/$/, '');
const params = new URLSearchParams({
provider,
category,
minScore: String(minScore),
limit: String(limit),
});
if (clearsBarOnly) params.set('clearsBarOnly', '1');
if (deny.length > 0) params.set('swLegalDeny', deny.join(','));
const url = `${base}/engine/opportunity-locations?${params.toString()}`;

File diff suppressed because one or more lines are too long