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 { 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'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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue