Redesign AI Tagging dialog and add edition-wide tagging
Build and Push Docker Image / build (push) Successful in 9m22s
Details
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:
parent
7f95f681d6
commit
05862f1e55
|
|
@ -16,6 +16,7 @@ import { Badge } from '@/components/ui/badge'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
|
import { Progress } from '@/components/ui/progress'
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
|
|
@ -41,6 +42,13 @@ import {
|
||||||
AlertDialogHeader,
|
AlertDialogHeader,
|
||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
} from '@/components/ui/alert-dialog'
|
} from '@/components/ui/alert-dialog'
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog'
|
||||||
import {
|
import {
|
||||||
Plus,
|
Plus,
|
||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
|
|
@ -53,6 +61,12 @@ import {
|
||||||
Trash2,
|
Trash2,
|
||||||
Loader2,
|
Loader2,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
|
Tags,
|
||||||
|
Clock,
|
||||||
|
CheckCircle2,
|
||||||
|
AlertCircle,
|
||||||
|
Layers,
|
||||||
|
FolderOpen,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
|
|
@ -240,37 +254,98 @@ export default function ProjectsPage() {
|
||||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
|
||||||
const [projectToDelete, setProjectToDelete] = useState<{ id: string; title: string } | null>(null)
|
const [projectToDelete, setProjectToDelete] = useState<{ id: string; title: string } | null>(null)
|
||||||
const [aiTagDialogOpen, setAiTagDialogOpen] = useState(false)
|
const [aiTagDialogOpen, setAiTagDialogOpen] = useState(false)
|
||||||
|
const [taggingScope, setTaggingScope] = useState<'round' | 'program'>('round')
|
||||||
const [selectedRoundForTagging, setSelectedRoundForTagging] = useState<string>('')
|
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: 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({
|
const batchTagProjects = trpc.tag.batchTagProjects.useMutation({
|
||||||
|
onMutate: () => {
|
||||||
|
setTaggingInProgress(true)
|
||||||
|
setTaggingResult(null)
|
||||||
|
},
|
||||||
onSuccess: (result) => {
|
onSuccess: (result) => {
|
||||||
|
setTaggingInProgress(false)
|
||||||
|
setTaggingResult(result)
|
||||||
if (result.errors && result.errors.length > 0) {
|
if (result.errors && result.errors.length > 0) {
|
||||||
// Show first error if there are any
|
|
||||||
toast.error(`AI Tagging issue: ${result.errors[0]}`)
|
toast.error(`AI Tagging issue: ${result.errors[0]}`)
|
||||||
} else if (result.processed === 0 && result.skipped === 0 && result.failed === 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 {
|
} else {
|
||||||
toast.success(
|
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()
|
utils.project.list.invalidate()
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
|
setTaggingInProgress(false)
|
||||||
toast.error(error.message || 'Failed to generate AI tags')
|
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({
|
const deleteProject = trpc.project.delete.useMutation({
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success('Project deleted successfully')
|
toast.success('Project deleted successfully')
|
||||||
|
|
@ -593,61 +668,229 @@ export default function ProjectsPage() {
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
|
|
||||||
{/* AI Tagging Dialog */}
|
{/* AI Tagging Dialog */}
|
||||||
<AlertDialog open={aiTagDialogOpen} onOpenChange={setAiTagDialogOpen}>
|
<Dialog open={aiTagDialogOpen} onOpenChange={handleCloseTaggingDialog}>
|
||||||
<AlertDialogContent>
|
<DialogContent className="sm:max-w-lg">
|
||||||
<AlertDialogHeader>
|
<DialogHeader>
|
||||||
<AlertDialogTitle className="flex items-center gap-2">
|
<DialogTitle className="flex items-center gap-2">
|
||||||
<Sparkles className="h-5 w-5" />
|
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-gradient-to-br from-amber-400 to-orange-500">
|
||||||
Generate AI Tags
|
<Sparkles className="h-5 w-5 text-white" />
|
||||||
</AlertDialogTitle>
|
</div>
|
||||||
<AlertDialogDescription>
|
<div>
|
||||||
Use AI to automatically generate expertise tags for all projects in a round
|
<span>AI Tag Generator</span>
|
||||||
that don't have tags yet. This helps with jury matching and filtering.
|
<p className="text-sm font-normal text-muted-foreground">
|
||||||
</AlertDialogDescription>
|
Automatically categorize projects with expertise tags
|
||||||
</AlertDialogHeader>
|
</p>
|
||||||
<div className="space-y-4 py-4">
|
</div>
|
||||||
<div className="space-y-2">
|
</DialogTitle>
|
||||||
<Label htmlFor="round-select">Select Round</Label>
|
</DialogHeader>
|
||||||
<Select
|
|
||||||
value={selectedRoundForTagging}
|
<div className="space-y-6 py-4">
|
||||||
onValueChange={setSelectedRoundForTagging}
|
{/* Progress Indicator (when running) */}
|
||||||
>
|
{taggingInProgress && (
|
||||||
<SelectTrigger id="round-select">
|
<div className="p-4 rounded-lg bg-blue-50 dark:bg-blue-950/20 border border-blue-200 dark:border-blue-900">
|
||||||
<SelectValue placeholder="Choose a round..." />
|
<div className="space-y-3">
|
||||||
</SelectTrigger>
|
<div className="flex items-center gap-3">
|
||||||
<SelectContent>
|
<Loader2 className="h-5 w-5 animate-spin text-blue-600" />
|
||||||
{filterOptions?.rounds?.map((round) => (
|
<div className="flex-1">
|
||||||
<SelectItem key={round.id} value={round.id}>
|
<p className="font-medium text-blue-900 dark:text-blue-100">
|
||||||
{round.name} ({round.program?.name})
|
AI Tagging in Progress
|
||||||
</SelectItem>
|
</p>
|
||||||
))}
|
<p className="text-sm text-blue-700 dark:text-blue-300">
|
||||||
</SelectContent>
|
Analyzing projects and assigning expertise tags...
|
||||||
</Select>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground">
|
</div>
|
||||||
Only projects without existing tags will be processed. Existing tags are preserved.
|
<div className="space-y-1">
|
||||||
</p>
|
<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}
|
||||||
|
onValueChange={setSelectedRoundForTagging}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="round-select">
|
||||||
|
<SelectValue placeholder="Choose a round..." />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{filterOptions?.rounds?.map((round) => (
|
||||||
|
<SelectItem key={round.id} value={round.id}>
|
||||||
|
{round.name} ({round.program?.name})
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</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. AI will analyze project descriptions and assign relevant expertise tags for jury matching.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<AlertDialogFooter>
|
|
||||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
{/* Footer */}
|
||||||
<AlertDialogAction
|
<div className="flex justify-end gap-3 pt-2 border-t">
|
||||||
onClick={() => {
|
{taggingResult ? (
|
||||||
if (selectedRoundForTagging) {
|
<Button onClick={handleCloseTaggingDialog}>
|
||||||
batchTagProjects.mutate({ roundId: selectedRoundForTagging })
|
Done
|
||||||
}
|
</Button>
|
||||||
}}
|
) : (
|
||||||
disabled={!selectedRoundForTagging || batchTagProjects.isPending}
|
<>
|
||||||
>
|
<Button
|
||||||
{batchTagProjects.isPending ? (
|
variant="outline"
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
onClick={handleCloseTaggingDialog}
|
||||||
) : (
|
disabled={taggingInProgress}
|
||||||
<Sparkles className="mr-2 h-4 w-4" />
|
>
|
||||||
)}
|
Cancel
|
||||||
{batchTagProjects.isPending ? 'Processing...' : 'Generate Tags'}
|
</Button>
|
||||||
</AlertDialogAction>
|
<Button
|
||||||
</AlertDialogFooter>
|
onClick={handleStartTagging}
|
||||||
</AlertDialogContent>
|
disabled={
|
||||||
</AlertDialog>
|
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" />
|
||||||
|
)}
|
||||||
|
{taggingInProgress ? 'Processing...' : 'Generate Tags'}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -161,7 +161,7 @@ export const projectRouter = router({
|
||||||
.query(async ({ ctx }) => {
|
.query(async ({ ctx }) => {
|
||||||
const [rounds, countries, categories, issues] = await Promise.all([
|
const [rounds, countries, categories, issues] = await Promise.all([
|
||||||
ctx.prisma.round.findMany({
|
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' }],
|
orderBy: [{ program: { year: 'desc' } }, { createdAt: 'asc' }],
|
||||||
}),
|
}),
|
||||||
ctx.prisma.project.findMany({
|
ctx.prisma.project.findMany({
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { router, adminProcedure, protectedProcedure } from '../trpc'
|
||||||
import {
|
import {
|
||||||
tagProject,
|
tagProject,
|
||||||
batchTagProjects,
|
batchTagProjects,
|
||||||
|
batchTagProgramProjects,
|
||||||
getTagSuggestions,
|
getTagSuggestions,
|
||||||
addProjectTag,
|
addProjectTag,
|
||||||
removeProjectTag,
|
removeProjectTag,
|
||||||
|
|
@ -494,6 +495,34 @@ export const tagRouter = router({
|
||||||
return result
|
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
|
* Manually add a tag to a project
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -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
|
* Batch tag all untagged projects in a round
|
||||||
*
|
*
|
||||||
|
|
@ -416,42 +458,13 @@ export async function batchTagProjects(
|
||||||
userId?: string,
|
userId?: string,
|
||||||
onProgress?: (processed: number, total: number) => void
|
onProgress?: (processed: number, total: number) => void
|
||||||
): Promise<BatchTaggingResult> {
|
): Promise<BatchTaggingResult> {
|
||||||
const settings = await getTaggingSettings()
|
const validation = await validateBatchTagging()
|
||||||
console.log('[AI Tagging] Settings:', settings)
|
if (!validation.valid) {
|
||||||
|
|
||||||
if (!settings.enabled) {
|
|
||||||
console.log('[AI Tagging] AI tagging is disabled in settings')
|
|
||||||
return {
|
return {
|
||||||
processed: 0,
|
processed: 0,
|
||||||
failed: 0,
|
failed: 0,
|
||||||
skipped: 0,
|
skipped: 0,
|
||||||
errors: ['AI tagging is disabled. Enable it in Settings > AI or set ai_enabled to true.'],
|
errors: [validation.error!],
|
||||||
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.'],
|
|
||||||
results: [],
|
results: [],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -462,16 +475,13 @@ export async function batchTagProjects(
|
||||||
include: {
|
include: {
|
||||||
files: { select: { fileType: true } },
|
files: { select: { fileType: true } },
|
||||||
_count: { select: { teamMembers: true, files: true } },
|
_count: { select: { teamMembers: true, files: true } },
|
||||||
projectTags: { select: { tagId: true } },
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
console.log(`[AI Tagging] Found ${allProjects.length} total projects in round`)
|
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)
|
// Filter to only projects that truly have no tags (empty tags array)
|
||||||
const untaggedProjects = allProjects.filter(p =>
|
const untaggedProjects = allProjects.filter(p => p.tags.length === 0)
|
||||||
(p.tags.length === 0) && (p.projectTags.length === 0)
|
|
||||||
)
|
|
||||||
|
|
||||||
const alreadyTaggedCount = allProjects.length - untaggedProjects.length
|
const alreadyTaggedCount = allProjects.length - untaggedProjects.length
|
||||||
console.log(`[AI Tagging] ${untaggedProjects.length} untagged projects, ${alreadyTaggedCount} already have tags`)
|
console.log(`[AI Tagging] ${untaggedProjects.length} untagged projects, ${alreadyTaggedCount} already have tags`)
|
||||||
|
|
@ -513,7 +523,90 @@ export async function batchTagProjects(
|
||||||
return {
|
return {
|
||||||
processed,
|
processed,
|
||||||
failed,
|
failed,
|
||||||
skipped: 0,
|
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,
|
errors,
|
||||||
results,
|
results,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue