Fix filtering config save, auto-save, streamed results, improved AI prompt
All checks were successful
Build and Push Docker Image / build (push) Successful in 9m7s
All checks were successful
Build and Push Docker Image / build (push) Successful in 9m7s
- Add missing fields to FilteringConfigSchema (aiParseFiles, startupAdvanceCount, conceptAdvanceCount, notifyOnEntry, notifyOnAdvance) — Zod was silently stripping them on save - Restore auto-save with 800ms debounce on config changes - Add staggered animations for filtering results (stream in one-by-one) - Improve AI screening prompt: file type label mappings, soft cap handling, missing documents = fail, better user prompt structure Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useMemo, useCallback, useRef } from 'react'
|
import { useState, useMemo, useCallback, useRef, useEffect } from 'react'
|
||||||
import { useParams } from 'next/navigation'
|
import { useParams } from 'next/navigation'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import type { Route } from 'next'
|
import type { Route } from 'next'
|
||||||
@@ -397,6 +397,20 @@ export default function RoundDetailPage() {
|
|||||||
updateMutation.mutate({ id: roundId, configJson: config })
|
updateMutation.mutate({ id: roundId, configJson: config })
|
||||||
}, [config, roundId, updateMutation])
|
}, [config, roundId, updateMutation])
|
||||||
|
|
||||||
|
// ── Auto-save: debounce config changes and save automatically ────────
|
||||||
|
useEffect(() => {
|
||||||
|
if (!configInitialized.current) return
|
||||||
|
if (JSON.stringify(config) === JSON.stringify(serverConfig)) return
|
||||||
|
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setAutosaveStatus('saving')
|
||||||
|
updateMutation.mutate({ id: roundId, configJson: config })
|
||||||
|
}, 800)
|
||||||
|
|
||||||
|
return () => clearTimeout(timer)
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [config])
|
||||||
|
|
||||||
// ── Computed values ────────────────────────────────────────────────────
|
// ── Computed values ────────────────────────────────────────────────────
|
||||||
const projectCount = round?._count?.projectRoundStates ?? 0
|
const projectCount = round?._count?.projectRoundStates ?? 0
|
||||||
const stateCounts = useMemo(() =>
|
const stateCounts = useMemo(() =>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
import { useState, useEffect, useCallback, useRef, useMemo } from 'react'
|
||||||
import { trpc } from '@/lib/trpc/client'
|
import { trpc } from '@/lib/trpc/client'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
@@ -70,6 +70,7 @@ import {
|
|||||||
ListFilter,
|
ListFilter,
|
||||||
Settings2,
|
Settings2,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
import { motion, AnimatePresence } from 'motion/react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import type { Route } from 'next'
|
import type { Route } from 'next'
|
||||||
import { AwardShortlist } from './award-shortlist'
|
import { AwardShortlist } from './award-shortlist'
|
||||||
@@ -401,6 +402,30 @@ export function FilteringDashboard({ competitionId, roundId }: FilteringDashboar
|
|||||||
)
|
)
|
||||||
}) ?? []
|
}) ?? []
|
||||||
|
|
||||||
|
// ── Staggered reveal: animate new results in one by one ─────────────
|
||||||
|
const prevResultIdsRef = useRef<Set<string>>(new Set())
|
||||||
|
const isFirstLoad = useRef(true)
|
||||||
|
|
||||||
|
const resultStagger = useMemo(() => {
|
||||||
|
const stagger: Record<string, number> = {}
|
||||||
|
if (isFirstLoad.current && displayResults.length > 0) {
|
||||||
|
isFirstLoad.current = false
|
||||||
|
return stagger // No stagger on first load — show immediately
|
||||||
|
}
|
||||||
|
let newIdx = 0
|
||||||
|
for (const r of displayResults) {
|
||||||
|
if (!prevResultIdsRef.current.has((r as any).id)) {
|
||||||
|
stagger[(r as any).id] = newIdx++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return stagger
|
||||||
|
}, [displayResults])
|
||||||
|
|
||||||
|
// Update known IDs after render
|
||||||
|
useEffect(() => {
|
||||||
|
prevResultIdsRef.current = new Set(displayResults.map((r: any) => r.id))
|
||||||
|
}, [displayResults])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Main Card: AI Screening Criteria + Controls */}
|
{/* Main Card: AI Screening Criteria + Controls */}
|
||||||
@@ -766,13 +791,26 @@ export function FilteringDashboard({ competitionId, roundId }: FilteringDashboar
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Rows */}
|
{/* Rows */}
|
||||||
|
<AnimatePresence initial={false}>
|
||||||
{displayResults.map((result: any) => {
|
{displayResults.map((result: any) => {
|
||||||
const ai = parseAIData(result.aiScreeningJson)
|
const ai = parseAIData(result.aiScreeningJson)
|
||||||
const effectiveOutcome = result.finalOutcome || result.outcome
|
const effectiveOutcome = result.finalOutcome || result.outcome
|
||||||
const isExpanded = expandedId === result.id
|
const isExpanded = expandedId === result.id
|
||||||
|
const staggerIdx = resultStagger[result.id]
|
||||||
|
const isNew = staggerIdx !== undefined
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={result.id} className="border-b last:border-b-0 animate-in fade-in-0 duration-300">
|
<motion.div
|
||||||
|
key={result.id}
|
||||||
|
initial={isNew ? { opacity: 0, y: 16, scale: 0.98 } : false}
|
||||||
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||||
|
transition={{
|
||||||
|
duration: 0.35,
|
||||||
|
delay: isNew ? staggerIdx * 0.12 : 0,
|
||||||
|
ease: [0.25, 0.46, 0.45, 0.94],
|
||||||
|
}}
|
||||||
|
className="border-b last:border-b-0"
|
||||||
|
>
|
||||||
{/* Main Row */}
|
{/* Main Row */}
|
||||||
<div
|
<div
|
||||||
className="grid grid-cols-[40px_1fr_120px_100px_70px_70px_120px] gap-2 px-3 py-2.5 items-center hover:bg-muted/50 text-sm cursor-pointer"
|
className="grid grid-cols-[40px_1fr_120px_100px_70px_70px_120px] gap-2 px-3 py-2.5 items-center hover:bg-muted/50 text-sm cursor-pointer"
|
||||||
@@ -980,9 +1018,10 @@ export function FilteringDashboard({ competitionId, roundId }: FilteringDashboar
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</motion.div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
{/* Pagination */}
|
{/* Pagination */}
|
||||||
{resultsPage && resultsPage.totalPages > 1 && (
|
{resultsPage && resultsPage.totalPages > 1 && (
|
||||||
|
|||||||
@@ -149,7 +149,7 @@ const MAX_PARALLEL_BATCHES = 10
|
|||||||
const AI_SCREENING_SYSTEM_PROMPT = `You are an expert project screening assistant for an ocean conservation competition.
|
const AI_SCREENING_SYSTEM_PROMPT = `You are an expert project screening assistant for an ocean conservation competition.
|
||||||
|
|
||||||
## Your Role
|
## Your Role
|
||||||
Evaluate each project against the provided screening criteria. Be objective and base evaluation only on provided data.
|
Evaluate each project against the provided screening criteria. Be objective and base evaluation only on provided data. Your goal is to identify projects that clearly FAIL the criteria — not to nitpick minor issues.
|
||||||
|
|
||||||
## Output Format
|
## Output Format
|
||||||
Return a JSON object with this exact structure:
|
Return a JSON object with this exact structure:
|
||||||
@@ -179,12 +179,43 @@ Return a JSON object with this exact structure:
|
|||||||
- founded_year: when the company/initiative was founded (use for age checks)
|
- founded_year: when the company/initiative was founded (use for age checks)
|
||||||
- ocean_issue: the ocean conservation area
|
- ocean_issue: the ocean conservation area
|
||||||
- file_count, file_types: uploaded documents summary
|
- file_count, file_types: uploaded documents summary
|
||||||
- files[]: per-file details with file_type, page_count (if known), size_kb, detected_lang (ISO 639-3 language code like 'eng', 'fra'), lang_confidence (0-1), round_name (which round the file was submitted for), and is_current_round flag
|
- files[]: per-file details (see File Type Reference below) with file_type, page_count (if known), size_kb, detected_lang (ISO 639-3 language code like 'eng', 'fra'), lang_confidence (0-1), round_name (which round the file was submitted for), and is_current_round flag
|
||||||
- description: project summary text
|
- description: project summary text
|
||||||
- tags: topic tags
|
- tags: topic tags
|
||||||
- If document content is provided (text_content field in files), use it for deeper analysis. Pay SPECIAL ATTENTION to files from the current round (is_current_round=true) as they are the most recent and relevant submissions.
|
- If document content is provided (text_content field in files), use it for deeper analysis. Pay SPECIAL ATTENTION to files from the current round (is_current_round=true) as they are the most recent and relevant submissions.
|
||||||
- If detected_lang is provided, use it to evaluate language requirements (e.g. 'eng' = English, 'fra' = French). lang_confidence indicates detection reliability.
|
- If detected_lang is provided, use it to evaluate language requirements (e.g. 'eng' = English, 'fra' = French). lang_confidence indicates detection reliability.
|
||||||
|
|
||||||
|
## File Type Reference
|
||||||
|
The file_type field uses these codes. When criteria mention document names, match them to the correct code:
|
||||||
|
- EXEC_SUMMARY = Executive Summary
|
||||||
|
- PRESENTATION = Pitch Deck / Presentation
|
||||||
|
- BUSINESS_PLAN = Business Plan
|
||||||
|
- VIDEO = Video
|
||||||
|
- VIDEO_PITCH = Video Pitch
|
||||||
|
- SUPPORTING_DOC = Supporting Document
|
||||||
|
- OTHER = Other / miscellaneous document
|
||||||
|
|
||||||
|
IMPORTANT: Only evaluate page limits against the CORRECT document type. For example, a page limit on "executive summary" applies ONLY to files with file_type=EXEC_SUMMARY, not to pitch decks or other documents.
|
||||||
|
|
||||||
|
## How to Interpret Criteria
|
||||||
|
- Treat criteria as reasonable guidelines written by a human, not as rigid legal rules.
|
||||||
|
- When criteria say "soft cap", "approximately", "around", "about", or "reasonable amount", be LENIENT. A document that is 1-3 pages over a soft cap still meets criteria. Only flag if it is egregiously over (e.g. double the stated limit).
|
||||||
|
- When criteria have NO softening language, treat them as firm but still use judgment — a 1-page overage on a hard limit is borderline, not an automatic fail.
|
||||||
|
- Page count data may be unavailable (null) for some files. If page_count is null, do NOT penalize — only evaluate page limits when page_count is actually provided for that file.
|
||||||
|
- Focus on the INTENT behind each criterion, not a hyper-literal reading.
|
||||||
|
|
||||||
|
## Missing Documents
|
||||||
|
- If criteria mention document requirements (executive summary, pitch deck, etc.) and the project has ZERO files (file_count=0), this is a CLEAR FAIL. Every competition applicant is expected to upload documents.
|
||||||
|
- "Where applicable" in criteria refers to edge cases within specific document types — it does NOT mean documents are optional.
|
||||||
|
- A project with no uploaded documents at all should always have meets_criteria=FALSE unless the criteria explicitly say documents are optional.
|
||||||
|
|
||||||
|
## Decision Framework
|
||||||
|
- Set meets_criteria=TRUE if the project satisfies the core intent of the criteria, even with minor imperfections (e.g. a few pages over a soft cap).
|
||||||
|
- Set meets_criteria=FALSE when there is a clear, material violation: missing required documents entirely, startup far exceeding age limit, documents entirely in the wrong language, no visible ocean impact, etc.
|
||||||
|
- When in doubt on a SOFT criterion (page lengths, formatting), set meets_criteria=TRUE with a lower confidence score and note the concern in reasoning.
|
||||||
|
- When in doubt on a HARD criterion (age limits, language, having documents at all), set meets_criteria=FALSE.
|
||||||
|
- Mention specific concerns in the reasoning field so the admin can review.
|
||||||
|
|
||||||
## Guidelines
|
## Guidelines
|
||||||
- Evaluate ONLY against the provided criteria, not your own standards
|
- Evaluate ONLY against the provided criteria, not your own standards
|
||||||
- A confidence of 1.0 means absolute certainty; 0.5 means borderline
|
- A confidence of 1.0 means absolute certainty; 0.5 means borderline
|
||||||
@@ -192,7 +223,7 @@ Return a JSON object with this exact structure:
|
|||||||
- When criteria differ by category (e.g. stricter for STARTUP vs BUSINESS_CONCEPT), apply the appropriate threshold
|
- When criteria differ by category (e.g. stricter for STARTUP vs BUSINESS_CONCEPT), apply the appropriate threshold
|
||||||
- When criteria mention regional considerations (e.g. African projects), use the country/region fields
|
- When criteria mention regional considerations (e.g. African projects), use the country/region fields
|
||||||
- Do not include any personal identifiers in reasoning
|
- Do not include any personal identifiers in reasoning
|
||||||
- If project data is insufficient to evaluate, set confidence below 0.3`
|
- If project data is insufficient to evaluate, set confidence below 0.3 and default meets_criteria to TRUE`
|
||||||
|
|
||||||
// ─── Field-Based Rule Evaluation ────────────────────────────────────────────
|
// ─── Field-Based Rule Evaluation ────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -371,10 +402,16 @@ async function processAIBatch(
|
|||||||
// Sanitize user-supplied criteria
|
// Sanitize user-supplied criteria
|
||||||
const { sanitized: safeCriteria } = sanitizeUserInput(criteriaText)
|
const { sanitized: safeCriteria } = sanitizeUserInput(criteriaText)
|
||||||
|
|
||||||
// Build optimized prompt
|
// Build user prompt with clear structure
|
||||||
const userPrompt = `CRITERIA: ${safeCriteria}
|
const userPrompt = `## Screening Criteria
|
||||||
PROJECTS: ${JSON.stringify(anonymized)}
|
The admin has defined the following requirements. Evaluate each project against ALL of these criteria:
|
||||||
Evaluate and return JSON.`
|
|
||||||
|
${safeCriteria}
|
||||||
|
|
||||||
|
## Projects to Evaluate (${anonymized.length} total)
|
||||||
|
${JSON.stringify(anonymized)}
|
||||||
|
|
||||||
|
Evaluate each project and return JSON with your assessment.`
|
||||||
|
|
||||||
const MAX_PARSE_RETRIES = 2
|
const MAX_PARSE_RETRIES = 2
|
||||||
let parseAttempts = 0
|
let parseAttempts = 0
|
||||||
|
|||||||
@@ -69,6 +69,12 @@ export const FilteringConfigSchema = z.object({
|
|||||||
autoAdvanceEligible: z.boolean().default(false),
|
autoAdvanceEligible: z.boolean().default(false),
|
||||||
duplicateDetectionEnabled: z.boolean().default(true),
|
duplicateDetectionEnabled: z.boolean().default(true),
|
||||||
batchSize: z.number().int().positive().default(20),
|
batchSize: z.number().int().positive().default(20),
|
||||||
|
|
||||||
|
aiParseFiles: z.boolean().default(false),
|
||||||
|
startupAdvanceCount: z.number().int().nonnegative().optional(),
|
||||||
|
conceptAdvanceCount: z.number().int().nonnegative().optional(),
|
||||||
|
notifyOnEntry: z.boolean().default(false),
|
||||||
|
notifyOnAdvance: z.boolean().default(false),
|
||||||
})
|
})
|
||||||
|
|
||||||
export type FilteringConfig = z.infer<typeof FilteringConfigSchema>
|
export type FilteringConfig = z.infer<typeof FilteringConfigSchema>
|
||||||
|
|||||||
Reference in New Issue
Block a user