From d50ae8f3aeb03283bb23655af1c9ab7f0a03f336 Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Wed, 15 Jul 2026 08:19:25 +0300 Subject: [PATCH 01/33] PM-5598 - review context management in work manager --- src/apps/work/src/lib/constants.ts | 4 + .../models/ChallengeReviewContext.model.ts | 12 + src/apps/work/src/lib/models/index.ts | 1 + .../challenge-review-context.service.ts | 194 ++++++ src/apps/work/src/lib/services/index.ts | 1 + .../src/lib/services/workflow-ai.service.ts | 116 +++- .../ReviewersField/ReviewContextTab.tsx | 592 ++++++++++++++++++ .../ReviewersField/ReviewersField.module.scss | 114 ++++ .../ReviewersField/ReviewersField.spec.tsx | 23 +- .../ReviewersField/ReviewersField.tsx | 92 ++- 10 files changed, 1115 insertions(+), 34 deletions(-) create mode 100644 src/apps/work/src/lib/models/ChallengeReviewContext.model.ts create mode 100644 src/apps/work/src/lib/services/challenge-review-context.service.ts create mode 100644 src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/ReviewContextTab.tsx diff --git a/src/apps/work/src/lib/constants.ts b/src/apps/work/src/lib/constants.ts index f89755041..cf1aef596 100644 --- a/src/apps/work/src/lib/constants.ts +++ b/src/apps/work/src/lib/constants.ts @@ -145,6 +145,10 @@ export const TC_AI_SKILLS_EXTRACTION_WORKFLOW_ID = process.env.REACT_APP_TC_AI_S || process.env.TC_AI_SKILLS_EXTRACTION_WORKFLOW_ID || 'skillExtractionWorkflow' +export const TC_AI_CONTEXT_WORKFLOW_ID = process.env.REACT_APP_TC_AI_CONTEXT_WORKFLOW_ID + || process.env.TC_AI_CONTEXT_WORKFLOW_ID + || 'challengeContextWorkflow' + export const TC_AI_AUTOWRITE_WORKFLOW_ID = process.env.REACT_APP_TC_AI_AUTOWRITE_WORKFLOW_ID || process.env.TC_AI_AUTOWRITE_WORKFLOW_ID || 'jdAutowriteWorkflow' diff --git a/src/apps/work/src/lib/models/ChallengeReviewContext.model.ts b/src/apps/work/src/lib/models/ChallengeReviewContext.model.ts new file mode 100644 index 000000000..ef9e1647e --- /dev/null +++ b/src/apps/work/src/lib/models/ChallengeReviewContext.model.ts @@ -0,0 +1,12 @@ +export type ChallengeReviewContextStatus = 'AI_GENERATED' | 'HUMAN_APPROVED' | 'HUMAN_REJECTED' + +export interface ChallengeReviewContext { + id: string + challengeId: string + context: Record + status: ChallengeReviewContextStatus + createdAt?: string + createdBy?: string | null + updatedAt?: string + updatedBy?: string | null +} diff --git a/src/apps/work/src/lib/models/index.ts b/src/apps/work/src/lib/models/index.ts index 676d64555..69213f847 100644 --- a/src/apps/work/src/lib/models/index.ts +++ b/src/apps/work/src/lib/models/index.ts @@ -3,6 +3,7 @@ export * from './AiReview.model' export * from './Engagement.model' export * from './ChallengeEditor.model' export * from './Challenge.model' +export * from './ChallengeReviewContext.model' export * from './ChallengeFilters.model' export * from './MarathonMatch.model' export * from './ChallengeType.model' diff --git a/src/apps/work/src/lib/services/challenge-review-context.service.ts b/src/apps/work/src/lib/services/challenge-review-context.service.ts new file mode 100644 index 000000000..782ba6f5a --- /dev/null +++ b/src/apps/work/src/lib/services/challenge-review-context.service.ts @@ -0,0 +1,194 @@ +import { + xhrGetAsync, + xhrPostAsync, + xhrPutAsync, +} from '~/libs/core' +import { EnvironmentConfig } from '~/config' + +import { + ChallengeReviewContext, + ChallengeReviewContextStatus, +} from '../models' + +const CHALLENGE_REVIEW_CONTEXT_API_URL = `${EnvironmentConfig.API.V6}/ai-review/context` + +function normalizeText(value: unknown): string | undefined { + if (value === undefined || value === null) { + return undefined + } + + const normalizedValue = String(value).trim() + + return normalizedValue || undefined +} + +function normalizeReviewContextStatus(value: unknown): ChallengeReviewContextStatus | undefined { + const normalizedValue = normalizeText(value) + + if (normalizedValue === 'AI_GENERATED' + || normalizedValue === 'HUMAN_APPROVED' + || normalizedValue === 'HUMAN_REJECTED') { + return normalizedValue + } + + return undefined +} + +function normalizeChallengeReviewContext( + input: unknown, +): ChallengeReviewContext | undefined { + if (typeof input !== 'object' || input === null) { + return undefined + } + + const typedInput = input as Record + const id = normalizeText(typedInput.id) + const challengeId = normalizeText(typedInput.challengeId) + const status = normalizeReviewContextStatus(typedInput.status) + const context = typeof typedInput.context === 'object' && typedInput.context + ? typedInput.context as Record + : undefined + + if (!id || !challengeId || !status || !context) { + return undefined + } + + return { + id, + challengeId, + context, + status, + createdAt: normalizeText(typedInput.createdAt), + createdBy: typeof typedInput.createdBy === 'string' + ? typedInput.createdBy + : null, + updatedAt: normalizeText(typedInput.updatedAt), + updatedBy: typeof typedInput.updatedBy === 'string' + ? typedInput.updatedBy + : null, + } +} + +function normalizeError(error: unknown, fallbackMessage: string): Error { + const typedError = error as { + message?: string + response?: { + data?: { + message?: string + } + } + } + + return new Error( + typedError?.response?.data?.message + || typedError?.message + || fallbackMessage, + ) +} + +function serializeInput( + input: { + challengeId: string + context: Record + status: ChallengeReviewContextStatus + }, +): { + challengeId: string + context: Record + status: ChallengeReviewContextStatus +} { + return { + challengeId: normalizeText(input.challengeId) || input.challengeId, + context: input.context, + status: input.status, + } +} + +export async function fetchChallengeReviewContextByChallenge( + challengeId: string, +): Promise { + try { + const response = await xhrGetAsync( + `${CHALLENGE_REVIEW_CONTEXT_API_URL}/${encodeURIComponent(challengeId.trim())}`, + ) + + return normalizeChallengeReviewContext(response) + } catch (error) { + const typedError = error as { + response?: { + status?: number + } + status?: number + } + const status = typedError?.status || typedError?.response?.status + + if (status === 404) { + return undefined + } + + throw normalizeError(error, 'Failed to fetch review context') + } +} + +export interface CreateChallengeReviewContextInput { + challengeId: string + context: Record + status: ChallengeReviewContextStatus +} + +export async function createChallengeReviewContext( + input: CreateChallengeReviewContextInput, +): Promise { + try { + const response = await xhrPostAsync< + CreateChallengeReviewContextInput, + unknown + >( + CHALLENGE_REVIEW_CONTEXT_API_URL, + serializeInput(input), + ) + + const normalizedResult = normalizeChallengeReviewContext(response) + + if (!normalizedResult) { + throw new Error('Challenge review context response was invalid') + } + + return normalizedResult + } catch (error) { + throw normalizeError(error, 'Failed to create review context') + } +} + +export interface UpdateChallengeReviewContextInput { + context: Record + status?: ChallengeReviewContextStatus +} + +export async function updateChallengeReviewContext( + challengeId: string, + input: UpdateChallengeReviewContextInput, +): Promise { + try { + const response = await xhrPutAsync< + UpdateChallengeReviewContextInput, + unknown + >( + `${CHALLENGE_REVIEW_CONTEXT_API_URL}/${encodeURIComponent(challengeId.trim())}`, + { + context: input.context, + status: input.status, + }, + ) + + const normalizedResult = normalizeChallengeReviewContext(response) + + if (!normalizedResult) { + throw new Error('Challenge review context response was invalid') + } + + return normalizedResult + } catch (error) { + throw normalizeError(error, 'Failed to update review context') + } +} diff --git a/src/apps/work/src/lib/services/index.ts b/src/apps/work/src/lib/services/index.ts index c5ab10ede..f58b5bd44 100644 --- a/src/apps/work/src/lib/services/index.ts +++ b/src/apps/work/src/lib/services/index.ts @@ -1,6 +1,7 @@ export * from './attachments.service' export * from './ai-review-configs.service' export * from './ai-review-templates.service' +export * from './challenge-review-context.service' export * from './applications.service' export * from './billing-accounts.service' export * from './challenges.service' diff --git a/src/apps/work/src/lib/services/workflow-ai.service.ts b/src/apps/work/src/lib/services/workflow-ai.service.ts index 2d8af3759..963b19829 100644 --- a/src/apps/work/src/lib/services/workflow-ai.service.ts +++ b/src/apps/work/src/lib/services/workflow-ai.service.ts @@ -8,6 +8,7 @@ import { AI_WORKFLOW_POLL_TIMEOUT_MS, TC_AI_API_BASE_URL, TC_AI_AUTOWRITE_WORKFLOW_ID, + TC_AI_CONTEXT_WORKFLOW_ID, TC_AI_SKILLS_EXTRACTION_WORKFLOW_ID, } from '../constants' import { @@ -136,13 +137,10 @@ async function createWorkflowRun(workflowId: string): Promise { async function startWorkflowRun( workflowId: string, runId: string, - description: string, - payloadKey: string = 'jobDescription', + payloadData: any, ): Promise { const payload: WorkflowStartPayload = { - inputData: { - [payloadKey]: description, - }, + inputData: payloadData, } await xhrPostAsync( @@ -178,7 +176,14 @@ async function pollWorkflowRunResult( throw new Error('Workflow request timed out') } - const runStatus = await fetchWorkflowRunStatus(workflowId, runId) + let runStatus: WorkflowRunStatusResponse + try { + runStatus = await fetchWorkflowRunStatus(workflowId, runId) + } catch (e) { + console.error('Failed to fetch run status!', e) + runStatus = {} + } + const status = String(runStatus.status || '') .trim() .toLowerCase() @@ -230,7 +235,7 @@ export async function extractSkillsFromText( await startWorkflowRun( normalizedWorkflowId, runId, - normalizedDescription, + { jobDescription: normalizedDescription }, ) const result = await pollWorkflowRunResult(normalizedWorkflowId, runId, Date.now()) @@ -241,6 +246,100 @@ export async function extractSkillsFromText( } } +function parseWorkflowResultToObject(value: unknown): Record { + if (typeof value === 'string') { + const trimmed = value.trim() + + if (!trimmed) { + throw new Error('Workflow result did not contain any data') + } + + try { + const parsed = JSON.parse(trimmed) + + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + throw new Error('Workflow result JSON must be an object') + } + + return parsed + } catch { + throw new Error('Workflow result was not valid JSON') + } + } + + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error('Workflow result must be an object') + } + + const resultObject = value as Record + + const candidateContext = resultObject.context + const candidateResult = resultObject.result + const candidateOutput = resultObject.output + const candidateData = resultObject.data + + const preferredContext = [candidateContext, candidateResult, candidateOutput, candidateData] + .find(item => item && typeof item === 'object' && !Array.isArray(item)) as Record | undefined + + return preferredContext || resultObject +} + +function buildContextWorkflowInput( + challengeName?: string, + challengeDescription?: string, +): string { + const normalizedName = challengeName?.trim() || 'Unknown challenge name' + const normalizedDescription = challengeDescription?.trim() || 'No challenge description provided.' + + return [ + 'Generate a review context object for the challenge below.', + 'The result should be a JSON object containing review criteria for AI-enabled review.', + 'Include fields such as summary, description, requirements, constraints,', + 'and any additional context needed to evaluate submissions.', + '', + `Challenge name: ${normalizedName}`, + 'Challenge description:', + normalizedDescription, + ].join('\n') +} + +export async function generateChallengeReviewContext( + challengeId: string, + challengeDescription: string, + workflowId?: string, +): Promise> { + const normalizedChallengeName = '' + const normalizedChallengeDescription = challengeDescription.trim() + const inputText = buildContextWorkflowInput(normalizedChallengeName, normalizedChallengeDescription) + const normalizedWorkflowId = String( + workflowId || TC_AI_CONTEXT_WORKFLOW_ID, + ) + .trim() + + if (!normalizedWorkflowId) { + throw new Error('Workflow ID is required to generate review context') + } + + try { + const runId = await createWorkflowRun(normalizedWorkflowId) + + await startWorkflowRun( + normalizedWorkflowId, + runId, + { + challengeId, + jobDescription: inputText, + }, + ) + + const result = await pollWorkflowRunResult(normalizedWorkflowId, runId, Date.now()) + + return parseWorkflowResultToObject(result) + } catch (error) { + throw normalizeError(error, 'Failed to generate review context') + } +} + /** * Rewrites an engagement description with the AI autowrite workflow. * @@ -275,8 +374,7 @@ export async function autowriteDescription( await startWorkflowRun( normalizedWorkflowId, runId, - normalizedDescription, - 'rawDescription', + { rawDescription: normalizedDescription }, ) const result = await pollWorkflowRunResult(normalizedWorkflowId, runId, Date.now()) diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/ReviewContextTab.tsx b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/ReviewContextTab.tsx new file mode 100644 index 000000000..cac7279bf --- /dev/null +++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/ReviewContextTab.tsx @@ -0,0 +1,592 @@ +import { + ChangeEvent, + FC, + useCallback, + useEffect, + useMemo, + useState, +} from 'react' + +import { Button } from '~/libs/ui' + +import { + createChallengeReviewContext, + fetchChallengeReviewContextByChallenge, + generateChallengeReviewContext, + updateChallengeReviewContext, +} from '../../../../../lib/services' +import { + ChallengeReviewContextStatus, +} from '../../../../../lib/models' +import { + showErrorToast, + showSuccessToast, +} from '../../../../../lib/utils' +import styles from './ReviewersField.module.scss' + +interface ReviewContextTabProps { + challengeId?: string + challengeName?: string + challengeDescription?: string +} + +type ContextFieldRow = { + key: string + value: string +} + +function normalizeStringArray(value: unknown): string[] { + if (!Array.isArray(value)) { + return [] + } + + return value + .map(item => (typeof item === 'string' ? item.trim() : '')) + .filter(item => item) +} + +function parseJsonValue(value: string): unknown { + const trimmedValue = value.trim() + + if (!trimmedValue) { + return '' + } + + if (trimmedValue === 'true') { + return true + } + + if (trimmedValue === 'false') { + return false + } + + if (trimmedValue === 'null') { + return null + } + + if (!Number.isNaN(Number(trimmedValue)) && trimmedValue !== '') { + const numberValue = Number(trimmedValue) + + if (String(numberValue) === trimmedValue || trimmedValue.startsWith('0')) { + return numberValue + } + } + + if (trimmedValue.startsWith('{') || trimmedValue.startsWith('[')) { + try { + return JSON.parse(trimmedValue) + } catch { + return trimmedValue + } + } + + return trimmedValue +} + +function buildJsonValue(value: unknown): string { + if (typeof value === 'string') { + return value + } + + try { + return JSON.stringify(value, null, 2) + } catch { + return String(value) + } +} + +function buildContextFields( + context: Record, +): ContextFieldRow[] { + return Object.entries(context) + .filter(([key]) => key !== 'requirements' && key !== 'constraints') + .map(([key, value]) => ({ + key, + value: buildJsonValue(value), + })) +} + +function buildContextFromState( + fields: ContextFieldRow[], + requirements: string[], + constraints: string[], +): Record { + const context: Record = {} + + fields.forEach(field => { + const key = field.key.trim() + + if (!key) { + return + } + + context[key] = parseJsonValue(field.value) + }) + + if (requirements.length > 0) { + context.requirements = requirements + } + + if (constraints.length > 0) { + context.constraints = constraints + } + + return context +} + +export const ReviewContextTab: FC = ( + props: ReviewContextTabProps, +) => { + const { + challengeId, + challengeName, + challengeDescription, + } = props + const [isLoading, setIsLoading] = useState(false) + const [isSaving, setIsSaving] = useState(false) + const [isRawView, setIsRawView] = useState(false) + const [hasLoadedContext, setHasLoadedContext] = useState(false) + const [requirements, setRequirements] = useState([]) + const [constraints, setConstraints] = useState([]) + const [fields, setFields] = useState([]) + const [rawJson, setRawJson] = useState('{}') + const [saveError, setSaveError] = useState() + const [jsonError, setJsonError] = useState() + const [fetchError, setFetchError] = useState() + const [isDirty, setIsDirty] = useState(false) + + const hasContext = useMemo( + () => fields.length > 0 + || requirements.length > 0 + || constraints.length > 0, + [constraints.length, fields.length, requirements.length], + ) + + const loadContext = useCallback(async (): Promise => { + if (!challengeId) { + setHasLoadedContext(true) + return + } + + setIsLoading(true) + setFetchError(undefined) + + try { + const existingContext = await fetchChallengeReviewContextByChallenge(challengeId) + + if (existingContext?.context) { + setRequirements(normalizeStringArray(existingContext.context.requirements)) + setConstraints(normalizeStringArray(existingContext.context.constraints)) + setFields(buildContextFields(existingContext.context)) + setRawJson(JSON.stringify(existingContext.context, null, 2)) + } else { + setRequirements([]) + setConstraints([]) + setFields([]) + setRawJson('{}') + } + } catch (error) { + setFetchError(error instanceof Error ? error.message : 'Failed to load review context') + } finally { + setHasLoadedContext(true) + setIsLoading(false) + setIsDirty(false) + } + }, [challengeId]) + + useEffect(() => { + if (!hasLoadedContext) { + void loadContext() + } + }, [hasLoadedContext, loadContext]) + + const setContextState = useCallback( + (context: Record | undefined): void => { + if (!context) { + setRequirements([]) + setConstraints([]) + setFields([]) + setRawJson('{}') + + return + } + + setRequirements(normalizeStringArray(context.requirements)) + setConstraints(normalizeStringArray(context.constraints)) + setFields(buildContextFields(context)) + setRawJson(JSON.stringify(context, null, 2)) + }, + [], + ) + + const handleGenerateClick = useCallback(async (): Promise => { + if (!challengeId) { + showErrorToast('Please save the challenge before generating review context.') + return + } + + setIsSaving(true) + setSaveError(undefined) + + try { + const generatedContext = await generateChallengeReviewContext( + challengeId || '', + challengeDescription || '', + ) + + const response = await createChallengeReviewContext({ + challengeId, + context: generatedContext, + status: 'AI_GENERATED', + }) + + setContextState(response.context) + setIsRawView(false) + setIsDirty(false) + showSuccessToast('Review context generated successfully.') + } catch (error) { + const message = error instanceof Error + ? error.message + : 'Failed to generate review context.' + + setSaveError(message) + showErrorToast(message) + } finally { + setIsSaving(false) + } + }, [challengeDescription, challengeId, challengeName, setContextState]) + + const handleSaveClick = useCallback(async (): Promise => { + if (!challengeId) { + showErrorToast('Please save the challenge before saving review context.') + return + } + + setIsSaving(true) + setSaveError(undefined) + + try { + let payloadContext: Record + + if (isRawView) { + const parsed = JSON.parse(rawJson) + + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + throw new Error('Review context must be a JSON object.') + } + + payloadContext = parsed + } else { + payloadContext = buildContextFromState(fields, requirements, constraints) + } + + const response = await updateChallengeReviewContext(challengeId, { + context: payloadContext, + }).catch(async (error) => { + const typedError = error as { response?: { status?: number } } + const status = typedError?.response?.status + + if (status === 404) { + return await createChallengeReviewContext({ + challengeId, + context: payloadContext, + status: 'AI_GENERATED', + }) + } + + throw error + }) + + setContextState(response.context) + setIsDirty(false) + setIsRawView(false) + showSuccessToast('Review context saved successfully.') + } catch (error) { + const message = error instanceof Error + ? error.message + : 'Failed to save review context.' + + setSaveError(message) + showErrorToast(message) + } finally { + setIsSaving(false) + } + }, [challengeId, constraints, createChallengeReviewContext, fields, isRawView, rawJson, requirements, setContextState]) + + const handleRawToggle = useCallback((): void => { + if (isRawView) { + try { + const parsed = JSON.parse(rawJson) + + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + throw new Error('Review context must be a JSON object.') + } + + setContextState(parsed) + setJsonError(undefined) + setIsRawView(false) + } catch (error) { + setJsonError(error instanceof Error ? error.message : 'Invalid JSON.') + } + + return + } + + setRawJson(JSON.stringify(buildContextFromState(fields, requirements, constraints), null, 2)) + setIsRawView(true) + setJsonError(undefined) + }, [constraints, fields, isRawView, rawJson, requirements, setContextState]) + + const handleFieldChange = useCallback( + (index: number, key: string, value: string): void => { + setFields(prevFields => prevFields.map((field, fieldIndex) => ( + fieldIndex === index + ? { key, value } + : field + ))) + setIsDirty(true) + }, + [], + ) + + const handleRemoveField = useCallback((index: number): void => { + setFields(prevFields => prevFields.filter((_, fieldIndex) => fieldIndex !== index)) + setIsDirty(true) + }, []) + + const handleAddField = useCallback((): void => { + setFields(prevFields => [ + ...prevFields, + { key: '', value: '' }, + ]) + setIsDirty(true) + }, []) + + const handleRequirementChange = useCallback((index: number, value: string): void => { + setRequirements(prev => prev.map((item, itemIndex) => ( + itemIndex === index ? value : item + ))) + setIsDirty(true) + }, []) + + const handleAddRequirement = useCallback((): void => { + setRequirements(prev => [...prev, '']) + setIsDirty(true) + }, []) + + const handleRemoveRequirement = useCallback((index: number): void => { + setRequirements(prev => prev.filter((_, itemIndex) => itemIndex !== index)) + setIsDirty(true) + }, []) + + const handleConstraintChange = useCallback((index: number, value: string): void => { + setConstraints(prev => prev.map((item, itemIndex) => ( + itemIndex === index ? value : item + ))) + setIsDirty(true) + }, []) + + const handleAddConstraint = useCallback((): void => { + setConstraints(prev => [...prev, '']) + setIsDirty(true) + }, []) + + const handleRemoveConstraint = useCallback((index: number): void => { + setConstraints(prev => prev.filter((_, itemIndex) => itemIndex !== index)) + setIsDirty(true) + }, []) + + const handleRawJsonChange = useCallback( + (event: ChangeEvent): void => { + setRawJson(event.target.value) + setJsonError(undefined) + setIsDirty(true) + }, + [], + ) + + const descriptionText = useMemo(() => { + if (!challengeId) { + return 'Save the challenge before generating review context.' + } + + if (fetchError) { + return fetchError + } + + if (hasLoadedContext && !hasContext) { + return 'No review context defined for this challenge.' + } + + return undefined + }, [challengeId, fetchError, hasContext, hasLoadedContext]) + + return ( +
+ {isLoading + ? ( +
+ Loading review context... +
+ ) + : descriptionText && !hasContext + ? ( +
+
📋
+

Review context requirements

+

+ Define the evaluation criteria for AI-powered requirements review. +

+

{descriptionText}

+
+ ) + : ( +
+
+
+ {saveError && ( +
{saveError}
+ )} + {jsonError && ( +
{jsonError}
+ )} + {isRawView + ? ( +