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:
@@ -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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user