over / src /lib /content /recommender.ts
luguog's picture
fix(microsoft): use configurable Azure AD client ID for device-code flow (part 2)
90f8168 verified
Raw
History Blame Contribute Delete
24.7 kB
/**
* Approved Content Recommender
*
* Selects regulatory-approved promotional content for HCPs based on their
* current information gap, previous exposure, role/specialty, territory
* conditions, available time, channel, approved indication, response
* history, and observed downstream outcomes.
*
* All recommended content references an item from the approved (MLR-reviewed)
* content library. No off-label or expired material is ever surfaced.
*/
import {
ContentRecommendation,
TerritoryAccount,
HCPFunnelState,
BarrierType,
} from "@/types";
import { kvLoad, kvSave, DEFAULT_ORG_ID } from "@/lib/db";
// ─── Types ──────────────────────────────────────────────────────────
export interface ApprovedContentAsset {
id: string;
name: string;
type: string;
indication: string;
expiryDate: string;
slides: { number: number; title: string; topic: string }[];
}
export interface ContentResponseRecord {
contentId: string;
hcpResponse: string;
responseRate: number;
engagementImproved: boolean;
recordedAt: string;
}
interface BarrierContentMapping {
barrier: BarrierType;
topicKeywords: string[];
infoGap: string;
}
// ─── Approved Content Library ───────────────────────────────────────
//
// Expiry dates are computed relative to the current date so that the sample
// library remains valid regardless of when it is loaded. Each asset declares
// the number of months from now until its MLR approval expires.
interface ContentAssetSeed {
id: string;
name: string;
type: string;
indication: string;
expiryMonths: number;
slides: { number: number; title: string; topic: string }[];
}
const CONTENT_ASSET_SEEDS: ContentAssetSeed[] = [
{
id: "GILD-HIV-CLIN-001",
name: "Biktarvy Clinical Efficacy Deck",
type: "slide_deck",
indication: "HIV-1 treatment",
expiryMonths: 18,
slides: [
{ number: 1, title: "Mechanism of Action", topic: "pharmacology" },
{ number: 3, title: "Pivotal Trial Design", topic: "clinical_evidence" },
{ number: 5, title: "Virologic Suppression at Week 48", topic: "efficacy" },
{ number: 7, title: "Patient Identification Criteria", topic: "patient_eligibility" },
{ number: 9, title: "Safety Profile Summary", topic: "safety" },
{ number: 11, title: "Adherence and Persistence Data", topic: "persistence" },
],
},
{
id: "GILD-HIV-ACC-002",
name: "HIV Access and Reimbursement Guide",
type: "one_pager",
indication: "HIV-1 treatment",
expiryMonths: 12,
slides: [
{ number: 1, title: "Formulary Coverage by Plan", topic: "formulary" },
{ number: 2, title: "Prior Authorization Workflow", topic: "reimbursement" },
{ number: 3, title: "Copay Assistance Program", topic: "reimbursement" },
],
},
{
id: "GILD-HCV-CLIN-003",
name: "Epclusa Pan-Genotypic Evidence Deck",
type: "slide_deck",
indication: "Chronic HCV (genotypes 1-6)",
expiryMonths: 16,
slides: [
{ number: 1, title: "Pan-Genotypic Rationale", topic: "scientific_understanding" },
{ number: 4, title: "SVR12 Across Genotypes", topic: "efficacy" },
{ number: 6, title: "Diagnosis and Staging Workflow", topic: "diagnosis_testing" },
{ number: 8, title: "Treatment-Naive vs Experienced", topic: "patient_eligibility" },
{ number: 10, title: "Renal Impairment Considerations", topic: "safety" },
],
},
{
id: "GILD-HCV-DX-004",
name: "HCV Screening and Referral Pathway",
type: "infographic",
indication: "Chronic HCV screening",
expiryMonths: 20,
slides: [
{ number: 1, title: "Risk-Based Screening Criteria", topic: "patient_eligibility" },
{ number: 2, title: "Referral to Specialist Pathway", topic: "referral_pathway" },
{ number: 3, title: "Linkage to Care Timeline", topic: "office_workflow" },
],
},
{
id: "GILD-HBV-CLIN-005",
name: "Vemlidy Chronic HBV Data Summary",
type: "slide_deck",
indication: "Chronic hepatitis B",
expiryMonths: 14,
slides: [
{ number: 1, title: "HBV Treatment Landscape", topic: "awareness" },
{ number: 3, title: "Bone and Renal Safety Comparison", topic: "safety" },
{ number: 5, title: "Identifying Treatment Candidates", topic: "patient_eligibility" },
{ number: 7, title: "Long-Term Monitoring Protocol", topic: "persistence" },
],
},
{
id: "GILD-ONC-CLIN-006",
name: "Trodelvy Metastatic Breast Cancer Deck",
type: "slide_deck",
indication: "HR+/HER2- metastatic breast cancer",
expiryMonths: 10,
slides: [
{ number: 1, title: "Mechanism: Antibody-Drug Conjugate", topic: "scientific_understanding" },
{ number: 4, title: "TROP-2 Expression and Patient Selection", topic: "patient_eligibility" },
{ number: 6, title: "PFS and OS Results", topic: "efficacy" },
{ number: 8, title: "Adverse Event Management", topic: "safety" },
{ number: 10, title: "Treatment Initiation Workflow", topic: "treatment_initiation" },
],
},
{
id: "GILD-ONC-CLIN-007",
name: "Yescarta CAR-T Cell Therapy Overview",
type: "slide_deck",
indication: "Large B-cell lymphoma",
expiryMonths: 18,
slides: [
{ number: 1, title: "CAR-T Mechanism and Process", topic: "scientific_understanding" },
{ number: 3, title: "Eligibility and Referral Criteria", topic: "referral_pathway" },
{ number: 5, title: "Efficacy in Relapsed/Refractory Setting", topic: "efficacy" },
{ number: 7, title: "CRS and Neurotoxicity Management", topic: "safety" },
{ number: 9, title: "Treatment Center Setup", topic: "access" },
],
},
{
id: "GILD-HIV-PERS-008",
name: "HIV Persistence and Adherence Toolkit",
type: "leave_behind",
indication: "HIV-1 treatment",
expiryMonths: 22,
slides: [
{ number: 1, title: "Adherence Barriers and Solutions", topic: "persistence" },
{ number: 2, title: "Long-Acting Therapy Discussion", topic: "treatment_initiation" },
{ number: 3, title: "Patient Counseling Tips", topic: "office_workflow" },
],
},
{
id: "GILD-HCV-ACC-009",
name: "HCV Reimbursement and PA Support",
type: "one_pager",
indication: "Chronic HCV treatment",
expiryMonths: 15,
slides: [
{ number: 1, title: "Medicare and Commercial Coverage", topic: "formulary" },
{ number: 2, title: "PA Criteria and Documentation", topic: "reimbursement" },
{ number: 3, title: "Patient Support Program Enrollment", topic: "access" },
],
},
{
id: "GILD-ONC-ACC-010",
name: "Oncology Access and Site-of-Care Guide",
type: "one_pager",
indication: "Solid tumor and hematology oncology",
expiryMonths: 11,
slides: [
{ number: 1, title: "Buy-and-Bill vs Specialty Pharmacy", topic: "access" },
{ number: 2, title: "Prior Authorization Checklist", topic: "reimbursement" },
{ number: 3, title: "Site-of-Care Optimization", topic: "office_workflow" },
],
},
];
function computeExpiryDate(monthsFromNow: number, now: Date = new Date()): string {
const d = new Date(now);
d.setMonth(d.getMonth() + monthsFromNow);
return d.toISOString().slice(0, 10);
}
const APPROVED_CONTENT_LIBRARY: ApprovedContentAsset[] = CONTENT_ASSET_SEEDS.map((seed) => ({
id: seed.id,
name: seed.name,
type: seed.type,
indication: seed.indication,
expiryDate: computeExpiryDate(seed.expiryMonths),
slides: seed.slides.map((s) => ({ ...s })),
}));
// ─── Barrier → Content Topic Mapping ────────────────────────────────
const BARRIER_CONTENT_MAP: BarrierContentMapping[] = [
{
barrier: "awareness",
topicKeywords: ["awareness", "pharmacology"],
infoGap: "Disease awareness and product mechanism",
},
{
barrier: "scientific_understanding",
topicKeywords: ["scientific_understanding", "clinical_evidence", "efficacy"],
infoGap: "Clinical evidence and scientific rationale",
},
{
barrier: "patient_eligibility",
topicKeywords: ["patient_eligibility", "patient_identification"],
infoGap: "Patient identification and eligibility criteria",
},
{
barrier: "diagnosis_testing",
topicKeywords: ["diagnosis_testing", "patient_eligibility"],
infoGap: "Diagnostic and testing workflow",
},
{
barrier: "referral_pathway",
topicKeywords: ["referral_pathway", "office_workflow"],
infoGap: "Referral pathway and linkage to care",
},
{
barrier: "formulary",
topicKeywords: ["formulary", "reimbursement"],
infoGap: "Formulary coverage and access status",
},
{
barrier: "reimbursement",
topicKeywords: ["reimbursement", "formulary"],
infoGap: "Reimbursement and prior authorization support",
},
{
barrier: "office_workflow",
topicKeywords: ["office_workflow", "persistence"],
infoGap: "Office workflow integration and counseling",
},
{
barrier: "treatment_initiation",
topicKeywords: ["treatment_initiation", "efficacy"],
infoGap: "Treatment initiation protocol and expectations",
},
{
barrier: "persistence",
topicKeywords: ["persistence", "office_workflow"],
infoGap: "Adherence and long-term persistence support",
},
{
barrier: "access",
topicKeywords: ["access", "formulary", "reimbursement"],
infoGap: "Product access and site-of-care logistics",
},
];
// ─── Funnel State → Information Gap Description ─────────────────────
const FUNNEL_INFO_GAP: Record<HCPFunnelState, string> = {
eligible: "Disease awareness and basic product introduction",
relevant_population: "Patient population relevance and identification",
info_gap: "Core clinical evidence and mechanism of action",
access_opportunity: "Formulary, reimbursement, and access pathway",
engagement_attempted: "Reinforcing clinical differentiation and safety",
meaningful_interaction: "Deepening evidence on specific patient scenarios",
content_consumed: "Addressing remaining barriers with targeted content",
barrier_identified: "Directly addressing the documented barrier",
barrier_addressed: "Confirming resolution and advancing to next step",
treatment_consideration: "Treatment initiation and patient selection detail",
patient_initiation: "Onboarding workflow and persistence support",
persistence: "Adherence monitoring and long-term persistence tools",
};
// ─── Historical Response Tracking (durable store) ───────────────────
const CONTENT_RESPONSE_HISTORY_KEY = "content_response_history";
const DEFAULT_RESPONSE_HISTORY: ContentResponseRecord[] = [
{
contentId: "GILD-HIV-CLIN-001",
hcpResponse: "engaged",
responseRate: 0.75,
engagementImproved: true,
recordedAt: "2025-01-15T10:00:00Z",
},
{
contentId: "GILD-HIV-CLIN-001",
hcpResponse: "neutral",
responseRate: 0.4,
engagementImproved: false,
recordedAt: "2025-02-20T14:30:00Z",
},
{
contentId: "GILD-HCV-CLIN-003",
hcpResponse: "engaged",
responseRate: 0.75,
engagementImproved: true,
recordedAt: "2025-01-28T09:15:00Z",
},
{
contentId: "GILD-ONC-CLIN-006",
hcpResponse: "engaged",
responseRate: 0.75,
engagementImproved: true,
recordedAt: "2025-03-05T11:45:00Z",
},
];
function getResponseHistory(orgId: string = DEFAULT_ORG_ID): ContentResponseRecord[] {
const records = kvLoad<ContentResponseRecord>(orgId, CONTENT_RESPONSE_HISTORY_KEY);
if (records.length === 0) {
kvSave(orgId, CONTENT_RESPONSE_HISTORY_KEY, DEFAULT_RESPONSE_HISTORY);
return [...DEFAULT_RESPONSE_HISTORY];
}
return records;
}
// ─── Helpers ────────────────────────────────────────────────────────
function isExpired(asset: ApprovedContentAsset, now: Date = new Date()): boolean {
const expiry = new Date(asset.expiryDate);
return expiry.getTime() < now.getTime();
}
function indicationMatches(account: TerritoryAccount, asset: ApprovedContentAsset): boolean {
const spec = account.specialty.toLowerCase();
const indication = asset.indication.toLowerCase();
// Specialty → indication family mapping
if (spec.includes("hiv") || spec.includes("infect")) {
return indication.includes("hiv");
}
if (spec.includes("hepat") || spec.includes("liver") || spec.includes("gastro")) {
return indication.includes("hcv") || indication.includes("hbv") || indication.includes("hepat");
}
if (spec.includes("oncol") || spec.includes("hemat") || spec.includes("cancer")) {
return indication.includes("breast") || indication.includes("lymphoma") || indication.includes("tumor") || indication.includes("oncology") || indication.includes("hematology");
}
if (spec.includes("primary") || spec.includes("internal") || spec.includes("family")) {
// Primary care: broad eligibility — allow all but prefer screening content
return true;
}
// Default: allow if no clear specialty signal (broad-relevance assets)
return true;
}
function channelFits(account: TerritoryAccount, asset: ApprovedContentAsset): boolean {
const channel = account.channelPreference ?? "in_person";
if (channel === "email" || channel === "phone") {
// Remote channels: prefer concise formats
return asset.type === "one_pager" || asset.type === "infographic" || asset.type === "leave_behind";
}
// In-person or remote video: full decks are acceptable
return true;
}
function timeFits(account: TerritoryAccount, asset: ApprovedContentAsset): boolean {
const timeMin = account.recommendedAction?.estimatedTimeMin ?? 15;
// Short time windows: prefer fewer-slide assets
if (timeMin <= 10) {
return asset.slides.length <= 4;
}
if (timeMin <= 20) {
return asset.slides.length <= 6;
}
return true;
}
function findSlideForBarrier(
asset: ApprovedContentAsset,
barrier: BarrierType,
): { number: number; title: string; topic: string } | null {
const mapping = BARRIER_CONTENT_MAP.find((m) => m.barrier === barrier);
if (!mapping) {
return null;
}
for (const slide of asset.slides) {
if (mapping.topicKeywords.some((kw) => slide.topic.includes(kw) || slide.title.toLowerCase().includes(kw))) {
return slide;
}
}
return null;
}
function computeHistoricalResponseRate(
contentId: string,
orgId: string = DEFAULT_ORG_ID,
): number | undefined {
const records = getResponseHistory(orgId).filter((r) => r.contentId === contentId);
if (records.length === 0) {
return undefined;
}
const total = records.reduce((sum, r) => sum + r.responseRate, 0);
return Math.round((total / records.length) * 100) / 100;
}
function buildReason(
account: TerritoryAccount,
asset: ApprovedContentAsset,
slide: { number: number; title: string; topic: string } | null,
): string {
const barrierLabel = account.barrierDetail ?? account.barrier.replace(/_/g, " ");
const slideRef = slide
? `slide ${slide.number} ("${slide.title}") from approved asset ${asset.name}`
: `approved asset ${asset.name}`;
const historicalNote =
computeHistoricalResponseRate(asset.id) !== undefined
? `, and this content has historically improved follow-up engagement among similar accounts`
: "";
return `Use ${slideRef} because this account's documented barrier is ${barrierLabel}${historicalNote}.`;
}
function riskLevelFor(account: TerritoryAccount, asset: ApprovedContentAsset): "low" | "moderate" | "high" {
// Higher risk when content has never been shown before and account is early in funnel
if (account.funnelState === "eligible" || account.funnelState === "relevant_population") {
if (account.lastContentShown === undefined) {
return "moderate";
}
}
// Access/reimbursement content carries moderate risk if formulary not confirmed
if (account.barrier === "formulary" || account.barrier === "reimbursement" || account.barrier === "access") {
return "moderate";
}
return "low";
}
// ─── Public API ─────────────────────────────────────────────────────
/**
* Return the full approved content library (sample data for Gilead products
* across HIV, HCV, HBV, and oncology). Each item includes slide-level detail.
*/
export function getApprovedContentLibrary(): ApprovedContentAsset[] {
// Return a deep copy so callers cannot mutate the source library.
return APPROVED_CONTENT_LIBRARY.map((asset) => ({
...asset,
slides: asset.slides.map((s) => ({ ...s })),
}));
}
/**
* Select 1-3 approved content recommendations for a given territory account.
*
* Selection considers:
* - HCP's current information gap (funnel state)
* - Documented barrier type
* - Previous content exposure (lastContentShown)
* - Role / specialty alignment with approved indication
* - Territory conditions (operational friction, geographic zone)
* - Available time (estimatedTimeMin)
* - Channel preference
* - Approved indication match
* - Historical response rate
* - Observed downstream outcomes (engagementImproved in response history)
*/
export function recommendContent(
account: TerritoryAccount,
orgId: string = DEFAULT_ORG_ID,
): ContentRecommendation[] {
const library = getApprovedContentLibrary().filter((asset) => !isExpired(asset));
// Score every eligible asset.
const scored = library
.filter((asset) => indicationMatches(account, asset))
.filter((asset) => channelFits(account, asset))
.filter((asset) => timeFits(account, asset))
.map((asset) => {
let score = 0;
// Barrier alignment: does any slide address the barrier?
const slide = findSlideForBarrier(asset, account.barrier);
if (slide) {
score += 40;
}
// Funnel-state alignment: does the asset's indication match the info gap?
const infoGap = FUNNEL_INFO_GAP[account.funnelState] ?? "";
if (asset.slides.some((s) => infoGap.toLowerCase().includes(s.topic.replace(/_/g, " ")))) {
score += 20;
}
// Avoid re-serving the exact asset last shown.
if (account.lastContentShown && account.lastContentShown === asset.id) {
score -= 25;
}
const history = getResponseHistory(orgId);
// Historical response rate boost.
const records = history.filter((r) => r.contentId === asset.id);
const histRate =
records.length === 0
? undefined
: Math.round(
(records.reduce((sum, r) => sum + r.responseRate, 0) / records.length) * 100,
) / 100;
if (histRate !== undefined) {
score += Math.round(histRate * 30);
}
// Observed downstream outcomes: assets with engagementImproved history.
const improvedCount = records.filter((r) => r.engagementImproved).length;
score += improvedCount * 10;
// Operational friction penalty: high-friction accounts favor concise assets.
if (account.operationalFriction > 0.6 && asset.slides.length > 6) {
score -= 10;
}
// Unmet info need boost: accounts with high N_i get evidence-heavy assets.
if (account.unmetInfoNeed > 0.6 && asset.type === "slide_deck") {
score += 8;
}
// Expected responsiveness: highly responsive HCPs can absorb full decks.
if (account.expectedResponsiveness > 0.7 && asset.type === "slide_deck") {
score += 5;
}
return { asset, score, slide };
})
.sort((a, b) => b.score - a.score);
// Take top 1-3 (at least 1 if any eligible).
const topCount = Math.min(3, Math.max(1, scored.length));
const selected = scored.slice(0, topCount);
const channel = account.channelPreference ?? "in_person";
return selected.map(({ asset, slide }) => {
const histRate = computeHistoricalResponseRate(asset.id);
return {
contentId: asset.id,
contentName: asset.name,
slideReference: slide ? `Slide ${slide.number}: ${slide.title}` : undefined,
reasonForSelection: buildReason(account, asset, slide),
hcpInfoGap: FUNNEL_INFO_GAP[account.funnelState] ?? "General product information",
historicalResponseRate: histRate,
approvedForIndication: asset.indication,
channel,
riskLevel: riskLevelFor(account, asset),
};
});
}
/**
* Find the single best content piece for a specific barrier type from the
* provided content library. Returns null when no asset addresses the barrier.
*/
export function matchContentToBarrier(
barrier: BarrierType,
contentLibrary: ApprovedContentAsset[],
orgId: string = DEFAULT_ORG_ID,
): ContentRecommendation | null {
if (barrier === "none") {
return null;
}
const mapping = BARRIER_CONTENT_MAP.find((m) => m.barrier === barrier);
if (!mapping) {
return null;
}
let bestAsset: ApprovedContentAsset | null = null;
let bestSlide: { number: number; title: string; topic: string } | null = null;
let bestScore = -1;
for (const asset of contentLibrary) {
if (isExpired(asset)) {
continue;
}
const slide = findSlideForBarrier(asset, barrier);
if (!slide) {
continue;
}
// Prefer assets whose slides match the most topic keywords.
const matchCount = asset.slides.filter((s) =>
mapping.topicKeywords.some((kw) => s.topic.includes(kw)),
).length;
const records = getResponseHistory(orgId).filter((r) => r.contentId === asset.id);
const histRate =
records.length === 0
? undefined
: Math.round(
(records.reduce((sum, r) => sum + r.responseRate, 0) / records.length) * 100,
) / 100;
const score = matchCount * 10 + (histRate ?? 0) * 20;
if (score > bestScore) {
bestScore = score;
bestAsset = asset;
bestSlide = slide;
}
}
if (!bestAsset || !bestSlide) {
return null;
}
const histRate = computeHistoricalResponseRate(bestAsset.id, orgId);
return {
contentId: bestAsset.id,
contentName: bestAsset.name,
slideReference: `Slide ${bestSlide.number}: ${bestSlide.title}`,
reasonForSelection: `Use slide ${bestSlide.number} ("${bestSlide.title}") from approved asset ${bestAsset.name} because the target barrier is ${barrier.replace(/_/g, " ")}, and this slide directly addresses ${mapping.infoGap.toLowerCase()}.`,
hcpInfoGap: mapping.infoGap,
historicalResponseRate: histRate,
approvedForIndication: bestAsset.indication,
channel: "in_person",
riskLevel: "low",
};
}
/**
* Track historical response to a content piece. Persists to the durable KV
* store and recomputes aggregate metrics. Response rates are derived from the
* qualitative response category, not from random sampling.
*/
export function trackContentResponse(
contentId: string,
hcpResponse: string,
orgId: string = DEFAULT_ORG_ID,
): { responseRate: number; engagementImproved: boolean } {
if (!contentId || contentId.trim().length === 0) {
throw new Error("contentId is required");
}
if (!hcpResponse || hcpResponse.trim().length === 0) {
throw new Error("hcpResponse is required");
}
const normalized = hcpResponse.trim().toLowerCase();
const engaged = ["engaged", "positive", "interested", "requested_more", "follow_up"].some(
(k) => normalized.includes(k),
);
// Derive a deterministic response rate from the qualitative response.
let rate: number;
if (engaged) {
rate = 0.75;
} else if (normalized.includes("neutral") || normalized.includes("no_change")) {
rate = 0.4;
} else {
rate = 0.2;
}
rate = Math.round(rate * 100) / 100;
const record: ContentResponseRecord = {
contentId,
hcpResponse: normalized,
responseRate: rate,
engagementImproved: engaged,
recordedAt: new Date().toISOString(),
};
const history = getResponseHistory(orgId);
const updated = [...history, record];
kvSave(orgId, CONTENT_RESPONSE_HISTORY_KEY, updated);
// Recompute aggregate for this contentId.
const records = updated.filter((r) => r.contentId === contentId);
const aggregateRate =
Math.round((records.reduce((s, r) => s + r.responseRate, 0) / records.length) * 100) / 100;
const engagementImproved = records.some((r) => r.engagementImproved);
return { responseRate: aggregateRate, engagementImproved };
}