2026-02-14 15:26:42 +01:00
|
|
|
/**
|
|
|
|
|
* AI-Powered Award Eligibility Service
|
|
|
|
|
*
|
|
|
|
|
* Determines project eligibility for special awards using:
|
|
|
|
|
* - Deterministic field matching (tags, country, category)
|
|
|
|
|
* - AI interpretation of plain-language criteria
|
|
|
|
|
*
|
|
|
|
|
* GDPR Compliance:
|
|
|
|
|
* - All project data is anonymized before AI processing
|
|
|
|
|
* - IDs replaced with sequential identifiers
|
|
|
|
|
* - No personal information sent to OpenAI
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
import { getOpenAI, getConfiguredModel, buildCompletionParams } from '@/lib/openai'
|
|
|
|
|
import { logAIUsage, extractTokenUsage } from '@/server/utils/ai-usage'
|
|
|
|
|
import { classifyAIError, createParseError, logAIError } from './ai-errors'
|
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
|
|
|
import { sanitizeUserInput } from '@/server/services/ai-prompt-guard'
|
2026-02-14 15:26:42 +01:00
|
|
|
import {
|
|
|
|
|
anonymizeProjectsForAI,
|
|
|
|
|
validateAnonymizedProjects,
|
|
|
|
|
toProjectWithRelations,
|
|
|
|
|
type AnonymizedProjectForAI,
|
|
|
|
|
type ProjectAIMapping,
|
|
|
|
|
} from './anonymization'
|
|
|
|
|
import type { SubmissionSource } from '@prisma/client'
|
|
|
|
|
|
|
|
|
|
// ─── Constants ───────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
const BATCH_SIZE = 20
|
|
|
|
|
|
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
|
|
|
// Structured system prompt for award eligibility
|
|
|
|
|
const AI_ELIGIBILITY_SYSTEM_PROMPT = `You are an award eligibility evaluator for an ocean conservation competition.
|
|
|
|
|
|
|
|
|
|
## Your Role
|
|
|
|
|
Determine whether each project meets the criteria for a specific award category.
|
|
|
|
|
|
|
|
|
|
## Evaluation Dimensions
|
|
|
|
|
- Geographic Relevance: Does the project's location/focus match the award's geographic requirements?
|
|
|
|
|
- Category Fit: Does the project category align with the award criteria?
|
|
|
|
|
- Topic Alignment: Does the project's ocean issue focus match the award's thematic area?
|
|
|
|
|
- Maturity Level: Is the project at the right stage for this award?
|
|
|
|
|
|
|
|
|
|
## Output Format
|
|
|
|
|
Return a JSON object:
|
|
|
|
|
{
|
|
|
|
|
"evaluations": [
|
|
|
|
|
{
|
|
|
|
|
"project_id": "PROJECT_001",
|
|
|
|
|
"eligible": true/false,
|
|
|
|
|
"confidence": 0.0-1.0,
|
Fix AI filtering bugs, add special award shortlist integration
Part 1 - Bug Fixes:
- Fix toProjectWithRelations() stripping file fields needed by AI (detectedLang, textContent, etc.)
- Fix parseAIData() reading flat when aiScreeningJson is nested under rule ID
- Fix getAIConfidenceScore() with same nesting issue (always returned 0)
Part 2 - Special Award Track Integration:
- Add shortlistSize to SpecialAward, qualityScore/shortlisted/confirmed fields to AwardEligibility
- Add specialAwardId to Round for award-owned rounds
- Update AI eligibility service to return qualityScore (0-100) for ranking
- Update eligibility job with filteringRoundId scoping and auto-shortlist top N
- Add 8 new specialAward router procedures (listForRound, runEligibilityForRound,
listShortlist, toggleShortlisted, confirmShortlist, listRounds, createRound, deleteRound)
- Create award-shortlist.tsx component with ranked table, shortlist checkboxes, confirm dialog
- Add "Special Award Tracks" section to filtering dashboard
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 15:38:31 +01:00
|
|
|
"quality_score": 0-100,
|
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
|
|
|
"reasoning": "2-3 sentence explanation covering key dimensions",
|
|
|
|
|
"dimensionScores": {
|
|
|
|
|
"geographic": 0.0-1.0,
|
|
|
|
|
"category": 0.0-1.0,
|
|
|
|
|
"topic": 0.0-1.0,
|
|
|
|
|
"maturity": 0.0-1.0
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
]
|
|
|
|
|
}
|
|
|
|
|
|
Fix AI filtering bugs, add special award shortlist integration
Part 1 - Bug Fixes:
- Fix toProjectWithRelations() stripping file fields needed by AI (detectedLang, textContent, etc.)
- Fix parseAIData() reading flat when aiScreeningJson is nested under rule ID
- Fix getAIConfidenceScore() with same nesting issue (always returned 0)
Part 2 - Special Award Track Integration:
- Add shortlistSize to SpecialAward, qualityScore/shortlisted/confirmed fields to AwardEligibility
- Add specialAwardId to Round for award-owned rounds
- Update AI eligibility service to return qualityScore (0-100) for ranking
- Update eligibility job with filteringRoundId scoping and auto-shortlist top N
- Add 8 new specialAward router procedures (listForRound, runEligibilityForRound,
listShortlist, toggleShortlisted, confirmShortlist, listRounds, createRound, deleteRound)
- Create award-shortlist.tsx component with ranked table, shortlist checkboxes, confirm dialog
- Add "Special Award Tracks" section to filtering dashboard
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 15:38:31 +01:00
|
|
|
quality_score is a 0-100 integer measuring how well the project fits the award criteria (used for ranking shortlists). 100 = perfect fit, 0 = no fit. Even ineligible projects should receive a score for reference.
|
|
|
|
|
|
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
|
|
|
## Guidelines
|
|
|
|
|
- Base evaluation only on provided data — do not infer missing information
|
|
|
|
|
- eligible=true only when ALL required dimensions score above 0.5
|
|
|
|
|
- confidence reflects how clearly the data supports the determination
|
|
|
|
|
- No personal identifiers in reasoning`
|
2026-02-14 15:26:42 +01:00
|
|
|
|
|
|
|
|
// ─── 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
|
Fix AI filtering bugs, add special award shortlist integration
Part 1 - Bug Fixes:
- Fix toProjectWithRelations() stripping file fields needed by AI (detectedLang, textContent, etc.)
- Fix parseAIData() reading flat when aiScreeningJson is nested under rule ID
- Fix getAIConfidenceScore() with same nesting issue (always returned 0)
Part 2 - Special Award Track Integration:
- Add shortlistSize to SpecialAward, qualityScore/shortlisted/confirmed fields to AwardEligibility
- Add specialAwardId to Round for award-owned rounds
- Update AI eligibility service to return qualityScore (0-100) for ranking
- Update eligibility job with filteringRoundId scoping and auto-shortlist top N
- Add 8 new specialAward router procedures (listForRound, runEligibilityForRound,
listShortlist, toggleShortlisted, confirmShortlist, listRounds, createRound, deleteRound)
- Create award-shortlist.tsx component with ranked table, shortlist checkboxes, confirm dialog
- Add "Special Award Tracks" section to filtering dashboard
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 15:38:31 +01:00
|
|
|
qualityScore: number
|
2026-02-14 15:26:42 +01:00
|
|
|
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
|
|
|
|
|
institution?: string | null
|
|
|
|
|
foundedAt?: Date | null
|
|
|
|
|
wantsMentorship?: boolean
|
|
|
|
|
submissionSource?: SubmissionSource
|
|
|
|
|
submittedAt?: Date | null
|
|
|
|
|
_count?: {
|
|
|
|
|
teamMembers?: number
|
|
|
|
|
files?: number
|
|
|
|
|
}
|
|
|
|
|
files?: Array<{ fileType: string | null }>
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── Auto Tag Rules ─────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
export function applyAutoTagRules(
|
|
|
|
|
rules: AutoTagRule[],
|
|
|
|
|
projects: ProjectForEligibility[]
|
|
|
|
|
): Map<string, boolean> {
|
|
|
|
|
const results = new Map<string, boolean>()
|
|
|
|
|
|
|
|
|
|
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 ─────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Process a batch for AI eligibility evaluation
|
|
|
|
|
*/
|
|
|
|
|
async function processEligibilityBatch(
|
|
|
|
|
openai: NonNullable<Awaited<ReturnType<typeof getOpenAI>>>,
|
|
|
|
|
model: string,
|
|
|
|
|
criteriaText: string,
|
|
|
|
|
anonymized: AnonymizedProjectForAI[],
|
|
|
|
|
mappings: ProjectAIMapping[],
|
|
|
|
|
userId?: string,
|
|
|
|
|
entityId?: string
|
|
|
|
|
): Promise<{
|
|
|
|
|
results: EligibilityResult[]
|
|
|
|
|
tokensUsed: number
|
|
|
|
|
}> {
|
|
|
|
|
const results: EligibilityResult[] = []
|
|
|
|
|
let tokensUsed = 0
|
|
|
|
|
|
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
|
|
|
// Sanitize user-supplied criteria
|
|
|
|
|
const { sanitized: safeCriteria } = sanitizeUserInput(criteriaText)
|
|
|
|
|
|
|
|
|
|
const userPrompt = `CRITERIA: ${safeCriteria}
|
2026-02-14 15:26:42 +01:00
|
|
|
PROJECTS: ${JSON.stringify(anonymized)}
|
|
|
|
|
Evaluate eligibility for each project.`
|
|
|
|
|
|
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
|
|
|
const MAX_PARSE_RETRIES = 2
|
|
|
|
|
let parseAttempts = 0
|
|
|
|
|
let response: Awaited<ReturnType<typeof openai.chat.completions.create>>
|
|
|
|
|
|
2026-02-14 15:26:42 +01:00
|
|
|
try {
|
|
|
|
|
const params = buildCompletionParams(model, {
|
|
|
|
|
messages: [
|
|
|
|
|
{ role: 'system', content: AI_ELIGIBILITY_SYSTEM_PROMPT },
|
|
|
|
|
{ role: 'user', content: userPrompt },
|
|
|
|
|
],
|
|
|
|
|
jsonMode: true,
|
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
|
|
|
temperature: 0.1,
|
2026-02-14 15:26:42 +01:00
|
|
|
maxTokens: 4000,
|
|
|
|
|
})
|
|
|
|
|
|
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
|
|
|
response = await openai.chat.completions.create(params)
|
2026-02-14 15:26:42 +01:00
|
|
|
const usage = extractTokenUsage(response)
|
|
|
|
|
tokensUsed = usage.totalTokens
|
|
|
|
|
|
|
|
|
|
// Log usage
|
|
|
|
|
await logAIUsage({
|
|
|
|
|
userId,
|
|
|
|
|
action: 'AWARD_ELIGIBILITY',
|
|
|
|
|
entityType: 'Award',
|
|
|
|
|
entityId,
|
|
|
|
|
model,
|
|
|
|
|
promptTokens: usage.promptTokens,
|
|
|
|
|
completionTokens: usage.completionTokens,
|
|
|
|
|
totalTokens: usage.totalTokens,
|
|
|
|
|
batchSize: anonymized.length,
|
|
|
|
|
itemsProcessed: anonymized.length,
|
|
|
|
|
status: 'SUCCESS',
|
|
|
|
|
})
|
|
|
|
|
|
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
|
|
|
// Parse with retry logic
|
|
|
|
|
let parsed: {
|
2026-02-14 15:26:42 +01:00
|
|
|
evaluations: Array<{
|
|
|
|
|
project_id: string
|
|
|
|
|
eligible: boolean
|
|
|
|
|
confidence: number
|
Fix AI filtering bugs, add special award shortlist integration
Part 1 - Bug Fixes:
- Fix toProjectWithRelations() stripping file fields needed by AI (detectedLang, textContent, etc.)
- Fix parseAIData() reading flat when aiScreeningJson is nested under rule ID
- Fix getAIConfidenceScore() with same nesting issue (always returned 0)
Part 2 - Special Award Track Integration:
- Add shortlistSize to SpecialAward, qualityScore/shortlisted/confirmed fields to AwardEligibility
- Add specialAwardId to Round for award-owned rounds
- Update AI eligibility service to return qualityScore (0-100) for ranking
- Update eligibility job with filteringRoundId scoping and auto-shortlist top N
- Add 8 new specialAward router procedures (listForRound, runEligibilityForRound,
listShortlist, toggleShortlisted, confirmShortlist, listRounds, createRound, deleteRound)
- Create award-shortlist.tsx component with ranked table, shortlist checkboxes, confirm dialog
- Add "Special Award Tracks" section to filtering dashboard
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 15:38:31 +01:00
|
|
|
quality_score?: number
|
2026-02-14 15:26:42 +01:00
|
|
|
reasoning: string
|
|
|
|
|
}>
|
|
|
|
|
}
|
|
|
|
|
|
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
|
|
|
while (true) {
|
|
|
|
|
try {
|
|
|
|
|
const content = response.choices[0]?.message?.content
|
|
|
|
|
if (!content) {
|
|
|
|
|
throw new Error('Empty response from AI')
|
|
|
|
|
}
|
|
|
|
|
parsed = JSON.parse(content)
|
|
|
|
|
break
|
|
|
|
|
} catch (parseError) {
|
|
|
|
|
if (parseError instanceof SyntaxError && parseAttempts < MAX_PARSE_RETRIES) {
|
|
|
|
|
parseAttempts++
|
|
|
|
|
console.warn(`[AI Eligibility] JSON parse failed, retrying (${parseAttempts}/${MAX_PARSE_RETRIES})`)
|
|
|
|
|
|
|
|
|
|
// Retry the API call with hint
|
|
|
|
|
const retryParams = buildCompletionParams(model, {
|
|
|
|
|
messages: [
|
|
|
|
|
{ role: 'system', content: AI_ELIGIBILITY_SYSTEM_PROMPT },
|
|
|
|
|
{ role: 'user', content: userPrompt + '\n\nIMPORTANT: Please ensure valid JSON output.' },
|
|
|
|
|
],
|
|
|
|
|
jsonMode: true,
|
|
|
|
|
temperature: 0.1,
|
|
|
|
|
maxTokens: 4000,
|
|
|
|
|
})
|
|
|
|
|
response = await openai.chat.completions.create(retryParams)
|
|
|
|
|
const retryUsage = extractTokenUsage(response)
|
|
|
|
|
tokensUsed += retryUsage.totalTokens
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
throw parseError
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-14 15:26:42 +01:00
|
|
|
// Map results back to real IDs
|
|
|
|
|
for (const eval_ of parsed.evaluations || []) {
|
|
|
|
|
const mapping = mappings.find((m) => m.anonymousId === eval_.project_id)
|
|
|
|
|
if (mapping) {
|
|
|
|
|
results.push({
|
|
|
|
|
projectId: mapping.realId,
|
|
|
|
|
eligible: eval_.eligible,
|
|
|
|
|
confidence: eval_.confidence,
|
Fix AI filtering bugs, add special award shortlist integration
Part 1 - Bug Fixes:
- Fix toProjectWithRelations() stripping file fields needed by AI (detectedLang, textContent, etc.)
- Fix parseAIData() reading flat when aiScreeningJson is nested under rule ID
- Fix getAIConfidenceScore() with same nesting issue (always returned 0)
Part 2 - Special Award Track Integration:
- Add shortlistSize to SpecialAward, qualityScore/shortlisted/confirmed fields to AwardEligibility
- Add specialAwardId to Round for award-owned rounds
- Update AI eligibility service to return qualityScore (0-100) for ranking
- Update eligibility job with filteringRoundId scoping and auto-shortlist top N
- Add 8 new specialAward router procedures (listForRound, runEligibilityForRound,
listShortlist, toggleShortlisted, confirmShortlist, listRounds, createRound, deleteRound)
- Create award-shortlist.tsx component with ranked table, shortlist checkboxes, confirm dialog
- Add "Special Award Tracks" section to filtering dashboard
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 15:38:31 +01:00
|
|
|
qualityScore: Math.max(0, Math.min(100, eval_.quality_score ?? 0)),
|
2026-02-14 15:26:42 +01:00
|
|
|
reasoning: eval_.reasoning,
|
|
|
|
|
method: 'AI',
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
if (error instanceof SyntaxError) {
|
|
|
|
|
const parseError = createParseError(error.message)
|
|
|
|
|
logAIError('AwardEligibility', 'batch processing', parseError)
|
|
|
|
|
|
|
|
|
|
await logAIUsage({
|
|
|
|
|
userId,
|
|
|
|
|
action: 'AWARD_ELIGIBILITY',
|
|
|
|
|
entityType: 'Award',
|
|
|
|
|
entityId,
|
|
|
|
|
model,
|
|
|
|
|
promptTokens: 0,
|
|
|
|
|
completionTokens: 0,
|
|
|
|
|
totalTokens: tokensUsed,
|
|
|
|
|
batchSize: anonymized.length,
|
|
|
|
|
itemsProcessed: 0,
|
|
|
|
|
status: 'ERROR',
|
|
|
|
|
errorMessage: parseError.message,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Flag all for manual review
|
|
|
|
|
for (const mapping of mappings) {
|
|
|
|
|
results.push({
|
|
|
|
|
projectId: mapping.realId,
|
|
|
|
|
eligible: false,
|
|
|
|
|
confidence: 0,
|
Fix AI filtering bugs, add special award shortlist integration
Part 1 - Bug Fixes:
- Fix toProjectWithRelations() stripping file fields needed by AI (detectedLang, textContent, etc.)
- Fix parseAIData() reading flat when aiScreeningJson is nested under rule ID
- Fix getAIConfidenceScore() with same nesting issue (always returned 0)
Part 2 - Special Award Track Integration:
- Add shortlistSize to SpecialAward, qualityScore/shortlisted/confirmed fields to AwardEligibility
- Add specialAwardId to Round for award-owned rounds
- Update AI eligibility service to return qualityScore (0-100) for ranking
- Update eligibility job with filteringRoundId scoping and auto-shortlist top N
- Add 8 new specialAward router procedures (listForRound, runEligibilityForRound,
listShortlist, toggleShortlisted, confirmShortlist, listRounds, createRound, deleteRound)
- Create award-shortlist.tsx component with ranked table, shortlist checkboxes, confirm dialog
- Add "Special Award Tracks" section to filtering dashboard
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 15:38:31 +01:00
|
|
|
qualityScore: 0,
|
2026-02-14 15:26:42 +01:00
|
|
|
reasoning: 'AI response parse error — requires manual review',
|
|
|
|
|
method: 'AI',
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
throw error
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return { results, tokensUsed }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function aiInterpretCriteria(
|
|
|
|
|
criteriaText: string,
|
|
|
|
|
projects: ProjectForEligibility[],
|
|
|
|
|
userId?: string,
|
|
|
|
|
awardId?: string
|
|
|
|
|
): Promise<EligibilityResult[]> {
|
|
|
|
|
const results: EligibilityResult[] = []
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const openai = await getOpenAI()
|
|
|
|
|
if (!openai) {
|
|
|
|
|
console.warn('[AI Eligibility] OpenAI not configured')
|
|
|
|
|
return projects.map((p) => ({
|
|
|
|
|
projectId: p.id,
|
|
|
|
|
eligible: false,
|
|
|
|
|
confidence: 0,
|
Fix AI filtering bugs, add special award shortlist integration
Part 1 - Bug Fixes:
- Fix toProjectWithRelations() stripping file fields needed by AI (detectedLang, textContent, etc.)
- Fix parseAIData() reading flat when aiScreeningJson is nested under rule ID
- Fix getAIConfidenceScore() with same nesting issue (always returned 0)
Part 2 - Special Award Track Integration:
- Add shortlistSize to SpecialAward, qualityScore/shortlisted/confirmed fields to AwardEligibility
- Add specialAwardId to Round for award-owned rounds
- Update AI eligibility service to return qualityScore (0-100) for ranking
- Update eligibility job with filteringRoundId scoping and auto-shortlist top N
- Add 8 new specialAward router procedures (listForRound, runEligibilityForRound,
listShortlist, toggleShortlisted, confirmShortlist, listRounds, createRound, deleteRound)
- Create award-shortlist.tsx component with ranked table, shortlist checkboxes, confirm dialog
- Add "Special Award Tracks" section to filtering dashboard
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 15:38:31 +01:00
|
|
|
qualityScore: 0,
|
2026-02-14 15:26:42 +01:00
|
|
|
reasoning: 'AI unavailable — requires manual eligibility review',
|
|
|
|
|
method: 'AI' as const,
|
|
|
|
|
}))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const model = await getConfiguredModel()
|
|
|
|
|
console.log(`[AI Eligibility] Using model: ${model} for ${projects.length} projects`)
|
|
|
|
|
|
|
|
|
|
// Convert and anonymize projects
|
|
|
|
|
const projectsWithRelations = projects.map(toProjectWithRelations)
|
|
|
|
|
const { anonymized, mappings } = anonymizeProjectsForAI(projectsWithRelations, 'ELIGIBILITY')
|
|
|
|
|
|
|
|
|
|
// Validate anonymization
|
|
|
|
|
if (!validateAnonymizedProjects(anonymized)) {
|
|
|
|
|
console.error('[AI Eligibility] Anonymization validation failed')
|
|
|
|
|
throw new Error('GDPR compliance check failed: PII detected in anonymized data')
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let totalTokens = 0
|
|
|
|
|
|
|
|
|
|
// Process in batches
|
|
|
|
|
for (let i = 0; i < anonymized.length; i += BATCH_SIZE) {
|
|
|
|
|
const batchAnon = anonymized.slice(i, i + BATCH_SIZE)
|
|
|
|
|
const batchMappings = mappings.slice(i, i + BATCH_SIZE)
|
|
|
|
|
|
|
|
|
|
console.log(`[AI Eligibility] Processing batch ${Math.floor(i / BATCH_SIZE) + 1}/${Math.ceil(anonymized.length / BATCH_SIZE)}`)
|
|
|
|
|
|
|
|
|
|
const { results: batchResults, tokensUsed } = await processEligibilityBatch(
|
|
|
|
|
openai,
|
|
|
|
|
model,
|
|
|
|
|
criteriaText,
|
|
|
|
|
batchAnon,
|
|
|
|
|
batchMappings,
|
|
|
|
|
userId,
|
|
|
|
|
awardId
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
results.push(...batchResults)
|
|
|
|
|
totalTokens += tokensUsed
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
console.log(`[AI Eligibility] Completed. Total tokens: ${totalTokens}`)
|
|
|
|
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
const classified = classifyAIError(error)
|
|
|
|
|
logAIError('AwardEligibility', 'aiInterpretCriteria', classified)
|
|
|
|
|
|
|
|
|
|
// Log failed attempt
|
|
|
|
|
await logAIUsage({
|
|
|
|
|
userId,
|
|
|
|
|
action: 'AWARD_ELIGIBILITY',
|
|
|
|
|
entityType: 'Award',
|
|
|
|
|
entityId: awardId,
|
|
|
|
|
model: 'unknown',
|
|
|
|
|
promptTokens: 0,
|
|
|
|
|
completionTokens: 0,
|
|
|
|
|
totalTokens: 0,
|
|
|
|
|
batchSize: projects.length,
|
|
|
|
|
itemsProcessed: 0,
|
|
|
|
|
status: 'ERROR',
|
|
|
|
|
errorMessage: classified.message,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Return all as needing manual review
|
|
|
|
|
return projects.map((p) => ({
|
|
|
|
|
projectId: p.id,
|
|
|
|
|
eligible: false,
|
|
|
|
|
confidence: 0,
|
Fix AI filtering bugs, add special award shortlist integration
Part 1 - Bug Fixes:
- Fix toProjectWithRelations() stripping file fields needed by AI (detectedLang, textContent, etc.)
- Fix parseAIData() reading flat when aiScreeningJson is nested under rule ID
- Fix getAIConfidenceScore() with same nesting issue (always returned 0)
Part 2 - Special Award Track Integration:
- Add shortlistSize to SpecialAward, qualityScore/shortlisted/confirmed fields to AwardEligibility
- Add specialAwardId to Round for award-owned rounds
- Update AI eligibility service to return qualityScore (0-100) for ranking
- Update eligibility job with filteringRoundId scoping and auto-shortlist top N
- Add 8 new specialAward router procedures (listForRound, runEligibilityForRound,
listShortlist, toggleShortlisted, confirmShortlist, listRounds, createRound, deleteRound)
- Create award-shortlist.tsx component with ranked table, shortlist checkboxes, confirm dialog
- Add "Special Award Tracks" section to filtering dashboard
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 15:38:31 +01:00
|
|
|
qualityScore: 0,
|
2026-02-14 15:26:42 +01:00
|
|
|
reasoning: `AI error: ${classified.message}`,
|
|
|
|
|
method: 'AI' as const,
|
|
|
|
|
}))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return results
|
|
|
|
|
}
|