Compare commits

...

5 Commits

Author SHA1 Message Date
Matt a72e815d3a Remove remaining Settings and Assignments links
Build and Push Docker Image / build (push) Successful in 8m43s Details
Fix stale links to deleted pages:
- Remove Settings link from programs list dropdown
- Remove Manage Assignments link from projects list dropdown

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 10:56:14 +01:00
Matt 406ec46c81 UI simplification: remove redundant pages, reorganize Quick Actions
Phase 2 UI Cleanup:
- Delete empty programs/[id]/settings page
- Delete redundant projects/[id]/assignments page (use round-level management)
- Delete unused /users redirect directory
- Remove Settings button from program detail page
- Reorganize Round Quick Actions into visual groups (Project Management, Round Management)

Note: Mentor inline assignment deferred - the existing mentor page has
complex AI suggestion functionality that would need careful refactoring.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 10:50:47 +01:00
Matt d068d9b6f6 Improve AI filtering error handling and visibility
- Add listAvailableModels() and validateModel() to openai.ts
- Improve testOpenAIConnection() to test configured model
- Add checkAIStatus endpoint to filtering router
- Add pre-execution AI config check in executeRules
- Improve error messages in AI filtering service (rate limit, quota, etc.)
- Add AI status warning banner on round detail page for filtering rounds

Now admins get clear errors when AI is misconfigured instead of silent flags.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 10:46:38 +01:00
Matt d45eccea47 Add detailed logging to AI filtering for debugging
Added console logging throughout the AI screening process to help
diagnose issues when all projects get flagged. Logs model being used,
batch processing, token usage, and actual error messages.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 10:36:51 +01:00
Matt c063f5bba3 Display filtering controls inline for FILTERING round type
For rounds with roundType=FILTERING, the filtering controls (run button,
stats, finalize) are now shown directly on the round detail page instead
of requiring navigation to a separate /filtering page. Rules configuration
and results review still link to their dedicated pages for detailed work.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 10:33:34 +01:00
15 changed files with 477 additions and 359 deletions

View File

@ -0,0 +1,15 @@
Notes: Filtering Round: Criteria- Older than 3 years (for all in the startup category), those who submit something random (like a spam project) (AI?)
-Add filters to the page (who sent documents, etc.)
-Partners section should be a semi-crm system to track possible sponsors and partners
-No translation into french (no localization)
-Ameliorate the user experience (make it more simple)
-Special Awards- Specific Jury Members (jury members will have to choose amongst projects that fit specific criteria (like the country they're based))- Spotlight on Africa, Coup de Coeur - Make a special award section and make a special case for judge invitation for special awards and allow us to make special awards and assign them to the selection of judges for the special awards specifically (And give them their own login space to see everything)
-Invite jury member (with tag for special awards) --> Make special award (Criteria needed, Add a tag for special award (so for example, if a location is italy it will auto have the tag for COup de Coeur (since it's criteria is it only exists in certain countries))) - This is also a separate Jury Round - Use AI to sort through elligible projects based on the plain-language criteria (so the AI interprets the criteria and all projects and smart-assigns them to the round) - Make sure this round allows them to simply choose which project will win (since they have independent criteria) - Make a mix of voting for which project wins the project (or recommended project for the award), but also in the round they can simply assign a project to the award without any criteria requirements and such

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

BIN
public/images/ocean.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 MiB

View File

@ -18,7 +18,7 @@ import {
TableHeader,
TableRow,
} from '@/components/ui/table'
import { ArrowLeft, Pencil, Plus, Settings } from 'lucide-react'
import { ArrowLeft, Pencil, Plus } from 'lucide-react'
import { formatDateOnly } from '@/lib/utils'
interface ProgramDetailPageProps {
@ -64,20 +64,12 @@ export default async function ProgramDetailPage({ params }: ProgramDetailPagePro
</p>
</div>
</div>
<div className="flex gap-2">
<Button variant="outline" asChild>
<Link href={`/admin/programs/${id}/edit`}>
<Pencil className="mr-2 h-4 w-4" />
Edit
</Link>
</Button>
<Button variant="outline" asChild>
<Link href={`/admin/programs/${id}/settings`}>
<Settings className="mr-2 h-4 w-4" />
Settings
</Link>
</Button>
</div>
<Button variant="outline" asChild>
<Link href={`/admin/programs/${id}/edit`}>
<Pencil className="mr-2 h-4 w-4" />
Edit
</Link>
</Button>
</div>
{program.description && (

View File

@ -1,83 +0,0 @@
'use client'
import { useParams } from 'next/navigation'
import Link from 'next/link'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
import { ArrowLeft } from 'lucide-react'
export default function ProgramSettingsPage() {
const params = useParams()
const id = params.id as string
const { data: program, isLoading } = trpc.program.get.useQuery({ id })
if (isLoading) {
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
<Skeleton className="h-10 w-10" />
<div className="space-y-2">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-4 w-32" />
</div>
</div>
<Card>
<CardContent className="space-y-4 pt-6">
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
</CardContent>
</Card>
</div>
)
}
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
<Link href={`/admin/programs/${id}`}>
<Button variant="ghost" size="icon">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<div>
<h1 className="text-2xl font-bold">Program Settings</h1>
<p className="text-muted-foreground">
Configure settings for {program?.name}
</p>
</div>
</div>
<Card>
<CardHeader>
<CardTitle>Program Configuration</CardTitle>
<CardDescription>
Advanced settings for this program
</CardDescription>
</CardHeader>
<CardContent>
<div className="py-8 text-center text-muted-foreground">
Program-specific settings will be available in a future update.
<br />
For now, manage rounds and projects through the program detail page.
</div>
<div className="flex justify-center">
<Button asChild>
<Link href={`/admin/programs/${id}`}>
Back to Program
</Link>
</Button>
</div>
</CardContent>
</Card>
</div>
)
}

View File

@ -31,7 +31,6 @@ import {
Plus,
MoreHorizontal,
FolderKanban,
Settings,
Eye,
Pencil,
} from 'lucide-react'
@ -147,12 +146,6 @@ async function ProgramsContent() {
Edit
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href={`/admin/programs/${program.id}/settings`}>
<Settings className="mr-2 h-4 w-4" />
Settings
</Link>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>

View File

@ -1,177 +0,0 @@
'use client'
import { useParams } from 'next/navigation'
import Link from 'next/link'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { Skeleton } from '@/components/ui/skeleton'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog'
import { ArrowLeft, Plus, UserMinus } from 'lucide-react'
import { toast } from 'sonner'
export default function ProjectAssignmentsPage() {
const params = useParams()
const id = params.id as string
const { data: project, isLoading: projectLoading } = trpc.project.get.useQuery({ id })
const { data: assignments = [], isLoading: assignmentsLoading } = trpc.assignment.listByProject.useQuery({ projectId: id })
const utils = trpc.useUtils()
const removeAssignment = trpc.assignment.delete.useMutation({
onSuccess: () => {
toast.success('Assignment removed')
utils.assignment.listByProject.invalidate({ projectId: id })
},
onError: (error) => {
toast.error(error.message || 'Failed to remove assignment')
},
})
// Remove handled via AlertDialog in JSX
const isLoading = projectLoading || assignmentsLoading
if (isLoading) {
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
<Skeleton className="h-10 w-10" />
<div className="space-y-2">
<Skeleton className="h-8 w-64" />
<Skeleton className="h-4 w-32" />
</div>
</div>
<Card>
<CardContent className="space-y-4 pt-6">
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
</CardContent>
</Card>
</div>
)
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Link href={`/admin/projects/${id}`}>
<Button variant="ghost" size="icon">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<div>
<h1 className="text-2xl font-bold">Jury Assignments</h1>
<p className="text-muted-foreground">
{project?.title}
</p>
</div>
</div>
<Button asChild>
<Link href={`/admin/rounds/${project?.roundProjects?.[0]?.round?.id}/assignments`}>
<Plus className="mr-2 h-4 w-4" />
Manage in Round
</Link>
</Button>
</div>
<Card>
<CardHeader>
<CardTitle>Assigned Jury Members</CardTitle>
<CardDescription>
{assignments.length} jury member{assignments.length !== 1 ? 's' : ''} assigned to evaluate this project
</CardDescription>
</CardHeader>
<CardContent>
{assignments.length === 0 ? (
<div className="py-8 text-center text-muted-foreground">
No jury members assigned yet.
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Jury Member</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{assignments.map((assignment) => (
<TableRow key={assignment.id}>
<TableCell>
<div>
<p className="font-medium">{assignment.user.name}</p>
<p className="text-sm text-muted-foreground">{assignment.user.email}</p>
</div>
</TableCell>
<TableCell>
<Badge variant={assignment.evaluation?.status === 'SUBMITTED' ? 'success' : 'secondary'}>
{assignment.evaluation?.status || 'Pending'}
</Badge>
</TableCell>
<TableCell className="text-right">
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="ghost"
size="sm"
disabled={removeAssignment.isPending}
>
<UserMinus className="h-4 w-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Remove Assignment</AlertDialogTitle>
<AlertDialogDescription>
Remove this jury member from the project? Their evaluation data will also be deleted.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={() => removeAssignment.mutate({ id: assignment.id })}>
Remove
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</div>
)
}

View File

@ -391,14 +391,6 @@ export default function ProjectsPage() {
Edit
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link
href={`/admin/projects/${project.id}/assignments`}
>
<Users className="mr-2 h-4 w-4" />
Manage Assignments
</Link>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>

View File

@ -47,6 +47,10 @@ import {
Plus,
ArrowRightCircle,
Minus,
XCircle,
AlertTriangle,
ListChecks,
ClipboardCheck,
} from 'lucide-react'
import { toast } from 'sonner'
import { AssignProjectsDialog } from '@/components/admin/assign-projects-dialog'
@ -64,9 +68,27 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
const [advanceOpen, setAdvanceOpen] = useState(false)
const [removeOpen, setRemoveOpen] = useState(false)
const { data: round, isLoading } = trpc.round.get.useQuery({ id: roundId })
const { data: round, isLoading, refetch: refetchRound } = trpc.round.get.useQuery({ id: roundId })
const { data: progress } = trpc.round.getProgress.useQuery({ id: roundId })
// Filtering queries (only fetch for FILTERING rounds)
const roundType = (round?.settingsJson as { roundType?: string } | null)?.roundType
const isFilteringRound = roundType === 'FILTERING'
const { data: filteringStats, refetch: refetchFilteringStats } =
trpc.filtering.getResultStats.useQuery(
{ roundId },
{ enabled: isFilteringRound }
)
const { data: filteringRules } = trpc.filtering.getRules.useQuery(
{ roundId },
{ enabled: isFilteringRound }
)
const { data: aiStatus } = trpc.filtering.checkAIStatus.useQuery(
{ roundId },
{ enabled: isFilteringRound }
)
const utils = trpc.useUtils()
const updateStatus = trpc.round.updateStatus.useMutation({
onSuccess: () => {
@ -85,6 +107,40 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
},
})
// Filtering mutations
const executeRules = trpc.filtering.executeRules.useMutation()
const finalizeResults = trpc.filtering.finalizeResults.useMutation()
const handleExecuteFiltering = async () => {
try {
const result = await executeRules.mutateAsync({ roundId })
toast.success(
`Filtering complete: ${result.passed} passed, ${result.filteredOut} filtered out, ${result.flagged} flagged`
)
refetchFilteringStats()
} catch (error) {
toast.error(
error instanceof Error ? error.message : 'Failed to execute filtering'
)
}
}
const handleFinalizeFiltering = async () => {
try {
const result = await finalizeResults.mutateAsync({ roundId })
toast.success(
`Finalized: ${result.passed} passed, ${result.filteredOut} filtered out`
)
refetchFilteringStats()
refetchRound()
utils.project.list.invalidate()
} catch (error) {
toast.error(
error instanceof Error ? error.message : 'Failed to finalize'
)
}
}
if (isLoading) {
return <RoundDetailSkeleton />
}
@ -403,49 +459,200 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
</CardContent>
</Card>
{/* Filtering Section (for FILTERING rounds) */}
{isFilteringRound && (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-lg flex items-center gap-2">
<Filter className="h-5 w-5" />
Project Filtering
</CardTitle>
<CardDescription>
Run automated screening rules on projects in this round
</CardDescription>
</div>
<div className="flex gap-2">
<Button
onClick={handleExecuteFiltering}
disabled={executeRules.isPending || !filteringRules || filteringRules.length === 0}
>
{executeRules.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Play className="mr-2 h-4 w-4" />
)}
Run Filtering
</Button>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
{/* AI Status Warning */}
{aiStatus?.hasAIRules && !aiStatus?.configured && (
<div className="flex items-center gap-3 p-4 rounded-lg bg-amber-500/10 border border-amber-500/20">
<AlertTriangle className="h-5 w-5 text-amber-600 flex-shrink-0" />
<div className="flex-1">
<p className="font-medium text-amber-700">AI Configuration Required</p>
<p className="text-sm text-amber-600">
{aiStatus.error || 'AI screening rules require OpenAI to be configured.'}
</p>
</div>
<Button variant="outline" size="sm" asChild>
<Link href="/admin/settings">Configure AI</Link>
</Button>
</div>
)}
{/* Stats */}
{filteringStats && filteringStats.total > 0 ? (
<div className="grid gap-4 sm:grid-cols-4">
<div className="flex items-center gap-3 p-3 rounded-lg bg-muted">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-background">
<Filter className="h-5 w-5" />
</div>
<div>
<p className="text-2xl font-bold">{filteringStats.total}</p>
<p className="text-sm text-muted-foreground">Total</p>
</div>
</div>
<div className="flex items-center gap-3 p-3 rounded-lg bg-green-500/10">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-green-500/20">
<CheckCircle2 className="h-5 w-5 text-green-600" />
</div>
<div>
<p className="text-2xl font-bold text-green-600">
{filteringStats.passed}
</p>
<p className="text-sm text-muted-foreground">Passed</p>
</div>
</div>
<div className="flex items-center gap-3 p-3 rounded-lg bg-red-500/10">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-red-500/20">
<XCircle className="h-5 w-5 text-red-600" />
</div>
<div>
<p className="text-2xl font-bold text-red-600">
{filteringStats.filteredOut}
</p>
<p className="text-sm text-muted-foreground">Filtered Out</p>
</div>
</div>
<div className="flex items-center gap-3 p-3 rounded-lg bg-amber-500/10">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-amber-500/20">
<AlertTriangle className="h-5 w-5 text-amber-600" />
</div>
<div>
<p className="text-2xl font-bold text-amber-600">
{filteringStats.flagged}
</p>
<p className="text-sm text-muted-foreground">Flagged</p>
</div>
</div>
</div>
) : (
<div className="flex flex-col items-center justify-center py-8 text-center">
<Filter className="h-12 w-12 text-muted-foreground/50" />
<p className="mt-2 font-medium">No filtering results yet</p>
<p className="text-sm text-muted-foreground">
Configure rules and run filtering to screen projects
</p>
</div>
)}
{/* Quick links */}
<div className="flex flex-wrap gap-3 pt-2 border-t">
<Button variant="outline" asChild>
<Link href={`/admin/rounds/${round.id}/filtering/rules`}>
<ListChecks className="mr-2 h-4 w-4" />
Configure Rules
<Badge variant="secondary" className="ml-2">
{filteringRules?.length || 0}
</Badge>
</Link>
</Button>
<Button variant="outline" asChild>
<Link href={`/admin/rounds/${round.id}/filtering/results`}>
<ClipboardCheck className="mr-2 h-4 w-4" />
Review Results
</Link>
</Button>
{filteringStats && filteringStats.total > 0 && (
<Button
onClick={handleFinalizeFiltering}
disabled={finalizeResults.isPending}
variant="default"
>
{finalizeResults.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<CheckCircle2 className="mr-2 h-4 w-4" />
)}
Finalize Results
</Button>
)}
</div>
</CardContent>
</Card>
)}
{/* Quick Actions */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Quick Actions</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-3">
<Button variant="outline" asChild>
<Link href={`/admin/projects/import?round=${round.id}`}>
<Upload className="mr-2 h-4 w-4" />
Import Projects
</Link>
</Button>
<Button variant="outline" asChild>
<Link href={`/admin/rounds/${round.id}/filtering`}>
<Filter className="mr-2 h-4 w-4" />
Manage Filtering
</Link>
</Button>
<Button variant="outline" asChild>
<Link href={`/admin/rounds/${round.id}/assignments`}>
<Users className="mr-2 h-4 w-4" />
Manage Assignments
</Link>
</Button>
<Button variant="outline" asChild>
<Link href={`/admin/projects?round=${round.id}`}>
<FileText className="mr-2 h-4 w-4" />
View Projects
</Link>
</Button>
<Button variant="outline" onClick={() => setAssignOpen(true)}>
<Plus className="mr-2 h-4 w-4" />
Assign Projects
</Button>
<Button variant="outline" onClick={() => setAdvanceOpen(true)}>
<ArrowRightCircle className="mr-2 h-4 w-4" />
Advance Projects
</Button>
<Button variant="outline" onClick={() => setRemoveOpen(true)}>
<Minus className="mr-2 h-4 w-4" />
Remove Projects
</Button>
<CardContent className="space-y-4">
{/* Project Management */}
<div>
<p className="text-sm font-medium text-muted-foreground mb-2">Project Management</p>
<div className="flex flex-wrap gap-2">
<Button variant="outline" size="sm" asChild>
<Link href={`/admin/projects/import?round=${round.id}`}>
<Upload className="mr-2 h-4 w-4" />
Import
</Link>
</Button>
<Button variant="outline" size="sm" onClick={() => setAssignOpen(true)}>
<Plus className="mr-2 h-4 w-4" />
Assign
</Button>
<Button variant="outline" size="sm" onClick={() => setAdvanceOpen(true)}>
<ArrowRightCircle className="mr-2 h-4 w-4" />
Advance
</Button>
<Button variant="outline" size="sm" onClick={() => setRemoveOpen(true)}>
<Minus className="mr-2 h-4 w-4" />
Remove
</Button>
<Button variant="outline" size="sm" asChild>
<Link href={`/admin/projects?round=${round.id}`}>
<FileText className="mr-2 h-4 w-4" />
View All
</Link>
</Button>
</div>
</div>
{/* Round Management */}
<div>
<p className="text-sm font-medium text-muted-foreground mb-2">Round Management</p>
<div className="flex flex-wrap gap-2">
<Button variant="outline" size="sm" asChild>
<Link href={`/admin/rounds/${round.id}/assignments`}>
<Users className="mr-2 h-4 w-4" />
Jury Assignments
</Link>
</Button>
{!isFilteringRound && (
<Button variant="outline" size="sm" asChild>
<Link href={`/admin/rounds/${round.id}/filtering`}>
<Filter className="mr-2 h-4 w-4" />
Filtering
</Link>
</Button>
)}
</div>
</div>
</CardContent>
</Card>

View File

@ -1,10 +0,0 @@
import { redirect } from 'next/navigation'
export default async function UserEditPage({
params,
}: {
params: Promise<{ id: string }>
}) {
const { id } = await params
redirect(`/admin/members/${id}`)
}

View File

@ -1,5 +0,0 @@
import { redirect } from 'next/navigation'
export default function UserInvitePage() {
redirect('/admin/members/invite')
}

View File

@ -1,5 +0,0 @@
import { redirect } from 'next/navigation'
export default function UsersPage() {
redirect('/admin/members')
}

View File

@ -66,12 +66,12 @@ export async function isOpenAIConfigured(): Promise<boolean> {
}
/**
* Test OpenAI connection
* List available models from OpenAI API
*/
export async function testOpenAIConnection(): Promise<{
export async function listAvailableModels(): Promise<{
success: boolean
models?: string[]
error?: string
model?: string
}> {
try {
const client = await getOpenAI()
@ -83,9 +83,90 @@ export async function testOpenAIConnection(): Promise<{
}
}
// Simple test request
const response = await client.models.list()
const chatModels = response.data
.filter((m) => m.id.includes('gpt') || m.id.includes('o1') || m.id.includes('o3') || m.id.includes('o4'))
.map((m) => m.id)
.sort()
return {
success: true,
models: chatModels,
}
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
}
}
}
/**
* Validate that a specific model is available
*/
export async function validateModel(modelId: string): Promise<{
valid: boolean
error?: string
}> {
try {
const client = await getOpenAI()
if (!client) {
return {
valid: false,
error: 'OpenAI API key not configured',
}
}
// Try a minimal completion with the model
await client.chat.completions.create({
model: modelId,
messages: [{ role: 'user', content: 'test' }],
max_tokens: 1,
})
return { valid: true }
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error'
// Check for specific model errors
if (message.includes('does not exist') || message.includes('model_not_found')) {
return {
valid: false,
error: `Model "${modelId}" is not available with your API key`,
}
}
return {
valid: false,
error: message,
}
}
}
/**
* Test OpenAI connection with the configured model
*/
export async function testOpenAIConnection(): Promise<{
success: boolean
error?: string
model?: string
modelTested?: string
}> {
try {
const client = await getOpenAI()
if (!client) {
return {
success: false,
error: 'OpenAI API key not configured',
}
}
// Get the configured model
const configuredModel = await getConfiguredModel()
// Test with the configured model
const response = await client.chat.completions.create({
model: 'gpt-4o-mini',
model: configuredModel,
messages: [{ role: 'user', content: 'Hello' }],
max_tokens: 5,
})
@ -93,11 +174,25 @@ export async function testOpenAIConnection(): Promise<{
return {
success: true,
model: response.model,
modelTested: configuredModel,
}
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error'
const configuredModel = await getConfiguredModel()
// Check for model-specific errors
if (message.includes('does not exist') || message.includes('model_not_found')) {
return {
success: false,
error: `Model "${configuredModel}" is not available. Check Settings → AI to select a valid model.`,
modelTested: configuredModel,
}
}
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
error: message,
modelTested: configuredModel,
}
}
}

View File

@ -4,8 +4,48 @@ import { Prisma } from '@prisma/client'
import { router, adminProcedure, protectedProcedure } from '../trpc'
import { executeFilteringRules } from '../services/ai-filtering'
import { logAudit } from '../utils/audit'
import { isOpenAIConfigured, testOpenAIConnection } from '@/lib/openai'
export const filteringRouter = router({
/**
* Check if AI is configured and ready for filtering
*/
checkAIStatus: protectedProcedure
.input(z.object({ roundId: z.string() }))
.query(async ({ ctx, input }) => {
// Check if round has AI rules
const aiRules = await ctx.prisma.filteringRule.count({
where: {
roundId: input.roundId,
ruleType: 'AI_SCREENING',
isActive: true,
},
})
if (aiRules === 0) {
return { hasAIRules: false, configured: true, error: null }
}
// Check if OpenAI is configured
const configured = await isOpenAIConfigured()
if (!configured) {
return {
hasAIRules: true,
configured: false,
error: 'OpenAI API key not configured',
}
}
// Test the connection
const testResult = await testOpenAIConnection()
return {
hasAIRules: true,
configured: testResult.success,
error: testResult.error || null,
model: testResult.modelTested,
}
}),
/**
* Get filtering rules for a round
*/
@ -146,6 +186,30 @@ export const filteringRouter = router({
})
}
// Check if any AI_SCREENING rules exist
const hasAIRules = rules.some((r) => r.ruleType === 'AI_SCREENING' && r.isActive)
if (hasAIRules) {
// Verify OpenAI is configured before proceeding
const aiConfigured = await isOpenAIConfigured()
if (!aiConfigured) {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message:
'AI screening rules require OpenAI to be configured. Go to Settings → AI to configure your API key.',
})
}
// Also verify the model works
const testResult = await testOpenAIConnection()
if (!testResult.success) {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: `AI configuration error: ${testResult.error}. Go to Settings → AI to fix.`,
})
}
}
// Get projects in this round
const roundProjectEntries = await ctx.prisma.roundProject.findMany({
where: { roundId: input.roundId },

View File

@ -274,6 +274,7 @@ export async function executeAIScreening(
const openai = await getOpenAI()
if (!openai) {
// No OpenAI configured — flag all for manual review
console.warn('[AI Filtering] OpenAI client not available - API key may not be configured')
for (const p of projects) {
results.set(p.id, {
meetsCriteria: false,
@ -287,6 +288,7 @@ export async function executeAIScreening(
}
const model = await getConfiguredModel()
console.log(`[AI Filtering] Using model: ${model} for ${projects.length} projects`)
// Anonymize project data — use numeric IDs
const anonymizedProjects = projects.map((p, i) => ({
@ -319,6 +321,8 @@ ${JSON.stringify(
Return your evaluation as JSON.`
console.log(`[AI Filtering] Processing batch ${Math.floor(i / batchSize) + 1}, ${batch.length} projects`)
const response = await openai.chat.completions.create({
model,
messages: [
@ -330,6 +334,8 @@ Return your evaluation as JSON.`
max_tokens: 4000,
})
console.log(`[AI Filtering] Batch completed, usage: ${response.usage?.total_tokens} tokens`)
const content = response.choices[0]?.message?.content
if (content) {
try {
@ -344,6 +350,8 @@ Return your evaluation as JSON.`
}>
}
console.log(`[AI Filtering] Parsed ${parsed.projects?.length || 0} results from response`)
for (const result of parsed.projects) {
const anon = batch.find((b) => b.project_id === result.project_id)
if (anon) {
@ -356,8 +364,10 @@ Return your evaluation as JSON.`
})
}
}
} catch {
} catch (parseError) {
// Parse error — flag batch for manual review
console.error('[AI Filtering] JSON parse error:', parseError)
console.error('[AI Filtering] Raw response content:', content.slice(0, 500))
for (const item of batch) {
results.set(item.real_id, {
meetsCriteria: false,
@ -368,15 +378,45 @@ Return your evaluation as JSON.`
})
}
}
} else {
console.error('[AI Filtering] Empty response content from API')
}
}
} catch {
// OpenAI error — flag all for manual review
} catch (error) {
// OpenAI error — flag all for manual review with specific error info
console.error('[AI Filtering] OpenAI API error:', error)
// Extract meaningful error message
let errorType = 'unknown_error'
let errorDetail = 'Unknown error occurred'
if (error instanceof Error) {
const message = error.message.toLowerCase()
if (message.includes('rate_limit') || message.includes('rate limit')) {
errorType = 'rate_limit'
errorDetail = 'OpenAI rate limit exceeded. Try again in a few minutes.'
} else if (message.includes('model') && (message.includes('not found') || message.includes('does not exist'))) {
errorType = 'model_not_found'
errorDetail = 'The configured AI model is not available. Check Settings → AI.'
} else if (message.includes('insufficient_quota') || message.includes('quota')) {
errorType = 'quota_exceeded'
errorDetail = 'OpenAI API quota exceeded. Check your billing settings.'
} else if (message.includes('invalid_api_key') || message.includes('unauthorized')) {
errorType = 'invalid_api_key'
errorDetail = 'Invalid OpenAI API key. Check Settings → AI.'
} else if (message.includes('context_length') || message.includes('token')) {
errorType = 'context_length'
errorDetail = 'Request too large. Try with fewer projects or shorter descriptions.'
} else {
errorDetail = error.message
}
}
for (const p of projects) {
results.set(p.id, {
meetsCriteria: false,
confidence: 0,
reasoning: 'AI screening error — flagged for manual review',
reasoning: `AI screening error (${errorType}): ${errorDetail}`,
qualityScore: 5,
spamRisk: false,
})