Redesign AI Tagging dialog and add edition-wide tagging
Build and Push Docker Image / build (push) Successful in 9m22s Details

- Redesign AI Tagging dialog with scope selection (Round vs Edition)
- Add visual progress indicator during AI processing
- Display result stats (tagged/skipped/failed) after completion
- Add batchTagProgramProjects endpoint for edition-wide tagging
- Fix getFilterOptions to include program.id for filtering
- Improve error handling with toast notifications

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Matt 2026-02-05 10:27:52 +01:00
parent 7f95f681d6
commit 05862f1e55
4 changed files with 469 additions and 104 deletions

View File

@ -16,6 +16,7 @@ import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Skeleton } from '@/components/ui/skeleton'
import { Progress } from '@/components/ui/progress'
import {
Table,
TableBody,
@ -41,6 +42,13 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Plus,
MoreHorizontal,
@ -53,6 +61,12 @@ import {
Trash2,
Loader2,
Sparkles,
Tags,
Clock,
CheckCircle2,
AlertCircle,
Layers,
FolderOpen,
} from 'lucide-react'
import {
Select,
@ -240,37 +254,98 @@ export default function ProjectsPage() {
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
const [projectToDelete, setProjectToDelete] = useState<{ id: string; title: string } | null>(null)
const [aiTagDialogOpen, setAiTagDialogOpen] = useState(false)
const [taggingScope, setTaggingScope] = useState<'round' | 'program'>('round')
const [selectedRoundForTagging, setSelectedRoundForTagging] = useState<string>('')
const [selectedProgramForTagging, setSelectedProgramForTagging] = useState<string>('')
const [taggingInProgress, setTaggingInProgress] = useState(false)
const [taggingResult, setTaggingResult] = useState<{
processed: number
skipped: number
failed: number
errors: string[]
} | null>(null)
// Fetch rounds for the AI tagging dialog
// Fetch programs and rounds for the AI tagging dialog
const { data: programs } = trpc.program.list.useQuery()
const { data: allRounds } = trpc.round.list.useQuery(
{ programId: programs?.[0]?.id ?? '' },
{ enabled: !!programs?.[0]?.id }
)
// AI batch tagging mutation
// AI batch tagging mutations
const batchTagProjects = trpc.tag.batchTagProjects.useMutation({
onMutate: () => {
setTaggingInProgress(true)
setTaggingResult(null)
},
onSuccess: (result) => {
setTaggingInProgress(false)
setTaggingResult(result)
if (result.errors && result.errors.length > 0) {
// Show first error if there are any
toast.error(`AI Tagging issue: ${result.errors[0]}`)
} else if (result.processed === 0 && result.skipped === 0 && result.failed === 0) {
toast.info('No projects to tag - all projects in this round already have tags')
toast.info('No projects to tag - all projects already have tags')
} else {
toast.success(
`AI Tagging complete: ${result.processed} tagged, ${result.skipped} skipped, ${result.failed} failed`
`AI Tagging complete: ${result.processed} tagged, ${result.skipped} already tagged, ${result.failed} failed`
)
}
setAiTagDialogOpen(false)
setSelectedRoundForTagging('')
utils.project.list.invalidate()
},
onError: (error) => {
setTaggingInProgress(false)
toast.error(error.message || 'Failed to generate AI tags')
},
})
const batchTagProgramProjects = trpc.tag.batchTagProgramProjects.useMutation({
onMutate: () => {
setTaggingInProgress(true)
setTaggingResult(null)
},
onSuccess: (result) => {
setTaggingInProgress(false)
setTaggingResult(result)
if (result.errors && result.errors.length > 0) {
toast.error(`AI Tagging issue: ${result.errors[0]}`)
} else if (result.processed === 0 && result.skipped === 0 && result.failed === 0) {
toast.info('No projects to tag - all projects already have tags')
} else {
toast.success(
`AI Tagging complete: ${result.processed} tagged, ${result.skipped} already tagged, ${result.failed} failed`
)
}
utils.project.list.invalidate()
},
onError: (error) => {
setTaggingInProgress(false)
toast.error(error.message || 'Failed to generate AI tags')
},
})
const handleStartTagging = () => {
if (taggingScope === 'round' && selectedRoundForTagging) {
batchTagProjects.mutate({ roundId: selectedRoundForTagging })
} else if (taggingScope === 'program' && selectedProgramForTagging) {
batchTagProgramProjects.mutate({ programId: selectedProgramForTagging })
}
}
const handleCloseTaggingDialog = () => {
if (!taggingInProgress) {
setAiTagDialogOpen(false)
setTaggingResult(null)
setSelectedRoundForTagging('')
setSelectedProgramForTagging('')
}
}
// Get selected program's rounds
const selectedProgram = programs?.find(p => p.id === selectedProgramForTagging)
const programRounds = filterOptions?.rounds?.filter(r => r.program?.id === selectedProgramForTagging) ?? []
// Calculate stats for display
const selectedRound = filterOptions?.rounds?.find(r => r.id === selectedRoundForTagging)
const displayProgram = taggingScope === 'program'
? selectedProgram
: (selectedRound ? programs?.find(p => p.id === selectedRound.program?.id) : null)
const deleteProject = trpc.project.delete.useMutation({
onSuccess: () => {
toast.success('Project deleted successfully')
@ -593,20 +668,144 @@ export default function ProjectsPage() {
</AlertDialog>
{/* AI Tagging Dialog */}
<AlertDialog open={aiTagDialogOpen} onOpenChange={setAiTagDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle className="flex items-center gap-2">
<Sparkles className="h-5 w-5" />
Generate AI Tags
</AlertDialogTitle>
<AlertDialogDescription>
Use AI to automatically generate expertise tags for all projects in a round
that don&apos;t have tags yet. This helps with jury matching and filtering.
</AlertDialogDescription>
</AlertDialogHeader>
<div className="space-y-4 py-4">
<Dialog open={aiTagDialogOpen} onOpenChange={handleCloseTaggingDialog}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-gradient-to-br from-amber-400 to-orange-500">
<Sparkles className="h-5 w-5 text-white" />
</div>
<div>
<span>AI Tag Generator</span>
<p className="text-sm font-normal text-muted-foreground">
Automatically categorize projects with expertise tags
</p>
</div>
</DialogTitle>
</DialogHeader>
<div className="space-y-6 py-4">
{/* Progress Indicator (when running) */}
{taggingInProgress && (
<div className="p-4 rounded-lg bg-blue-50 dark:bg-blue-950/20 border border-blue-200 dark:border-blue-900">
<div className="space-y-3">
<div className="flex items-center gap-3">
<Loader2 className="h-5 w-5 animate-spin text-blue-600" />
<div className="flex-1">
<p className="font-medium text-blue-900 dark:text-blue-100">
AI Tagging in Progress
</p>
<p className="text-sm text-blue-700 dark:text-blue-300">
Analyzing projects and assigning expertise tags...
</p>
</div>
</div>
<div className="space-y-1">
<div className="flex justify-between text-sm">
<span className="text-blue-700 dark:text-blue-300">
Processing projects...
</span>
</div>
<Progress value={undefined} className="h-2 animate-pulse" />
</div>
</div>
</div>
)}
{/* Result Display */}
{taggingResult && !taggingInProgress && (
<div className={`p-4 rounded-lg border ${
taggingResult.failed > 0
? 'bg-amber-50 dark:bg-amber-950/20 border-amber-200 dark:border-amber-900'
: taggingResult.processed > 0
? 'bg-green-50 dark:bg-green-950/20 border-green-200 dark:border-green-900'
: 'bg-muted border-border'
}`}>
<div className="flex items-center gap-3 mb-3">
{taggingResult.processed > 0 ? (
<CheckCircle2 className="h-5 w-5 text-green-600" />
) : taggingResult.errors.length > 0 ? (
<AlertCircle className="h-5 w-5 text-amber-600" />
) : (
<Tags className="h-5 w-5 text-muted-foreground" />
)}
<span className="font-medium">
{taggingResult.processed > 0
? 'Tagging Complete'
: taggingResult.errors.length > 0
? 'Tagging Issue'
: 'No Projects to Tag'}
</span>
</div>
<div className="grid grid-cols-3 gap-3 text-center">
<div className="p-2 rounded bg-background">
<p className="text-2xl font-bold text-green-600">{taggingResult.processed}</p>
<p className="text-xs text-muted-foreground">Tagged</p>
</div>
<div className="p-2 rounded bg-background">
<p className="text-2xl font-bold text-muted-foreground">{taggingResult.skipped}</p>
<p className="text-xs text-muted-foreground">Already Tagged</p>
</div>
<div className="p-2 rounded bg-background">
<p className="text-2xl font-bold text-red-600">{taggingResult.failed}</p>
<p className="text-xs text-muted-foreground">Failed</p>
</div>
</div>
{taggingResult.errors.length > 0 && (
<p className="mt-3 text-sm text-amber-700 dark:text-amber-300">
{taggingResult.errors[0]}
</p>
)}
</div>
)}
{/* Scope Selection */}
{!taggingInProgress && !taggingResult && (
<>
<div className="space-y-3">
<Label className="text-sm font-medium">Tagging Scope</Label>
<div className="grid grid-cols-2 gap-3">
<button
type="button"
onClick={() => setTaggingScope('round')}
className={`flex flex-col items-center gap-2 p-4 rounded-lg border-2 transition-colors ${
taggingScope === 'round'
? 'border-primary bg-primary/5'
: 'border-border hover:border-muted-foreground/30'
}`}
>
<FolderOpen className={`h-6 w-6 ${taggingScope === 'round' ? 'text-primary' : 'text-muted-foreground'}`} />
<span className={`text-sm font-medium ${taggingScope === 'round' ? 'text-primary' : ''}`}>
Single Round
</span>
<span className="text-xs text-muted-foreground text-center">
Tag projects in one specific round
</span>
</button>
<button
type="button"
onClick={() => setTaggingScope('program')}
className={`flex flex-col items-center gap-2 p-4 rounded-lg border-2 transition-colors ${
taggingScope === 'program'
? 'border-primary bg-primary/5'
: 'border-border hover:border-muted-foreground/30'
}`}
>
<Layers className={`h-6 w-6 ${taggingScope === 'program' ? 'text-primary' : 'text-muted-foreground'}`} />
<span className={`text-sm font-medium ${taggingScope === 'program' ? 'text-primary' : ''}`}>
Entire Edition
</span>
<span className="text-xs text-muted-foreground text-center">
Tag all projects across all rounds
</span>
</button>
</div>
</div>
{/* Selection */}
<div className="space-y-2">
{taggingScope === 'round' ? (
<>
<Label htmlFor="round-select">Select Round</Label>
<Select
value={selectedRoundForTagging}
@ -623,31 +822,75 @@ export default function ProjectsPage() {
))}
</SelectContent>
</Select>
</>
) : (
<>
<Label htmlFor="program-select">Select Edition</Label>
<Select
value={selectedProgramForTagging}
onValueChange={setSelectedProgramForTagging}
>
<SelectTrigger id="program-select">
<SelectValue placeholder="Choose an edition..." />
</SelectTrigger>
<SelectContent>
{programs?.map((program) => (
<SelectItem key={program.id} value={program.id}>
{program.name} ({program.year})
</SelectItem>
))}
</SelectContent>
</Select>
</>
)}
</div>
{/* Info Note */}
<div className="flex items-start gap-2 p-3 rounded-lg bg-muted/50">
<Tags className="h-4 w-4 mt-0.5 text-muted-foreground shrink-0" />
<p className="text-sm text-muted-foreground">
Only projects without existing tags will be processed. Existing tags are preserved.
Only projects without existing tags will be processed. AI will analyze project descriptions and assign relevant expertise tags for jury matching.
</p>
</div>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
if (selectedRoundForTagging) {
batchTagProjects.mutate({ roundId: selectedRoundForTagging })
}
}}
disabled={!selectedRoundForTagging || batchTagProjects.isPending}
</>
)}
</div>
{/* Footer */}
<div className="flex justify-end gap-3 pt-2 border-t">
{taggingResult ? (
<Button onClick={handleCloseTaggingDialog}>
Done
</Button>
) : (
<>
<Button
variant="outline"
onClick={handleCloseTaggingDialog}
disabled={taggingInProgress}
>
{batchTagProjects.isPending ? (
Cancel
</Button>
<Button
onClick={handleStartTagging}
disabled={
taggingInProgress ||
(taggingScope === 'round' && !selectedRoundForTagging) ||
(taggingScope === 'program' && !selectedProgramForTagging)
}
>
{taggingInProgress ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Sparkles className="mr-2 h-4 w-4" />
)}
{batchTagProjects.isPending ? 'Processing...' : 'Generate Tags'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{taggingInProgress ? 'Processing...' : 'Generate Tags'}
</Button>
</>
)}
</div>
</DialogContent>
</Dialog>
</div>
)
}

View File

@ -161,7 +161,7 @@ export const projectRouter = router({
.query(async ({ ctx }) => {
const [rounds, countries, categories, issues] = await Promise.all([
ctx.prisma.round.findMany({
select: { id: true, name: true, program: { select: { name: true, year: true } } },
select: { id: true, name: true, program: { select: { id: true, name: true, year: true } } },
orderBy: [{ program: { year: 'desc' } }, { createdAt: 'asc' }],
}),
ctx.prisma.project.findMany({

View File

@ -4,6 +4,7 @@ import { router, adminProcedure, protectedProcedure } from '../trpc'
import {
tagProject,
batchTagProjects,
batchTagProgramProjects,
getTagSuggestions,
addProjectTag,
removeProjectTag,
@ -494,6 +495,34 @@ export const tagRouter = router({
return result
}),
/**
* Batch tag all untagged projects in an entire program (edition)
*/
batchTagProgramProjects: adminProcedure
.input(z.object({ programId: z.string() }))
.mutation(async ({ ctx, input }) => {
const result = await batchTagProgramProjects(input.programId, ctx.user.id)
// Audit log
await ctx.prisma.auditLog.create({
data: {
userId: ctx.user.id,
action: 'BATCH_AI_TAG',
entityType: 'Program',
entityId: input.programId,
detailsJson: {
processed: result.processed,
failed: result.failed,
skipped: result.skipped,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return result
}),
/**
* Manually add a tag to a project
*/

View File

@ -406,6 +406,48 @@ export async function tagProject(
}
}
/**
* Common validation and setup for batch tagging
*/
async function validateBatchTagging(): Promise<{
valid: boolean
error?: string
availableTags?: AvailableTag[]
}> {
const settings = await getTaggingSettings()
console.log('[AI Tagging] Settings:', settings)
if (!settings.enabled) {
console.log('[AI Tagging] AI tagging is disabled in settings')
return {
valid: false,
error: 'AI tagging is disabled. Enable it in Settings > AI or set ai_enabled to true.',
}
}
// Check if OpenAI is configured
const openai = await getOpenAI()
if (!openai) {
console.log('[AI Tagging] OpenAI is not configured')
return {
valid: false,
error: 'OpenAI API is not configured. Add your API key in Settings > AI.',
}
}
// Check if there are any available tags
const availableTags = await getAvailableTags()
console.log(`[AI Tagging] Found ${availableTags.length} available expertise tags`)
if (availableTags.length === 0) {
return {
valid: false,
error: 'No expertise tags defined. Create tags in Settings > Tags first.',
}
}
return { valid: true, availableTags }
}
/**
* Batch tag all untagged projects in a round
*
@ -416,42 +458,13 @@ export async function batchTagProjects(
userId?: string,
onProgress?: (processed: number, total: number) => void
): Promise<BatchTaggingResult> {
const settings = await getTaggingSettings()
console.log('[AI Tagging] Settings:', settings)
if (!settings.enabled) {
console.log('[AI Tagging] AI tagging is disabled in settings')
const validation = await validateBatchTagging()
if (!validation.valid) {
return {
processed: 0,
failed: 0,
skipped: 0,
errors: ['AI tagging is disabled. Enable it in Settings > AI or set ai_enabled to true.'],
results: [],
}
}
// Check if OpenAI is configured
const openai = await getOpenAI()
if (!openai) {
console.log('[AI Tagging] OpenAI is not configured')
return {
processed: 0,
failed: 0,
skipped: 0,
errors: ['OpenAI API is not configured. Add your API key in Settings > AI.'],
results: [],
}
}
// Check if there are any available tags
const availableTags = await getAvailableTags()
console.log(`[AI Tagging] Found ${availableTags.length} available expertise tags`)
if (availableTags.length === 0) {
return {
processed: 0,
failed: 0,
skipped: 0,
errors: ['No expertise tags defined. Create tags in Settings > Tags first.'],
errors: [validation.error!],
results: [],
}
}
@ -462,16 +475,13 @@ export async function batchTagProjects(
include: {
files: { select: { fileType: true } },
_count: { select: { teamMembers: true, files: true } },
projectTags: { select: { tagId: true } },
},
})
console.log(`[AI Tagging] Found ${allProjects.length} total projects in round`)
// Filter to only projects that truly have no tags (empty tags array AND no projectTags)
const untaggedProjects = allProjects.filter(p =>
(p.tags.length === 0) && (p.projectTags.length === 0)
)
// Filter to only projects that truly have no tags (empty tags array)
const untaggedProjects = allProjects.filter(p => p.tags.length === 0)
const alreadyTaggedCount = allProjects.length - untaggedProjects.length
console.log(`[AI Tagging] ${untaggedProjects.length} untagged projects, ${alreadyTaggedCount} already have tags`)
@ -513,7 +523,90 @@ export async function batchTagProjects(
return {
processed,
failed,
skipped: alreadyTaggedCount,
errors,
results,
}
}
/**
* Batch tag all untagged projects in an entire program (edition)
*
* Processes all projects across all rounds in the program.
*/
export async function batchTagProgramProjects(
programId: string,
userId?: string,
onProgress?: (processed: number, total: number) => void
): Promise<BatchTaggingResult> {
const validation = await validateBatchTagging()
if (!validation.valid) {
return {
processed: 0,
failed: 0,
skipped: 0,
errors: [validation.error!],
results: [],
}
}
// Get ALL projects in the program (across all rounds)
const allProjects = await prisma.project.findMany({
where: {
round: { programId },
},
include: {
files: { select: { fileType: true } },
_count: { select: { teamMembers: true, files: true } },
},
})
console.log(`[AI Tagging] Found ${allProjects.length} total projects in program`)
// Filter to only projects that truly have no tags (empty tags array)
const untaggedProjects = allProjects.filter(p => p.tags.length === 0)
const alreadyTaggedCount = allProjects.length - untaggedProjects.length
console.log(`[AI Tagging] ${untaggedProjects.length} untagged projects, ${alreadyTaggedCount} already have tags`)
if (untaggedProjects.length === 0) {
return {
processed: 0,
failed: 0,
skipped: alreadyTaggedCount,
errors: alreadyTaggedCount > 0
? []
: ['No projects found in this program'],
results: [],
}
}
const results: TaggingResult[] = []
let processed = 0
let failed = 0
const errors: string[] = []
for (let i = 0; i < untaggedProjects.length; i++) {
const project = untaggedProjects[i]
try {
const result = await tagProject(project.id, userId)
results.push(result)
processed++
} catch (error) {
failed++
errors.push(`${project.title}: ${error instanceof Error ? error.message : 'Unknown error'}`)
}
// Report progress
if (onProgress) {
onProgress(i + 1, untaggedProjects.length)
}
}
return {
processed,
failed,
skipped: alreadyTaggedCount,
errors,
results,
}