Compare commits
4 Commits
5e0c8b2dfe
...
014bb15890
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
014bb15890 | ||
|
|
f12c29103c | ||
|
|
65a22e6f19 | ||
|
|
989db4dc14 |
@@ -105,14 +105,13 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
|
||||
// Extract all rounds from the competition
|
||||
const competitionRounds = competition?.rounds || []
|
||||
|
||||
// Fetch requirements for each round
|
||||
const requirementQueries = competitionRounds.map((round: { id: string; name: string }) =>
|
||||
trpc.file.listRequirements.useQuery({ roundId: round.id })
|
||||
// Fetch requirements for all rounds in a single query (avoids dynamic hook violation)
|
||||
const roundIds = competitionRounds.map((r: { id: string }) => r.id)
|
||||
const { data: allRequirements = [] } = trpc.file.listRequirementsByRounds.useQuery(
|
||||
{ roundIds },
|
||||
{ enabled: roundIds.length > 0 }
|
||||
)
|
||||
|
||||
// Combine requirements from all rounds
|
||||
const allRequirements = requirementQueries.flatMap((q: { data?: unknown[] }) => q.data || [])
|
||||
|
||||
const utils = trpc.useUtils()
|
||||
|
||||
if (isLoading) {
|
||||
@@ -592,7 +591,7 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground mt-0.5">
|
||||
{req.acceptedMimeTypes.length > 0 && (
|
||||
{req.acceptedMimeTypes?.length > 0 && (
|
||||
<span>
|
||||
{req.acceptedMimeTypes.map((mime: string) => {
|
||||
if (mime === 'application/pdf') return 'PDF'
|
||||
|
||||
@@ -366,8 +366,9 @@ export default function ProjectsPage() {
|
||||
}
|
||||
|
||||
const handleCloseTaggingDialog = () => {
|
||||
if (!taggingInProgress) {
|
||||
setAiTagDialogOpen(false)
|
||||
// Only reset job state if not in progress (preserve polling for background jobs)
|
||||
if (!taggingInProgress) {
|
||||
setActiveTaggingJobId(null)
|
||||
setSelectedRoundForTagging('')
|
||||
setSelectedProgramForTagging('')
|
||||
@@ -618,9 +619,22 @@ export default function ProjectsPage() {
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" onClick={() => setAiTagDialogOpen(true)}>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setAiTagDialogOpen(true)}
|
||||
className={taggingInProgress ? 'border-amber-400 bg-amber-50 dark:bg-amber-950/20' : ''}
|
||||
>
|
||||
{taggingInProgress ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin text-amber-600" />
|
||||
) : (
|
||||
<Bot className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
AI Tags
|
||||
{taggingInProgress && (
|
||||
<span className="ml-1.5 text-[10px] text-amber-600 font-medium">
|
||||
{taggingProgressPercent}%
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
<Button variant="outline" asChild>
|
||||
<Link href="/admin/projects/pool">
|
||||
@@ -1833,9 +1847,8 @@ export default function ProjectsPage() {
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleCloseTaggingDialog}
|
||||
disabled={taggingInProgress}
|
||||
>
|
||||
Cancel
|
||||
{taggingInProgress ? 'Run in Background' : 'Cancel'}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleStartTagging}
|
||||
|
||||
@@ -36,6 +36,7 @@ const formSchema = z.object({
|
||||
ai_model: z.string(),
|
||||
ai_send_descriptions: z.boolean(),
|
||||
openai_api_key: z.string().optional(),
|
||||
openai_base_url: z.string().optional(),
|
||||
})
|
||||
|
||||
type FormValues = z.infer<typeof formSchema>
|
||||
@@ -47,6 +48,7 @@ interface AISettingsFormProps {
|
||||
ai_model?: string
|
||||
ai_send_descriptions?: string
|
||||
openai_api_key?: string
|
||||
openai_base_url?: string
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,6 +63,7 @@ export function AISettingsForm({ settings }: AISettingsFormProps) {
|
||||
ai_model: settings.ai_model || 'gpt-4o',
|
||||
ai_send_descriptions: settings.ai_send_descriptions === 'true',
|
||||
openai_api_key: '',
|
||||
openai_base_url: settings.openai_base_url || '',
|
||||
},
|
||||
})
|
||||
|
||||
@@ -113,6 +116,9 @@ export function AISettingsForm({ settings }: AISettingsFormProps) {
|
||||
settingsToUpdate.push({ key: 'openai_api_key', value: data.openai_api_key })
|
||||
}
|
||||
|
||||
// Save base URL (empty string clears it)
|
||||
settingsToUpdate.push({ key: 'openai_base_url', value: data.openai_base_url?.trim() || '' })
|
||||
|
||||
updateSettings.mutate({ settings: settingsToUpdate })
|
||||
}
|
||||
|
||||
@@ -208,6 +214,27 @@ export function AISettingsForm({ settings }: AISettingsFormProps) {
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="openai_base_url"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>API Base URL (Optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="https://api.openai.com/v1"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Custom base URL for OpenAI-compatible providers. Leave blank for OpenAI.
|
||||
Use <code className="text-xs bg-muted px-1 rounded">https://openrouter.ai/api/v1</code> for OpenRouter (access Claude, Gemini, Llama, etc.)
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="ai_model"
|
||||
|
||||
@@ -84,6 +84,7 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
|
||||
'ai_model',
|
||||
'ai_send_descriptions',
|
||||
'openai_api_key',
|
||||
'openai_base_url',
|
||||
])
|
||||
|
||||
const brandingSettings = getSettingsByKeys([
|
||||
|
||||
@@ -187,7 +187,25 @@ async function getOpenAIApiKey(): Promise<string | null> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Create OpenAI client instance
|
||||
* Get custom base URL for OpenAI-compatible providers.
|
||||
* Supports OpenRouter, Together AI, Groq, local models, etc.
|
||||
* Set via Settings → AI or OPENAI_BASE_URL env var.
|
||||
*/
|
||||
async function getBaseURL(): Promise<string | undefined> {
|
||||
try {
|
||||
const setting = await prisma.systemSettings.findUnique({
|
||||
where: { key: 'openai_base_url' },
|
||||
})
|
||||
return setting?.value || process.env.OPENAI_BASE_URL || undefined
|
||||
} catch {
|
||||
return process.env.OPENAI_BASE_URL || undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create OpenAI client instance.
|
||||
* Supports custom baseURL for OpenAI-compatible providers
|
||||
* (OpenRouter, Groq, Together AI, local models, etc.)
|
||||
*/
|
||||
async function createOpenAIClient(): Promise<OpenAI | null> {
|
||||
const apiKey = await getOpenAIApiKey()
|
||||
@@ -197,8 +215,15 @@ async function createOpenAIClient(): Promise<OpenAI | null> {
|
||||
return null
|
||||
}
|
||||
|
||||
const baseURL = await getBaseURL()
|
||||
|
||||
if (baseURL) {
|
||||
console.log(`[OpenAI] Using custom base URL: ${baseURL}`)
|
||||
}
|
||||
|
||||
return new OpenAI({
|
||||
apiKey,
|
||||
...(baseURL ? { baseURL } : {}),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -221,6 +246,15 @@ export async function getOpenAI(): Promise<OpenAI | null> {
|
||||
return client
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the OpenAI client singleton (e.g., after settings change).
|
||||
* Next call to getOpenAI() will create a fresh client.
|
||||
*/
|
||||
export function resetOpenAIClient(): void {
|
||||
globalForOpenAI.openai = undefined
|
||||
globalForOpenAI.openaiInitialized = false
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if OpenAI is configured and available
|
||||
*/
|
||||
|
||||
@@ -818,6 +818,20 @@ export const fileRouter = router({
|
||||
})
|
||||
}),
|
||||
|
||||
/**
|
||||
* List file requirements for multiple rounds in a single query.
|
||||
* Avoids dynamic hook violations when fetching requirements per-round.
|
||||
*/
|
||||
listRequirementsByRounds: protectedProcedure
|
||||
.input(z.object({ roundIds: z.array(z.string()).max(50) }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
if (input.roundIds.length === 0) return []
|
||||
return ctx.prisma.fileRequirement.findMany({
|
||||
where: { roundId: { in: input.roundIds } },
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
})
|
||||
}),
|
||||
|
||||
/**
|
||||
* Create a file requirement for a stage (admin only)
|
||||
*/
|
||||
|
||||
@@ -201,6 +201,12 @@ export const settingsRouter = router({
|
||||
clearStorageProviderCache()
|
||||
}
|
||||
|
||||
// Reset OpenAI client if API key or base URL changed
|
||||
if (input.settings.some((s) => s.key === 'openai_api_key' || s.key === 'openai_base_url' || s.key === 'ai_model')) {
|
||||
const { resetOpenAIClient } = await import('@/lib/openai')
|
||||
resetOpenAIClient()
|
||||
}
|
||||
|
||||
// Audit log
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
|
||||
@@ -5,6 +5,7 @@ import { prisma } from '@/lib/prisma'
|
||||
import { logAudit } from '../utils/audit'
|
||||
import {
|
||||
tagProject,
|
||||
tagProjectsBatch,
|
||||
getTagSuggestions,
|
||||
addProjectTag,
|
||||
removeProjectTag,
|
||||
@@ -17,7 +18,7 @@ import {
|
||||
NotificationTypes,
|
||||
} from '../services/in-app-notification'
|
||||
|
||||
// Background job runner for tagging
|
||||
// Background job runner for tagging — uses batched API calls for efficiency
|
||||
async function runTaggingJob(jobId: string, userId: string) {
|
||||
const job = await prisma.taggingJob.findUnique({
|
||||
where: { id: jobId },
|
||||
@@ -28,7 +29,7 @@ async function runTaggingJob(jobId: string, userId: string) {
|
||||
return
|
||||
}
|
||||
|
||||
console.log(`[AI Tagging Job] Starting job ${jobId}...`)
|
||||
console.log(`[AI Tagging Job] Starting job ${jobId} (batched mode)...`)
|
||||
|
||||
// Mark as running
|
||||
await prisma.taggingJob.update({
|
||||
@@ -56,7 +57,7 @@ async function runTaggingJob(jobId: string, userId: string) {
|
||||
|
||||
const allProjects = await prisma.project.findMany({
|
||||
where: whereClause,
|
||||
select: { id: true, title: true, tags: true },
|
||||
select: { id: true, title: true, tags: true, projectTags: { select: { tagId: true } } },
|
||||
})
|
||||
|
||||
const untaggedProjects = allProjects.filter(p => p.tags.length === 0)
|
||||
@@ -83,48 +84,33 @@ async function runTaggingJob(jobId: string, userId: string) {
|
||||
return
|
||||
}
|
||||
|
||||
let taggedCount = 0
|
||||
let failedCount = 0
|
||||
const errors: string[] = []
|
||||
const startTime = Date.now()
|
||||
|
||||
for (let i = 0; i < untaggedProjects.length; i++) {
|
||||
const project = untaggedProjects[i]
|
||||
console.log(`[AI Tagging Job] Processing ${i + 1}/${untaggedProjects.length}: "${project.title.substring(0, 40)}..."`)
|
||||
|
||||
try {
|
||||
const result = await tagProject(project.id, userId)
|
||||
taggedCount++
|
||||
console.log(`[AI Tagging Job] ✓ Tagged with ${result.applied.length} tags`)
|
||||
} catch (error) {
|
||||
failedCount++
|
||||
const errorMsg = error instanceof Error ? error.message : 'Unknown error'
|
||||
errors.push(`${project.title}: ${errorMsg}`)
|
||||
console.error(`[AI Tagging Job] ✗ Failed: ${errorMsg}`)
|
||||
}
|
||||
|
||||
// Update progress
|
||||
// Use batched tagging — processes 10 projects per API call, 3 concurrent calls
|
||||
const { results, totalTokens } = await tagProjectsBatch(
|
||||
untaggedProjects,
|
||||
userId,
|
||||
async (processed, total) => {
|
||||
// Update job progress on each batch completion
|
||||
const taggedSoFar = results?.length ?? processed
|
||||
await prisma.taggingJob.update({
|
||||
where: { id: jobId },
|
||||
data: {
|
||||
processedCount: i + 1,
|
||||
taggedCount,
|
||||
failedCount,
|
||||
errorsJson: errors.length > 0 ? errors.slice(0, 20) : undefined, // Keep last 20 errors
|
||||
processedCount: processed,
|
||||
taggedCount: taggedSoFar,
|
||||
},
|
||||
})
|
||||
|
||||
// Log progress every 10 projects
|
||||
if ((i + 1) % 10 === 0) {
|
||||
const elapsed = ((Date.now() - startTime) / 1000).toFixed(0)
|
||||
const avgTime = (Date.now() - startTime) / (i + 1) / 1000
|
||||
const remaining = avgTime * (untaggedProjects.length - i - 1)
|
||||
console.log(`[AI Tagging Job] Progress: ${i + 1}/${untaggedProjects.length} (${elapsed}s elapsed, ~${remaining.toFixed(0)}s remaining)`)
|
||||
}
|
||||
console.log(`[AI Tagging Job] Progress: ${processed}/${total} (${elapsed}s elapsed)`)
|
||||
}
|
||||
)
|
||||
|
||||
const taggedCount = results.filter(r => r.applied.length > 0).length
|
||||
const failedCount = untaggedProjects.length - results.length
|
||||
|
||||
const totalTime = ((Date.now() - startTime) / 1000).toFixed(1)
|
||||
console.log(`[AI Tagging Job] Complete: ${taggedCount} tagged, ${failedCount} failed in ${totalTime}s`)
|
||||
console.log(`[AI Tagging Job] Complete: ${taggedCount} tagged, ${failedCount} failed in ${totalTime}s (${totalTokens} tokens)`)
|
||||
|
||||
// Mark as completed
|
||||
await prisma.taggingJob.update({
|
||||
@@ -132,7 +118,9 @@ async function runTaggingJob(jobId: string, userId: string) {
|
||||
data: {
|
||||
status: 'COMPLETED',
|
||||
completedAt: new Date(),
|
||||
errorsJson: errors.length > 0 ? errors : undefined,
|
||||
processedCount: results.length,
|
||||
taggedCount,
|
||||
failedCount,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -144,7 +132,7 @@ async function runTaggingJob(jobId: string, userId: string) {
|
||||
linkUrl: '/admin/projects',
|
||||
linkLabel: 'View Projects',
|
||||
priority: 'normal',
|
||||
metadata: { jobId, taggedCount, failedCount, skippedCount },
|
||||
metadata: { jobId, taggedCount, failedCount, skippedCount, totalTokens },
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
|
||||
@@ -142,7 +142,7 @@ interface FilteringRuleInput {
|
||||
const DEFAULT_BATCH_SIZE = 20
|
||||
const MAX_BATCH_SIZE = 50
|
||||
const MIN_BATCH_SIZE = 1
|
||||
const DEFAULT_PARALLEL_BATCHES = 1
|
||||
const DEFAULT_PARALLEL_BATCHES = 3
|
||||
const MAX_PARALLEL_BATCHES = 10
|
||||
|
||||
// Structured system prompt for AI screening
|
||||
|
||||
@@ -344,8 +344,8 @@ export async function generateShortlist(
|
||||
let totalTokens = 0
|
||||
const allErrors: string[] = []
|
||||
|
||||
// Run each category independently
|
||||
for (const cat of categories) {
|
||||
// Run categories in parallel for efficiency
|
||||
const categoryPromises = categories.map(async (cat) => {
|
||||
const catTopN = cat === 'STARTUP'
|
||||
? (startupTopN ?? topN)
|
||||
: (conceptTopN ?? topN)
|
||||
@@ -357,6 +357,12 @@ export async function generateShortlist(
|
||||
prisma,
|
||||
)
|
||||
|
||||
return { cat, result }
|
||||
})
|
||||
|
||||
const categoryResults = await Promise.all(categoryPromises)
|
||||
|
||||
for (const { cat, result } of categoryResults) {
|
||||
if (cat === 'STARTUP') {
|
||||
allRecommendations.STARTUP = result.recommendations
|
||||
} else {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*
|
||||
* Features:
|
||||
* - Single project tagging (on-submit or manual)
|
||||
* - Batch tagging for rounds
|
||||
* - Batch tagging with concurrent processing (10 projects/batch, 3 concurrent)
|
||||
* - Confidence scores for each tag
|
||||
* - Additive only - never removes existing tags
|
||||
*
|
||||
@@ -16,7 +16,7 @@
|
||||
*/
|
||||
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { getOpenAI, getConfiguredModel, buildCompletionParams } from '@/lib/openai'
|
||||
import { getOpenAI, getConfiguredModel, buildCompletionParams, AI_MODELS } from '@/lib/openai'
|
||||
import { logAIUsage, extractTokenUsage } from '@/server/utils/ai-usage'
|
||||
import { classifyAIError, createParseError, logAIError } from './ai-errors'
|
||||
import {
|
||||
@@ -53,8 +53,10 @@ interface AvailableTag {
|
||||
|
||||
const CONFIDENCE_THRESHOLD = 0.5
|
||||
const DEFAULT_MAX_TAGS = 5
|
||||
const BATCH_SIZE = 10 // Projects per API call
|
||||
const BATCH_CONCURRENCY = 3 // Concurrent API calls
|
||||
|
||||
// System prompt optimized for tag suggestion
|
||||
// System prompt optimized for single-project tag suggestion
|
||||
const TAG_SUGGESTION_SYSTEM_PROMPT = `You are an expert at categorizing ocean conservation and sustainability projects.
|
||||
|
||||
Analyze the project and suggest the most relevant expertise tags from the provided list.
|
||||
@@ -78,6 +80,36 @@ Rules:
|
||||
- Maximum 7 suggestions per project
|
||||
- Be conservative - only suggest tags that truly apply`
|
||||
|
||||
// System prompt optimized for batch tagging (multiple projects in one call)
|
||||
const BATCH_TAG_SYSTEM_PROMPT = `You are an expert at categorizing ocean conservation and sustainability projects.
|
||||
|
||||
Analyze EACH project and suggest the most relevant expertise tags from the provided list.
|
||||
Consider each project's focus areas, technology, methodology, and domain.
|
||||
|
||||
Return JSON with this format:
|
||||
{
|
||||
"projects": [
|
||||
{
|
||||
"project_id": "PROJECT_001",
|
||||
"suggestions": [
|
||||
{
|
||||
"tag_name": "exact tag name from list",
|
||||
"confidence": 0.0-1.0,
|
||||
"reasoning": "brief explanation"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Rules:
|
||||
- Only suggest tags from the provided list (exact names)
|
||||
- Order by relevance (most relevant first)
|
||||
- Confidence should reflect how well the tag matches
|
||||
- Maximum 7 suggestions per project
|
||||
- Be conservative - only suggest tags that truly apply
|
||||
- Return results for ALL projects provided`
|
||||
|
||||
// ─── Helper Functions ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -132,7 +164,8 @@ export async function getAvailableTags(): Promise<AvailableTag[]> {
|
||||
// ─── AI Tagging Core ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Call OpenAI to get tag suggestions for a project
|
||||
* Call OpenAI to get tag suggestions for a single project
|
||||
* Used for on-demand single-project tagging
|
||||
*/
|
||||
async function getAISuggestions(
|
||||
anonymizedProject: AnonymizedProjectForAI,
|
||||
@@ -145,9 +178,10 @@ async function getAISuggestions(
|
||||
return { suggestions: [], tokensUsed: 0 }
|
||||
}
|
||||
|
||||
const model = await getConfiguredModel()
|
||||
// Use QUICK model — tag classification is simple, doesn't need expensive reasoning
|
||||
const model = await getConfiguredModel(AI_MODELS.QUICK)
|
||||
|
||||
// Build tag list for prompt
|
||||
// Build compact tag list for prompt
|
||||
const tagList = availableTags.map((t) => ({
|
||||
name: t.name,
|
||||
category: t.category,
|
||||
@@ -155,10 +189,10 @@ async function getAISuggestions(
|
||||
}))
|
||||
|
||||
const userPrompt = `PROJECT:
|
||||
${JSON.stringify(anonymizedProject, null, 2)}
|
||||
${JSON.stringify(anonymizedProject)}
|
||||
|
||||
AVAILABLE TAGS:
|
||||
${JSON.stringify(tagList, null, 2)}
|
||||
${JSON.stringify(tagList)}
|
||||
|
||||
Suggest relevant tags for this project.`
|
||||
|
||||
@@ -246,6 +280,161 @@ Suggest relevant tags for this project.`
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Call OpenAI to get tag suggestions for a batch of projects in one API call.
|
||||
* Returns a map of project_id -> TagSuggestion[].
|
||||
*/
|
||||
async function getAISuggestionsBatch(
|
||||
anonymizedProjects: AnonymizedProjectForAI[],
|
||||
availableTags: AvailableTag[],
|
||||
userId?: string
|
||||
): Promise<{ suggestionsMap: Map<string, TagSuggestion[]>; tokensUsed: number }> {
|
||||
const openai = await getOpenAI()
|
||||
if (!openai) {
|
||||
console.warn('[AI Tagging] OpenAI not configured')
|
||||
return { suggestionsMap: new Map(), tokensUsed: 0 }
|
||||
}
|
||||
|
||||
// Use QUICK model — tag classification is simple, doesn't need expensive reasoning
|
||||
const model = await getConfiguredModel(AI_MODELS.QUICK)
|
||||
const suggestionsMap = new Map<string, TagSuggestion[]>()
|
||||
|
||||
// Build compact tag list (sent once for entire batch)
|
||||
const tagList = availableTags.map((t) => ({
|
||||
name: t.name,
|
||||
category: t.category,
|
||||
description: t.description,
|
||||
}))
|
||||
|
||||
const userPrompt = `PROJECTS (${anonymizedProjects.length}):
|
||||
${JSON.stringify(anonymizedProjects)}
|
||||
|
||||
AVAILABLE TAGS:
|
||||
${JSON.stringify(tagList)}
|
||||
|
||||
Suggest relevant tags for each project.`
|
||||
|
||||
const MAX_PARSE_RETRIES = 2
|
||||
let parseAttempts = 0
|
||||
|
||||
try {
|
||||
const params = buildCompletionParams(model, {
|
||||
messages: [
|
||||
{ role: 'system', content: BATCH_TAG_SYSTEM_PROMPT },
|
||||
{ role: 'user', content: userPrompt },
|
||||
],
|
||||
jsonMode: true,
|
||||
temperature: 0.1,
|
||||
maxTokens: Math.min(4000, anonymizedProjects.length * 500),
|
||||
})
|
||||
|
||||
let response = await openai.chat.completions.create(params)
|
||||
let usage = extractTokenUsage(response)
|
||||
let totalTokens = usage.totalTokens
|
||||
|
||||
// Parse with retry logic
|
||||
let parsed: {
|
||||
projects: Array<{
|
||||
project_id: string
|
||||
suggestions: Array<{
|
||||
tag_name: string
|
||||
confidence: number
|
||||
reasoning: string
|
||||
}>
|
||||
}>
|
||||
}
|
||||
|
||||
while (true) {
|
||||
try {
|
||||
const content = response.choices[0]?.message?.content
|
||||
if (!content) throw new Error('Empty response from AI')
|
||||
|
||||
const raw = JSON.parse(content)
|
||||
parsed = raw.projects ? raw : { projects: Array.isArray(raw) ? raw : [] }
|
||||
break
|
||||
} catch (parseError) {
|
||||
if (parseError instanceof SyntaxError && parseAttempts < MAX_PARSE_RETRIES) {
|
||||
parseAttempts++
|
||||
console.warn(`[AI Tagging Batch] JSON parse failed, retrying (${parseAttempts}/${MAX_PARSE_RETRIES})`)
|
||||
const retryParams = buildCompletionParams(model, {
|
||||
messages: [
|
||||
{ role: 'system', content: BATCH_TAG_SYSTEM_PROMPT },
|
||||
{ role: 'user', content: userPrompt + '\n\nIMPORTANT: Please ensure valid JSON output.' },
|
||||
],
|
||||
jsonMode: true,
|
||||
temperature: 0.1,
|
||||
maxTokens: Math.min(4000, anonymizedProjects.length * 500),
|
||||
})
|
||||
response = await openai.chat.completions.create(retryParams)
|
||||
const retryUsage = extractTokenUsage(response)
|
||||
totalTokens += retryUsage.totalTokens
|
||||
continue
|
||||
}
|
||||
throw parseError
|
||||
}
|
||||
}
|
||||
|
||||
// Log usage for the entire batch
|
||||
await logAIUsage({
|
||||
userId,
|
||||
action: 'PROJECT_TAGGING',
|
||||
entityType: 'Project',
|
||||
model,
|
||||
promptTokens: usage.promptTokens,
|
||||
completionTokens: usage.completionTokens,
|
||||
totalTokens,
|
||||
batchSize: anonymizedProjects.length,
|
||||
itemsProcessed: parsed.projects?.length || 0,
|
||||
status: 'SUCCESS',
|
||||
})
|
||||
|
||||
// Map results back to TagSuggestion format
|
||||
for (const projectResult of parsed.projects || []) {
|
||||
const suggestions: TagSuggestion[] = []
|
||||
for (const s of projectResult.suggestions || []) {
|
||||
const tag = availableTags.find(
|
||||
(t) => t.name.toLowerCase() === s.tag_name.toLowerCase()
|
||||
)
|
||||
if (tag) {
|
||||
suggestions.push({
|
||||
tagId: tag.id,
|
||||
tagName: tag.name,
|
||||
confidence: Math.max(0, Math.min(1, s.confidence)),
|
||||
reasoning: s.reasoning || '',
|
||||
})
|
||||
}
|
||||
}
|
||||
suggestionsMap.set(projectResult.project_id, suggestions)
|
||||
}
|
||||
|
||||
return { suggestionsMap, tokensUsed: totalTokens }
|
||||
} catch (error) {
|
||||
if (error instanceof SyntaxError) {
|
||||
const parseError = createParseError(error.message)
|
||||
logAIError('Tagging', 'getAISuggestionsBatch', parseError)
|
||||
}
|
||||
|
||||
const classified = classifyAIError(error)
|
||||
logAIError('Tagging', 'getAISuggestionsBatch', classified)
|
||||
|
||||
await logAIUsage({
|
||||
userId,
|
||||
action: 'PROJECT_TAGGING',
|
||||
entityType: 'Project',
|
||||
model: 'unknown',
|
||||
promptTokens: 0,
|
||||
completionTokens: 0,
|
||||
totalTokens: 0,
|
||||
batchSize: anonymizedProjects.length,
|
||||
itemsProcessed: 0,
|
||||
status: 'ERROR',
|
||||
errorMessage: error instanceof Error ? error.message : 'Unknown error',
|
||||
})
|
||||
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Public API ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -355,6 +544,153 @@ export async function tagProject(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tag a batch of projects using batched API calls with concurrency.
|
||||
* Much more efficient than tagging one-by-one for bulk operations.
|
||||
*
|
||||
* @param projects Array of { id, projectTags } to tag
|
||||
* @param userId The user initiating the tagging
|
||||
* @param onProgress Callback for progress updates
|
||||
* @returns Array of TaggingResult
|
||||
*/
|
||||
export async function tagProjectsBatch(
|
||||
projects: Array<{
|
||||
id: string
|
||||
title: string
|
||||
projectTags: Array<{ tagId: string }>
|
||||
}>,
|
||||
userId: string,
|
||||
onProgress?: (processed: number, total: number) => Promise<void>
|
||||
): Promise<{ results: TaggingResult[]; totalTokens: number }> {
|
||||
const settings = await getTaggingSettings()
|
||||
if (!settings.enabled) {
|
||||
return { results: [], totalTokens: 0 }
|
||||
}
|
||||
|
||||
const availableTags = await getAvailableTags()
|
||||
if (availableTags.length === 0) {
|
||||
return { results: [], totalTokens: 0 }
|
||||
}
|
||||
|
||||
// Fetch full project data for all projects at once (single DB query)
|
||||
const fullProjects = await prisma.project.findMany({
|
||||
where: { id: { in: projects.map((p) => p.id) } },
|
||||
include: {
|
||||
projectTags: true,
|
||||
files: { select: { fileType: true } },
|
||||
_count: { select: { teamMembers: true, files: true } },
|
||||
},
|
||||
})
|
||||
|
||||
const projectMap = new Map(fullProjects.map((p) => [p.id, p]))
|
||||
|
||||
// Anonymize all projects at once
|
||||
const projectsWithRelations = fullProjects.map(toProjectWithRelations)
|
||||
const { anonymized, mappings } = anonymizeProjectsForAI(projectsWithRelations, 'FILTERING')
|
||||
|
||||
if (!validateAnonymizedProjects(anonymized)) {
|
||||
throw new Error('GDPR compliance check failed: PII detected in anonymized data')
|
||||
}
|
||||
|
||||
// Build mapping from anonymous ID to real project
|
||||
const anonToRealMap = new Map<string, string>()
|
||||
for (const mapping of mappings) {
|
||||
anonToRealMap.set(mapping.anonymousId, mapping.realId)
|
||||
}
|
||||
|
||||
// Split into batches
|
||||
const batches: AnonymizedProjectForAI[][] = []
|
||||
for (let i = 0; i < anonymized.length; i += BATCH_SIZE) {
|
||||
batches.push(anonymized.slice(i, i + BATCH_SIZE))
|
||||
}
|
||||
|
||||
const allResults: TaggingResult[] = []
|
||||
let totalTokens = 0
|
||||
let processedCount = 0
|
||||
|
||||
// Process batches with concurrency
|
||||
for (let i = 0; i < batches.length; i += BATCH_CONCURRENCY) {
|
||||
const concurrentBatches = batches.slice(i, i + BATCH_CONCURRENCY)
|
||||
|
||||
const batchPromises = concurrentBatches.map(async (batch) => {
|
||||
try {
|
||||
const { suggestionsMap, tokensUsed } = await getAISuggestionsBatch(
|
||||
batch,
|
||||
availableTags,
|
||||
userId
|
||||
)
|
||||
return { suggestionsMap, tokensUsed, error: null }
|
||||
} catch (error) {
|
||||
console.error('[AI Tagging Batch] Batch failed:', error)
|
||||
return { suggestionsMap: new Map<string, TagSuggestion[]>(), tokensUsed: 0, error }
|
||||
}
|
||||
})
|
||||
|
||||
const batchResults = await Promise.all(batchPromises)
|
||||
|
||||
// Process results from all concurrent batches
|
||||
for (const { suggestionsMap, tokensUsed } of batchResults) {
|
||||
totalTokens += tokensUsed
|
||||
|
||||
for (const [anonId, suggestions] of suggestionsMap) {
|
||||
const realId = anonToRealMap.get(anonId)
|
||||
if (!realId) continue
|
||||
|
||||
const project = projectMap.get(realId)
|
||||
if (!project) continue
|
||||
|
||||
// Filter by confidence
|
||||
const validSuggestions = suggestions.filter(
|
||||
(s) => s.confidence >= settings.confidenceThreshold
|
||||
)
|
||||
|
||||
// Get existing tags
|
||||
const existingTagIds = new Set(project.projectTags.map((pt) => pt.tagId))
|
||||
const currentTagCount = project.projectTags.length
|
||||
const remainingSlots = Math.max(0, settings.maxTags - currentTagCount)
|
||||
|
||||
const newSuggestions = validSuggestions
|
||||
.filter((s) => !existingTagIds.has(s.tagId))
|
||||
.slice(0, remainingSlots)
|
||||
|
||||
// Apply tags
|
||||
const applied: TagSuggestion[] = []
|
||||
for (const suggestion of newSuggestions) {
|
||||
try {
|
||||
await prisma.projectTag.create({
|
||||
data: {
|
||||
projectId: realId,
|
||||
tagId: suggestion.tagId,
|
||||
confidence: suggestion.confidence,
|
||||
source: 'AI',
|
||||
},
|
||||
})
|
||||
applied.push(suggestion)
|
||||
} catch {
|
||||
// Skip duplicates
|
||||
}
|
||||
}
|
||||
|
||||
allResults.push({
|
||||
projectId: realId,
|
||||
suggestions,
|
||||
applied,
|
||||
tokensUsed: 0, // Token tracking is per-batch, not per-project
|
||||
})
|
||||
|
||||
processedCount++
|
||||
}
|
||||
}
|
||||
|
||||
// Report progress after each concurrent chunk
|
||||
if (onProgress) {
|
||||
await onProgress(processedCount, projects.length)
|
||||
}
|
||||
}
|
||||
|
||||
return { results: allResults, totalTokens }
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tag suggestions for a project without applying them
|
||||
* Useful for preview/review before applying
|
||||
|
||||
Reference in New Issue
Block a user