Compare commits
No commits in common. "e5b7cdf67048509cd5209aaea5b9d75e116695cf" and "e547d2bd0327dc8c9ac580e6cc823e90bc2d2318" have entirely different histories.
e5b7cdf670
...
e547d2bd03
|
|
@ -49,7 +49,6 @@
|
||||||
"cmdk": "^1.0.4",
|
"cmdk": "^1.0.4",
|
||||||
"csv-parse": "^6.1.0",
|
"csv-parse": "^6.1.0",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"franc": "^6.2.0",
|
|
||||||
"html2canvas": "^1.4.1",
|
"html2canvas": "^1.4.1",
|
||||||
"jspdf": "^4.1.0",
|
"jspdf": "^4.1.0",
|
||||||
"jspdf-autotable": "^5.0.7",
|
"jspdf-autotable": "^5.0.7",
|
||||||
|
|
@ -6148,16 +6147,6 @@
|
||||||
"react-dom": "^18 || ^19 || ^19.0.0-rc"
|
"react-dom": "^18 || ^19 || ^19.0.0-rc"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/collapse-white-space": {
|
|
||||||
"version": "2.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/collapse-white-space/-/collapse-white-space-2.1.0.tgz",
|
|
||||||
"integrity": "sha512-loKTxY1zCOuG4j9f6EPnuyyYkf58RnhhWTvRoZEokgB+WbdXehfjFviyOVYkqzEWz1Q5kRiZdBYS5SwxbQYwzw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"funding": {
|
|
||||||
"type": "github",
|
|
||||||
"url": "https://github.com/sponsors/wooorm"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/color-convert": {
|
"node_modules/color-convert": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||||
|
|
@ -7747,19 +7736,6 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/franc": {
|
|
||||||
"version": "6.2.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/franc/-/franc-6.2.0.tgz",
|
|
||||||
"integrity": "sha512-rcAewP7PSHvjq7Kgd7dhj82zE071kX5B4W1M4ewYMf/P+i6YsDQmj62Xz3VQm9zyUzUXwhIde/wHLGCMrM+yGg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"trigram-utils": "^2.0.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "github",
|
|
||||||
"url": "https://github.com/sponsors/wooorm"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/fsevents": {
|
"node_modules/fsevents": {
|
||||||
"version": "2.3.2",
|
"version": "2.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||||
|
|
@ -10465,16 +10441,6 @@
|
||||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/n-gram": {
|
|
||||||
"version": "2.0.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/n-gram/-/n-gram-2.0.2.tgz",
|
|
||||||
"integrity": "sha512-S24aGsn+HLBxUGVAUFOwGpKs7LBcG4RudKU//eWzt/mQ97/NMKQxDWHyHx63UNWk/OOdihgmzoETn1tf5nQDzQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"funding": {
|
|
||||||
"type": "github",
|
|
||||||
"url": "https://github.com/sponsors/wooorm"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/nanoid": {
|
"node_modules/nanoid": {
|
||||||
"version": "3.3.11",
|
"version": "3.3.11",
|
||||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
||||||
|
|
@ -13144,20 +13110,6 @@
|
||||||
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
|
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/trigram-utils": {
|
|
||||||
"version": "2.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/trigram-utils/-/trigram-utils-2.0.1.tgz",
|
|
||||||
"integrity": "sha512-nfWIXHEaB+HdyslAfMxSqWKDdmqY9I32jS7GnqpdWQnLH89r6A5sdk3fDVYqGAZ0CrT8ovAFSAo6HRiWcWNIGQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"collapse-white-space": "^2.0.0",
|
|
||||||
"n-gram": "^2.0.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "github",
|
|
||||||
"url": "https://github.com/sponsors/wooorm"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/trim-lines": {
|
"node_modules/trim-lines": {
|
||||||
"version": "3.0.1",
|
"version": "3.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz",
|
||||||
|
|
|
||||||
|
|
@ -62,7 +62,6 @@
|
||||||
"cmdk": "^1.0.4",
|
"cmdk": "^1.0.4",
|
||||||
"csv-parse": "^6.1.0",
|
"csv-parse": "^6.1.0",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"franc": "^6.2.0",
|
|
||||||
"html2canvas": "^1.4.1",
|
"html2canvas": "^1.4.1",
|
||||||
"jspdf": "^4.1.0",
|
"jspdf": "^4.1.0",
|
||||||
"jspdf-autotable": "^5.0.7",
|
"jspdf-autotable": "^5.0.7",
|
||||||
|
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
-- AlterTable
|
|
||||||
ALTER TABLE "ProjectFile" ADD COLUMN "textPreview" TEXT;
|
|
||||||
ALTER TABLE "ProjectFile" ADD COLUMN "detectedLang" TEXT;
|
|
||||||
ALTER TABLE "ProjectFile" ADD COLUMN "langConfidence" DOUBLE PRECISION;
|
|
||||||
ALTER TABLE "ProjectFile" ADD COLUMN "analyzedAt" TIMESTAMP(3);
|
|
||||||
|
|
@ -689,12 +689,6 @@ model ProjectFile {
|
||||||
size Int // bytes
|
size Int // bytes
|
||||||
pageCount Int? // Number of pages (PDFs, presentations, etc.)
|
pageCount Int? // Number of pages (PDFs, presentations, etc.)
|
||||||
|
|
||||||
// Document analysis (optional, populated by document-analyzer service)
|
|
||||||
textPreview String? @db.Text // First ~2000 chars of extracted text
|
|
||||||
detectedLang String? // ISO 639-3 code (e.g. 'eng', 'fra', 'und')
|
|
||||||
langConfidence Float? // 0.0–1.0 confidence
|
|
||||||
analyzedAt DateTime? // When analysis last ran
|
|
||||||
|
|
||||||
// MinIO location
|
// MinIO location
|
||||||
bucket String
|
bucket String
|
||||||
objectKey String
|
objectKey String
|
||||||
|
|
|
||||||
|
|
@ -49,10 +49,7 @@ import {
|
||||||
Heart,
|
Heart,
|
||||||
Crown,
|
Crown,
|
||||||
UserPlus,
|
UserPlus,
|
||||||
Loader2,
|
|
||||||
ScanSearch,
|
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { toast } from 'sonner'
|
|
||||||
import { formatDate, formatDateOnly } from '@/lib/utils'
|
import { formatDate, formatDateOnly } from '@/lib/utils'
|
||||||
|
|
||||||
interface PageProps {
|
interface PageProps {
|
||||||
|
|
@ -532,8 +529,6 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
|
||||||
<AnimatedCard index={4}>
|
<AnimatedCard index={4}>
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<CardTitle className="flex items-center gap-2.5 text-lg">
|
<CardTitle className="flex items-center gap-2.5 text-lg">
|
||||||
<div className="rounded-lg bg-rose-500/10 p-1.5">
|
<div className="rounded-lg bg-rose-500/10 p-1.5">
|
||||||
<FileText className="h-4 w-4 text-rose-500" />
|
<FileText className="h-4 w-4 text-rose-500" />
|
||||||
|
|
@ -543,9 +538,6 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Project documents and materials organized by competition round
|
Project documents and materials organized by competition round
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
|
||||||
<AnalyzeDocumentsButton projectId={projectId} onComplete={() => utils.file.listByProject.invalidate({ projectId })} />
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-6">
|
<CardContent className="space-y-6">
|
||||||
{/* Requirements organized by round */}
|
{/* Requirements organized by round */}
|
||||||
|
|
@ -672,11 +664,6 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
|
||||||
size: f.size,
|
size: f.size,
|
||||||
bucket: f.bucket,
|
bucket: f.bucket,
|
||||||
objectKey: f.objectKey,
|
objectKey: f.objectKey,
|
||||||
pageCount: f.pageCount,
|
|
||||||
textPreview: f.textPreview,
|
|
||||||
detectedLang: f.detectedLang,
|
|
||||||
langConfidence: f.langConfidence,
|
|
||||||
analyzedAt: f.analyzedAt ? String(f.analyzedAt) : null,
|
|
||||||
}))}
|
}))}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -860,36 +847,6 @@ function ProjectDetailSkeleton() {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function AnalyzeDocumentsButton({ projectId, onComplete }: { projectId: string; onComplete: () => void }) {
|
|
||||||
const analyzeMutation = trpc.file.analyzeProjectFiles.useMutation({
|
|
||||||
onSuccess: (result) => {
|
|
||||||
toast.success(
|
|
||||||
`Analyzed ${result.analyzed} file${result.analyzed !== 1 ? 's' : ''}${result.failed > 0 ? ` (${result.failed} failed)` : ''}`
|
|
||||||
)
|
|
||||||
onComplete()
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
toast.error(error.message || 'Analysis failed')
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => analyzeMutation.mutate({ projectId })}
|
|
||||||
disabled={analyzeMutation.isPending}
|
|
||||||
>
|
|
||||||
{analyzeMutation.isPending ? (
|
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<ScanSearch className="mr-2 h-4 w-4" />
|
|
||||||
)}
|
|
||||||
{analyzeMutation.isPending ? 'Analyzing...' : 'Analyze Documents'}
|
|
||||||
</Button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ProjectDetailPage({ params }: PageProps) {
|
export default function ProjectDetailPage({ params }: PageProps) {
|
||||||
const { id } = use(params)
|
const { id } = use(params)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1599,7 +1599,7 @@ function FilteringRulesSection({ roundId }: { roundId: string }) {
|
||||||
className="text-sm"
|
className="text-sm"
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground mt-1">
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
The AI has access to: category, country, region, founded year, ocean issue, tags, description, file details (type, page count, size, detected language), and team size.
|
The AI has access to: category, country, region, founded year, ocean issue, tags, description, file details (type, page count, size), and team size.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -65,12 +65,6 @@ interface ProjectFile {
|
||||||
isLate?: boolean
|
isLate?: boolean
|
||||||
requirementId?: string | null
|
requirementId?: string | null
|
||||||
requirement?: FileRequirementInfo | null
|
requirement?: FileRequirementInfo | null
|
||||||
// Document analysis fields
|
|
||||||
pageCount?: number | null
|
|
||||||
textPreview?: string | null
|
|
||||||
detectedLang?: string | null
|
|
||||||
langConfidence?: number | null
|
|
||||||
analyzedAt?: Date | string | null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RoundGroup {
|
interface RoundGroup {
|
||||||
|
|
@ -276,25 +270,6 @@ function FileItem({ file }: { file: ProjectFile }) {
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
<span>{formatFileSize(file.size)}</span>
|
<span>{formatFileSize(file.size)}</span>
|
||||||
{file.pageCount != null && (
|
|
||||||
<Badge variant="outline" className="text-xs gap-1">
|
|
||||||
<FileText className="h-3 w-3" />
|
|
||||||
{file.pageCount} {file.pageCount === 1 ? 'page' : 'pages'}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
{file.detectedLang && file.detectedLang !== 'und' && (
|
|
||||||
<Badge
|
|
||||||
variant="outline"
|
|
||||||
className={cn('text-xs font-mono uppercase', {
|
|
||||||
'border-green-300 text-green-700 bg-green-50': file.langConfidence != null && file.langConfidence >= 0.8,
|
|
||||||
'border-amber-300 text-amber-700 bg-amber-50': file.langConfidence != null && file.langConfidence >= 0.4 && file.langConfidence < 0.8,
|
|
||||||
'border-red-300 text-red-700 bg-red-50': file.langConfidence != null && file.langConfidence < 0.4,
|
|
||||||
})}
|
|
||||||
title={`Language: ${file.detectedLang} (${Math.round((file.langConfidence ?? 0) * 100)}% confidence)`}
|
|
||||||
>
|
|
||||||
{file.detectedLang.toUpperCase()}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@ import { getPresignedUrl, generateObjectKey } from '@/lib/minio'
|
||||||
import { sendStyledNotificationEmail, sendTeamMemberInviteEmail } from '@/lib/email'
|
import { sendStyledNotificationEmail, sendTeamMemberInviteEmail } from '@/lib/email'
|
||||||
import { logAudit } from '@/server/utils/audit'
|
import { logAudit } from '@/server/utils/audit'
|
||||||
import { createNotification } from '../services/in-app-notification'
|
import { createNotification } from '../services/in-app-notification'
|
||||||
import { checkRequirementsAndTransition } from '../services/round-engine'
|
|
||||||
|
|
||||||
// Bucket for applicant submissions
|
// Bucket for applicant submissions
|
||||||
export const SUBMISSIONS_BUCKET = 'mopc-submissions'
|
export const SUBMISSIONS_BUCKET = 'mopc-submissions'
|
||||||
|
|
@ -411,24 +410,6 @@ export const applicantRouter = router({
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
// Auto-transition: if uploading against a round requirement, check completion
|
|
||||||
if (roundId && requirementId) {
|
|
||||||
await checkRequirementsAndTransition(
|
|
||||||
projectId,
|
|
||||||
roundId,
|
|
||||||
ctx.user.id,
|
|
||||||
ctx.prisma,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Auto-analyze document (fire-and-forget, delayed for presigned upload)
|
|
||||||
import('../services/document-analyzer').then(({ analyzeFileDelayed, isAutoAnalysisEnabled }) =>
|
|
||||||
isAutoAnalysisEnabled().then((enabled) => {
|
|
||||||
if (enabled) analyzeFileDelayed(file.id).catch((err) =>
|
|
||||||
console.warn('[DocAnalyzer] Post-upload analysis failed:', err))
|
|
||||||
})
|
|
||||||
).catch(() => {})
|
|
||||||
|
|
||||||
return file
|
return file
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -74,22 +74,10 @@ async function runAIAssignmentJob(jobId: string, roundId: string, userId: string
|
||||||
description: true,
|
description: true,
|
||||||
tags: true,
|
tags: true,
|
||||||
teamName: true,
|
teamName: true,
|
||||||
projectTags: {
|
|
||||||
select: { tag: { select: { name: true } }, confidence: true },
|
|
||||||
},
|
|
||||||
_count: { select: { assignments: { where: { roundId } } } },
|
_count: { select: { assignments: { where: { roundId } } } },
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
// Enrich projects with tag confidence data for AI matching
|
|
||||||
const projectsWithConfidence = projects.map((p) => ({
|
|
||||||
...p,
|
|
||||||
tagConfidences: p.projectTags.map((pt) => ({
|
|
||||||
name: pt.tag.name,
|
|
||||||
confidence: pt.confidence,
|
|
||||||
})),
|
|
||||||
}))
|
|
||||||
|
|
||||||
const existingAssignments = await prisma.assignment.findMany({
|
const existingAssignments = await prisma.assignment.findMany({
|
||||||
where: { roundId },
|
where: { roundId },
|
||||||
select: { userId: true, projectId: true },
|
select: { userId: true, projectId: true },
|
||||||
|
|
@ -136,7 +124,7 @@ async function runAIAssignmentJob(jobId: string, roundId: string, userId: string
|
||||||
|
|
||||||
const result = await generateAIAssignments(
|
const result = await generateAIAssignments(
|
||||||
jurors,
|
jurors,
|
||||||
projectsWithConfidence,
|
projects,
|
||||||
constraints,
|
constraints,
|
||||||
userId,
|
userId,
|
||||||
roundId,
|
roundId,
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ import { TRPCError } from '@trpc/server'
|
||||||
import { router, protectedProcedure, adminProcedure } from '../trpc'
|
import { router, protectedProcedure, adminProcedure } from '../trpc'
|
||||||
import { getPresignedUrl, generateObjectKey, deleteObject, BUCKET_NAME } from '@/lib/minio'
|
import { getPresignedUrl, generateObjectKey, deleteObject, BUCKET_NAME } from '@/lib/minio'
|
||||||
import { logAudit } from '../utils/audit'
|
import { logAudit } from '../utils/audit'
|
||||||
import { checkRequirementsAndTransition } from '../services/round-engine'
|
|
||||||
|
|
||||||
export const fileRouter = router({
|
export const fileRouter = router({
|
||||||
/**
|
/**
|
||||||
|
|
@ -206,14 +205,6 @@ export const fileRouter = router({
|
||||||
userAgent: ctx.userAgent,
|
userAgent: ctx.userAgent,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Auto-analyze document (fire-and-forget, delayed for presigned upload)
|
|
||||||
import('../services/document-analyzer').then(({ analyzeFileDelayed, isAutoAnalysisEnabled }) =>
|
|
||||||
isAutoAnalysisEnabled().then((enabled) => {
|
|
||||||
if (enabled) analyzeFileDelayed(file.id).catch((err) =>
|
|
||||||
console.warn('[DocAnalyzer] Post-upload analysis failed:', err))
|
|
||||||
})
|
|
||||||
).catch(() => {})
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
uploadUrl,
|
uploadUrl,
|
||||||
file,
|
file,
|
||||||
|
|
@ -1209,14 +1200,6 @@ export const fileRouter = router({
|
||||||
userAgent: ctx.userAgent,
|
userAgent: ctx.userAgent,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Auto-analyze document (fire-and-forget, delayed for presigned upload)
|
|
||||||
import('../services/document-analyzer').then(({ analyzeFileDelayed, isAutoAnalysisEnabled }) =>
|
|
||||||
isAutoAnalysisEnabled().then((enabled) => {
|
|
||||||
if (enabled) analyzeFileDelayed(file.id).catch((err) =>
|
|
||||||
console.warn('[DocAnalyzer] Post-upload analysis failed:', err))
|
|
||||||
})
|
|
||||||
).catch(() => {})
|
|
||||||
|
|
||||||
return { uploadUrl, file }
|
return { uploadUrl, file }
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
|
@ -1518,22 +1501,6 @@ export const fileRouter = router({
|
||||||
userAgent: ctx.userAgent,
|
userAgent: ctx.userAgent,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Auto-transition: check if all required documents are now uploaded
|
|
||||||
await checkRequirementsAndTransition(
|
|
||||||
input.projectId,
|
|
||||||
input.roundId,
|
|
||||||
ctx.user.id,
|
|
||||||
ctx.prisma,
|
|
||||||
)
|
|
||||||
|
|
||||||
// Auto-analyze document (fire-and-forget, delayed for presigned upload)
|
|
||||||
import('../services/document-analyzer').then(({ analyzeFileDelayed, isAutoAnalysisEnabled }) =>
|
|
||||||
isAutoAnalysisEnabled().then((enabled) => {
|
|
||||||
if (enabled) analyzeFileDelayed(file.id).catch((err) =>
|
|
||||||
console.warn('[DocAnalyzer] Post-upload analysis failed:', err))
|
|
||||||
})
|
|
||||||
).catch(() => {})
|
|
||||||
|
|
||||||
return { uploadUrl, file }
|
return { uploadUrl, file }
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
|
@ -1569,25 +1536,4 @@ export const fileRouter = router({
|
||||||
)
|
)
|
||||||
return results
|
return results
|
||||||
}),
|
}),
|
||||||
|
|
||||||
/**
|
|
||||||
* Analyze all files for a specific project (page count, language, text preview).
|
|
||||||
* Retroactive: re-analyzes even previously analyzed files.
|
|
||||||
*/
|
|
||||||
analyzeProjectFiles: adminProcedure
|
|
||||||
.input(z.object({ projectId: z.string() }))
|
|
||||||
.mutation(async ({ input }) => {
|
|
||||||
const { analyzeProjectFiles } = await import('../services/document-analyzer')
|
|
||||||
return analyzeProjectFiles(input.projectId)
|
|
||||||
}),
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Batch analyze all unanalyzed files across the platform.
|
|
||||||
* For retroactive analysis of files uploaded before this feature.
|
|
||||||
*/
|
|
||||||
analyzeAllFiles: adminProcedure
|
|
||||||
.mutation(async () => {
|
|
||||||
const { analyzeAllUnanalyzed } = await import('../services/document-analyzer')
|
|
||||||
return analyzeAllUnanalyzed()
|
|
||||||
}),
|
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -69,8 +69,6 @@ export async function runFilteringJob(jobId: string, roundId: string, userId: st
|
||||||
mimeType: true,
|
mimeType: true,
|
||||||
size: true,
|
size: true,
|
||||||
pageCount: true,
|
pageCount: true,
|
||||||
detectedLang: true,
|
|
||||||
langConfidence: true,
|
|
||||||
objectKey: true,
|
objectKey: true,
|
||||||
roundId: true,
|
roundId: true,
|
||||||
createdAt: true,
|
createdAt: true,
|
||||||
|
|
|
||||||
|
|
@ -263,41 +263,4 @@ export const roundEngineRouter = router({
|
||||||
|
|
||||||
return { success: true, removedCount: deleted.count }
|
return { success: true, removedCount: deleted.count }
|
||||||
}),
|
}),
|
||||||
|
|
||||||
/**
|
|
||||||
* Retroactive document check: auto-PASS any PENDING/IN_PROGRESS projects
|
|
||||||
* that already have all required documents uploaded for this round.
|
|
||||||
* Useful for rounds activated before the auto-transition feature was deployed.
|
|
||||||
*/
|
|
||||||
checkDocumentCompletion: adminProcedure
|
|
||||||
.input(z.object({ roundId: z.string() }))
|
|
||||||
.mutation(async ({ ctx, input }) => {
|
|
||||||
const { batchCheckRequirementsAndTransition } = await import('../services/round-engine')
|
|
||||||
|
|
||||||
const projectStates = await ctx.prisma.projectRoundState.findMany({
|
|
||||||
where: {
|
|
||||||
roundId: input.roundId,
|
|
||||||
state: { in: ['PENDING', 'IN_PROGRESS'] },
|
|
||||||
},
|
|
||||||
select: { projectId: true },
|
|
||||||
})
|
|
||||||
|
|
||||||
if (projectStates.length === 0) {
|
|
||||||
return { transitionedCount: 0, checkedCount: 0, projectIds: [] }
|
|
||||||
}
|
|
||||||
|
|
||||||
const projectIds = projectStates.map((ps: { projectId: string }) => ps.projectId)
|
|
||||||
const result = await batchCheckRequirementsAndTransition(
|
|
||||||
input.roundId,
|
|
||||||
projectIds,
|
|
||||||
ctx.user.id,
|
|
||||||
ctx.prisma,
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
transitionedCount: result.transitionedCount,
|
|
||||||
checkedCount: projectIds.length,
|
|
||||||
projectIds: result.projectIds,
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,7 @@ const ASSIGNMENT_SYSTEM_PROMPT = `You are an expert jury assignment optimizer fo
|
||||||
Match jurors to projects based on expertise alignment, workload balance, and coverage requirements.
|
Match jurors to projects based on expertise alignment, workload balance, and coverage requirements.
|
||||||
|
|
||||||
## Matching Criteria (Weighted)
|
## Matching Criteria (Weighted)
|
||||||
- Expertise Match (50%): How well juror tags/expertise align with project topics. Project tags include a confidence score (0-1) — weight higher-confidence tags more heavily as they are more reliably assigned. A tag with confidence 0.9 is a strong signal; one with 0.5 is uncertain.
|
- Expertise Match (50%): How well juror tags/expertise align with project topics
|
||||||
- Workload Balance (30%): Distribute assignments evenly; prefer jurors below capacity
|
- Workload Balance (30%): Distribute assignments evenly; prefer jurors below capacity
|
||||||
- Minimum Target (20%): Prioritize jurors who haven't reached their minimum assignment count
|
- Minimum Target (20%): Prioritize jurors who haven't reached their minimum assignment count
|
||||||
|
|
||||||
|
|
@ -99,7 +99,6 @@ interface ProjectForAssignment {
|
||||||
title: string
|
title: string
|
||||||
description?: string | null
|
description?: string | null
|
||||||
tags: string[]
|
tags: string[]
|
||||||
tagConfidences?: Array<{ name: string; confidence: number }>
|
|
||||||
teamName?: string | null
|
teamName?: string | null
|
||||||
_count?: {
|
_count?: {
|
||||||
assignments: number
|
assignments: number
|
||||||
|
|
@ -540,7 +539,7 @@ export function generateFallbackAssignments(
|
||||||
|
|
||||||
return {
|
return {
|
||||||
juror,
|
juror,
|
||||||
score: calculateExpertiseScore(juror.expertiseTags, project.tags, project.tagConfidences),
|
score: calculateExpertiseScore(juror.expertiseTags, project.tags),
|
||||||
loadScore: calculateLoadScore(currentLoad, maxLoad),
|
loadScore: calculateLoadScore(currentLoad, maxLoad),
|
||||||
underMinBonus: calculateUnderMinBonus(currentLoad, minTarget),
|
underMinBonus: calculateUnderMinBonus(currentLoad, minTarget),
|
||||||
}
|
}
|
||||||
|
|
@ -587,44 +586,24 @@ export function generateFallbackAssignments(
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calculate expertise match score based on tag overlap
|
* Calculate expertise match score based on tag overlap
|
||||||
* When tagConfidences are available, weights matches by confidence
|
|
||||||
*/
|
*/
|
||||||
function calculateExpertiseScore(
|
function calculateExpertiseScore(
|
||||||
jurorTags: string[],
|
jurorTags: string[],
|
||||||
projectTags: string[],
|
projectTags: string[]
|
||||||
tagConfidences?: Array<{ name: string; confidence: number }>
|
|
||||||
): number {
|
): number {
|
||||||
if (jurorTags.length === 0 || projectTags.length === 0) {
|
if (jurorTags.length === 0 || projectTags.length === 0) {
|
||||||
return 0.5 // Neutral score if no tags
|
return 0.5 // Neutral score if no tags
|
||||||
}
|
}
|
||||||
|
|
||||||
const jurorTagsLower = new Set(jurorTags.map((t) => t.toLowerCase()))
|
const jurorTagsLower = new Set(jurorTags.map((t) => t.toLowerCase()))
|
||||||
|
|
||||||
// If we have confidence data, use weighted scoring
|
|
||||||
if (tagConfidences && tagConfidences.length > 0) {
|
|
||||||
let weightedMatches = 0
|
|
||||||
let totalWeight = 0
|
|
||||||
|
|
||||||
for (const tc of tagConfidences) {
|
|
||||||
totalWeight += tc.confidence
|
|
||||||
if (jurorTagsLower.has(tc.name.toLowerCase())) {
|
|
||||||
weightedMatches += tc.confidence
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (totalWeight === 0) return 0.5
|
|
||||||
|
|
||||||
const weightedRatio = weightedMatches / totalWeight
|
|
||||||
const hasExpertise = weightedMatches > 0 ? 0.2 : 0
|
|
||||||
return Math.min(1, weightedRatio * 0.8 + hasExpertise)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback: unweighted matching using flat tags
|
|
||||||
const matchingTags = projectTags.filter((t) =>
|
const matchingTags = projectTags.filter((t) =>
|
||||||
jurorTagsLower.has(t.toLowerCase())
|
jurorTagsLower.has(t.toLowerCase())
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Score based on percentage of project tags matched
|
||||||
const matchRatio = matchingTags.length / projectTags.length
|
const matchRatio = matchingTags.length / projectTags.length
|
||||||
|
|
||||||
|
// Boost for having expertise, even if not all match
|
||||||
const hasExpertise = matchingTags.length > 0 ? 0.2 : 0
|
const hasExpertise = matchingTags.length > 0 ? 0.2 : 0
|
||||||
|
|
||||||
return Math.min(1, matchRatio * 0.8 + hasExpertise)
|
return Math.min(1, matchRatio * 0.8 + hasExpertise)
|
||||||
|
|
|
||||||
|
|
@ -179,11 +179,10 @@ Return a JSON object with this exact structure:
|
||||||
- founded_year: when the company/initiative was founded (use for age checks)
|
- founded_year: when the company/initiative was founded (use for age checks)
|
||||||
- ocean_issue: the ocean conservation area
|
- ocean_issue: the ocean conservation area
|
||||||
- file_count, file_types: uploaded documents summary
|
- file_count, file_types: uploaded documents summary
|
||||||
- files[]: per-file details with file_type, page_count (if known), size_kb, detected_lang (ISO 639-3 language code like 'eng', 'fra'), lang_confidence (0-1), round_name (which round the file was submitted for), and is_current_round flag
|
- files[]: per-file details with file_type, page_count (if known), size_kb, round_name (which round the file was submitted for), and is_current_round flag
|
||||||
- description: project summary text
|
- description: project summary text
|
||||||
- tags: topic tags
|
- tags: topic tags
|
||||||
- If document content is provided (text_content field in files), use it for deeper analysis. Pay SPECIAL ATTENTION to files from the current round (is_current_round=true) as they are the most recent and relevant submissions.
|
- If document content is provided (text_content field in files), use it for deeper analysis. Pay SPECIAL ATTENTION to files from the current round (is_current_round=true) as they are the most recent and relevant submissions.
|
||||||
- If detected_lang is provided, use it to evaluate language requirements (e.g. 'eng' = English, 'fra' = French). lang_confidence indicates detection reliability.
|
|
||||||
|
|
||||||
## Guidelines
|
## Guidelines
|
||||||
- Evaluate ONLY against the provided criteria, not your own standards
|
- Evaluate ONLY against the provided criteria, not your own standards
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,7 @@ export interface AnonymizedProject {
|
||||||
anonymousId: string
|
anonymousId: string
|
||||||
title: string
|
title: string
|
||||||
description: string | null
|
description: string | null
|
||||||
tags: Array<{ name: string; confidence: number }>
|
tags: string[]
|
||||||
teamName: string | null
|
teamName: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -83,8 +83,6 @@ export interface AnonymizedFileInfo {
|
||||||
file_type: string // FileType enum value
|
file_type: string // FileType enum value
|
||||||
page_count: number | null // Number of pages if known
|
page_count: number | null // Number of pages if known
|
||||||
size_kb: number // File size in KB
|
size_kb: number // File size in KB
|
||||||
detected_lang?: string | null // ISO 639-3 language code (e.g. 'eng', 'fra')
|
|
||||||
lang_confidence?: number | null // 0.0–1.0 confidence score
|
|
||||||
round_name?: string | null // Which round the file was submitted for
|
round_name?: string | null // Which round the file was submitted for
|
||||||
is_current_round?: boolean // Whether this file belongs to the current filtering/evaluation round
|
is_current_round?: boolean // Whether this file belongs to the current filtering/evaluation round
|
||||||
text_content?: string // Extracted text content (when aiParseFiles is enabled)
|
text_content?: string // Extracted text content (when aiParseFiles is enabled)
|
||||||
|
|
@ -211,7 +209,6 @@ interface ProjectInput {
|
||||||
title: string
|
title: string
|
||||||
description?: string | null
|
description?: string | null
|
||||||
tags: string[]
|
tags: string[]
|
||||||
tagConfidences?: Array<{ name: string; confidence: number }>
|
|
||||||
teamName?: string | null
|
teamName?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -256,9 +253,7 @@ export function anonymizeForAI(
|
||||||
description: project.description
|
description: project.description
|
||||||
? truncateAndSanitize(project.description, DESCRIPTION_LIMITS.ASSIGNMENT)
|
? truncateAndSanitize(project.description, DESCRIPTION_LIMITS.ASSIGNMENT)
|
||||||
: null,
|
: null,
|
||||||
tags: project.tagConfidences && project.tagConfidences.length > 0
|
tags: project.tags,
|
||||||
? project.tagConfidences
|
|
||||||
: project.tags.map((t) => ({ name: t, confidence: 1.0 })),
|
|
||||||
teamName: project.teamName ? `Team ${index + 1}` : null,
|
teamName: project.teamName ? `Team ${index + 1}` : null,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -311,8 +306,6 @@ export function anonymizeProjectForAI(
|
||||||
file_type: f.fileType ?? 'OTHER',
|
file_type: f.fileType ?? 'OTHER',
|
||||||
page_count: f.pageCount ?? null,
|
page_count: f.pageCount ?? null,
|
||||||
size_kb: Math.round((f.size ?? 0) / 1024),
|
size_kb: Math.round((f.size ?? 0) / 1024),
|
||||||
...(f.detectedLang ? { detected_lang: f.detectedLang } : {}),
|
|
||||||
...(f.langConfidence != null ? { lang_confidence: f.langConfidence } : {}),
|
|
||||||
...(f.roundName ? { round_name: f.roundName } : {}),
|
...(f.roundName ? { round_name: f.roundName } : {}),
|
||||||
...(f.isCurrentRound !== undefined ? { is_current_round: f.isCurrentRound } : {}),
|
...(f.isCurrentRound !== undefined ? { is_current_round: f.isCurrentRound } : {}),
|
||||||
...(f.textContent ? { text_content: f.textContent } : {}),
|
...(f.textContent ? { text_content: f.textContent } : {}),
|
||||||
|
|
@ -531,7 +524,7 @@ export function validateAnonymization(data: AnonymizationResult): boolean {
|
||||||
if (!checkText(project.title)) return false
|
if (!checkText(project.title)) return false
|
||||||
if (!checkText(project.description)) return false
|
if (!checkText(project.description)) return false
|
||||||
for (const tag of project.tags) {
|
for (const tag of project.tags) {
|
||||||
if (!checkText(typeof tag === 'string' ? tag : tag.name)) return false
|
if (!checkText(tag)) return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,367 +0,0 @@
|
||||||
/**
|
|
||||||
* Document Analyzer Service
|
|
||||||
*
|
|
||||||
* Extracts metadata from uploaded files:
|
|
||||||
* - Page count (PDFs)
|
|
||||||
* - Text preview (first ~2000 chars)
|
|
||||||
* - Language detection via franc
|
|
||||||
*
|
|
||||||
* Runs optionally on upload (controlled by SystemSettings) and
|
|
||||||
* retroactively via admin endpoint.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { getStorageProvider } from '@/lib/storage'
|
|
||||||
import { isParseableMimeType } from './file-content-extractor'
|
|
||||||
import { prisma } from '@/lib/prisma'
|
|
||||||
|
|
||||||
const TEXT_PREVIEW_LIMIT = 2000
|
|
||||||
const BATCH_SIZE = 10
|
|
||||||
|
|
||||||
// ─── Types ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export type AnalysisResult = {
|
|
||||||
fileId: string
|
|
||||||
pageCount: number | null
|
|
||||||
textPreview: string | null
|
|
||||||
detectedLang: string | null
|
|
||||||
langConfidence: number | null
|
|
||||||
error?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Language Detection ──────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Detect language using franc. Returns ISO 639-3 code and confidence.
|
|
||||||
* franc returns a distance-based score where lower = better match.
|
|
||||||
* We convert to 0-1 confidence where 1 = perfect match.
|
|
||||||
*/
|
|
||||||
async function detectLanguage(
|
|
||||||
text: string
|
|
||||||
): Promise<{ lang: string; confidence: number }> {
|
|
||||||
if (!text || text.trim().length < 20) {
|
|
||||||
return { lang: 'und', confidence: 0 }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use a reasonable sample for detection (first 5000 chars)
|
|
||||||
const sample = text.slice(0, 5000)
|
|
||||||
|
|
||||||
const { francAll } = await import('franc')
|
|
||||||
const results = francAll(sample, { minLength: 10 })
|
|
||||||
|
|
||||||
if (!results || results.length === 0 || results[0][0] === 'und') {
|
|
||||||
return { lang: 'und', confidence: 0 }
|
|
||||||
}
|
|
||||||
|
|
||||||
const topLang = results[0][0]
|
|
||||||
const topScore = results[0][1] // 1.0 = best match, 0.0 = worst
|
|
||||||
|
|
||||||
// franc scores: 1.0 is best match, scale drops from there
|
|
||||||
// Convert to a 0-1 confidence
|
|
||||||
const confidence = Math.max(0, Math.min(1, topScore))
|
|
||||||
|
|
||||||
return { lang: topLang, confidence: Math.round(confidence * 100) / 100 }
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Core Analysis ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Analyze a single file: extract page count, text preview, and detect language.
|
|
||||||
* Downloads the file from storage, parses it, and returns results.
|
|
||||||
*/
|
|
||||||
export async function analyzeFileContent(
|
|
||||||
objectKey: string,
|
|
||||||
bucket: string,
|
|
||||||
mimeType: string,
|
|
||||||
fileName: string,
|
|
||||||
fileId: string
|
|
||||||
): Promise<AnalysisResult> {
|
|
||||||
const result: AnalysisResult = {
|
|
||||||
fileId,
|
|
||||||
pageCount: null,
|
|
||||||
textPreview: null,
|
|
||||||
detectedLang: null,
|
|
||||||
langConfidence: null,
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isParseableMimeType(mimeType)) {
|
|
||||||
return { ...result, error: 'Unsupported mime type for analysis' }
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const storage = await getStorageProvider()
|
|
||||||
const buffer = await storage.getObject(objectKey)
|
|
||||||
|
|
||||||
let text = ''
|
|
||||||
let pageCount: number | null = null
|
|
||||||
|
|
||||||
if (mimeType === 'application/pdf') {
|
|
||||||
const pdfParseModule = await import('pdf-parse')
|
|
||||||
const pdfParse =
|
|
||||||
typeof pdfParseModule === 'function'
|
|
||||||
? pdfParseModule
|
|
||||||
: (pdfParseModule as any).default ?? pdfParseModule
|
|
||||||
const pdf = await pdfParse(buffer)
|
|
||||||
text = pdf.text || ''
|
|
||||||
pageCount = pdf.numpages ?? null
|
|
||||||
} else {
|
|
||||||
// Text-based files (plain text, CSV, markdown, HTML, RTF)
|
|
||||||
text = buffer.toString('utf-8')
|
|
||||||
}
|
|
||||||
|
|
||||||
result.pageCount = pageCount
|
|
||||||
|
|
||||||
// Text preview
|
|
||||||
if (text.trim()) {
|
|
||||||
result.textPreview =
|
|
||||||
text.length > TEXT_PREVIEW_LIMIT
|
|
||||||
? text.slice(0, TEXT_PREVIEW_LIMIT)
|
|
||||||
: text
|
|
||||||
}
|
|
||||||
|
|
||||||
// Language detection
|
|
||||||
if (text.trim().length >= 20) {
|
|
||||||
const langResult = await detectLanguage(text)
|
|
||||||
result.detectedLang = langResult.lang
|
|
||||||
result.langConfidence = langResult.confidence
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
} catch (error) {
|
|
||||||
console.warn(
|
|
||||||
`[DocAnalyzer] Failed to analyze ${fileName}:`,
|
|
||||||
error instanceof Error ? error.message : error
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
...result,
|
|
||||||
error: error instanceof Error ? error.message : 'Analysis failed',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── DB-Integrated Operations ───────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Analyze a single file by ID and persist results to DB.
|
|
||||||
*/
|
|
||||||
export async function analyzeFile(fileId: string): Promise<AnalysisResult> {
|
|
||||||
const file = await prisma.projectFile.findUnique({
|
|
||||||
where: { id: fileId },
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
objectKey: true,
|
|
||||||
bucket: true,
|
|
||||||
mimeType: true,
|
|
||||||
fileName: true,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!file) {
|
|
||||||
return {
|
|
||||||
fileId,
|
|
||||||
pageCount: null,
|
|
||||||
textPreview: null,
|
|
||||||
detectedLang: null,
|
|
||||||
langConfidence: null,
|
|
||||||
error: 'File not found',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await analyzeFileContent(
|
|
||||||
file.objectKey,
|
|
||||||
file.bucket,
|
|
||||||
file.mimeType,
|
|
||||||
file.fileName,
|
|
||||||
file.id
|
|
||||||
)
|
|
||||||
|
|
||||||
// Persist results
|
|
||||||
await prisma.projectFile.update({
|
|
||||||
where: { id: fileId },
|
|
||||||
data: {
|
|
||||||
pageCount: result.pageCount,
|
|
||||||
textPreview: result.textPreview,
|
|
||||||
detectedLang: result.detectedLang,
|
|
||||||
langConfidence: result.langConfidence,
|
|
||||||
analyzedAt: new Date(),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Analyze a single file by ID with a delay (for post-upload use).
|
|
||||||
* The delay accounts for presigned URL uploads where the file
|
|
||||||
* may not be in storage yet when the DB record is created.
|
|
||||||
*/
|
|
||||||
export async function analyzeFileDelayed(
|
|
||||||
fileId: string,
|
|
||||||
delayMs = 3000
|
|
||||||
): Promise<AnalysisResult> {
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, delayMs))
|
|
||||||
return analyzeFile(fileId)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Analyze all files for a specific project.
|
|
||||||
*/
|
|
||||||
export async function analyzeProjectFiles(
|
|
||||||
projectId: string
|
|
||||||
): Promise<{ analyzed: number; failed: number; total: number }> {
|
|
||||||
const files = await prisma.projectFile.findMany({
|
|
||||||
where: { projectId },
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
objectKey: true,
|
|
||||||
bucket: true,
|
|
||||||
mimeType: true,
|
|
||||||
fileName: true,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
let analyzed = 0
|
|
||||||
let failed = 0
|
|
||||||
|
|
||||||
// Process in batches
|
|
||||||
for (let i = 0; i < files.length; i += BATCH_SIZE) {
|
|
||||||
const batch = files.slice(i, i + BATCH_SIZE)
|
|
||||||
const results = await Promise.allSettled(
|
|
||||||
batch.map(async (file) => {
|
|
||||||
if (!isParseableMimeType(file.mimeType)) {
|
|
||||||
// Mark non-parseable files as analyzed with no data
|
|
||||||
await prisma.projectFile.update({
|
|
||||||
where: { id: file.id },
|
|
||||||
data: { analyzedAt: new Date() },
|
|
||||||
})
|
|
||||||
return 'skipped'
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await analyzeFileContent(
|
|
||||||
file.objectKey,
|
|
||||||
file.bucket,
|
|
||||||
file.mimeType,
|
|
||||||
file.fileName,
|
|
||||||
file.id
|
|
||||||
)
|
|
||||||
|
|
||||||
await prisma.projectFile.update({
|
|
||||||
where: { id: file.id },
|
|
||||||
data: {
|
|
||||||
pageCount: result.pageCount,
|
|
||||||
textPreview: result.textPreview,
|
|
||||||
detectedLang: result.detectedLang,
|
|
||||||
langConfidence: result.langConfidence,
|
|
||||||
analyzedAt: new Date(),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
return result.error ? 'failed' : 'analyzed'
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
for (const r of results) {
|
|
||||||
if (r.status === 'fulfilled') {
|
|
||||||
if (r.value === 'analyzed') analyzed++
|
|
||||||
else if (r.value === 'failed') failed++
|
|
||||||
} else {
|
|
||||||
failed++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { analyzed, failed, total: files.length }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Retroactive batch analysis: analyze all files that haven't been analyzed yet.
|
|
||||||
* Returns counts. Processes in batches to avoid memory issues.
|
|
||||||
*/
|
|
||||||
export async function analyzeAllUnanalyzed(): Promise<{
|
|
||||||
analyzed: number
|
|
||||||
failed: number
|
|
||||||
skipped: number
|
|
||||||
total: number
|
|
||||||
}> {
|
|
||||||
const files = await prisma.projectFile.findMany({
|
|
||||||
where: { analyzedAt: null },
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
objectKey: true,
|
|
||||||
bucket: true,
|
|
||||||
mimeType: true,
|
|
||||||
fileName: true,
|
|
||||||
},
|
|
||||||
orderBy: { createdAt: 'desc' },
|
|
||||||
})
|
|
||||||
|
|
||||||
let analyzed = 0
|
|
||||||
let failed = 0
|
|
||||||
let skipped = 0
|
|
||||||
|
|
||||||
for (let i = 0; i < files.length; i += BATCH_SIZE) {
|
|
||||||
const batch = files.slice(i, i + BATCH_SIZE)
|
|
||||||
const results = await Promise.allSettled(
|
|
||||||
batch.map(async (file) => {
|
|
||||||
if (!isParseableMimeType(file.mimeType)) {
|
|
||||||
await prisma.projectFile.update({
|
|
||||||
where: { id: file.id },
|
|
||||||
data: { analyzedAt: new Date() },
|
|
||||||
})
|
|
||||||
return 'skipped'
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await analyzeFileContent(
|
|
||||||
file.objectKey,
|
|
||||||
file.bucket,
|
|
||||||
file.mimeType,
|
|
||||||
file.fileName,
|
|
||||||
file.id
|
|
||||||
)
|
|
||||||
|
|
||||||
await prisma.projectFile.update({
|
|
||||||
where: { id: file.id },
|
|
||||||
data: {
|
|
||||||
pageCount: result.pageCount,
|
|
||||||
textPreview: result.textPreview,
|
|
||||||
detectedLang: result.detectedLang,
|
|
||||||
langConfidence: result.langConfidence,
|
|
||||||
analyzedAt: new Date(),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
return result.error ? 'failed' : 'analyzed'
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
for (const r of results) {
|
|
||||||
if (r.status === 'fulfilled') {
|
|
||||||
if (r.value === 'analyzed') analyzed++
|
|
||||||
else if (r.value === 'failed') failed++
|
|
||||||
else if (r.value === 'skipped') skipped++
|
|
||||||
} else {
|
|
||||||
failed++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(
|
|
||||||
`[DocAnalyzer] Batch progress: ${i + batch.length}/${files.length} (${analyzed} analyzed, ${skipped} skipped, ${failed} failed)`
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return { analyzed, failed, skipped, total: files.length }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if auto-analysis is enabled via SystemSettings.
|
|
||||||
*/
|
|
||||||
export async function isAutoAnalysisEnabled(): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
const setting = await prisma.systemSettings.findUnique({
|
|
||||||
where: { key: 'file_analysis_auto_enabled' },
|
|
||||||
})
|
|
||||||
// Default to true if setting doesn't exist
|
|
||||||
return setting?.value !== 'false'
|
|
||||||
} catch {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -143,24 +143,6 @@ export async function activateRound(
|
||||||
detailsJson: { name: round.name, roundType: round.roundType },
|
detailsJson: { name: round.name, roundType: round.roundType },
|
||||||
})
|
})
|
||||||
|
|
||||||
// Retroactive check: auto-PASS any projects that already have all required docs uploaded
|
|
||||||
// Non-fatal — runs after activation so it never blocks the transition
|
|
||||||
try {
|
|
||||||
const projectStates = await prisma.projectRoundState.findMany({
|
|
||||||
where: { roundId, state: { in: ['PENDING', 'IN_PROGRESS'] } },
|
|
||||||
select: { projectId: true },
|
|
||||||
})
|
|
||||||
if (projectStates.length > 0) {
|
|
||||||
const projectIds = projectStates.map((ps: { projectId: string }) => ps.projectId)
|
|
||||||
const result = await batchCheckRequirementsAndTransition(roundId, projectIds, actorId, prisma)
|
|
||||||
if (result.transitionedCount > 0) {
|
|
||||||
console.log(`[RoundEngine] On activation: auto-passed ${result.transitionedCount} projects with complete documents`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (retroError) {
|
|
||||||
console.error('[RoundEngine] Retroactive document check failed (non-fatal):', retroError)
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
round: { id: updated.id, status: updated.status },
|
round: { id: updated.id, status: updated.status },
|
||||||
|
|
@ -447,23 +429,6 @@ export async function reopenRound(
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
// Retroactive check: auto-PASS any projects that already have all required docs
|
|
||||||
try {
|
|
||||||
const projectStates = await prisma.projectRoundState.findMany({
|
|
||||||
where: { roundId, state: { in: ['PENDING', 'IN_PROGRESS'] } },
|
|
||||||
select: { projectId: true },
|
|
||||||
})
|
|
||||||
if (projectStates.length > 0) {
|
|
||||||
const projectIds = projectStates.map((ps: { projectId: string }) => ps.projectId)
|
|
||||||
const batchResult = await batchCheckRequirementsAndTransition(roundId, projectIds, actorId, prisma)
|
|
||||||
if (batchResult.transitionedCount > 0) {
|
|
||||||
console.log(`[RoundEngine] On reopen: auto-passed ${batchResult.transitionedCount} projects with complete documents`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (retroError) {
|
|
||||||
console.error('[RoundEngine] Retroactive document check on reopen failed (non-fatal):', retroError)
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
round: { id: result.updated.id, status: result.updated.status },
|
round: { id: result.updated.id, status: result.updated.status },
|
||||||
|
|
@ -660,109 +625,6 @@ export async function getProjectRoundState(
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Auto-Transition on Document Completion ─────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a project has fulfilled all required FileRequirements for a round.
|
|
||||||
* If yes, and the project is currently PENDING, transition it to PASSED.
|
|
||||||
*
|
|
||||||
* Called after file uploads (admin bulk upload or applicant upload).
|
|
||||||
* Non-fatal: errors are logged but never propagated to callers.
|
|
||||||
*/
|
|
||||||
export async function checkRequirementsAndTransition(
|
|
||||||
projectId: string,
|
|
||||||
roundId: string,
|
|
||||||
actorId: string,
|
|
||||||
prisma: PrismaClient | any,
|
|
||||||
): Promise<{ transitioned: boolean; newState?: string }> {
|
|
||||||
try {
|
|
||||||
// Get all required FileRequirements for this round
|
|
||||||
const requirements = await prisma.fileRequirement.findMany({
|
|
||||||
where: { roundId, isRequired: true },
|
|
||||||
select: { id: true },
|
|
||||||
})
|
|
||||||
|
|
||||||
// If the round has no file requirements, nothing to check
|
|
||||||
if (requirements.length === 0) {
|
|
||||||
return { transitioned: false }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check which requirements this project has satisfied (has a file uploaded)
|
|
||||||
const fulfilledFiles = await prisma.projectFile.findMany({
|
|
||||||
where: {
|
|
||||||
projectId,
|
|
||||||
roundId,
|
|
||||||
requirementId: { in: requirements.map((r: { id: string }) => r.id) },
|
|
||||||
},
|
|
||||||
select: { requirementId: true },
|
|
||||||
})
|
|
||||||
|
|
||||||
const fulfilledIds = new Set(
|
|
||||||
fulfilledFiles
|
|
||||||
.map((f: { requirementId: string | null }) => f.requirementId)
|
|
||||||
.filter(Boolean)
|
|
||||||
)
|
|
||||||
|
|
||||||
// Check if all required requirements are met
|
|
||||||
const allMet = requirements.every((r: { id: string }) => fulfilledIds.has(r.id))
|
|
||||||
|
|
||||||
if (!allMet) {
|
|
||||||
return { transitioned: false }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check current state — only transition if PENDING or IN_PROGRESS
|
|
||||||
const currentState = await prisma.projectRoundState.findUnique({
|
|
||||||
where: { projectId_roundId: { projectId, roundId } },
|
|
||||||
select: { state: true },
|
|
||||||
})
|
|
||||||
|
|
||||||
const eligibleStates = ['PENDING', 'IN_PROGRESS']
|
|
||||||
if (!currentState || !eligibleStates.includes(currentState.state)) {
|
|
||||||
return { transitioned: false }
|
|
||||||
}
|
|
||||||
|
|
||||||
// All requirements met — transition to PASSED
|
|
||||||
const result = await transitionProject(projectId, roundId, 'PASSED' as ProjectRoundStateValue, actorId, prisma)
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
console.log(`[RoundEngine] Auto-transitioned project ${projectId} to PASSED in round ${roundId} (all ${requirements.length} requirements met)`)
|
|
||||||
return { transitioned: true, newState: 'PASSED' }
|
|
||||||
}
|
|
||||||
|
|
||||||
return { transitioned: false }
|
|
||||||
} catch (error) {
|
|
||||||
// Non-fatal — log and continue
|
|
||||||
console.error('[RoundEngine] checkRequirementsAndTransition failed:', error)
|
|
||||||
return { transitioned: false }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Batch version: check all projects in a round and transition any that
|
|
||||||
* have all required documents uploaded. Useful after bulk upload.
|
|
||||||
*/
|
|
||||||
export async function batchCheckRequirementsAndTransition(
|
|
||||||
roundId: string,
|
|
||||||
projectIds: string[],
|
|
||||||
actorId: string,
|
|
||||||
prisma: PrismaClient | any,
|
|
||||||
): Promise<{ transitionedCount: number; projectIds: string[] }> {
|
|
||||||
const transitioned: string[] = []
|
|
||||||
|
|
||||||
for (const projectId of projectIds) {
|
|
||||||
const result = await checkRequirementsAndTransition(projectId, roundId, actorId, prisma)
|
|
||||||
if (result.transitioned) {
|
|
||||||
transitioned.push(projectId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (transitioned.length > 0) {
|
|
||||||
console.log(`[RoundEngine] Batch auto-transition: ${transitioned.length}/${projectIds.length} projects moved to PASSED in round ${roundId}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
return { transitionedCount: transitioned.length, projectIds: transitioned }
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Internals ──────────────────────────────────────────────────────────────
|
// ─── Internals ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function isTerminalState(state: ProjectRoundStateValue): boolean {
|
function isTerminalState(state: ProjectRoundStateValue): boolean {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue