Redesign AI Tagging dialog and add edition-wide tagging

- 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:
2026-02-05 10:27:52 +01:00
parent 7f95f681d6
commit 05862f1e55
4 changed files with 469 additions and 104 deletions

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: 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,
results,
}