/** * AI-Powered Award Eligibility Service * * Determines project eligibility for special awards using: * - Deterministic field matching (tags, country, category) * - AI interpretation of plain-language criteria */ import { getOpenAI, getConfiguredModel } from '@/lib/openai' // ─── Types ────────────────────────────────────────────────────────────────── export type AutoTagRule = { field: 'competitionCategory' | 'country' | 'geographicZone' | 'tags' | 'oceanIssue' operator: 'equals' | 'contains' | 'in' value: string | string[] } export interface EligibilityResult { projectId: string eligible: boolean confidence: number reasoning: string method: 'AUTO' | 'AI' } interface ProjectForEligibility { id: string title: string description?: string | null competitionCategory?: string | null country?: string | null geographicZone?: string | null tags: string[] oceanIssue?: string | null } // ─── Auto Tag Rules ───────────────────────────────────────────────────────── export function applyAutoTagRules( rules: AutoTagRule[], projects: ProjectForEligibility[] ): Map { const results = new Map() for (const project of projects) { const matches = rules.every((rule) => { const fieldValue = getFieldValue(project, rule.field) switch (rule.operator) { case 'equals': return String(fieldValue).toLowerCase() === String(rule.value).toLowerCase() case 'contains': if (Array.isArray(fieldValue)) { return fieldValue.some((v) => String(v).toLowerCase().includes(String(rule.value).toLowerCase()) ) } return String(fieldValue || '').toLowerCase().includes(String(rule.value).toLowerCase()) case 'in': if (Array.isArray(rule.value)) { return rule.value.some((v) => String(v).toLowerCase() === String(fieldValue).toLowerCase() ) } return false default: return false } }) results.set(project.id, matches) } return results } function getFieldValue( project: ProjectForEligibility, field: AutoTagRule['field'] ): unknown { switch (field) { case 'competitionCategory': return project.competitionCategory case 'country': return project.country case 'geographicZone': return project.geographicZone case 'tags': return project.tags case 'oceanIssue': return project.oceanIssue default: return null } } // ─── AI Criteria Interpretation ───────────────────────────────────────────── const AI_ELIGIBILITY_SYSTEM_PROMPT = `You are a special award eligibility evaluator. Given a list of projects and award criteria, determine which projects are eligible. Return a JSON object with this structure: { "evaluations": [ { "project_id": "string", "eligible": boolean, "confidence": number (0-1), "reasoning": "string" } ] } Be fair, objective, and base your evaluation only on the provided information. Do not include personal identifiers in reasoning.` export async function aiInterpretCriteria( criteriaText: string, projects: ProjectForEligibility[] ): Promise { const results: EligibilityResult[] = [] try { const openai = await getOpenAI() if (!openai) { // No OpenAI — mark all as needing manual review return projects.map((p) => ({ projectId: p.id, eligible: false, confidence: 0, reasoning: 'AI unavailable — requires manual eligibility review', method: 'AI' as const, })) } const model = await getConfiguredModel() // Anonymize and batch const anonymized = projects.map((p, i) => ({ project_id: `P${i + 1}`, real_id: p.id, title: p.title, description: p.description?.slice(0, 500) || '', category: p.competitionCategory || 'Unknown', ocean_issue: p.oceanIssue || 'Unknown', country: p.country || 'Unknown', region: p.geographicZone || 'Unknown', tags: p.tags.join(', '), })) const batchSize = 20 for (let i = 0; i < anonymized.length; i += batchSize) { const batch = anonymized.slice(i, i + batchSize) const userPrompt = `Award criteria: ${criteriaText} Projects to evaluate: ${JSON.stringify( batch.map(({ real_id, ...rest }) => rest), null, 2 )} Evaluate each project against the award criteria.` const response = await openai.chat.completions.create({ model, messages: [ { role: 'system', content: AI_ELIGIBILITY_SYSTEM_PROMPT }, { role: 'user', content: userPrompt }, ], response_format: { type: 'json_object' }, temperature: 0.3, max_tokens: 4000, }) const content = response.choices[0]?.message?.content if (content) { try { const parsed = JSON.parse(content) as { evaluations: Array<{ project_id: string eligible: boolean confidence: number reasoning: string }> } for (const eval_ of parsed.evaluations) { const anon = batch.find((b) => b.project_id === eval_.project_id) if (anon) { results.push({ projectId: anon.real_id, eligible: eval_.eligible, confidence: eval_.confidence, reasoning: eval_.reasoning, method: 'AI', }) } } } catch { // Parse error — mark batch for manual review for (const item of batch) { results.push({ projectId: item.real_id, eligible: false, confidence: 0, reasoning: 'AI response parse error — requires manual review', method: 'AI', }) } } } } } catch { // OpenAI error — mark all for manual review return projects.map((p) => ({ projectId: p.id, eligible: false, confidence: 0, reasoning: 'AI error — requires manual eligibility review', method: 'AI' as const, })) } return results }