Special awards: Rounds tab UI, auto-filter threshold, remove auto-tag rules
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m23s
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m23s
- Add Rounds tab to award detail page with create/list/delete functionality - Add "Entry point" badge on first award round (confirmShortlist routes here) - Fix round detail back-link to navigate to parent award when specialAwardId set - Filter award rounds out of competition round list - Add specialAwardId to competition getById round select - Warn on confirmShortlist when no award rounds exist (SEPARATE_POOL mode) - Remove auto-tag rules from award config, edit page, router, and AI service - Fix competitionId not passed when creating awards from competition context - Add AUTO_FILTER quality threshold to AI filtering dashboard Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,9 +1,8 @@
|
||||
/**
|
||||
* AI-Powered Award Eligibility Service
|
||||
*
|
||||
* Determines project eligibility for special awards using:
|
||||
* - Deterministic field matching (tags, country, category)
|
||||
* - AI interpretation of plain-language criteria
|
||||
* Determines project eligibility for special awards using
|
||||
* AI interpretation of plain-language criteria.
|
||||
*
|
||||
* GDPR Compliance:
|
||||
* - All project data is anonymized before AI processing
|
||||
@@ -70,12 +69,6 @@ quality_score is a 0-100 integer measuring how well the project fits the award c
|
||||
|
||||
// ─── Types ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export type AutoTagRule = {
|
||||
field: 'competitionCategory' | 'country' | 'geographicZone' | 'tags' | 'oceanIssue'
|
||||
operator: 'equals' | 'contains' | 'in'
|
||||
value: string | string[]
|
||||
}
|
||||
|
||||
export interface EligibilityResult {
|
||||
projectId: string
|
||||
eligible: boolean
|
||||
@@ -106,66 +99,6 @@ interface ProjectForEligibility {
|
||||
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 ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
@@ -74,10 +74,12 @@ export type DocumentCheckConfig = {
|
||||
|
||||
export type AIScreeningConfig = {
|
||||
criteriaText: string
|
||||
action: 'PASS' | 'REJECT' | 'FLAG' // REJECT = auto-filter-out, FLAG = flag for human review
|
||||
action: 'PASS' | 'REJECT' | 'FLAG' | 'AUTO_FILTER' // AUTO_FILTER = reject spam/low-quality, flag borderline
|
||||
// Performance settings
|
||||
batchSize?: number // Projects per API call (1-50, default 20)
|
||||
parallelBatches?: number // Concurrent API calls (1-10, default 1)
|
||||
// AUTO_FILTER settings
|
||||
autoFilterThreshold?: number // Quality score cutoff (1-10, default 4). Scores at or below are auto-rejected.
|
||||
}
|
||||
|
||||
export type RuleConfig = FieldRuleConfig | DocumentCheckConfig | AIScreeningConfig
|
||||
@@ -794,8 +796,26 @@ export async function executeFilteringRules(
|
||||
await executeAIScreening(config, projects, userId, roundId, onProgress, async (batchAIResults) => {
|
||||
const batchResults: ProjectFilteringResult[] = []
|
||||
for (const [projectId, aiResult] of batchAIResults) {
|
||||
const passed = aiResult.meetsCriteria && !aiResult.spamRisk
|
||||
const aiAction = config.action || 'FLAG'
|
||||
let passed: boolean
|
||||
let aiAction: string
|
||||
if (config.action === 'AUTO_FILTER') {
|
||||
const threshold = config.autoFilterThreshold ?? 4
|
||||
if (aiResult.spamRisk || aiResult.qualityScore <= threshold) {
|
||||
// Clear spam/junk — auto-reject
|
||||
passed = false
|
||||
aiAction = 'REJECT'
|
||||
} else if (!aiResult.meetsCriteria) {
|
||||
// Borderline — flag for human review
|
||||
passed = false
|
||||
aiAction = 'FLAG'
|
||||
} else {
|
||||
passed = true
|
||||
aiAction = 'FLAG'
|
||||
}
|
||||
} else {
|
||||
passed = aiResult.meetsCriteria && !aiResult.spamRisk
|
||||
aiAction = config.action || 'FLAG'
|
||||
}
|
||||
batchResults.push(
|
||||
computeProjectResult(
|
||||
projectId,
|
||||
@@ -827,9 +847,25 @@ export async function executeFilteringRules(
|
||||
for (const aiRule of aiRules) {
|
||||
const screening = aiResults.get(aiRule.id)?.get(project.id)
|
||||
if (screening) {
|
||||
const passed = screening.meetsCriteria && !screening.spamRisk
|
||||
const aiConfig = aiRule.configJson as unknown as AIScreeningConfig
|
||||
const aiAction = aiConfig?.action || 'FLAG'
|
||||
let passed: boolean
|
||||
let aiAction: string
|
||||
if (aiConfig?.action === 'AUTO_FILTER') {
|
||||
const threshold = aiConfig.autoFilterThreshold ?? 4
|
||||
if (screening.spamRisk || screening.qualityScore <= threshold) {
|
||||
passed = false
|
||||
aiAction = 'REJECT'
|
||||
} else if (!screening.meetsCriteria) {
|
||||
passed = false
|
||||
aiAction = 'FLAG'
|
||||
} else {
|
||||
passed = true
|
||||
aiAction = 'FLAG'
|
||||
}
|
||||
} else {
|
||||
passed = screening.meetsCriteria && !screening.spamRisk
|
||||
aiAction = aiConfig?.action || 'FLAG'
|
||||
}
|
||||
aiRuleResults.push({ ruleId: aiRule.id, ruleName: aiRule.name, passed, action: aiAction, reasoning: screening.reasoning })
|
||||
aiScreeningData[aiRule.id] = screening
|
||||
}
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import {
|
||||
applyAutoTagRules,
|
||||
aiInterpretCriteria,
|
||||
type AutoTagRule,
|
||||
} from './ai-award-eligibility'
|
||||
import { aiInterpretCriteria } from './ai-award-eligibility'
|
||||
|
||||
const BATCH_SIZE = 20
|
||||
|
||||
@@ -118,14 +114,7 @@ export async function processEligibilityJob(
|
||||
},
|
||||
})
|
||||
|
||||
// Phase 1: Auto-tag rules (deterministic, fast)
|
||||
const autoTagRules = award.autoTagRulesJson as unknown as AutoTagRule[] | null
|
||||
let autoResults: Map<string, boolean> | undefined
|
||||
if (autoTagRules && Array.isArray(autoTagRules) && autoTagRules.length > 0) {
|
||||
autoResults = applyAutoTagRules(autoTagRules, projects)
|
||||
}
|
||||
|
||||
// Phase 2: AI interpretation (if criteria text exists AND AI eligibility is enabled)
|
||||
// AI interpretation (if criteria text exists AND AI eligibility is enabled)
|
||||
// Process in batches to avoid timeouts
|
||||
let aiResults: Map<string, { eligible: boolean; confidence: number; qualityScore: number; reasoning: string }> | undefined
|
||||
|
||||
@@ -161,14 +150,11 @@ export async function processEligibilityJob(
|
||||
})
|
||||
}
|
||||
|
||||
// Combine results: auto-tag AND AI must agree (or just one if only one configured)
|
||||
// Combine results
|
||||
const eligibilities = projects.map((project) => {
|
||||
const autoEligible = autoResults?.get(project.id) ?? true
|
||||
const aiEval = aiResults?.get(project.id)
|
||||
const aiEligible = aiEval?.eligible ?? true
|
||||
|
||||
const eligible = autoEligible && aiEligible
|
||||
const method = autoResults && aiResults ? 'AUTO' : autoResults ? 'AUTO' : 'MANUAL'
|
||||
const eligible = aiEval?.eligible ?? true
|
||||
const method = aiResults ? 'AUTO' : 'MANUAL'
|
||||
|
||||
return {
|
||||
projectId: project.id,
|
||||
|
||||
Reference in New Issue
Block a user