From 6fd4ef92ad2009368ee7115acad9c458126290b1 Mon Sep 17 00:00:00 2001 From: "Ed (Paperclip CEO)" Date: Tue, 31 Mar 2026 17:08:10 -0500 Subject: [PATCH] feat(THE-37): add donor impact reallocation recommendations engine - Supabase migration 023: tpa_programs, donor_preferences, donor_recommendations tables with RLS policies and seeded program data for all 5 TPA programs - AI engine (src/lib/ai/donor-impact-engine.ts): OpenAI gpt-4o powered portfolio allocation generator; static baselines ready to accept live metrics from THE-33/THE-35 - API routes: POST /api/donor-impact/recommendations (generate + persist), GET /api/donor-impact/recommendations (fetch user history), GET /api/donor-impact/programs (program profiles for UI) Co-Authored-By: Paperclip --- src/app/api/donor-impact/programs/route.ts | 39 +++ .../api/donor-impact/recommendations/route.ts | 136 +++++++++++ src/lib/ai/donor-impact-engine.ts | 223 ++++++++++++++++++ .../023_donor_impact_intelligence.sql | 168 +++++++++++++ 4 files changed, 566 insertions(+) create mode 100644 src/app/api/donor-impact/programs/route.ts create mode 100644 src/app/api/donor-impact/recommendations/route.ts create mode 100644 src/lib/ai/donor-impact-engine.ts create mode 100644 supabase/migrations/023_donor_impact_intelligence.sql diff --git a/src/app/api/donor-impact/programs/route.ts b/src/app/api/donor-impact/programs/route.ts new file mode 100644 index 0000000..697951c --- /dev/null +++ b/src/app/api/donor-impact/programs/route.ts @@ -0,0 +1,39 @@ +import { NextResponse } from 'next/server' +import { createClient } from '@/lib/supabase/server' +import { TPA_PROGRAMS } from '@/lib/ai/donor-impact-engine' + +// GET /api/donor-impact/programs +// Returns TPA program profiles for the recommendation UI. +// Merges static profiles with any live metrics from the DB. +export async function GET() { + try { + const supabase = await createClient() + const { data: dbPrograms } = await supabase + .from('tpa_programs') + .select('id, name, description, focus_area, status, families_served_annual, avg_outcome_score, cost_per_family_usd, roi_multiplier, evidence_level, metadata') + .order('id') + + // Merge live DB data over static defaults + const programs = TPA_PROGRAMS.map(p => { + const live = dbPrograms?.find(d => d.id === p.id) + if (!live) return p + return { + ...p, + name: live.name ?? p.name, + description: live.description ?? p.description, + focusArea: live.focus_area ?? p.focusArea, + status: live.status ?? p.status, + familiesServedAnnual: live.families_served_annual ?? p.familiesServedAnnual, + avgOutcomeScore: live.avg_outcome_score ?? p.avgOutcomeScore, + costPerFamilyUsd: live.cost_per_family_usd ?? p.costPerFamilyUsd, + roiMultiplier: live.roi_multiplier ?? p.roiMultiplier, + evidenceLevel: live.evidence_level ?? p.evidenceLevel, + } + }) + + return NextResponse.json(programs) + } catch (err) { + console.error('[donor-impact/programs] error:', err) + return NextResponse.json(TPA_PROGRAMS) + } +} diff --git a/src/app/api/donor-impact/recommendations/route.ts b/src/app/api/donor-impact/recommendations/route.ts new file mode 100644 index 0000000..91c3ae9 --- /dev/null +++ b/src/app/api/donor-impact/recommendations/route.ts @@ -0,0 +1,136 @@ +import { NextRequest, NextResponse } from 'next/server' +import { createClient } from '@/lib/supabase/server' +import { + generateAllocationRecommendation, + TPA_PROGRAMS, + type DonorPreferences, +} from '@/lib/ai/donor-impact-engine' + +// POST /api/donor-impact/recommendations +// Body: DonorPreferences +// Generates an AI allocation recommendation and optionally persists it. +export async function POST(req: NextRequest) { + try { + const body = await req.json() + const prefs: DonorPreferences = body + + // Validate priorities exist + const requiredKeys = ['technology', 'data', 'transparency', 'education', 'advocacy'] + for (const key of requiredKeys) { + if (typeof prefs.priorities?.[key as keyof typeof prefs.priorities] !== 'number') { + return NextResponse.json( + { error: `Missing or invalid priority: ${key}` }, + { status: 400 } + ) + } + } + + // Optionally pull live program metrics from DB to override defaults + const supabase = await createClient() + const { data: dbPrograms } = await supabase + .from('tpa_programs') + .select('id, families_served_annual, avg_outcome_score, cost_per_family_usd, roi_multiplier, evidence_level') + .eq('status', 'active') + + // Merge live DB metrics into static profiles + const programs = TPA_PROGRAMS.map(p => { + const live = dbPrograms?.find(d => d.id === p.id) + if (!live) return p + return { + ...p, + familiesServedAnnual: live.families_served_annual ?? p.familiesServedAnnual, + avgOutcomeScore: live.avg_outcome_score ?? p.avgOutcomeScore, + costPerFamilyUsd: live.cost_per_family_usd ?? p.costPerFamilyUsd, + roiMultiplier: live.roi_multiplier ?? p.roiMultiplier, + evidenceLevel: (live.evidence_level ?? p.evidenceLevel) as typeof p.evidenceLevel, + } + }) + + const recommendation = await generateAllocationRecommendation(prefs, programs) + + // Persist preferences + recommendation if user is authenticated + const { + data: { user }, + } = await supabase.auth.getUser() + + if (user) { + const { data: prefRow, error: prefErr } = await supabase + .from('donor_preferences') + .insert({ + user_id: user.id, + priority_technology: prefs.priorities.technology, + priority_data: prefs.priorities.data, + priority_transparency: prefs.priorities.transparency, + priority_education: prefs.priorities.education, + priority_advocacy: prefs.priorities.advocacy, + annual_giving_usd: prefs.annualGivingUsd ?? null, + giving_goals: prefs.givingGoals ?? [], + risk_tolerance: prefs.riskTolerance ?? 'balanced', + raw_input: prefs, + }) + .select('id') + .single() + + if (!prefErr && prefRow) { + await supabase.from('donor_recommendations').insert({ + preference_id: prefRow.id, + allocations: recommendation.allocations, + rationale: recommendation.rationale, + key_insights: recommendation.keyInsights, + total_impact_estimate: recommendation.totalImpactEstimate, + confidence_score: recommendation.confidenceScore, + model_version: recommendation.modelVersion, + prompt_version: recommendation.promptVersion, + }) + } + } + + return NextResponse.json(recommendation) + } catch (err) { + console.error('[donor-impact/recommendations] error:', err) + return NextResponse.json( + { error: 'Failed to generate recommendation' }, + { status: 500 } + ) + } +} + +// GET /api/donor-impact/recommendations +// Returns the authenticated user's most recent recommendation. +export async function GET() { + try { + const supabase = await createClient() + const { + data: { user }, + } = await supabase.auth.getUser() + + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { data, error } = await supabase + .from('donor_preferences') + .select( + `id, priority_technology, priority_data, priority_transparency, priority_education, + priority_advocacy, annual_giving_usd, giving_goals, risk_tolerance, created_at, + donor_recommendations( + id, allocations, rationale, key_insights, total_impact_estimate, + confidence_score, created_at + )` + ) + .eq('user_id', user.id) + .order('created_at', { ascending: false }) + .limit(1) + .single() + + if (error) { + return NextResponse.json({ error: 'No recommendations found' }, { status: 404 }) + } + + return NextResponse.json(data) + } catch (err) { + console.error('[donor-impact/recommendations GET] error:', err) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } +} + diff --git a/src/lib/ai/donor-impact-engine.ts b/src/lib/ai/donor-impact-engine.ts new file mode 100644 index 0000000..2c40bab --- /dev/null +++ b/src/lib/ai/donor-impact-engine.ts @@ -0,0 +1,223 @@ +import OpenAI from 'openai' + +const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY }) + +// ─── Program profiles ────────────────────────────────────────────────────── +// Static baseline; will be superseded by live metrics once +// THE-33 (per-program effectiveness) and THE-35 (ROI calculator) are live. + +export interface ProgramProfile { + id: string + name: string + focusArea: string + description: string + status: 'active' | 'pilot' | 'planned' + // Metrics — zeros until THE-33/THE-35 populate them + familiesServedAnnual: number + avgOutcomeScore: number // 0–10 + costPerFamilyUsd: number + roiMultiplier: number // impact dollars per donated dollar + evidenceLevel: 'emerging' | 'promising' | 'proven' +} + +export const TPA_PROGRAMS: ProgramProfile[] = [ + { + id: 'guideed_ai', + name: 'GuideEd.ai', + focusArea: 'technology', + description: + 'AI-powered platform giving Oklahoma parents school governance tools, incident management, and real-time advocacy support.', + status: 'active', + familiesServedAnnual: 0, + avgOutcomeScore: 0, + costPerFamilyUsd: 0, + roiMultiplier: 1.0, + evidenceLevel: 'emerging', + }, + { + id: 'parent_voice', + name: 'OEQA Parent Voice', + focusArea: 'data', + description: + 'Continuous parent perception surveys feeding real-time data to the Oklahoma Education Quality and Accountability Commission.', + status: 'pilot', + familiesServedAnnual: 0, + avgOutcomeScore: 0, + costPerFamilyUsd: 0, + roiMultiplier: 1.0, + evidenceLevel: 'emerging', + }, + { + id: 'district_dashboards', + name: 'District Data Dashboards', + focusArea: 'transparency', + description: + 'Open data dashboards giving Oklahoma families transparent access to district performance, spending, and outcome data.', + status: 'planned', + familiesServedAnnual: 0, + avgOutcomeScore: 0, + costPerFamilyUsd: 0, + roiMultiplier: 1.0, + evidenceLevel: 'emerging', + }, + { + id: 'parent_education', + name: 'Parent Education Series', + focusArea: 'education', + description: + 'Workshop curriculum teaching parents how to navigate IEPs, understand school governance, and advocate effectively for their children.', + status: 'planned', + familiesServedAnnual: 0, + avgOutcomeScore: 0, + costPerFamilyUsd: 0, + roiMultiplier: 1.0, + evidenceLevel: 'emerging', + }, + { + id: 'policy_watch', + name: 'Policy Watch & Advocacy Center', + focusArea: 'advocacy', + description: + 'Real-time Oklahoma education legislation monitoring with tools for parents to engage legislators and drive systemic change.', + status: 'planned', + familiesServedAnnual: 0, + avgOutcomeScore: 0, + costPerFamilyUsd: 0, + roiMultiplier: 1.0, + evidenceLevel: 'emerging', + }, +] + +// ─── Donor preferences input ─────────────────────────────────────────────── + +export interface DonorPreferences { + priorities: { + technology: number // 0–100 + data: number + transparency: number + education: number + advocacy: number + } + annualGivingUsd?: number + givingGoals?: string[] // e.g. 'scale_proven_programs', 'fund_innovation' + riskTolerance?: 'conservative' | 'balanced' | 'growth' +} + +// ─── Recommendation output ───────────────────────────────────────────────── + +export interface AllocationRecommendation { + allocations: Record // programId → percentage (sums to 100) + rationale: string + keyInsights: string[] + totalImpactEstimate: { + estimatedFamiliesReached: number + weightedOutcomeScore: number + blendedRoi: number + note: string + } + confidenceScore: number // 0.00–1.00 + modelVersion: string + promptVersion: string +} + +// ─── Core function ───────────────────────────────────────────────────────── + +export async function generateAllocationRecommendation( + prefs: DonorPreferences, + programOverrides?: ProgramProfile[] +): Promise { + const programs = programOverrides ?? TPA_PROGRAMS + + const programSummaries = programs + .map( + p => + `- **${p.name}** (id: ${p.id}) + Focus: ${p.focusArea} | Status: ${p.status} | Evidence: ${p.evidenceLevel} + ${p.description} + Metrics: ${p.familiesServedAnnual} families/yr | Outcome score: ${p.avgOutcomeScore}/10 | Cost/family: $${p.costPerFamilyUsd} | ROI: ${p.roiMultiplier}x` + ) + .join('\n') + + const priorityDescriptions = Object.entries(prefs.priorities) + .sort(([, a], [, b]) => b - a) + .map(([k, v]) => `${k}: ${v}/100`) + .join(', ') + + const systemPrompt = `You are a philanthropic portfolio advisor for The Parent Advocate (TPA), an Oklahoma education nonprofit. +Your job is to recommend how a donor should allocate their giving across TPA's programs to maximize impact aligned with their priorities. + +Be data-driven, honest about uncertainty, and direct. When metrics are zero or unproven, say so and weight toward strategic fit instead. +Always produce allocations that sum to exactly 100.` + + const userPrompt = `## Donor Profile +- Priority weights: ${priorityDescriptions} +- Annual giving budget: ${prefs.annualGivingUsd ? `$${prefs.annualGivingUsd.toLocaleString()}` : 'not specified'} +- Giving goals: ${prefs.givingGoals?.join(', ') || 'not specified'} +- Risk tolerance: ${prefs.riskTolerance || 'balanced'} + +## TPA Programs +${programSummaries} + +## Task +Produce a JSON allocation recommendation with this exact schema: +{ + "allocations": { "": }, // must sum to 100 + "rationale": "<2-3 paragraph explanation>", + "keyInsights": ["", "", ""], + "totalImpactEstimate": { + "estimatedFamiliesReached": , + "weightedOutcomeScore": <0.0-10.0>, + "blendedRoi": , + "note": "" + }, + "confidenceScore": <0.00-1.00> +} + +Return only valid JSON. No markdown fences.` + + const response = await openai.chat.completions.create({ + model: 'gpt-4o', + messages: [ + { role: 'system', content: systemPrompt }, + { role: 'user', content: userPrompt }, + ], + temperature: 0.3, + response_format: { type: 'json_object' }, + }) + + const raw = response.choices[0].message.content + if (!raw) throw new Error('Empty response from OpenAI') + + const parsed = JSON.parse(raw) + + // Normalize allocations to ensure they sum to 100 + const allocations = parsed.allocations as Record + const total = Object.values(allocations).reduce((s, v) => s + v, 0) + if (total !== 100) { + const factor = 100 / total + for (const key of Object.keys(allocations)) { + allocations[key] = Math.round(allocations[key] * factor) + } + // Fix rounding drift on the largest allocation + const diff = 100 - Object.values(allocations).reduce((s, v) => s + v, 0) + if (diff !== 0) { + const largest = Object.entries(allocations).sort(([, a], [, b]) => b - a)[0][0] + allocations[largest] += diff + } + } + + return { + allocations, + rationale: parsed.rationale, + keyInsights: parsed.keyInsights ?? [], + totalImpactEstimate: parsed.totalImpactEstimate ?? { + estimatedFamiliesReached: 0, + weightedOutcomeScore: 0, + blendedRoi: 1.0, + note: 'Impact metrics not yet available — pending program effectiveness data.', + }, + confidenceScore: parsed.confidenceScore ?? 0.5, + modelVersion: 'gpt-4o', + promptVersion: 'v1', + } +} diff --git a/supabase/migrations/023_donor_impact_intelligence.sql b/supabase/migrations/023_donor_impact_intelligence.sql new file mode 100644 index 0000000..83f1e49 --- /dev/null +++ b/supabase/migrations/023_donor_impact_intelligence.sql @@ -0,0 +1,168 @@ +-- Migration: 023_donor_impact_intelligence.sql +-- Donor Impact Intelligence: schema for program metrics, donor preferences, +-- and AI-generated allocation recommendations. + +-- 1) TPA program registry +CREATE TABLE IF NOT EXISTS tpa_programs ( + id TEXT PRIMARY KEY, -- e.g. 'guideed_ai', 'parent_voice' + name TEXT NOT NULL, + description TEXT NOT NULL, + focus_area TEXT NOT NULL, -- e.g. 'technology', 'advocacy', 'education' + status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'pilot', 'planned', 'archived')), + -- Outcome metrics (updated by per-program effectiveness pipeline) + families_served_annual INTEGER DEFAULT 0, + avg_outcome_score NUMERIC(4,2) DEFAULT 0, -- 0-10 scale + cost_per_family_usd NUMERIC(10,2) DEFAULT 0, + evidence_level TEXT DEFAULT 'emerging' CHECK (evidence_level IN ('emerging', 'promising', 'proven')), + -- ROI metrics (populated by ROI calculator) + roi_multiplier NUMERIC(6,2) DEFAULT 1.0, -- dollars of impact per dollar donated + last_metrics_updated_at TIMESTAMPTZ, + metadata JSONB DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ DEFAULT now(), + updated_at TIMESTAMPTZ DEFAULT now() +); + +-- Seed the 5 TPA programs +INSERT INTO tpa_programs (id, name, description, focus_area, status, metadata) +VALUES + ( + 'guideed_ai', + 'GuideEd.ai', + 'AI-powered education guidance platform connecting Oklahoma parents with school governance tools, incident management, and advocacy resources.', + 'technology', + 'active', + '{"url": "https://guideed.ai", "pilot_school": "Tulsa Classical Academy"}'::jsonb + ), + ( + 'parent_voice', + 'OEQA Parent Voice', + 'Continuous parent perception survey program providing real-time data to the Oklahoma Education Quality and Accountability Commission.', + 'data', + 'pilot', + '{"partner": "OEQA", "survey_tiers": ["school_pulse", "family_needs"]}'::jsonb + ), + ( + 'district_dashboards', + 'District Data Dashboards', + 'Open data dashboards giving Oklahoma families transparent access to district performance, spending, and outcome data.', + 'transparency', + 'planned', + '{}'::jsonb + ), + ( + 'parent_education', + 'Parent Education Series', + 'Workshop curriculum teaching parents how to navigate IEPs, understand school governance, and advocate for their children effectively.', + 'education', + 'planned', + '{}'::jsonb + ), + ( + 'policy_watch', + 'Policy Watch & Advocacy Center', + 'Real-time monitoring of Oklahoma education legislation and policy, with tools for parents to engage legislators and advocate for change.', + 'advocacy', + 'planned', + '{}'::jsonb + ) +ON CONFLICT (id) DO NOTHING; + +-- 2) Donor philanthropic preference profiles +CREATE TABLE IF NOT EXISTS donor_preferences ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + -- Donor identification (nullable for anonymous/session-based recommendations) + user_id UUID REFERENCES auth.users(id) ON DELETE SET NULL, + session_token TEXT, -- for anonymous donors + -- Philanthropic priority weights (0-100, should sum to ~100) + priority_technology INTEGER DEFAULT 20 CHECK (priority_technology BETWEEN 0 AND 100), + priority_data INTEGER DEFAULT 20 CHECK (priority_data BETWEEN 0 AND 100), + priority_transparency INTEGER DEFAULT 20 CHECK (priority_transparency BETWEEN 0 AND 100), + priority_education INTEGER DEFAULT 20 CHECK (priority_education BETWEEN 0 AND 100), + priority_advocacy INTEGER DEFAULT 20 CHECK (priority_advocacy BETWEEN 0 AND 100), + -- Donor goals and context + annual_giving_usd NUMERIC(12,2), + giving_goals TEXT[], -- e.g. ['scale_proven_programs', 'fund_innovation', 'maximize_families_reached'] + geographic_focus TEXT DEFAULT 'oklahoma', + risk_tolerance TEXT DEFAULT 'balanced' CHECK (risk_tolerance IN ('conservative', 'balanced', 'growth')), + -- Metadata + raw_input JSONB DEFAULT '{}'::jsonb, -- stores the original preference form input + created_at TIMESTAMPTZ DEFAULT now(), + updated_at TIMESTAMPTZ DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_donor_preferences_user_id ON donor_preferences(user_id) WHERE user_id IS NOT NULL; + +-- 3) AI-generated allocation recommendations +CREATE TABLE IF NOT EXISTS donor_recommendations ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + preference_id UUID NOT NULL REFERENCES donor_preferences(id) ON DELETE CASCADE, + -- Recommended allocations as percentages (should sum to 100) + allocations JSONB NOT NULL, -- {"guideed_ai": 35, "parent_voice": 25, ...} + -- AI reasoning + rationale TEXT NOT NULL, + key_insights TEXT[], -- bullet points for the donor + total_impact_estimate JSONB, -- estimated families reached, outcome scores, etc. + -- Generation metadata + model_version TEXT DEFAULT 'gpt-4o', + prompt_version TEXT DEFAULT 'v1', + confidence_score NUMERIC(3,2), -- 0.00-1.00 + created_at TIMESTAMPTZ DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_donor_recommendations_preference_id ON donor_recommendations(preference_id); + +-- 4) RLS policies +ALTER TABLE donor_preferences ENABLE ROW LEVEL SECURITY; +ALTER TABLE donor_recommendations ENABLE ROW LEVEL SECURITY; +ALTER TABLE tpa_programs ENABLE ROW LEVEL SECURITY; + +-- tpa_programs: publicly readable +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE policyname = 'Programs are publicly readable' AND tablename = 'tpa_programs') THEN + CREATE POLICY "Programs are publicly readable" + ON tpa_programs FOR SELECT USING (true); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE policyname = 'Admins can manage programs' AND tablename = 'tpa_programs') THEN + CREATE POLICY "Admins can manage programs" + ON tpa_programs FOR ALL USING ( + EXISTS (SELECT 1 FROM profiles WHERE id = auth.uid() AND role = 'admin') + ); + END IF; +END $$; + +-- donor_preferences: users can manage their own; anonymous via session token handled in API +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE policyname = 'Users can view own preferences' AND tablename = 'donor_preferences') THEN + CREATE POLICY "Users can view own preferences" + ON donor_preferences FOR SELECT USING (auth.uid() = user_id OR user_id IS NULL); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE policyname = 'Users can insert preferences' AND tablename = 'donor_preferences') THEN + CREATE POLICY "Users can insert preferences" + ON donor_preferences FOR INSERT WITH CHECK (auth.uid() = user_id OR user_id IS NULL); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE policyname = 'Users can update own preferences' AND tablename = 'donor_preferences') THEN + CREATE POLICY "Users can update own preferences" + ON donor_preferences FOR UPDATE USING (auth.uid() = user_id OR user_id IS NULL); + END IF; +END $$; + +-- donor_recommendations: readable if you own the linked preference +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE policyname = 'Users can view own recommendations' AND tablename = 'donor_recommendations') THEN + CREATE POLICY "Users can view own recommendations" + ON donor_recommendations FOR SELECT USING ( + EXISTS ( + SELECT 1 FROM donor_preferences dp + WHERE dp.id = preference_id + AND (dp.user_id = auth.uid() OR dp.user_id IS NULL) + ) + ); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE policyname = 'Service can insert recommendations' AND tablename = 'donor_recommendations') THEN + CREATE POLICY "Service can insert recommendations" + ON donor_recommendations FOR INSERT WITH CHECK (true); + END IF; +END $$;