Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions src/api/aiRuns.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>
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<AiRunResponse> {
return apiFetch<AiRunResponse>('/ai-runs', {
method: 'POST',
headers: { 'Idempotency-Key': idempotencyKey },
body: JSON.stringify({ instruction }),
})
}

export function fetchAiRun(aiRunId: string): Promise<AiRunResponse> {
return apiFetch<AiRunResponse>(`/ai-runs/${encodeURIComponent(aiRunId)}`)
}

export function submitAiRunAnswers(
aiRunId: string,
expectedVersion: number,
answers: Record<string, string>,
): Promise<AiRunResponse> {
return apiFetch<AiRunResponse>(`/ai-runs/${encodeURIComponent(aiRunId)}/answers`, {
method: 'POST',
body: JSON.stringify({ expected_version: expectedVersion, answers }),
})
}
33 changes: 33 additions & 0 deletions src/pages/CreateWorkPage/CreateWorkPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,21 @@ const CATALOG = {
},
],
}
const AI_RUN = {
ai_run_id: 'A-1',
request_id: 'R-1',
instruction: '체류연장 준비, 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',
}

beforeEach(() => {
useToastStore.setState({ toasts: [] })
Expand All @@ -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 }))
})
})
Expand All @@ -68,6 +84,7 @@ function renderPage() {
}
/>
<Route path="/tasks/:taskId" element={<p>업무 상세</p>} />
<Route path="/tasks/new/review" element={<p>Agent 추가 질문</p>} />
</Routes>
</MemoryRouter>,
)
Expand All @@ -94,6 +111,22 @@ describe('CreateWorkPage', () => {
expect(screen.getByLabelText('업무 요청 내용')).toHaveValue('체류연장 준비')
})

it('sends the natural-language request with an intent hint and opens the review page', async () => {
const user = userEvent.setup()
renderPage()

await user.click(screen.getByRole('button', { name: '체류연장 준비' }))
await user.click(screen.getByRole('button', { name: '요청 분석하기 →' }))

expect(await screen.findByText('Agent 추가 질문')).toBeInTheDocument()
const analyzeCall = vi.mocked(fetch).mock.calls.find(([url]) => String(url).endsWith('/ai-runs'))
expect(analyzeCall).toBeDefined()
expect(JSON.parse((analyzeCall![1] as RequestInit).body as string)).toEqual({
instruction: '체류연장 준비, EXPIRY_RENEWAL',
})
expect(new Headers((analyzeCall![1] as RequestInit).headers).get('Idempotency-Key')).toBeTruthy()
})

it('switches the active input mode', async () => {
const user = userEvent.setup()
renderPage()
Expand Down
30 changes: 25 additions & 5 deletions src/pages/CreateWorkPage/CreateWorkPage.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useCallback, useMemo, useState } from 'react'
import { Link, useLocation, useNavigate } from 'react-router-dom'
import { ApiError, getErrorMessage } from '../../api/errors'
import { createAiRun } from '../../api/aiRuns'
import { createTask, type TaskType } from '../../api/tasks'
import { fetchWorkers } from '../../api/workers'
import { fetchWorkflowCatalog } from '../../api/workflows'
Expand All @@ -12,9 +13,11 @@ import { TASK_TYPE_LABEL } from '../../utils/taskStatus'
import styles from './CreateWorkPage.module.css'
import {
AGENT_TRACE_PREVIEW,
EXAMPLE_PROMPT_INTENTS,
EXAMPLE_PROMPTS,
INPUT_MODES,
MAX_LENGTH,
instructionWithHint,
type InputModeId,
} from './createWorkData'
import { ImportWizardModal } from './importWizard/ImportWizardModal'
Expand All @@ -34,6 +37,7 @@ export function CreateWorkPage() {
const prefill = (location.state as { prefill?: string } | null)?.prefill
const [mode, setMode] = useState<InputModeId>('nl')
const [request, setRequest] = useState(prefill ?? '')
const [intentHint, setIntentHint] = useState<string | null>(null)
const [importWizardOpen, setImportWizardOpen] = useState(false)
const showToast = useToastStore((state) => state.showToast)

Expand All @@ -50,6 +54,8 @@ export function CreateWorkPage() {
const [dueDate, setDueDate] = useState('')
const [slotValues, setSlotValues] = useState<Record<string, string>>({})
const [submitting, setSubmitting] = useState(false)
const [analyzing, setAnalyzing] = useState(false)
const [analysisError, setAnalysisError] = useState<string | null>(null)
const [formError, setFormError] = useState<string | null>(null)

const workerOptions = useMemo(
Expand Down Expand Up @@ -109,13 +115,25 @@ export function CreateWorkPage() {
}
}

function handleExampleClick(example: string) {
function handleExampleClick(example: (typeof EXAMPLE_PROMPTS)[number]) {
setRequest(example)
setIntentHint(EXAMPLE_PROMPT_INTENTS[example])
}

function handleAnalyze() {
// TODO(backend): POST /api/work-items/analyze { mode, request } -> 분류·필수정보 확인 결과 반영
navigate('/tasks/new/review')
async function handleAnalyze() {
if (request.trim() === '' || analyzing) return
setAnalyzing(true)
setAnalysisError(null)
try {
const instruction = instructionWithHint(request, intentHint)
const idempotencyKey = globalThis.crypto.randomUUID()
const aiRun = await createAiRun(instruction, idempotencyKey)
navigate(`/tasks/new/review?aiRunId=${encodeURIComponent(aiRun.ai_run_id)}`, { state: { aiRun } })
} catch (error) {
setAnalysisError(error instanceof ApiError ? getErrorMessage(error) : '요청을 분석하지 못했습니다.')
} finally {
setAnalyzing(false)
}
}

function handleSaveDraft() {
Expand Down Expand Up @@ -235,11 +253,13 @@ export function CreateWorkPage() {
<Link to="/tasks" className={styles.cancel}>
취소
</Link>
<Button onClick={handleAnalyze} disabled={request.trim() === ''}>
<Button onClick={handleAnalyze} disabled={request.trim() === '' || analyzing} isLoading={analyzing}>
요청 분석하기 →
</Button>
</div>

{analysisError && <p className={styles.fieldError}>{analysisError}</p>}

<p className={styles.footnote}>
버튼·파일·정기 실행은 등록된 처리 절차로 직접 연결되며, 자연어 요청만 분류와 정보 확인을
거칩니다.
Expand Down
14 changes: 13 additions & 1 deletion src/pages/CreateWorkPage/createWorkData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,19 @@ export const INPUT_MODES = [

export type InputModeId = (typeof INPUT_MODES)[number]['id']

export const EXAMPLE_PROMPTS = ['체류연장 준비', '입사자료 취합', '외부기관 제출', '근태자료 설명']
export const EXAMPLE_PROMPTS = ['체류연장 준비', '입사자료 취합', '외부기관 제출', '근태자료 설명'] as const

export const EXAMPLE_PROMPT_INTENTS: Record<(typeof EXAMPLE_PROMPTS)[number], string> = {
'체류연장 준비': 'EXPIRY_RENEWAL',
'입사자료 취합': 'WORKER_ONBOARDING',
'외부기관 제출': 'DOCUMENT_REQUEST',
'근태자료 설명': 'WORK_INSTRUCTION',
}

export function instructionWithHint(instruction: string, intentHint: string | null) {
const normalized = instruction.trim()
return intentHint ? `${normalized}, ${intentHint}` : normalized
}

export const MAX_LENGTH = 2000

Expand Down
Loading