From cf1508f8565443a21a69e7a3639c50780d474689 Mon Sep 17 00:00:00 2001 From: Matt Date: Tue, 17 Feb 2026 17:18:04 +0100 Subject: [PATCH] Fix filtering config save, auto-save, streamed results, improved AI prompt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../(admin)/admin/rounds/[roundId]/page.tsx | 16 +++++- .../admin/round/filtering-dashboard.tsx | 45 ++++++++++++++-- src/server/services/ai-filtering.ts | 51 ++++++++++++++++--- src/types/competition-configs.ts | 6 +++ 4 files changed, 107 insertions(+), 11 deletions(-) diff --git a/src/app/(admin)/admin/rounds/[roundId]/page.tsx b/src/app/(admin)/admin/rounds/[roundId]/page.tsx index 7850009..48c79f7 100644 --- a/src/app/(admin)/admin/rounds/[roundId]/page.tsx +++ b/src/app/(admin)/admin/rounds/[roundId]/page.tsx @@ -1,6 +1,6 @@ 'use client' -import { useState, useMemo, useCallback, useRef } from 'react' +import { useState, useMemo, useCallback, useRef, useEffect } from 'react' import { useParams } from 'next/navigation' import Link from 'next/link' import type { Route } from 'next' @@ -397,6 +397,20 @@ export default function RoundDetailPage() { updateMutation.mutate({ id: roundId, configJson: config }) }, [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 ──────────────────────────────────────────────────── const projectCount = round?._count?.projectRoundStates ?? 0 const stateCounts = useMemo(() => diff --git a/src/components/admin/round/filtering-dashboard.tsx b/src/components/admin/round/filtering-dashboard.tsx index f68e2a9..f0acda0 100644 --- a/src/components/admin/round/filtering-dashboard.tsx +++ b/src/components/admin/round/filtering-dashboard.tsx @@ -1,6 +1,6 @@ 'use client' -import { useState, useEffect, useCallback, useRef } from 'react' +import { useState, useEffect, useCallback, useRef, useMemo } from 'react' import { trpc } from '@/lib/trpc/client' import { toast } from 'sonner' import { Button } from '@/components/ui/button' @@ -70,6 +70,7 @@ import { ListFilter, Settings2, } from 'lucide-react' +import { motion, AnimatePresence } from 'motion/react' import Link from 'next/link' import type { Route } from 'next' 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>(new Set()) + const isFirstLoad = useRef(true) + + const resultStagger = useMemo(() => { + const stagger: Record = {} + 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 (
{/* Main Card: AI Screening Criteria + Controls */} @@ -766,13 +791,26 @@ export function FilteringDashboard({ competitionId, roundId }: FilteringDashboar
{/* Rows */} + {displayResults.map((result: any) => { const ai = parseAIData(result.aiScreeningJson) const effectiveOutcome = result.finalOutcome || result.outcome const isExpanded = expandedId === result.id + const staggerIdx = resultStagger[result.id] + const isNew = staggerIdx !== undefined return ( -
+ {/* Main Row */}
)} -
+ ) })} +
{/* Pagination */} {resultsPage && resultsPage.totalPages > 1 && ( diff --git a/src/server/services/ai-filtering.ts b/src/server/services/ai-filtering.ts index 9156e9f..1a30158 100644 --- a/src/server/services/ai-filtering.ts +++ b/src/server/services/ai-filtering.ts @@ -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. ## 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 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) - ocean_issue: the ocean conservation area - 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 - 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 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 - Evaluate ONLY against the provided criteria, not your own standards - 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 mention regional considerations (e.g. African projects), use the country/region fields - 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 ──────────────────────────────────────────── @@ -371,10 +402,16 @@ async function processAIBatch( // Sanitize user-supplied criteria const { sanitized: safeCriteria } = sanitizeUserInput(criteriaText) - // Build optimized prompt - const userPrompt = `CRITERIA: ${safeCriteria} -PROJECTS: ${JSON.stringify(anonymized)} -Evaluate and return JSON.` + // Build user prompt with clear structure + const userPrompt = `## Screening Criteria +The admin has defined the following requirements. Evaluate each project against ALL of these criteria: + +${safeCriteria} + +## Projects to Evaluate (${anonymized.length} total) +${JSON.stringify(anonymized)} + +Evaluate each project and return JSON with your assessment.` const MAX_PARSE_RETRIES = 2 let parseAttempts = 0 diff --git a/src/types/competition-configs.ts b/src/types/competition-configs.ts index 51d0ffd..90ed63b 100644 --- a/src/types/competition-configs.ts +++ b/src/types/competition-configs.ts @@ -69,6 +69,12 @@ export const FilteringConfigSchema = z.object({ autoAdvanceEligible: z.boolean().default(false), duplicateDetectionEnabled: z.boolean().default(true), 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