diff --git a/src/api/aiRuns.ts b/src/api/aiRuns.ts new file mode 100644 index 0000000..a560e8a --- /dev/null +++ b/src/api/aiRuns.ts @@ -0,0 +1,61 @@ +import { apiFetch } from './client' + +export type AiRunStatus = 'QUEUED' | 'RUNNING' | 'SUCCEEDED' | 'FAILED' +export type AiAnalysisOutcome = 'CONTEXT_REQUIRED' | 'NEEDS_INFO' | 'REVIEW_REQUIRED' + +export interface AiRunQuestion { + slot_key: string + label: string + input_type: string + required: boolean + answer: string | null +} + +export interface AiRunCandidate { + candidate_id: string + candidate_ref: string + worker_id: string | null + workflow_id: string + extracted_slots: Record + missing_slots: string[] + confidence: number | null +} + +export interface AiRunResponse { + ai_run_id: string + request_id: string + instruction: string + status: AiRunStatus + analysis_outcome: AiAnalysisOutcome | null + detected_intent: string | null + error_code: string | null + attempt_count: number + version: number + questions: AiRunQuestion[] + candidates: AiRunCandidate[] + created_at: string + updated_at: string +} + +export function createAiRun(instruction: string, idempotencyKey: string): Promise { + return apiFetch('/ai-runs', { + method: 'POST', + headers: { 'Idempotency-Key': idempotencyKey }, + body: JSON.stringify({ instruction }), + }) +} + +export function fetchAiRun(aiRunId: string): Promise { + return apiFetch(`/ai-runs/${encodeURIComponent(aiRunId)}`) +} + +export function submitAiRunAnswers( + aiRunId: string, + expectedVersion: number, + answers: Record, +): Promise { + return apiFetch(`/ai-runs/${encodeURIComponent(aiRunId)}/answers`, { + method: 'POST', + body: JSON.stringify({ expected_version: expectedVersion, answers }), + }) +} diff --git a/src/pages/CreateWorkPage/CreateWorkPage.test.tsx b/src/pages/CreateWorkPage/CreateWorkPage.test.tsx index 32b2e3a..5884d79 100644 --- a/src/pages/CreateWorkPage/CreateWorkPage.test.tsx +++ b/src/pages/CreateWorkPage/CreateWorkPage.test.tsx @@ -38,6 +38,21 @@ const CATALOG = { }, ], } +const AI_RUN = { + ai_run_id: 'A-1', + request_id: 'R-1', + instruction: '체류연장 준비', + status: 'SUCCEEDED', + analysis_outcome: 'NEEDS_INFO', + detected_intent: 'EXPIRY_RENEWAL', + error_code: null, + attempt_count: 2, + version: 2, + questions: [{ slot_key: 'due_at', label: '신청 목표일을 입력해 주세요.', input_type: 'DATE', required: true, answer: null }], + candidates: [], + created_at: '2026-08-04T00:00:00Z', + updated_at: '2026-08-04T00:00:01Z', +} beforeEach(() => { useToastStore.setState({ toasts: [] }) @@ -46,6 +61,7 @@ beforeEach(() => { const url = String(input) if (url.includes('/workflow-catalogs')) return Promise.resolve(jsonResponse(CATALOG)) if (url.includes('/workers')) return Promise.resolve(jsonResponse(WORKER_PAGE)) + if (url.includes('/ai-runs')) return Promise.resolve(jsonResponse(AI_RUN, { status: 202 })) return Promise.resolve(jsonResponse({ task_id: 'T-new' }, { status: 201 })) }) }) @@ -68,6 +84,7 @@ function renderPage() { } /> 업무 상세
{analysisError}
버튼·파일·정기 실행은 등록된 처리 절차로 직접 연결되며, 자연어 요청만 분류와 정보 확인을 거칩니다. diff --git a/src/pages/CreateWorkPage/createWorkData.ts b/src/pages/CreateWorkPage/createWorkData.ts index aa57d62..50a2aaf 100644 --- a/src/pages/CreateWorkPage/createWorkData.ts +++ b/src/pages/CreateWorkPage/createWorkData.ts @@ -10,7 +10,7 @@ export const INPUT_MODES = [ export type InputModeId = (typeof INPUT_MODES)[number]['id'] -export const EXAMPLE_PROMPTS = ['체류연장 준비', '입사자료 취합', '외부기관 제출', '근태자료 설명'] +export const EXAMPLE_PROMPTS = ['체류연장 준비', '입사자료 취합', '외부기관 제출', '근태자료 설명'] as const export const MAX_LENGTH = 2000 diff --git a/src/pages/ReviewWorkPage/AiRunReview.tsx b/src/pages/ReviewWorkPage/AiRunReview.tsx new file mode 100644 index 0000000..489fcb7 --- /dev/null +++ b/src/pages/ReviewWorkPage/AiRunReview.tsx @@ -0,0 +1,221 @@ +import { useEffect, useMemo, useState } from 'react' +import { Link, useNavigate } from 'react-router-dom' +import { ApiError, getErrorMessage } from '../../api/errors' +import { + fetchAiRun, + submitAiRunAnswers, + type AiRunResponse, + type AiRunQuestion, +} from '../../api/aiRuns' +import { Button } from '../../components/ui/Button/Button' +import { StatusLabel, type StatusTone } from '../../components/ui/StatusLabel/StatusLabel' +import styles from './ReviewWorkPage.module.css' + +interface AiRunReviewProps { + initialRun: AiRunResponse +} + +function statusPresentation(run: AiRunResponse): { title: string; description: string; tone: StatusTone } { + if (run.status === 'FAILED') { + return { + title: 'Agent가 요청을 완료하지 못했습니다.', + description: '잠시 후 다시 요청하거나 담당자에게 오류 코드를 알려 주세요.', + tone: 'critical', + } + } + if (run.status === 'QUEUED' || run.status === 'RUNNING' || run.analysis_outcome === 'CONTEXT_REQUIRED') { + return { + title: 'Agent가 요청을 분석하고 있습니다.', + description: '등록된 근로자 정보와 처리 절차를 확인하고 있습니다.', + tone: 'agent', + } + } + if (run.analysis_outcome === 'NEEDS_INFO') { + return { + title: `Agent가 확인할 정보 ${run.questions.length}개를 찾았습니다.`, + description: '아래 질문에 답하면 같은 요청을 이어서 분석합니다.', + tone: 'warning', + } + } + return { + title: `Agent가 요청을 ${run.candidates.length}개의 후보로 정리했습니다.`, + description: '후보 내용을 검토해 실제 업무로 만들 항목을 선택합니다.', + tone: 'success', + } +} + +function inputType(question: AiRunQuestion) { + return question.input_type.toUpperCase() === 'DATE' ? 'date' : 'text' +} + +export function AiRunReview({ initialRun }: AiRunReviewProps) { + const navigate = useNavigate() + const [run, setRun] = useState(initialRun) + const [answers, setAnswers] = useState>(() => + Object.fromEntries(initialRun.questions.map((question) => [question.slot_key, question.answer ?? ''])), + ) + const [submitting, setSubmitting] = useState(false) + const [error, setError] = useState(null) + const presentation = statusPresentation(run) + + useEffect(() => { + if (run.status !== 'QUEUED' && run.status !== 'RUNNING') return + + let cancelled = false + const timer = window.setTimeout(async () => { + try { + const latest = await fetchAiRun(run.ai_run_id) + if (!cancelled) setRun(latest) + } catch (pollError) { + if (!cancelled) { + setError(pollError instanceof ApiError ? getErrorMessage(pollError) : '분석 상태를 확인하지 못했습니다.') + } + } + }, 1200) + + return () => { + cancelled = true + window.clearTimeout(timer) + } + }, [run]) + + useEffect(() => { + setAnswers(Object.fromEntries(run.questions.map((question) => [question.slot_key, question.answer ?? '']))) + }, [run.questions]) + + const canSubmitAnswers = useMemo( + () => + run.analysis_outcome === 'NEEDS_INFO' && + run.questions.every((question) => !question.required || answers[question.slot_key]?.trim()), + [answers, run.analysis_outcome, run.questions], + ) + + async function handleSubmitAnswers() { + if (!canSubmitAnswers || submitting) return + setSubmitting(true) + setError(null) + try { + const submittedAnswers = Object.fromEntries( + Object.entries(answers).filter(([, value]) => value.trim() !== ''), + ) + setRun(await submitAiRunAnswers(run.ai_run_id, run.version, submittedAnswers)) + } catch (submitError) { + setError( + submitError instanceof ApiError ? getErrorMessage(submitError) : '추가 정보를 제출하지 못했습니다.', + ) + } finally { + setSubmitting(false) + } + } + + return ( + + + + ← 요청 수정 + + 실행 {run.ai_run_id.slice(0, 8)} + + + + + {presentation.title} + {presentation.description} + + + {run.status === 'FAILED' ? '분석 실패' : run.analysis_outcome ?? '분석 중'} + + + + + ✓요청 입력 + ✓Agent 분석 + + {run.analysis_outcome === 'NEEDS_INFO' ? '3' : '✓'}정보 확인 + + + 4후보 검토 + + + + + + + 전달한 요청 + 원문과 선택한 Intent 힌트 + {run.instruction} + + 감지 Intent: {run.detected_intent ?? '분석 중'} + 시도 횟수: {run.attempt_count} + + + + {run.analysis_outcome === 'NEEDS_INFO' && ( + + 추가 정보가 필요합니다 + {run.questions.map((question) => ( + + + {question.label}{question.required ? ' *' : ''} + + + setAnswers((current) => ({ ...current, [question.slot_key]: event.target.value })) + } + /> + + ))} + 답변은 현재 AiRun에 저장되며 새 분석 시도로 이어집니다. + + )} + + {run.status === 'FAILED' && ( + + Runtime 호출을 확인해 주세요 + 오류 코드: {run.error_code ?? 'UNKNOWN_ERROR'} + + )} + + {error && {error}} + + + + + + + 요청 수정 + {run.analysis_outcome === 'NEEDS_INFO' ? ( + + 답변하고 다시 분석 + + ) : ( + navigate('/tasks')} disabled={run.candidates.length === 0}> + 후보 확인 완료 + + )} + + + 후보는 아직 실제 업무가 아닙니다. HR이 검토한 뒤 별도 확정 단계가 필요합니다. + + ) +} diff --git a/src/pages/ReviewWorkPage/ReviewWorkPage.module.css b/src/pages/ReviewWorkPage/ReviewWorkPage.module.css index 395ed50..aa38c39 100644 --- a/src/pages/ReviewWorkPage/ReviewWorkPage.module.css +++ b/src/pages/ReviewWorkPage/ReviewWorkPage.module.css @@ -35,6 +35,11 @@ padding: 0; } +.runReference { + font-size: 12px; + color: var(--text-secondary); +} + .headerRow { display: flex; align-items: flex-start; @@ -205,6 +210,24 @@ border-radius: var(--fowoco-radius-6); } +.instruction { + margin: 0; + padding: 16px; + line-height: 1.6; + color: var(--text-primary); + background: var(--surface-subtle); + border-radius: var(--fowoco-radius-8); +} + +.runMetadata { + display: flex; + flex-wrap: wrap; + gap: 16px; + margin-top: 12px; + font-size: 12px; + color: var(--text-secondary); +} + .cardLinks { display: flex; gap: 24px; @@ -246,6 +269,33 @@ color: var(--status-warning); } +.questionField + .questionField { + margin-top: 16px; +} + +.questionInput { + width: 100%; + min-height: 42px; + padding: 10px 12px; + color: var(--text-primary); + background: var(--surface-default); + border: 1px solid var(--border-default); + border-radius: var(--fowoco-radius-6); + box-sizing: border-box; +} + +.errorCard { + padding: var(--fowoco-spacing-20) var(--fowoco-spacing-24); + background: var(--fowoco-red-50, #fff1f1); + border-radius: var(--fowoco-radius-8); +} + +.fieldError { + margin: 12px 0 0; + color: var(--status-critical, #b42318); + font-size: 13px; +} + .draftPanel { padding: var(--fowoco-spacing-20) var(--fowoco-spacing-24); background: var(--surface-default); @@ -275,6 +325,29 @@ color: var(--text-primary); } +.emptyState { + margin: 16px 0 0; + line-height: 1.6; + font-size: 13px; + color: var(--text-secondary); +} + +.candidateCard { + padding: 16px 0; + border-bottom: 1px solid var(--border-default); +} + +.candidateCard:last-child { + border-bottom: 0; +} + +.candidateMeta { + margin: 6px 0 0; + font-size: 12px; + overflow-wrap: anywhere; + color: var(--text-secondary); +} + .draftLink { display: block; margin-top: 16px; diff --git a/src/pages/ReviewWorkPage/ReviewWorkPage.test.tsx b/src/pages/ReviewWorkPage/ReviewWorkPage.test.tsx index 1ae4321..e9e64fe 100644 --- a/src/pages/ReviewWorkPage/ReviewWorkPage.test.tsx +++ b/src/pages/ReviewWorkPage/ReviewWorkPage.test.tsx @@ -1,7 +1,8 @@ import { render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { MemoryRouter } from 'react-router-dom' -import { beforeEach, describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { AiRunResponse } from '../../api/aiRuns' import { ToastViewport } from '../../components/ui/ToastViewport/ToastViewport' import { useToastStore } from '../../store/toastStore' import { ReviewWorkPage } from './ReviewWorkPage' @@ -14,13 +15,26 @@ import { UNDERSTOOD_REQUEST, } from './reviewWorkData' +function jsonResponse(body: unknown, init: ResponseInit = {}) { + return new Response(JSON.stringify(body), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + ...init, + }) +} + beforeEach(() => { useToastStore.setState({ toasts: [] }) + vi.stubGlobal('fetch', vi.fn()) +}) + +afterEach(() => { + vi.unstubAllGlobals() }) -function renderPage() { +function renderPage(aiRun?: AiRunResponse, path = '/tasks/new/review') { render( - + , @@ -82,4 +96,83 @@ describe('ReviewWorkPage', () => { expect(screen.getByText('분석 근거 보기는 준비 중입니다.')).toBeInTheDocument() }) + + it('submits missing information and renders the returned candidate', async () => { + const initialRun: AiRunResponse = { + ai_run_id: 'A-1', + request_id: 'R-1', + instruction: '응웬반A 체류연장 준비해줘, EXPIRY_RENEWAL', + status: 'SUCCEEDED', + analysis_outcome: 'NEEDS_INFO', + detected_intent: 'EXPIRY_RENEWAL', + error_code: null, + attempt_count: 2, + version: 2, + questions: [ + { slot_key: 'due_at', label: '신청 목표일을 입력해 주세요.', input_type: 'DATE', required: true, answer: null }, + ], + candidates: [], + created_at: '2026-08-04T00:00:00Z', + updated_at: '2026-08-04T00:00:01Z', + } + const completedRun: AiRunResponse = { + ...initialRun, + analysis_outcome: 'REVIEW_REQUIRED', + attempt_count: 3, + 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.96, + }, + ], + } + vi.mocked(fetch).mockResolvedValue(jsonResponse(completedRun, { status: 202 })) + const user = userEvent.setup() + renderPage(initialRun) + + await user.type(screen.getByLabelText('신청 목표일을 입력해 주세요. *'), '2026-08-31') + await user.click(screen.getByRole('button', { name: '답변하고 다시 분석' })) + + expect(await screen.findByText('WF-STY-001')).toBeInTheDocument() + const answerCall = vi.mocked(fetch).mock.calls[0] + expect(String(answerCall[0])).toContain('/ai-runs/A-1/answers') + expect(JSON.parse((answerCall[1] as RequestInit).body as string)).toEqual({ + expected_version: 2, + answers: { due_at: '2026-08-31' }, + }) + }) + + it('restores the AI run from the URL after a refresh', async () => { + const savedRun: AiRunResponse = { + ai_run_id: 'A-RESTORED', + request_id: 'R-RESTORED', + instruction: '응웬반A 체류연장 준비해줘, EXPIRY_RENEWAL', + status: 'SUCCEEDED', + analysis_outcome: 'NEEDS_INFO', + detected_intent: 'EXPIRY_RENEWAL', + error_code: null, + attempt_count: 1, + version: 1, + questions: [ + { slot_key: 'due_at', label: '신청 목표일을 입력해 주세요.', input_type: 'DATE', required: true, answer: null }, + ], + candidates: [], + created_at: '2026-08-04T00:00:00Z', + updated_at: '2026-08-04T00:00:01Z', + } + vi.mocked(fetch).mockResolvedValue(jsonResponse(savedRun)) + + renderPage(undefined, '/tasks/new/review?aiRunId=A-RESTORED') + + expect(screen.getByText('Agent 분석 결과를 불러오고 있습니다.')).toBeInTheDocument() + expect(await screen.findByText(savedRun.instruction)).toBeInTheDocument() + expect(String(vi.mocked(fetch).mock.calls[0][0])).toContain('/ai-runs/A-RESTORED') + }) }) diff --git a/src/pages/ReviewWorkPage/ReviewWorkPage.tsx b/src/pages/ReviewWorkPage/ReviewWorkPage.tsx index 7e7e1f9..ccaec03 100644 --- a/src/pages/ReviewWorkPage/ReviewWorkPage.tsx +++ b/src/pages/ReviewWorkPage/ReviewWorkPage.tsx @@ -1,11 +1,14 @@ -import { useState } from 'react' -import { Link, useNavigate } from 'react-router-dom' +import { useEffect, useState } from 'react' +import { Link, useLocation, useNavigate } from 'react-router-dom' +import { ApiError, getErrorMessage } from '../../api/errors' +import { fetchAiRun, type AiRunResponse } from '../../api/aiRuns' import { Button } from '../../components/ui/Button/Button' import { DetailRow } from '../../components/ui/DetailRow/DetailRow' import { Dropdown } from '../../components/ui/Dropdown/Dropdown' import { StatusLabel } from '../../components/ui/StatusLabel/StatusLabel' import { useToastStore } from '../../store/toastStore' import styles from './ReviewWorkPage.module.css' +import { AiRunReview } from './AiRunReview' import { CURRENT_STEP_INDEX, DRAFT_REASONS, @@ -23,9 +26,64 @@ const INSTITUTION_OPTIONS = [ export function ReviewWorkPage() { const navigate = useNavigate() + const location = useLocation() const [institution, setInstitution] = useState('') + const navigationRun = (location.state as { aiRun?: AiRunResponse } | null)?.aiRun + const aiRunId = new URLSearchParams(location.search).get('aiRunId') + const [recoveredRun, setRecoveredRun] = useState(null) + const [recovering, setRecovering] = useState(Boolean(aiRunId && !navigationRun)) + const [recoveryError, setRecoveryError] = useState(null) const canCreate = institution !== '' const showToast = useToastStore((state) => state.showToast) + const aiRun = navigationRun ?? recoveredRun + + useEffect(() => { + if (!aiRunId || navigationRun) return + + let cancelled = false + setRecovering(true) + setRecoveryError(null) + fetchAiRun(aiRunId) + .then((run) => { + if (!cancelled) setRecoveredRun(run) + }) + .catch((error) => { + if (!cancelled) { + setRecoveryError(error instanceof ApiError ? getErrorMessage(error) : '분석 결과를 불러오지 못했습니다.') + } + }) + .finally(() => { + if (!cancelled) setRecovering(false) + }) + + return () => { + cancelled = true + } + }, [aiRunId, navigationRun]) + + if (aiRun) { + return + } + + if (recovering || recoveryError) { + return ( + + + ← 요청 수정 + + + + + {recovering ? 'Agent 분석 결과를 불러오고 있습니다.' : 'Agent 분석 결과를 불러오지 못했습니다.'} + + + {recoveryError ?? '저장된 실행 번호로 최신 상태를 확인합니다.'} + + + + + ) + } function handleCreate() { // TODO(backend): POST /api/work-items { ...UNDERSTOOD_REQUEST, institution } -> 생성 후 WORK-001로 이동 diff --git a/vite.config.ts b/vite.config.ts index d74bb4f..30a9c2f 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -4,6 +4,11 @@ import react from '@vitejs/plugin-react' // https://vite.dev/config/ export default defineConfig({ plugins: [react()], + server: { + proxy: { + '/api': 'http://127.0.0.1:8080', + }, + }, test: { environment: 'jsdom', globals: true,
{presentation.description}
원문과 선택한 Intent 힌트
{run.instruction}
답변은 현재 AiRun에 저장되며 새 분석 시도로 이어집니다.
오류 코드: {run.error_code ?? 'UNKNOWN_ERROR'}
{error}
후보는 아직 실제 업무가 아닙니다. HR이 검토한 뒤 별도 확정 단계가 필요합니다.
+ {recoveryError ?? '저장된 실행 번호로 최신 상태를 확인합니다.'} +