From 1fe666740093efb9272b82903d816bf58573fb5d Mon Sep 17 00:00:00 2001 From: Matt Date: Tue, 17 Feb 2026 19:53:20 +0100 Subject: [PATCH] Special awards: Rounds tab UI, auto-filter threshold, remove auto-tag rules - 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 --- .../(admin)/admin/awards/[id]/edit/page.tsx | 171 +------------- src/app/(admin)/admin/awards/[id]/page.tsx | 221 ++++++++++++++++++ .../[competitionId]/awards/new/page.tsx | 1 + .../competitions/[competitionId]/page.tsx | 6 +- .../(admin)/admin/rounds/[roundId]/page.tsx | 10 +- .../admin/round/award-shortlist.tsx | 29 ++- .../admin/round/filtering-dashboard.tsx | 123 +++++++--- src/server/routers/competition.ts | 1 + src/server/routers/specialAward.ts | 10 +- src/server/services/ai-award-eligibility.ts | 71 +----- src/server/services/ai-filtering.ts | 46 +++- src/server/services/award-eligibility-job.ts | 24 +- src/types/competition-configs.ts | 16 -- 13 files changed, 389 insertions(+), 340 deletions(-) diff --git a/src/app/(admin)/admin/awards/[id]/edit/page.tsx b/src/app/(admin)/admin/awards/[id]/edit/page.tsx index 8d00f36..6d6c579 100644 --- a/src/app/(admin)/admin/awards/[id]/edit/page.tsx +++ b/src/app/(admin)/admin/awards/[id]/edit/page.tsx @@ -25,15 +25,7 @@ import { import { Switch } from '@/components/ui/switch' import { Skeleton } from '@/components/ui/skeleton' import { toast } from 'sonner' -import { ArrowLeft, Save, Loader2, Plus, X, Info } from 'lucide-react' -import { Badge } from '@/components/ui/badge' - -type AutoTagRule = { - id: string - field: 'competitionCategory' | 'country' | 'geographicZone' | 'tags' | 'oceanIssue' - operator: 'equals' | 'contains' | 'in' - value: string -} +import { ArrowLeft, Save, Loader2 } from 'lucide-react' export default function EditAwardPage({ params, @@ -70,7 +62,6 @@ export default function EditAwardPage({ const [votingEndAt, setVotingEndAt] = useState('') const [evaluationRoundId, setEvaluationRoundId] = useState('') const [eligibilityMode, setEligibilityMode] = useState<'STAY_IN_MAIN' | 'SEPARATE_POOL'>('STAY_IN_MAIN') - const [autoTagRules, setAutoTagRules] = useState([]) // Helper to format date for datetime-local input const formatDateForInput = (date: Date | string | null | undefined): string => { @@ -93,14 +84,6 @@ export default function EditAwardPage({ setVotingEndAt(formatDateForInput(award.votingEndAt)) setEvaluationRoundId(award.evaluationRoundId || '') setEligibilityMode(award.eligibilityMode as 'STAY_IN_MAIN' | 'SEPARATE_POOL') - - // Parse autoTagRulesJson - if (award.autoTagRulesJson && typeof award.autoTagRulesJson === 'object') { - const rules = award.autoTagRulesJson as { rules?: AutoTagRule[] } - setAutoTagRules(rules.rules || []) - } else { - setAutoTagRules([]) - } } }, [award]) @@ -119,7 +102,6 @@ export default function EditAwardPage({ votingEndAt: votingEndAt ? new Date(votingEndAt) : undefined, evaluationRoundId: evaluationRoundId || undefined, eligibilityMode, - autoTagRulesJson: autoTagRules.length > 0 ? { rules: autoTagRules } : undefined, }) toast.success('Award updated') router.push(`/admin/awards/${awardId}`) @@ -130,28 +112,6 @@ export default function EditAwardPage({ } } - const addRule = () => { - setAutoTagRules([ - ...autoTagRules, - { - id: `rule-${Date.now()}`, - field: 'competitionCategory', - operator: 'equals', - value: '', - }, - ]) - } - - const removeRule = (id: string) => { - setAutoTagRules(autoTagRules.filter((r) => r.id !== id)) - } - - const updateRule = (id: string, updates: Partial) => { - setAutoTagRules( - autoTagRules.map((r) => (r.id === id ? { ...r, ...updates } : r)) - ) - } - if (isLoading) { return (
@@ -348,135 +308,6 @@ export default function EditAwardPage({ - {/* Auto-Tag Rules */} - - -
-
- Auto-Tag Rules - - Deterministic eligibility rules based on project metadata - -
- -
-
- - {autoTagRules.length === 0 ? ( -
- -

- No rules defined. Add rules to automatically filter projects based on category, location, tags, or ocean issues. - Rules work together with the source round setting. -

-
- ) : ( -
- {autoTagRules.map((rule, index) => ( -
-
-
- - -
- -
- - -
- -
- - - updateRule(rule.id, { value: e.target.value }) - } - placeholder={ - rule.operator === 'in' - ? 'value1,value2,value3' - : 'Enter value...' - } - /> -
-
- - -
- ))} -
- )} - - {autoTagRules.length > 0 && ( -
- -

- How it works: Filter from{' '} - - {evaluationRoundId - ? competition?.rounds?.find((r) => r.id === evaluationRoundId) - ?.name || 'Selected Round' - : 'All Projects'} - - , where ALL rules match (AND logic). Projects matching these deterministic rules will be marked eligible. -

-
- )} -
-
- {/* Voting Window Card */} diff --git a/src/app/(admin)/admin/awards/[id]/page.tsx b/src/app/(admin)/admin/awards/[id]/page.tsx index 98c2da3..474f8f8 100644 --- a/src/app/(admin)/admin/awards/[id]/page.tsx +++ b/src/app/(admin)/admin/awards/[id]/page.tsx @@ -89,6 +89,8 @@ import { Vote, ChevronDown, AlertCircle, + Layers, + Info, } from 'lucide-react' const STATUS_COLORS: Record = { @@ -151,6 +153,8 @@ export default function AwardDetailPage({ const [projectSearchQuery, setProjectSearchQuery] = useState('') const [expandedRows, setExpandedRows] = useState>(new Set()) const [activeTab, setActiveTab] = useState('eligibility') + const [addRoundOpen, setAddRoundOpen] = useState(false) + const [roundForm, setRoundForm] = useState({ name: '', roundType: 'EVALUATION' as string }) // Pagination for eligibility list const [eligibilityPage, setEligibilityPage] = useState(1) @@ -175,6 +179,10 @@ export default function AwardDetailPage({ trpc.specialAward.getVoteResults.useQuery({ awardId }, { enabled: activeTab === 'results', }) + const { data: awardRounds, refetch: refetchRounds } = + trpc.specialAward.listRounds.useQuery({ awardId }, { + enabled: activeTab === 'rounds', + }) // Deferred queries - only load when needed const { data: allUsers } = trpc.user.list.useQuery( @@ -258,6 +266,22 @@ export default function AwardDetailPage({ const deleteAward = trpc.specialAward.delete.useMutation({ onSuccess: () => utils.specialAward.list.invalidate(), }) + const createRound = trpc.specialAward.createRound.useMutation({ + onSuccess: () => { + refetchRounds() + setAddRoundOpen(false) + setRoundForm({ name: '', roundType: 'EVALUATION' }) + toast.success('Round created') + }, + onError: (err) => toast.error(err.message), + }) + const deleteRound = trpc.specialAward.deleteRound.useMutation({ + onSuccess: () => { + refetchRounds() + toast.success('Round deleted') + }, + onError: (err) => toast.error(err.message), + }) const handleStatusChange = async ( status: 'DRAFT' | 'NOMINATIONS_OPEN' | 'VOTING_OPEN' | 'CLOSED' | 'ARCHIVED' @@ -619,6 +643,10 @@ export default function AwardDetailPage({ Jurors ({award._count.jurors}) + + + Rounds {awardRounds ? `(${awardRounds.length})` : ''} + Results @@ -1083,6 +1111,199 @@ export default function AwardDetailPage({ )} + {/* Rounds Tab */} + + {award.eligibilityMode !== 'SEPARATE_POOL' && ( +
+ +

+ Rounds are used in Separate Pool mode to create a dedicated evaluation track for shortlisted projects. +

+
+ )} + {!award.competitionId && ( +
+ +

+ Link this award to a competition first before creating rounds. +

+
+ )} + +
+

Award Rounds ({awardRounds?.length ?? 0})

+ + + + + + + Create Award Round + + Add a new round to the "{award.name}" award evaluation track. + + +
+
+ + setRoundForm({ ...roundForm, name: e.target.value })} + /> +
+
+ + +
+
+ + + + +
+
+
+ + {!awardRounds ? ( +
+ {[1, 2, 3].map((i) => ( + + ))} +
+ ) : awardRounds.length === 0 ? ( + + + No rounds yet. Create your first award round to build an evaluation track. + + + ) : ( +
+ {awardRounds.map((round: any, index: number) => { + const projectCount = round._count?.projectRoundStates ?? 0 + const assignmentCount = round._count?.assignments ?? 0 + const statusLabel = round.status.replace('ROUND_', '') + const statusColors: Record = { + DRAFT: 'bg-gray-100 text-gray-600', + ACTIVE: 'bg-emerald-100 text-emerald-700', + CLOSED: 'bg-blue-100 text-blue-700', + ARCHIVED: 'bg-muted text-muted-foreground', + } + const roundTypeColors: Record = { + EVALUATION: 'bg-violet-100 text-violet-700', + FILTERING: 'bg-amber-100 text-amber-700', + SUBMISSION: 'bg-blue-100 text-blue-700', + MENTORING: 'bg-teal-100 text-teal-700', + LIVE_FINAL: 'bg-rose-100 text-rose-700', + DELIBERATION: 'bg-indigo-100 text-indigo-700', + } + return ( + + +
+
+ {index + 1} +
+
+ + {round.name} + +
+ + {round.roundType.replace('_', ' ')} + + + {statusLabel} + + {index === 0 && ( + + Entry point + + )} +
+
+
+ +
+
+ + {projectCount} project{projectCount !== 1 ? 's' : ''} +
+ {assignmentCount > 0 && ( +
+ + {assignmentCount} assignment{assignmentCount !== 1 ? 's' : ''} +
+ )} +
+ + {round.status === 'ROUND_DRAFT' && ( +
+ + + + + + + Delete Round + + This will permanently delete "{round.name}". This cannot be undone. + + + + Cancel + deleteRound.mutate({ roundId: round.id })} + className="bg-destructive text-destructive-foreground hover:bg-destructive/90" + > + Delete + + + + +
+ )} +
+
+ ) + })} +
+ )} +
+ {/* Results Tab */} {voteResults && voteResults.results.length > 0 ? (() => { diff --git a/src/app/(admin)/admin/competitions/[competitionId]/awards/new/page.tsx b/src/app/(admin)/admin/competitions/[competitionId]/awards/new/page.tsx index 5a389de..e682960 100644 --- a/src/app/(admin)/admin/competitions/[competitionId]/awards/new/page.tsx +++ b/src/app/(admin)/admin/competitions/[competitionId]/awards/new/page.tsx @@ -60,6 +60,7 @@ export default function NewAwardPage({ params: paramsPromise }: { params: Promis createMutation.mutate({ programId: competition.programId, + competitionId: params.competitionId, name: formData.name.trim(), description: formData.description.trim() || undefined, scoringMode: formData.scoringMode, diff --git a/src/app/(admin)/admin/competitions/[competitionId]/page.tsx b/src/app/(admin)/admin/competitions/[competitionId]/page.tsx index e8a0c48..d2975ae 100644 --- a/src/app/(admin)/admin/competitions/[competitionId]/page.tsx +++ b/src/app/(admin)/admin/competitions/[competitionId]/page.tsx @@ -338,14 +338,14 @@ export default function CompetitionDetailPage() { {/* Rounds Tab */}
-

Rounds ({competition.rounds.length})

+

Rounds ({competition.rounds.filter((r: any) => !r.specialAwardId).length})

- {competition.rounds.length === 0 ? ( + {competition.rounds.filter((r: any) => !r.specialAwardId).length === 0 ? ( No rounds configured. Add rounds to define the competition flow. @@ -353,7 +353,7 @@ export default function CompetitionDetailPage() { ) : (
- {competition.rounds.map((round: any, index: number) => { + {competition.rounds.filter((r: any) => !r.specialAwardId).map((round: any, index: number) => { const projectCount = round._count?.projectRoundStates ?? 0 const assignmentCount = round._count?.assignments ?? 0 const statusLabel = round.status.replace('ROUND_', '') diff --git a/src/app/(admin)/admin/rounds/[roundId]/page.tsx b/src/app/(admin)/admin/rounds/[roundId]/page.tsx index c7e3618..0eaf885 100644 --- a/src/app/(admin)/admin/rounds/[roundId]/page.tsx +++ b/src/app/(admin)/admin/rounds/[roundId]/page.tsx @@ -548,8 +548,8 @@ export default function RoundDetailPage() { >
- - @@ -1950,8 +1950,6 @@ export default function RoundDetailPage() {
{roundAwards.map((award) => { const eligibleCount = award._count?.eligibilities || 0 - const autoTagRules = award.autoTagRulesJson as { rules?: unknown[] } | null - const ruleCount = autoTagRules?.rules?.length || 0 return (
-
-
{ruleCount}
-
{ruleCount === 1 ? 'rule' : 'rules'}
-
{eligibleCount}
eligible
diff --git a/src/components/admin/round/award-shortlist.tsx b/src/components/admin/round/award-shortlist.tsx index 6fd1d25..533950d 100644 --- a/src/components/admin/round/award-shortlist.tsx +++ b/src/components/admin/round/award-shortlist.tsx @@ -34,6 +34,7 @@ import { Play, Star, Trophy, + AlertTriangle, } from 'lucide-react' type AwardShortlistProps = { @@ -92,6 +93,12 @@ export function AwardShortlist({ onError: (err) => toast.error(`Failed: ${err.message}`), }) + const { data: awardRounds } = trpc.specialAward.listRounds.useQuery( + { awardId }, + { enabled: expanded && eligibilityMode === 'SEPARATE_POOL' } + ) + const hasAwardRounds = (awardRounds?.length ?? 0) > 0 + const confirmMutation = trpc.specialAward.confirmShortlist.useMutation({ onSuccess: (data) => { utils.specialAward.listShortlist.invalidate({ awardId }) @@ -210,11 +217,23 @@ export function AwardShortlist({ Confirm Shortlist - - {eligibilityMode === 'SEPARATE_POOL' - ? `This will confirm ${shortlistedCount} projects for the "${awardName}" award track. Projects will be routed to the award's rounds for separate evaluation.` - : `This will confirm ${shortlistedCount} projects as eligible for the "${awardName}" award. Projects remain in the main competition pool.` - } + +
+

+ {eligibilityMode === 'SEPARATE_POOL' + ? `This will confirm ${shortlistedCount} projects for the "${awardName}" award track. Projects will be routed to the award's rounds for separate evaluation.` + : `This will confirm ${shortlistedCount} projects as eligible for the "${awardName}" award. Projects remain in the main competition pool.` + } +

+ {eligibilityMode === 'SEPARATE_POOL' && !hasAwardRounds && ( +
+ +

+ No award rounds have been created yet. Projects will be confirmed but not routed to an evaluation track. Create rounds on the award page first. +

+
+ )} +
diff --git a/src/components/admin/round/filtering-dashboard.tsx b/src/components/admin/round/filtering-dashboard.tsx index f0acda0..7f9cfa2 100644 --- a/src/components/admin/round/filtering-dashboard.tsx +++ b/src/components/admin/round/filtering-dashboard.tsx @@ -107,9 +107,10 @@ export function FilteringDashboard({ competitionId, roundId }: FilteringDashboar // AI criteria state const [criteriaText, setCriteriaText] = useState('') - const [aiAction, setAiAction] = useState<'PASS' | 'REJECT' | 'FLAG'>('FLAG') + const [aiAction, setAiAction] = useState<'PASS' | 'REJECT' | 'FLAG' | 'AUTO_FILTER'>('FLAG') const [batchSize, setBatchSize] = useState(20) const [parallelBatches, setParallelBatches] = useState(3) + const [autoFilterThreshold, setAutoFilterThreshold] = useState(4) const [advancedOpen, setAdvancedOpen] = useState(false) const [criteriaDirty, setCriteriaDirty] = useState(false) const criteriaLoaded = useRef(false) @@ -168,9 +169,10 @@ export function FilteringDashboard({ competitionId, roundId }: FilteringDashboar if (aiRule && !criteriaLoaded.current) { const config = (aiRule.configJson || {}) as Record setCriteriaText((config.criteriaText as string) || '') - setAiAction((config.action as 'PASS' | 'REJECT' | 'FLAG') || 'FLAG') + setAiAction((config.action as 'PASS' | 'REJECT' | 'FLAG' | 'AUTO_FILTER') || 'FLAG') setBatchSize((config.batchSize as number) || 20) setParallelBatches((config.parallelBatches as number) || 3) + setAutoFilterThreshold((config.autoFilterThreshold as number) || 4) criteriaLoaded.current = true } }, [aiRule]) @@ -269,6 +271,7 @@ export function FilteringDashboard({ competitionId, roundId }: FilteringDashboar action: aiAction, batchSize, parallelBatches, + ...(aiAction === 'AUTO_FILTER' && { autoFilterThreshold }), } try { @@ -296,6 +299,7 @@ export function FilteringDashboard({ competitionId, roundId }: FilteringDashboar action: aiAction, batchSize, parallelBatches, + ...(aiAction === 'AUTO_FILTER' && { autoFilterThreshold }), } if (aiRule) { @@ -519,42 +523,87 @@ export function FilteringDashboard({ competitionId, roundId }: FilteringDashboar {advancedOpen ? : } -
-
- - -
-
- - { setBatchSize(parseInt(e.target.value) || 20); setCriteriaDirty(true) }} - /> -
-
- - { setParallelBatches(parseInt(e.target.value) || 3); setCriteriaDirty(true) }} - /> +
+
+
+ + +
+
+ + { setBatchSize(parseInt(e.target.value) || 20); setCriteriaDirty(true) }} + /> +
+
+ + { setParallelBatches(parseInt(e.target.value) || 3); setCriteriaDirty(true) }} + /> +
+ + {aiAction === 'AUTO_FILTER' && ( +
+
+
+ +

+ Projects scoring at or below this are auto-rejected as spam/junk +

+
+
+ { + const v = Math.min(9, Math.max(1, parseInt(e.target.value) || 4)) + setAutoFilterThreshold(v) + setCriteriaDirty(true) + }} + /> + /10 +
+
+
+
+ Auto-reject +

Score 1-{autoFilterThreshold} or spam

+
+
+ Flag for review +

Score {autoFilterThreshold + 1}+ but fails criteria

+
+
+ Auto-pass +

Meets criteria

+
+
+
+ )}
diff --git a/src/server/routers/competition.ts b/src/server/routers/competition.ts index c45155e..006b0f1 100644 --- a/src/server/routers/competition.ts +++ b/src/server/routers/competition.ts @@ -74,6 +74,7 @@ export const competitionRouter = router({ sortOrder: true, windowOpenAt: true, windowCloseAt: true, + specialAwardId: true, juryGroup: { select: { id: true, name: true }, }, diff --git a/src/server/routers/specialAward.ts b/src/server/routers/specialAward.ts index 32349c4..c822044 100644 --- a/src/server/routers/specialAward.ts +++ b/src/server/routers/specialAward.ts @@ -93,7 +93,6 @@ export const specialAwardRouter = router({ useAiEligibility: z.boolean().optional(), scoringMode: z.enum(['PICK_WINNER', 'RANKED', 'SCORED']), maxRankedPicks: z.number().int().min(1).max(20).optional(), - autoTagRulesJson: z.record(z.unknown()).optional(), competitionId: z.string().optional(), evaluationRoundId: z.string().optional(), juryGroupId: z.string().optional(), @@ -115,7 +114,6 @@ export const specialAwardRouter = router({ useAiEligibility: input.useAiEligibility ?? true, scoringMode: input.scoringMode, maxRankedPicks: input.maxRankedPicks, - autoTagRulesJson: input.autoTagRulesJson as Prisma.InputJsonValue ?? undefined, competitionId: input.competitionId, evaluationRoundId: input.evaluationRoundId, juryGroupId: input.juryGroupId, @@ -152,7 +150,6 @@ export const specialAwardRouter = router({ useAiEligibility: z.boolean().optional(), scoringMode: z.enum(['PICK_WINNER', 'RANKED', 'SCORED']).optional(), maxRankedPicks: z.number().int().min(1).max(20).optional(), - autoTagRulesJson: z.record(z.unknown()).optional(), votingStartAt: z.date().optional(), votingEndAt: z.date().optional(), competitionId: z.string().nullable().optional(), @@ -162,13 +159,10 @@ export const specialAwardRouter = router({ }) ) .mutation(async ({ ctx, input }) => { - const { id, autoTagRulesJson, ...rest } = input + const { id, ...rest } = input const award = await ctx.prisma.specialAward.update({ where: { id }, - data: { - ...rest, - ...(autoTagRulesJson !== undefined && { autoTagRulesJson: autoTagRulesJson as Prisma.InputJsonValue }), - }, + data: rest, }) await logAudit({ diff --git a/src/server/services/ai-award-eligibility.ts b/src/server/services/ai-award-eligibility.ts index fa44636..f795f3f 100644 --- a/src/server/services/ai-award-eligibility.ts +++ b/src/server/services/ai-award-eligibility.ts @@ -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 { - 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 ───────────────────────────────────────────── /** diff --git a/src/server/services/ai-filtering.ts b/src/server/services/ai-filtering.ts index 1a30158..4a2ffb3 100644 --- a/src/server/services/ai-filtering.ts +++ b/src/server/services/ai-filtering.ts @@ -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 } diff --git a/src/server/services/award-eligibility-job.ts b/src/server/services/award-eligibility-job.ts index 52349b9..e3e0bee 100644 --- a/src/server/services/award-eligibility-job.ts +++ b/src/server/services/award-eligibility-job.ts @@ -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 | 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 | 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, diff --git a/src/types/competition-configs.ts b/src/types/competition-configs.ts index 90ed63b..59bb98a 100644 --- a/src/types/competition-configs.ts +++ b/src/types/competition-configs.ts @@ -317,22 +317,6 @@ export const AwardConfigSchema = z.object({ useAiEligibility: z.boolean().default(true), /** Plain-language criteria for AI interpretation */ criteriaText: z.string().optional(), - /** Structured auto-tag rules for deterministic eligibility */ - autoTagRules: z - .array( - z.object({ - field: z.enum([ - 'competitionCategory', - 'country', - 'geographicZone', - 'tags', - 'oceanIssue', - ]), - operator: z.enum(['equals', 'contains', 'in']), - value: z.union([z.string(), z.array(z.string())]), - }), - ) - .default([]), }) export type AwardConfig = z.infer