Convert AI tagging to background job with progress tracking

- Add TaggingJob model for tracking tagging progress
- Convert batch tagging to background job processing (prevents timeouts)
- Add real-time progress polling in UI with percentage/count display
- Add admin notifications when tagging job completes or fails
- Export getTaggingSettings and getAvailableTags functions

After deployment, run: npx prisma migrate deploy

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-05 11:48:57 +01:00
parent 0b86dc6477
commit 1b2311b4a3
5 changed files with 481 additions and 94 deletions

View File

@@ -257,80 +257,75 @@ export default function ProjectsPage() {
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)
const [activeTaggingJobId, setActiveTaggingJobId] = useState<string | null>(null)
// Fetch programs and rounds for the AI tagging dialog
const { data: programs } = trpc.program.list.useQuery()
// AI batch tagging mutations
const batchTagProjects = trpc.tag.batchTagProjects.useMutation({
onMutate: () => {
setTaggingInProgress(true)
setTaggingResult(null)
},
// Start tagging job mutation
const startTaggingJob = trpc.tag.startTaggingJob.useMutation({
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()
setActiveTaggingJobId(result.jobId)
toast.info('AI tagging job started. Progress will update automatically.')
},
onError: (error) => {
setTaggingInProgress(false)
toast.error(error.message || 'Failed to generate AI tags')
toast.error(error.message || 'Failed to start tagging job')
},
})
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`
)
}
// Poll for job status when job is active
const { data: jobStatus } = trpc.tag.getTaggingJobStatus.useQuery(
{ jobId: activeTaggingJobId! },
{
enabled: !!activeTaggingJobId,
refetchInterval: (query) => {
const status = query.state.data?.status
// Stop polling when job is done
if (status === 'COMPLETED' || status === 'FAILED') {
return false
}
return 1500 // Poll every 1.5 seconds
},
}
)
// Handle job completion
useEffect(() => {
if (jobStatus?.status === 'COMPLETED') {
toast.success(
`AI Tagging complete: ${jobStatus.taggedCount} tagged, ${jobStatus.skippedCount} already tagged, ${jobStatus.failedCount} failed`
)
utils.project.list.invalidate()
},
onError: (error) => {
setTaggingInProgress(false)
toast.error(error.message || 'Failed to generate AI tags')
},
})
} else if (jobStatus?.status === 'FAILED') {
toast.error(`AI Tagging failed: ${jobStatus.errorMessage || 'Unknown error'}`)
}
}, [jobStatus?.status, jobStatus?.taggedCount, jobStatus?.skippedCount, jobStatus?.failedCount, jobStatus?.errorMessage, utils.project.list])
const taggingInProgress = startTaggingJob.isPending ||
(jobStatus?.status === 'PENDING' || jobStatus?.status === 'RUNNING')
const taggingResult = jobStatus?.status === 'COMPLETED' || jobStatus?.status === 'FAILED'
? {
processed: jobStatus.taggedCount,
skipped: jobStatus.skippedCount,
failed: jobStatus.failedCount,
errors: jobStatus.errors || [],
status: jobStatus.status,
}
: null
const handleStartTagging = () => {
if (taggingScope === 'round' && selectedRoundForTagging) {
batchTagProjects.mutate({ roundId: selectedRoundForTagging })
startTaggingJob.mutate({ roundId: selectedRoundForTagging })
} else if (taggingScope === 'program' && selectedProgramForTagging) {
batchTagProgramProjects.mutate({ programId: selectedProgramForTagging })
startTaggingJob.mutate({ programId: selectedProgramForTagging })
}
}
const handleCloseTaggingDialog = () => {
if (!taggingInProgress) {
setAiTagDialogOpen(false)
setTaggingResult(null)
setActiveTaggingJobId(null)
setSelectedRoundForTagging('')
setSelectedProgramForTagging('')
}
@@ -346,6 +341,11 @@ export default function ProjectsPage() {
? selectedProgram
: (selectedRound ? programs?.find(p => p.id === selectedRound.program?.id) : null)
// Calculate progress percentage
const taggingProgressPercent = jobStatus && jobStatus.totalProjects > 0
? Math.round((jobStatus.processedCount / jobStatus.totalProjects) * 100)
: 0
const deleteProject = trpc.project.delete.useMutation({
onSuccess: () => {
toast.success('Project deleted successfully')
@@ -696,18 +696,40 @@ export default function ProjectsPage() {
AI Tagging in Progress
</p>
<p className="text-sm text-blue-700 dark:text-blue-300">
Analyzing projects and assigning expertise tags...
{jobStatus?.status === 'PENDING'
? 'Initializing...'
: `Processing ${jobStatus?.totalProjects || 0} projects with AI...`}
</p>
</div>
{jobStatus && jobStatus.totalProjects > 0 && (
<Badge variant="outline" className="border-blue-300 text-blue-700">
<Clock className="mr-1 h-3 w-3" />
{jobStatus.processedCount} / {jobStatus.totalProjects}
</Badge>
)}
</div>
<div className="space-y-1">
<div className="flex justify-between text-sm">
<span className="text-blue-700 dark:text-blue-300">
Processing projects...
{jobStatus?.processedCount || 0} of {jobStatus?.totalProjects || '?'} projects processed
{jobStatus?.taggedCount ? ` (${jobStatus.taggedCount} tagged)` : ''}
</span>
{jobStatus && jobStatus.totalProjects > 0 && (
<span className="font-medium text-blue-900 dark:text-blue-100">
{taggingProgressPercent}%
</span>
)}
</div>
<Progress value={undefined} className="h-2 animate-pulse" />
<Progress
value={jobStatus?.totalProjects ? taggingProgressPercent : undefined}
className={`h-2 ${!jobStatus?.totalProjects ? 'animate-pulse' : ''}`}
/>
</div>
{jobStatus?.failedCount ? (
<p className="text-xs text-amber-600">
{jobStatus.failedCount} projects failed so far
</p>
) : null}
</div>
</div>
)}