From 2345527d941d40658aa02c2f84f9556ebb48f8fd Mon Sep 17 00:00:00 2001
From: hywznn
Date: Sat, 8 Aug 2026 23:57:39 +0900
Subject: [PATCH] =?UTF-8?q?feat:=20AI=20=ED=9B=84=EB=B3=B4=20=EA=B2=B0?=
=?UTF-8?q?=EC=A0=95=EA=B3=BC=20=EC=97=85=EB=AC=B4=20=EC=83=9D=EC=84=B1=20?=
=?UTF-8?q?=ED=9D=90=EB=A6=84=20=EC=97=B0=EA=B2=B0?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/api/aiRuns.test.ts | 52 ++++
src/api/aiRuns.ts | 35 +++
.../ReviewWorkPage/AiRunReview.module.css | 20 +-
src/pages/ReviewWorkPage/AiRunReview.test.tsx | 186 +++++++++++++
src/pages/ReviewWorkPage/AiRunReview.tsx | 257 ++++++++++++++----
5 files changed, 492 insertions(+), 58 deletions(-)
create mode 100644 src/api/aiRuns.test.ts
create mode 100644 src/pages/ReviewWorkPage/AiRunReview.test.tsx
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) {
- - ✓요청 입력
- - ✓Agent 분석
- -
+
-
+ ✓요청 입력
+
+ -
+ ✓Agent 분석
+
+ -
{run.analysis_outcome === 'NEEDS_INFO' ? '3' : '✓'}정보 확인
- -
+
-
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}
+
+ )}