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

- 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:
2026-02-17 19:53:20 +01:00
parent 4fa3ca0bb6
commit 1fe6667400
13 changed files with 389 additions and 340 deletions

View File

@@ -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 ─────────────────────────────────────────────
/**

View File

@@ -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
}

View File

@@ -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,