All checks were successful
Build and Push Docker Image / build (push) Successful in 7m45s
Replace Pipeline/Stage system with Competition/Round architecture. New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy, ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow. New services: round-engine, round-assignment, deliberation, result-lock, submission-manager, competition-context, ai-prompt-guard. Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with structured prompts, retry logic, and injection detection. All legacy pipeline/stage code removed. 4 new migrations + seed aligned. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
263 lines
8.0 KiB
TypeScript
263 lines
8.0 KiB
TypeScript
import type { CapMode } from '@prisma/client'
|
|
import type { PolicyResolution } from '@/types/competition'
|
|
import type { MemberContext } from './competition-context'
|
|
|
|
// ============================================================================
|
|
// System Defaults (Layer 1)
|
|
// ============================================================================
|
|
|
|
export const SYSTEM_DEFAULT_CAP = 15
|
|
export const SYSTEM_DEFAULT_CAP_MODE: CapMode = 'SOFT'
|
|
export const SYSTEM_DEFAULT_SOFT_BUFFER = 2
|
|
|
|
// ============================================================================
|
|
// Effective Cap Resolution (5-layer precedence)
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Resolves the effective assignment cap for a jury member.
|
|
*
|
|
* Precedence (first non-null wins):
|
|
* Layer 4b: selfServiceCap (bounded by admin max, only if allowJurorCapAdjustment)
|
|
* Layer 4a: maxAssignmentsOverride (admin per-member override)
|
|
* Layer 3: juryGroup.defaultMaxAssignments
|
|
* Layer 1: SYSTEM_DEFAULT_CAP (15)
|
|
*/
|
|
export function resolveEffectiveCap(ctx: MemberContext): PolicyResolution<number> {
|
|
const group = ctx.juryGroup
|
|
|
|
// Layer 4b: Self-service cap (juror-set during onboarding)
|
|
if (group?.allowJurorCapAdjustment && ctx.member.selfServiceCap != null) {
|
|
const adminMax =
|
|
ctx.member.maxAssignmentsOverride
|
|
?? group.defaultMaxAssignments
|
|
?? SYSTEM_DEFAULT_CAP
|
|
const bounded = Math.min(ctx.member.selfServiceCap, adminMax)
|
|
return {
|
|
value: bounded,
|
|
source: 'member',
|
|
explanation: `Self-service cap ${ctx.member.selfServiceCap} (bounded to ${adminMax})`,
|
|
}
|
|
}
|
|
|
|
// Layer 4a: Admin per-member override
|
|
if (ctx.member.maxAssignmentsOverride != null) {
|
|
return {
|
|
value: ctx.member.maxAssignmentsOverride,
|
|
source: 'member',
|
|
explanation: `Admin per-member override: ${ctx.member.maxAssignmentsOverride}`,
|
|
}
|
|
}
|
|
|
|
// Layer 3: Jury group default
|
|
if (group) {
|
|
return {
|
|
value: group.defaultMaxAssignments,
|
|
source: 'jury_group',
|
|
explanation: `Jury group "${group.name}" default: ${group.defaultMaxAssignments}`,
|
|
}
|
|
}
|
|
|
|
// Layer 1: System default
|
|
return {
|
|
value: SYSTEM_DEFAULT_CAP,
|
|
source: 'system',
|
|
explanation: `System default: ${SYSTEM_DEFAULT_CAP}`,
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// Effective Cap Mode Resolution
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Resolves the effective cap mode for a jury member.
|
|
*
|
|
* Precedence:
|
|
* Layer 4a: capModeOverride (admin per-member)
|
|
* Layer 3: juryGroup.defaultCapMode
|
|
* Layer 1: SYSTEM_DEFAULT_CAP_MODE (SOFT)
|
|
*/
|
|
export function resolveEffectiveCapMode(ctx: MemberContext): PolicyResolution<CapMode> {
|
|
// Layer 4a: Admin per-member override
|
|
if (ctx.member.capModeOverride != null) {
|
|
return {
|
|
value: ctx.member.capModeOverride,
|
|
source: 'member',
|
|
explanation: `Admin per-member cap mode override: ${ctx.member.capModeOverride}`,
|
|
}
|
|
}
|
|
|
|
// Layer 3: Jury group default
|
|
if (ctx.juryGroup) {
|
|
return {
|
|
value: ctx.juryGroup.defaultCapMode,
|
|
source: 'jury_group',
|
|
explanation: `Jury group "${ctx.juryGroup.name}" default: ${ctx.juryGroup.defaultCapMode}`,
|
|
}
|
|
}
|
|
|
|
// Layer 1: System default
|
|
return {
|
|
value: SYSTEM_DEFAULT_CAP_MODE,
|
|
source: 'system',
|
|
explanation: `System default: ${SYSTEM_DEFAULT_CAP_MODE}`,
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// Effective Soft Cap Buffer Resolution
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Resolves the effective soft cap buffer.
|
|
* Only meaningful when capMode is SOFT.
|
|
*
|
|
* Precedence:
|
|
* Layer 3: juryGroup.softCapBuffer
|
|
* Layer 1: SYSTEM_DEFAULT_SOFT_BUFFER (2)
|
|
*/
|
|
export function resolveEffectiveSoftCapBuffer(
|
|
ctx: MemberContext,
|
|
): PolicyResolution<number> {
|
|
if (ctx.juryGroup) {
|
|
return {
|
|
value: ctx.juryGroup.softCapBuffer,
|
|
source: 'jury_group',
|
|
explanation: `Jury group "${ctx.juryGroup.name}" buffer: ${ctx.juryGroup.softCapBuffer}`,
|
|
}
|
|
}
|
|
|
|
return {
|
|
value: SYSTEM_DEFAULT_SOFT_BUFFER,
|
|
source: 'system',
|
|
explanation: `System default buffer: ${SYSTEM_DEFAULT_SOFT_BUFFER}`,
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// Effective Category Bias Resolution
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Resolves the effective category bias (startup ratio) for a jury member.
|
|
*
|
|
* Precedence:
|
|
* Layer 4b: selfServiceRatio (if allowJurorRatioAdjustment)
|
|
* Layer 4a: preferredStartupRatio (admin per-member)
|
|
* Layer 3: juryGroup.defaultCategoryQuotas (derived ratio)
|
|
* Default: null (no preference)
|
|
*/
|
|
export function resolveEffectiveCategoryBias(
|
|
ctx: MemberContext,
|
|
): PolicyResolution<Record<string, number> | null> {
|
|
const group = ctx.juryGroup
|
|
|
|
// Layer 4b: Self-service ratio
|
|
if (group?.allowJurorRatioAdjustment && ctx.member.selfServiceRatio != null) {
|
|
const ratio = ctx.member.selfServiceRatio
|
|
return {
|
|
value: { STARTUP: ratio, BUSINESS_CONCEPT: 1 - ratio },
|
|
source: 'member',
|
|
explanation: `Self-service ratio: ${Math.round(ratio * 100)}% startup`,
|
|
}
|
|
}
|
|
|
|
// Layer 4a: Admin per-member preferred ratio
|
|
if (ctx.member.preferredStartupRatio != null) {
|
|
const ratio = ctx.member.preferredStartupRatio
|
|
return {
|
|
value: { STARTUP: ratio, BUSINESS_CONCEPT: 1 - ratio },
|
|
source: 'member',
|
|
explanation: `Admin-set ratio: ${Math.round(ratio * 100)}% startup`,
|
|
}
|
|
}
|
|
|
|
// Layer 3: Jury group default category quotas (derive ratio from quotas)
|
|
if (group?.categoryQuotasEnabled && group.defaultCategoryQuotas) {
|
|
const quotas = group.defaultCategoryQuotas as Record<
|
|
string,
|
|
{ min: number; max: number }
|
|
>
|
|
const totalMax = Object.values(quotas).reduce((sum, q) => sum + q.max, 0)
|
|
if (totalMax > 0) {
|
|
const bias: Record<string, number> = {}
|
|
for (const [cat, q] of Object.entries(quotas)) {
|
|
bias[cat] = q.max / totalMax
|
|
}
|
|
return {
|
|
value: bias,
|
|
source: 'jury_group',
|
|
explanation: `Derived from group category quotas`,
|
|
}
|
|
}
|
|
}
|
|
|
|
// No preference
|
|
return {
|
|
value: null,
|
|
source: 'system',
|
|
explanation: 'No category bias configured',
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// Aggregate Policy Evaluation
|
|
// ============================================================================
|
|
|
|
export type AssignmentPolicyResult = {
|
|
effectiveCap: PolicyResolution<number>
|
|
effectiveCapMode: PolicyResolution<CapMode>
|
|
softCapBuffer: PolicyResolution<number>
|
|
categoryBias: PolicyResolution<Record<string, number> | null>
|
|
canAssignMore: boolean
|
|
remainingCapacity: number
|
|
isOverCap: boolean
|
|
overCapBy: number
|
|
}
|
|
|
|
/**
|
|
* Evaluates all assignment policies for a member in one call.
|
|
* Returns resolved values with provenance plus computed flags.
|
|
*/
|
|
export function evaluateAssignmentPolicy(
|
|
ctx: MemberContext,
|
|
): AssignmentPolicyResult {
|
|
const effectiveCap = resolveEffectiveCap(ctx)
|
|
const effectiveCapMode = resolveEffectiveCapMode(ctx)
|
|
const softCapBuffer = resolveEffectiveSoftCapBuffer(ctx)
|
|
const categoryBias = resolveEffectiveCategoryBias(ctx)
|
|
|
|
const cap = effectiveCap.value
|
|
const mode = effectiveCapMode.value
|
|
const buffer = softCapBuffer.value
|
|
const count = ctx.currentAssignmentCount
|
|
|
|
const isOverCap = count > cap
|
|
const overCapBy = Math.max(0, count - cap)
|
|
const remainingCapacity =
|
|
mode === 'NONE'
|
|
? Infinity
|
|
: mode === 'SOFT'
|
|
? Math.max(0, cap + buffer - count)
|
|
: Math.max(0, cap - count)
|
|
|
|
const canAssignMore =
|
|
mode === 'NONE'
|
|
? true
|
|
: mode === 'SOFT'
|
|
? count < cap + buffer
|
|
: count < cap
|
|
|
|
return {
|
|
effectiveCap,
|
|
effectiveCapMode,
|
|
softCapBuffer,
|
|
categoryBias,
|
|
canAssignMore,
|
|
remainingCapacity,
|
|
isOverCap,
|
|
overCapBy,
|
|
}
|
|
}
|