diff --git a/src/api/aiRuns.test.ts b/src/api/aiRuns.test.ts new file mode 100644 index 0000000..294e4f7 --- /dev/null +++ b/src/api/aiRuns.test.ts @@ -0,0 +1,52 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { decideAiRunCandidates } from './aiRuns' + +beforeEach(() => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + decision_batch_id: 'D-1', + ai_run_id: 'A-1', + case_id: 'CASE-1', + task_ids: ['T-1'], + decisions: [ + { candidate_id: 'C-1', action: 'ACCEPT' }, + { candidate_id: 'C-2', action: 'DISCARD' }, + ], + run_version: 4, + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ), + ), + ) +}) + +afterEach(() => vi.unstubAllGlobals()) + +describe('decideAiRunCandidates', () => { + it('sends the candidate decisions with the current run version and idempotency key', async () => { + await decideAiRunCandidates( + 'A/1', + 3, + [ + { candidate_id: 'C-1', action: 'ACCEPT' }, + { candidate_id: 'C-2', action: 'DISCARD' }, + ], + 'decision-key', + ) + + const [url, init] = vi.mocked(fetch).mock.calls[0] + expect(url).toContain('/ai-runs/A%2F1/candidate-decisions') + expect(init?.method).toBe('POST') + expect(new Headers(init?.headers).get('Idempotency-Key')).toBe('decision-key') + expect(JSON.parse(String(init?.body))).toEqual({ + expected_run_version: 3, + decisions: [ + { candidate_id: 'C-1', action: 'ACCEPT' }, + { candidate_id: 'C-2', action: 'DISCARD' }, + ], + }) + }) +}) diff --git a/src/api/aiRuns.ts b/src/api/aiRuns.ts index a560e8a..795d60d 100644 --- a/src/api/aiRuns.ts +++ b/src/api/aiRuns.ts @@ -37,6 +37,22 @@ export interface AiRunResponse { updated_at: string } +export type AiCandidateDecisionAction = 'ACCEPT' | 'DISCARD' + +export interface AiCandidateDecisionItem { + candidate_id: string + action: AiCandidateDecisionAction +} + +export interface AiCandidateDecisionResponse { + decision_batch_id: string + ai_run_id: string + case_id: string | null + task_ids: string[] + decisions: AiCandidateDecisionItem[] + run_version: number +} + export function createAiRun(instruction: string, idempotencyKey: string): Promise { return apiFetch('/ai-runs', { method: 'POST', @@ -59,3 +75,22 @@ export function submitAiRunAnswers( body: JSON.stringify({ expected_version: expectedVersion, answers }), }) } + +export function decideAiRunCandidates( + aiRunId: string, + expectedRunVersion: number, + decisions: AiCandidateDecisionItem[], + idempotencyKey: string, +): Promise { + return apiFetch( + `/ai-runs/${encodeURIComponent(aiRunId)}/candidate-decisions`, + { + method: 'POST', + headers: { 'Idempotency-Key': idempotencyKey }, + body: JSON.stringify({ + expected_run_version: expectedRunVersion, + decisions, + }), + }, + ) +} diff --git a/src/pages/ReviewWorkPage/AiRunReview.module.css b/src/pages/ReviewWorkPage/AiRunReview.module.css index 193a281..03b2c54 100644 --- a/src/pages/ReviewWorkPage/AiRunReview.module.css +++ b/src/pages/ReviewWorkPage/AiRunReview.module.css @@ -389,7 +389,7 @@ color: var(--fowoco-white, #fff); background: var(--surface-default); border: 1px solid var(--border-default); - border-radius: var(--fowoco-radius-4); + border-radius: var(--fowoco-radius-999); cursor: pointer; } @@ -398,6 +398,24 @@ border-color: var(--brand-primary); } +.candidateCheck:disabled { + background: var(--surface-subtle); + cursor: not-allowed; + opacity: 0.65; +} + +.candidateCheck:focus-visible, +.cardLink:focus-visible { + outline: 2px solid var(--brand-primary); + outline-offset: 2px; +} + +.cardLink:disabled { + color: var(--text-secondary); + cursor: not-allowed; + opacity: 0.65; +} + .candidateDetails { display: grid; gap: 12px; diff --git a/src/pages/ReviewWorkPage/AiRunReview.test.tsx b/src/pages/ReviewWorkPage/AiRunReview.test.tsx new file mode 100644 index 0000000..14bf26a --- /dev/null +++ b/src/pages/ReviewWorkPage/AiRunReview.test.tsx @@ -0,0 +1,186 @@ +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { MemoryRouter, Route, Routes } from 'react-router-dom' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { AiRunResponse } from '../../api/aiRuns' +import { AiRunReview } from './AiRunReview' + +function jsonResponse(body: unknown, init: ResponseInit = {}) { + return new Response(JSON.stringify(body), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + ...init, + }) +} + +const RUN: AiRunResponse = { + ai_run_id: 'A-1', + request_id: 'R-1', + instruction: '응웬반A 체류기간 연장과 급여 자료를 확인해 주세요', + status: 'SUCCEEDED', + analysis_outcome: 'REVIEW_REQUIRED', + detected_intent: 'EXPIRY_RENEWAL', + error_code: null, + attempt_count: 1, + version: 3, + questions: [], + candidates: [ + { + candidate_id: 'C-1', + candidate_ref: 'candidate-1', + worker_id: 'W-1', + workflow_id: 'WF-STY-001', + extracted_slots: { due_at: '2026-08-31' }, + missing_slots: [], + confidence: 0.92, + }, + { + candidate_id: 'C-2', + candidate_ref: 'candidate-2', + worker_id: 'W-1', + workflow_id: 'WF-PAY-001', + extracted_slots: {}, + missing_slots: [], + confidence: 0.72, + }, + ], + created_at: '2026-08-08T00:00:00Z', + updated_at: '2026-08-08T00:00:01Z', +} + +const CATALOG = { + bundle_id: 'bundle-1', + bundle_version: '1', + bundle_status: 'ACTIVE', + source_repository: 'fowoco/knowledge', + generated_at: '2026-08-08T00:00:00Z', + workflows: [ + { + workflow_id: 'WF-STY-001', + name: '체류기간 연장 처리', + intent: 'EXPIRY_RENEWAL', + sensitivity: 'NORMAL', + supported_task_types: [], + required_slots: [], + checklist_items: [], + completion_evidence: [], + source_ids: [], + }, + { + workflow_id: 'WF-PAY-001', + name: '급여 자료 확인', + intent: 'PAYROLL_EXPLANATION', + sensitivity: 'NORMAL', + supported_task_types: [], + required_slots: [], + checklist_items: [], + completion_evidence: [], + source_ids: [], + }, + ], +} + +beforeEach(() => { + vi.stubGlobal('fetch', vi.fn()) +}) + +afterEach(() => vi.unstubAllGlobals()) + +function renderReview() { + render( + + + } /> + 생성된 업무 상세

} /> + 업무함

} /> +
+
, + ) +} + +describe('AiRunReview candidate decision', () => { + it('accepts one candidate, discards the others, and opens the created task', async () => { + vi.mocked(fetch).mockImplementation((input) => { + const url = String(input) + if (url.includes('/workflow-catalogs')) return Promise.resolve(jsonResponse(CATALOG)) + if (url.includes('/workers')) { + return Promise.resolve( + jsonResponse({ + items: [{ worker_id: 'W-1', display_name: '응웬반A' }], + page: 0, + size: 100, + total_elements: 1, + }), + ) + } + if (url.includes('/candidate-decisions')) { + return Promise.resolve( + jsonResponse({ + decision_batch_id: 'BATCH-1', + ai_run_id: RUN.ai_run_id, + case_id: 'CASE-1', + task_ids: ['TASK-1'], + decisions: [ + { candidate_id: 'C-1', action: 'ACCEPT' }, + { candidate_id: 'C-2', action: 'DISCARD' }, + ], + run_version: 4, + }), + ) + } + return Promise.reject(new Error(`Unexpected request: ${url}`)) + }) + const user = userEvent.setup() + renderReview() + + expect(screen.getByText('선택 필요')).toBeInTheDocument() + const createButton = screen.getByRole('button', { name: '선택한 업무 생성' }) + expect(createButton).toBeDisabled() + + const candidateButton = await screen.findByRole('button', { + name: '체류기간 연장 처리 선택', + }) + await user.click(candidateButton) + expect(screen.getByText('1개 선택')).toBeInTheDocument() + expect(createButton).toBeEnabled() + + await user.click(createButton) + + expect(await screen.findByText('생성된 업무 상세')).toBeInTheDocument() + const decisionCall = vi + .mocked(fetch) + .mock.calls.find(([url]) => String(url).includes('/candidate-decisions')) + expect(new Headers(decisionCall?.[1]?.headers).get('Idempotency-Key')).toBeTruthy() + expect(JSON.parse(String(decisionCall?.[1]?.body))).toEqual({ + expected_run_version: 3, + decisions: [ + { candidate_id: 'C-1', action: 'ACCEPT' }, + { candidate_id: 'C-2', action: 'DISCARD' }, + ], + }) + }) + + it('does not allow a candidate with missing slots to be selected', async () => { + vi.mocked(fetch).mockImplementation((input) => { + const url = String(input) + if (url.includes('/workflow-catalogs')) return Promise.resolve(jsonResponse(CATALOG)) + if (url.includes('/workers')) { + return Promise.resolve(jsonResponse({ items: [], page: 0, size: 100, total_elements: 0 })) + } + return Promise.reject(new Error(`Unexpected request: ${url}`)) + }) + render( + + + , + ) + + expect(await screen.findByRole('button', { name: '체류기간 연장 처리 선택' })).toBeDisabled() + expect(screen.getByRole('button', { name: '선택한 업무 생성' })).toBeDisabled() + }) +}) diff --git a/src/pages/ReviewWorkPage/AiRunReview.tsx b/src/pages/ReviewWorkPage/AiRunReview.tsx index d94d32f..3eaa114 100644 --- a/src/pages/ReviewWorkPage/AiRunReview.tsx +++ b/src/pages/ReviewWorkPage/AiRunReview.tsx @@ -1,8 +1,9 @@ -import { useCallback, useEffect, useMemo, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Link, useNavigate } from 'react-router-dom' import { ApiError, getErrorMessage } from '../../api/errors' import { createAiRun, + decideAiRunCandidates, fetchAiRun, submitAiRunAnswers, type AiRunQuestion, @@ -26,7 +27,11 @@ interface AiRunReviewProps { initialDraft?: WorkRequestDraft | null } -function statusPresentation(run: AiRunResponse): { title: string; description: string; tone: StatusTone } { +function statusPresentation(run: AiRunResponse): { + title: string + description: string + tone: StatusTone +} { if (run.status === 'FAILED') { return { title: 'Agent가 요청을 완료하지 못했습니다.', @@ -34,7 +39,11 @@ function statusPresentation(run: AiRunResponse): { title: string; description: s tone: 'critical', } } - if (run.status === 'QUEUED' || run.status === 'RUNNING' || run.analysis_outcome === 'CONTEXT_REQUIRED') { + if ( + run.status === 'QUEUED' || + run.status === 'RUNNING' || + run.analysis_outcome === 'CONTEXT_REQUIRED' + ) { return { title: 'Agent가 요청을 분석하고 있습니다.', description: '등록된 근로자 정보와 처리 절차를 확인하고 있습니다.', @@ -51,7 +60,7 @@ function statusPresentation(run: AiRunResponse): { title: string; description: s if (run.candidates.length > 1) { return { title: `${run.candidates.length}개의 업무를 찾았습니다.`, - description: '서로 독립된 업무 후보입니다. 실제로 만들 후보만 포함해 주세요.', + description: '후보를 비교한 뒤 이번에 실제 업무로 만들 하나를 선택해 주세요.', tone: 'warning', } } @@ -70,20 +79,22 @@ export function AiRunReview({ initialRun, initialDraft }: AiRunReviewProps) { const navigate = useNavigate() const [run, setRun] = useState(initialRun) const [answers, setAnswers] = useState>(() => - Object.fromEntries(initialRun.questions.map((question) => [question.slot_key, question.answer ?? ''])), + Object.fromEntries( + initialRun.questions.map((question) => [question.slot_key, question.answer ?? '']), + ), ) - const [selectedCandidateIds, setSelectedCandidateIds] = useState>( - () => new Set(initialRun.candidates.map((candidate) => candidate.candidate_id)), + const [selectedCandidateId, setSelectedCandidateId] = useState(() => + initialRun.candidates.length === 1 && initialRun.candidates[0].missing_slots.length === 0 + ? initialRun.candidates[0].candidate_id + : null, ) const [submitting, setSubmitting] = useState(false) const [retrying, setRetrying] = useState(false) + const [deciding, setDeciding] = useState(false) const [error, setError] = useState(null) + const decisionKeys = useRef(new Map()) const presentation = statusPresentation(run) const hasCandidates = run.candidates.length > 0 - const candidateIds = useMemo( - () => run.candidates.map((candidate) => candidate.candidate_id), - [run.candidates], - ) const catalogFetcher = useCallback(() => { if (!hasCandidates) { @@ -108,14 +119,16 @@ export function AiRunReview({ initialRun, initialDraft }: AiRunReviewProps) { const { data: workerPage } = useApiQuery(workerFetcher) const workflowNames = useMemo( - () => new Map((catalog?.workflows ?? []).map((workflow) => [workflow.workflow_id, workflow.name])), + () => + new Map((catalog?.workflows ?? []).map((workflow) => [workflow.workflow_id, workflow.name])), [catalog], ) const workerNames = useMemo( - () => new Map((workerPage?.items ?? []).map((worker) => [worker.worker_id, worker.display_name])), + () => + new Map((workerPage?.items ?? []).map((worker) => [worker.worker_id, worker.display_name])), [workerPage], ) - const selectedCount = selectedCandidateIds.size + const selectedCount = selectedCandidateId ? 1 : 0 const editRequestState = { request: initialDraft?.request ?? run.instruction, mode: initialDraft?.mode ?? ('nl' as const), @@ -132,7 +145,11 @@ export function AiRunReview({ initialRun, initialDraft }: AiRunReviewProps) { if (!cancelled) setRun(latest) } catch (pollError) { if (!cancelled) { - setError(pollError instanceof ApiError ? getErrorMessage(pollError) : '분석 상태를 확인하지 못했습니다.') + setError( + pollError instanceof ApiError + ? getErrorMessage(pollError) + : '분석 상태를 확인하지 못했습니다.', + ) } } }, 1200) @@ -144,12 +161,19 @@ export function AiRunReview({ initialRun, initialDraft }: AiRunReviewProps) { }, [run]) useEffect(() => { - setAnswers(Object.fromEntries(run.questions.map((question) => [question.slot_key, question.answer ?? '']))) + setAnswers( + Object.fromEntries( + run.questions.map((question) => [question.slot_key, question.answer ?? '']), + ), + ) }, [run.questions]) useEffect(() => { - setSelectedCandidateIds(new Set(candidateIds)) - }, [candidateIds]) + const onlyCandidate = run.candidates.length === 1 ? run.candidates[0] : null + setSelectedCandidateId( + onlyCandidate && onlyCandidate.missing_slots.length === 0 ? onlyCandidate.candidate_id : null, + ) + }, [run.candidates]) const canSubmitAnswers = useMemo( () => @@ -169,7 +193,9 @@ export function AiRunReview({ initialRun, initialDraft }: AiRunReviewProps) { setRun(await submitAiRunAnswers(run.ai_run_id, run.version, submittedAnswers)) } catch (submitError) { setError( - submitError instanceof ApiError ? getErrorMessage(submitError) : '추가 정보를 제출하지 못했습니다.', + submitError instanceof ApiError + ? getErrorMessage(submitError) + : '추가 정보를 제출하지 못했습니다.', ) } finally { setSubmitting(false) @@ -196,19 +222,59 @@ export function AiRunReview({ initialRun, initialDraft }: AiRunReviewProps) { }) setRun(retriedRun) } catch (retryError) { - setError(retryError instanceof ApiError ? getErrorMessage(retryError) : '요청을 다시 분석하지 못했습니다.') + setError( + retryError instanceof ApiError + ? getErrorMessage(retryError) + : '요청을 다시 분석하지 못했습니다.', + ) } finally { setRetrying(false) } } function toggleCandidate(candidateId: string) { - setSelectedCandidateIds((current) => { - const next = new Set(current) - if (next.has(candidateId)) next.delete(candidateId) - else next.add(candidateId) - return next - }) + setSelectedCandidateId((current) => (current === candidateId ? null : candidateId)) + } + + async function handleDecideCandidates() { + if (deciding || run.analysis_outcome !== 'REVIEW_REQUIRED' || !selectedCandidateId) return + + const selectedCandidate = run.candidates.find( + (candidate) => candidate.candidate_id === selectedCandidateId, + ) + if (!selectedCandidate || selectedCandidate.missing_slots.length > 0) return + + setDeciding(true) + setError(null) + try { + let idempotencyKey = decisionKeys.current.get(selectedCandidateId) + if (!idempotencyKey) { + idempotencyKey = globalThis.crypto.randomUUID() + decisionKeys.current.set(selectedCandidateId, idempotencyKey) + } + const result = await decideAiRunCandidates( + run.ai_run_id, + run.version, + run.candidates.map((candidate) => ({ + candidate_id: candidate.candidate_id, + action: candidate.candidate_id === selectedCandidateId ? 'ACCEPT' : 'DISCARD', + })), + idempotencyKey, + ) + const firstTaskId = result.task_ids[0] + navigate(firstTaskId ? `/tasks/${encodeURIComponent(firstTaskId)}` : '/tasks', { + replace: true, + state: { createdTaskIds: result.task_ids, caseId: result.case_id }, + }) + } catch (decisionError) { + setError( + decisionError instanceof ApiError + ? getErrorMessage(decisionError) + : '업무 후보를 확정하지 못했습니다.', + ) + } finally { + setDeciding(false) + } } return ( @@ -231,12 +297,20 @@ export function AiRunReview({ initialRun, initialDraft }: AiRunReviewProps) {
    -
  1. 요청 입력
  2. -
  3. Agent 분석
  4. -
  5. +
  6. + 요청 입력 +
  7. +
  8. + Agent 분석 +
  9. +
  10. {run.analysis_outcome === 'NEEDS_INFO' ? '3' : '✓'}정보 확인
  11. -
  12. +
  13. 4후보 검토
@@ -264,8 +338,12 @@ export function AiRunReview({ initialRun, initialDraft }: AiRunReviewProps) {

HR이 확인할 정보 {run.questions.length}개

{run.questions.map((question) => (
-
))} -

답변은 현재 분석 실행에 저장되며 새 분석 시도로 이어집니다.

+

+ 답변은 현재 분석 실행에 저장되며 새 분석 시도로 이어집니다. +

)} {run.status === 'FAILED' && (

분석을 계속할 수 없습니다

-

오류 코드: {run.error_code ?? 'UNKNOWN_ERROR'}

-

업무는 생성되지 않았습니다. 원문은 그대로 유지됩니다.

+

+ 오류 코드: {run.error_code ?? 'UNKNOWN_ERROR'} +

+

+ 업무는 생성되지 않았습니다. 원문은 그대로 유지됩니다. +

)} - {error &&

{error}

} + {error && ( +

+ {error} +

+ )}