AI shortlist with approve/reject, assignment reasoning, fix review count badge
Some checks failed
Build and Push Docker Image / build (push) Has been cancelled
Some checks failed
Build and Push Docker Image / build (push) Has been cancelled
- Rewrite AIRecommendationsDisplay: show project titles, per-project checkboxes, Apply and Mark as Passed button with batch transition - Show AI jury assignment reasoning directly in rows (not tooltip) - Fix unassigned projects badge using requiredReviews instead of hardcoded 3 - Add aiParseFiles to EvaluationConfigSchema Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1293,7 +1293,13 @@ export default function RoundDetailPage() {
|
|||||||
{aiRecommendations && (
|
{aiRecommendations && (
|
||||||
<AIRecommendationsDisplay
|
<AIRecommendationsDisplay
|
||||||
recommendations={aiRecommendations}
|
recommendations={aiRecommendations}
|
||||||
|
projectStates={projectStates}
|
||||||
|
roundId={roundId}
|
||||||
onClear={() => setAiRecommendations(null)}
|
onClear={() => setAiRecommendations(null)}
|
||||||
|
onApplied={() => {
|
||||||
|
setAiRecommendations(null)
|
||||||
|
utils.roundEngine.getProjectStates.invalidate({ roundId })
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -2079,7 +2085,7 @@ function RoundUnassignedQueue({ roundId, requiredReviews = 3 }: { roundId: strin
|
|||||||
? 'bg-red-50 text-red-700 border-red-200'
|
? 'bg-red-50 text-red-700 border-red-200'
|
||||||
: 'bg-amber-50 text-amber-700 border-amber-200',
|
: 'bg-amber-50 text-amber-700 border-amber-200',
|
||||||
)}>
|
)}>
|
||||||
{project.assignmentCount || 0} / 3
|
{project.assignmentCount || 0} / {requiredReviews}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -3033,12 +3039,75 @@ type RecommendationItem = {
|
|||||||
|
|
||||||
function AIRecommendationsDisplay({
|
function AIRecommendationsDisplay({
|
||||||
recommendations,
|
recommendations,
|
||||||
|
projectStates,
|
||||||
|
roundId,
|
||||||
onClear,
|
onClear,
|
||||||
|
onApplied,
|
||||||
}: {
|
}: {
|
||||||
recommendations: { STARTUP: RecommendationItem[]; BUSINESS_CONCEPT: RecommendationItem[] }
|
recommendations: { STARTUP: RecommendationItem[]; BUSINESS_CONCEPT: RecommendationItem[] }
|
||||||
|
projectStates: any[] | undefined
|
||||||
|
roundId: string
|
||||||
onClear: () => void
|
onClear: () => void
|
||||||
|
onApplied: () => void
|
||||||
}) {
|
}) {
|
||||||
const [expandedId, setExpandedId] = useState<string | null>(null)
|
const [expandedId, setExpandedId] = useState<string | null>(null)
|
||||||
|
const [applying, setApplying] = useState(false)
|
||||||
|
|
||||||
|
// Initialize selected with all recommended project IDs
|
||||||
|
const allRecommendedIds = useMemo(() => {
|
||||||
|
const ids = new Set<string>()
|
||||||
|
for (const item of recommendations.STARTUP) ids.add(item.projectId)
|
||||||
|
for (const item of recommendations.BUSINESS_CONCEPT) ids.add(item.projectId)
|
||||||
|
return ids
|
||||||
|
}, [recommendations])
|
||||||
|
|
||||||
|
const [selectedIds, setSelectedIds] = useState<Set<string>>(() => new Set(allRecommendedIds))
|
||||||
|
|
||||||
|
// Build projectId → title map from projectStates
|
||||||
|
const projectTitleMap = useMemo(() => {
|
||||||
|
const map = new Map<string, string>()
|
||||||
|
if (projectStates) {
|
||||||
|
for (const ps of projectStates) {
|
||||||
|
if (ps.project?.id && ps.project?.title) {
|
||||||
|
map.set(ps.project.id, ps.project.title)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map
|
||||||
|
}, [projectStates])
|
||||||
|
|
||||||
|
const transitionMutation = trpc.roundEngine.transitionProject.useMutation()
|
||||||
|
|
||||||
|
const toggleProject = (projectId: string) => {
|
||||||
|
setSelectedIds((prev) => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
if (next.has(projectId)) next.delete(projectId)
|
||||||
|
else next.add(projectId)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedStartups = recommendations.STARTUP.filter((item) => selectedIds.has(item.projectId)).length
|
||||||
|
const selectedConcepts = recommendations.BUSINESS_CONCEPT.filter((item) => selectedIds.has(item.projectId)).length
|
||||||
|
|
||||||
|
const handleApply = async () => {
|
||||||
|
setApplying(true)
|
||||||
|
try {
|
||||||
|
// Transition all selected projects to PASSED
|
||||||
|
const promises = Array.from(selectedIds).map((projectId) =>
|
||||||
|
transitionMutation.mutateAsync({ projectId, roundId, newState: 'PASSED' }).catch(() => {
|
||||||
|
// Project might already be PASSED — that's OK
|
||||||
|
})
|
||||||
|
)
|
||||||
|
await Promise.all(promises)
|
||||||
|
toast.success(`Marked ${selectedIds.size} project(s) as passed`)
|
||||||
|
onApplied()
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Failed to apply recommendations')
|
||||||
|
} finally {
|
||||||
|
setApplying(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const renderCategory = (label: string, items: RecommendationItem[], colorClass: string) => {
|
const renderCategory = (label: string, items: RecommendationItem[], colorClass: string) => {
|
||||||
if (items.length === 0) return (
|
if (items.length === 0) return (
|
||||||
@@ -3051,33 +3120,46 @@ function AIRecommendationsDisplay({
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{items.map((item) => {
|
{items.map((item) => {
|
||||||
const isExpanded = expandedId === `${item.category}-${item.projectId}`
|
const isExpanded = expandedId === `${item.category}-${item.projectId}`
|
||||||
|
const isSelected = selectedIds.has(item.projectId)
|
||||||
|
const projectTitle = projectTitleMap.get(item.projectId) || item.projectId
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={item.projectId}
|
key={item.projectId}
|
||||||
className="border rounded-lg overflow-hidden"
|
className={cn(
|
||||||
|
'border rounded-lg overflow-hidden transition-colors',
|
||||||
|
!isSelected && 'opacity-50',
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<button
|
<div className="flex items-center gap-2 p-3">
|
||||||
onClick={() => setExpandedId(isExpanded ? null : `${item.category}-${item.projectId}`)}
|
<Checkbox
|
||||||
className="w-full flex items-center gap-3 p-3 text-left hover:bg-muted/30 transition-colors"
|
checked={isSelected}
|
||||||
>
|
onCheckedChange={() => toggleProject(item.projectId)}
|
||||||
<span className={cn(
|
className="shrink-0"
|
||||||
'h-7 w-7 rounded-full flex items-center justify-center text-xs font-bold text-white shrink-0 shadow-sm',
|
/>
|
||||||
colorClass === 'bg-blue-500' ? 'bg-gradient-to-br from-blue-400 to-blue-600' : 'bg-gradient-to-br from-purple-400 to-purple-600',
|
<button
|
||||||
)}>
|
onClick={() => setExpandedId(isExpanded ? null : `${item.category}-${item.projectId}`)}
|
||||||
{item.rank}
|
className="flex-1 flex items-center gap-3 text-left hover:bg-muted/30 rounded transition-colors min-w-0"
|
||||||
</span>
|
>
|
||||||
<div className="flex-1 min-w-0">
|
<span className={cn(
|
||||||
<p className="text-sm font-medium truncate">{item.projectId}</p>
|
'h-7 w-7 rounded-full flex items-center justify-center text-xs font-bold text-white shrink-0 shadow-sm',
|
||||||
<p className="text-xs text-muted-foreground truncate">{item.recommendation}</p>
|
colorClass === 'bg-blue-500' ? 'bg-gradient-to-br from-blue-400 to-blue-600' : 'bg-gradient-to-br from-purple-400 to-purple-600',
|
||||||
</div>
|
)}>
|
||||||
<Badge variant="outline" className="shrink-0 text-xs font-mono">
|
{item.rank}
|
||||||
{item.score}/100
|
</span>
|
||||||
</Badge>
|
<div className="flex-1 min-w-0">
|
||||||
<ChevronDown className={cn(
|
<p className="text-sm font-medium truncate">{projectTitle}</p>
|
||||||
'h-4 w-4 text-muted-foreground transition-transform shrink-0',
|
<p className="text-xs text-muted-foreground truncate">{item.recommendation}</p>
|
||||||
isExpanded && 'rotate-180',
|
</div>
|
||||||
)} />
|
<Badge variant="outline" className="shrink-0 text-xs font-mono">
|
||||||
</button>
|
{item.score}/100
|
||||||
|
</Badge>
|
||||||
|
<ChevronDown className={cn(
|
||||||
|
'h-4 w-4 text-muted-foreground transition-transform shrink-0',
|
||||||
|
isExpanded && 'rotate-180',
|
||||||
|
)} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
{isExpanded && (
|
{isExpanded && (
|
||||||
<div className="px-3 pb-3 pt-0 space-y-2 border-t bg-muted/10">
|
<div className="px-3 pb-3 pt-0 space-y-2 border-t bg-muted/10">
|
||||||
<div className="pt-2">
|
<div className="pt-2">
|
||||||
@@ -3114,7 +3196,7 @@ function AIRecommendationsDisplay({
|
|||||||
<div>
|
<div>
|
||||||
<CardTitle className="text-base">AI Shortlist Recommendations</CardTitle>
|
<CardTitle className="text-base">AI Shortlist Recommendations</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Ranked independently per category — {recommendations.STARTUP.length} startups, {recommendations.BUSINESS_CONCEPT.length} concepts
|
Ranked independently per category — {selectedStartups} of {recommendations.STARTUP.length} startups, {selectedConcepts} of {recommendations.BUSINESS_CONCEPT.length} concepts selected
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="ghost" size="sm" onClick={onClear}>
|
<Button variant="ghost" size="sm" onClick={onClear}>
|
||||||
@@ -3123,7 +3205,7 @@ function AIRecommendationsDisplay({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent className="space-y-6">
|
||||||
<div className="grid gap-6 lg:grid-cols-2">
|
<div className="grid gap-6 lg:grid-cols-2">
|
||||||
<div>
|
<div>
|
||||||
<h4 className="text-sm font-semibold mb-3 flex items-center gap-2">
|
<h4 className="text-sm font-semibold mb-3 flex items-center gap-2">
|
||||||
@@ -3140,6 +3222,24 @@ function AIRecommendationsDisplay({
|
|||||||
{renderCategory('Business Concept', recommendations.BUSINESS_CONCEPT, 'bg-purple-500')}
|
{renderCategory('Business Concept', recommendations.BUSINESS_CONCEPT, 'bg-purple-500')}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Apply button */}
|
||||||
|
<div className="flex items-center justify-between pt-4 border-t">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{selectedIds.size} project{selectedIds.size !== 1 ? 's' : ''} will be marked as <strong>Passed</strong>
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
onClick={handleApply}
|
||||||
|
disabled={selectedIds.size === 0 || applying}
|
||||||
|
className="bg-[#053d57] hover:bg-[#053d57]/90 text-white"
|
||||||
|
>
|
||||||
|
{applying ? (
|
||||||
|
<><Loader2 className="h-4 w-4 mr-1.5 animate-spin" />Applying...</>
|
||||||
|
) : (
|
||||||
|
<><CheckCircle2 className="h-4 w-4 mr-1.5" />Apply & Mark as Passed</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -877,10 +877,26 @@ function AssignmentRow({
|
|||||||
const a = assignment
|
const a = assignment
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-start gap-2 px-3 py-2 group hover:bg-muted/30 transition-colors border-t first:border-t-0">
|
<div className="flex items-start gap-2 px-3 py-2.5 group hover:bg-muted/30 transition-colors border-t first:border-t-0">
|
||||||
<div className="flex-1 min-w-0 space-y-1">
|
<div className="flex-1 min-w-0 space-y-1.5">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<span className="text-sm font-medium truncate">{a.projectTitle}</span>
|
<span className="text-sm font-medium truncate">{a.projectTitle}</span>
|
||||||
|
{!a.isManual && a.score > 0 && (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className={cn(
|
||||||
|
'text-[10px] h-4 px-1 tabular-nums shrink-0',
|
||||||
|
a.score >= 50
|
||||||
|
? 'border-green-300 text-green-700'
|
||||||
|
: a.score >= 25
|
||||||
|
? 'border-amber-300 text-amber-700'
|
||||||
|
: 'border-red-300 text-red-700',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Sparkles className="h-2.5 w-2.5 mr-0.5" />
|
||||||
|
{Math.round(a.score)}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
{a.isManual ? (
|
{a.isManual ? (
|
||||||
<Badge
|
<Badge
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -898,54 +914,33 @@ function AssignmentRow({
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Score + tags + reasoning */}
|
{/* AI reasoning — displayed directly */}
|
||||||
<div className="flex flex-wrap items-center gap-1">
|
{!a.isManual && a.reasoning.length > 0 && a.reasoning[0] !== 'Manually added by admin' && (
|
||||||
{!a.isManual && a.score > 0 && (
|
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||||
<TooltipProvider>
|
{a.reasoning.join(' ')}
|
||||||
<Tooltip>
|
</p>
|
||||||
<TooltipTrigger asChild>
|
)}
|
||||||
<Badge
|
|
||||||
variant="outline"
|
{/* Tags */}
|
||||||
className={cn(
|
{a.matchingTags.length > 0 && (
|
||||||
'text-[10px] h-4 px-1 tabular-nums',
|
<div className="flex flex-wrap items-center gap-1">
|
||||||
a.score >= 50
|
{a.matchingTags.slice(0, 4).map((tag) => (
|
||||||
? 'border-green-300 text-green-700'
|
<Badge
|
||||||
: a.score >= 25
|
key={tag}
|
||||||
? 'border-amber-300 text-amber-700'
|
variant="secondary"
|
||||||
: 'border-red-300 text-red-700',
|
className="text-[10px] h-4 px-1 gap-0.5"
|
||||||
)}
|
>
|
||||||
>
|
<Tag className="h-2.5 w-2.5" />
|
||||||
<Sparkles className="h-2.5 w-2.5 mr-0.5" />
|
{tag}
|
||||||
{Math.round(a.score)}
|
</Badge>
|
||||||
</Badge>
|
))}
|
||||||
</TooltipTrigger>
|
{a.matchingTags.length > 4 && (
|
||||||
<TooltipContent side="bottom" className="max-w-xs">
|
<span className="text-[10px] text-muted-foreground">
|
||||||
<p className="font-medium text-xs mb-1">Match Score Breakdown</p>
|
+{a.matchingTags.length - 4} more
|
||||||
<ul className="text-xs space-y-0.5">
|
</span>
|
||||||
{a.reasoning.map((r, i) => (
|
)}
|
||||||
<li key={i}>• {r}</li>
|
</div>
|
||||||
))}
|
)}
|
||||||
</ul>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</TooltipProvider>
|
|
||||||
)}
|
|
||||||
{a.matchingTags.slice(0, 3).map((tag) => (
|
|
||||||
<Badge
|
|
||||||
key={tag}
|
|
||||||
variant="secondary"
|
|
||||||
className="text-[10px] h-4 px-1 gap-0.5"
|
|
||||||
>
|
|
||||||
<Tag className="h-2.5 w-2.5" />
|
|
||||||
{tag}
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
{a.matchingTags.length > 3 && (
|
|
||||||
<span className="text-[10px] text-muted-foreground">
|
|
||||||
+{a.matchingTags.length - 3} more
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Policy violations */}
|
{/* Policy violations */}
|
||||||
{a.policyViolations.length > 0 && (
|
{a.policyViolations.length > 0 && (
|
||||||
|
|||||||
@@ -107,6 +107,7 @@ export const EvaluationConfigSchema = z.object({
|
|||||||
|
|
||||||
aiSummaryEnabled: z.boolean().default(false),
|
aiSummaryEnabled: z.boolean().default(false),
|
||||||
generateAiShortlist: z.boolean().default(false),
|
generateAiShortlist: z.boolean().default(false),
|
||||||
|
aiParseFiles: z.boolean().default(false),
|
||||||
|
|
||||||
advancementMode: z
|
advancementMode: z
|
||||||
.enum(['auto_top_n', 'admin_selection', 'ai_recommended'])
|
.enum(['auto_top_n', 'admin_selection', 'ai_recommended'])
|
||||||
|
|||||||
Reference in New Issue
Block a user