From 3fb65c33f22bc004dc420dda663da866f78f1fab Mon Sep 17 00:00:00 2001 From: hywznn Date: Tue, 4 Aug 2026 10:39:46 +0900 Subject: [PATCH 1/5] =?UTF-8?q?feat(airun):=20AiRun=20=EC=8B=A4=ED=96=89?= =?UTF-8?q?=20=EC=9D=B4=EB=A0=A5=20=EC=98=81=EC=86=8D=ED=99=94=20=EA=B8=B0?= =?UTF-8?q?=EB=B0=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AiRun, AiAttempt, Question, Candidate를 V12에 추가하고 사업장 격리 정책과 JDBC 저장소를 구현합니다. 멱등성 키는 원문 대신 SHA-256 hash를 저장하며 AI 버전과 지연 시간도 Attempt별로 추적합니다. --- .../application/AiRunCandidateResult.java | 27 + .../airun/application/AiRunCreation.java | 18 + .../application/AiRunQuestionResult.java | 17 + .../server/airun/application/AiRunResult.java | 35 + .../application/error/AiRunErrorCode.java | 37 + .../application/port/AiRunRepository.java | 103 +++ .../server/airun/domain/AiRunStatus.java | 8 + .../persistence/JdbcAiRunRepository.java | 661 ++++++++++++++++++ .../V14__prepare_ai_run_rls.sql | 51 ++ .../db/migration/V12__create_ai_run.sql | 158 +++++ 10 files changed, 1115 insertions(+) create mode 100644 src/main/java/com/fowoco/server/airun/application/AiRunCandidateResult.java create mode 100644 src/main/java/com/fowoco/server/airun/application/AiRunCreation.java create mode 100644 src/main/java/com/fowoco/server/airun/application/AiRunQuestionResult.java create mode 100644 src/main/java/com/fowoco/server/airun/application/AiRunResult.java create mode 100644 src/main/java/com/fowoco/server/airun/application/error/AiRunErrorCode.java create mode 100644 src/main/java/com/fowoco/server/airun/application/port/AiRunRepository.java create mode 100644 src/main/java/com/fowoco/server/airun/domain/AiRunStatus.java create mode 100644 src/main/java/com/fowoco/server/airun/infrastructure/persistence/JdbcAiRunRepository.java create mode 100644 src/main/resources/db/migration-postgresql/V14__prepare_ai_run_rls.sql create mode 100644 src/main/resources/db/migration/V12__create_ai_run.sql diff --git a/src/main/java/com/fowoco/server/airun/application/AiRunCandidateResult.java b/src/main/java/com/fowoco/server/airun/application/AiRunCandidateResult.java new file mode 100644 index 0000000..8eeb14d --- /dev/null +++ b/src/main/java/com/fowoco/server/airun/application/AiRunCandidateResult.java @@ -0,0 +1,27 @@ +package com.fowoco.server.airun.application; + +import java.math.BigDecimal; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.UUID; + +public record AiRunCandidateResult( + UUID candidateId, + String candidateRef, + UUID workerId, + String workflowId, + Map extractedSlots, + List missingSlots, + BigDecimal confidence +) { + public AiRunCandidateResult { + Objects.requireNonNull(candidateId, "candidateId must not be null"); + Objects.requireNonNull(candidateRef, "candidateRef must not be null"); + Objects.requireNonNull(workerId, "workerId must not be null"); + Objects.requireNonNull(workflowId, "workflowId must not be null"); + extractedSlots = Map.copyOf(extractedSlots); + missingSlots = List.copyOf(missingSlots); + Objects.requireNonNull(confidence, "confidence must not be null"); + } +} diff --git a/src/main/java/com/fowoco/server/airun/application/AiRunCreation.java b/src/main/java/com/fowoco/server/airun/application/AiRunCreation.java new file mode 100644 index 0000000..1c60043 --- /dev/null +++ b/src/main/java/com/fowoco/server/airun/application/AiRunCreation.java @@ -0,0 +1,18 @@ +package com.fowoco.server.airun.application; + +import com.fowoco.server.aiintegration.application.model.AiAnalysisRequest; +import java.util.Objects; +import java.util.UUID; + +public record AiRunCreation( + UUID aiRunId, + UUID companyId, + AiAnalysisRequest request, + boolean newlyCreated +) { + public AiRunCreation { + Objects.requireNonNull(aiRunId, "aiRunId must not be null"); + Objects.requireNonNull(companyId, "companyId must not be null"); + Objects.requireNonNull(request, "request must not be null"); + } +} diff --git a/src/main/java/com/fowoco/server/airun/application/AiRunQuestionResult.java b/src/main/java/com/fowoco/server/airun/application/AiRunQuestionResult.java new file mode 100644 index 0000000..24370a0 --- /dev/null +++ b/src/main/java/com/fowoco/server/airun/application/AiRunQuestionResult.java @@ -0,0 +1,17 @@ +package com.fowoco.server.airun.application; + +import java.util.Objects; + +public record AiRunQuestionResult( + String slotKey, + String label, + String inputType, + boolean required, + String answer +) { + public AiRunQuestionResult { + Objects.requireNonNull(slotKey, "slotKey must not be null"); + Objects.requireNonNull(label, "label must not be null"); + Objects.requireNonNull(inputType, "inputType must not be null"); + } +} diff --git a/src/main/java/com/fowoco/server/airun/application/AiRunResult.java b/src/main/java/com/fowoco/server/airun/application/AiRunResult.java new file mode 100644 index 0000000..64c2783 --- /dev/null +++ b/src/main/java/com/fowoco/server/airun/application/AiRunResult.java @@ -0,0 +1,35 @@ +package com.fowoco.server.airun.application; + +import com.fowoco.server.aiintegration.application.model.AiAnalysisOutcome; +import com.fowoco.server.airun.domain.AiRunStatus; +import java.time.Instant; +import java.util.List; +import java.util.Objects; +import java.util.UUID; + +public record AiRunResult( + UUID aiRunId, + UUID requestId, + String instruction, + AiRunStatus status, + AiAnalysisOutcome analysisOutcome, + String detectedIntent, + String errorCode, + int attemptCount, + long version, + List questions, + List candidates, + Instant createdAt, + Instant updatedAt +) { + public AiRunResult { + Objects.requireNonNull(aiRunId, "aiRunId must not be null"); + Objects.requireNonNull(requestId, "requestId must not be null"); + Objects.requireNonNull(instruction, "instruction must not be null"); + Objects.requireNonNull(status, "status must not be null"); + questions = List.copyOf(questions); + candidates = List.copyOf(candidates); + Objects.requireNonNull(createdAt, "createdAt must not be null"); + Objects.requireNonNull(updatedAt, "updatedAt must not be null"); + } +} diff --git a/src/main/java/com/fowoco/server/airun/application/error/AiRunErrorCode.java b/src/main/java/com/fowoco/server/airun/application/error/AiRunErrorCode.java new file mode 100644 index 0000000..4fcc97e --- /dev/null +++ b/src/main/java/com/fowoco/server/airun/application/error/AiRunErrorCode.java @@ -0,0 +1,37 @@ +package com.fowoco.server.airun.application.error; + +import com.fowoco.server.common.error.ApiErrorCode; +import org.springframework.http.HttpStatus; + +public enum AiRunErrorCode implements ApiErrorCode { + AI_RUN_NOT_FOUND(HttpStatus.NOT_FOUND, "AI 분석 요청을 찾을 수 없습니다."), + AI_RUN_IDEMPOTENCY_CONFLICT(HttpStatus.CONFLICT, "같은 Idempotency-Key가 다른 요청에 이미 사용되었습니다."), + AI_RUN_VERSION_CONFLICT(HttpStatus.CONFLICT, "다른 요청에서 먼저 변경했습니다. 최신 상태를 다시 확인해 주세요."), + AI_RUN_ANSWERS_NOT_ALLOWED(HttpStatus.UNPROCESSABLE_ENTITY, "현재 상태에서는 추가 답변을 제출할 수 없습니다."), + AI_RUN_INVALID_INSTRUCTION(HttpStatus.BAD_REQUEST, "업무 요청 문장을 확인해 주세요."), + AI_RUN_INVALID_IDEMPOTENCY_KEY(HttpStatus.BAD_REQUEST, "Idempotency-Key를 확인해 주세요."), + AI_RUN_INVALID_ANSWER(HttpStatus.BAD_REQUEST, "추가 답변의 항목과 값을 확인해 주세요."); + + private final HttpStatus status; + private final String defaultMessage; + + AiRunErrorCode(HttpStatus status, String defaultMessage) { + this.status = status; + this.defaultMessage = defaultMessage; + } + + @Override + public String code() { + return name(); + } + + @Override + public HttpStatus status() { + return status; + } + + @Override + public String defaultMessage() { + return defaultMessage; + } +} diff --git a/src/main/java/com/fowoco/server/airun/application/port/AiRunRepository.java b/src/main/java/com/fowoco/server/airun/application/port/AiRunRepository.java new file mode 100644 index 0000000..cd7490a --- /dev/null +++ b/src/main/java/com/fowoco/server/airun/application/port/AiRunRepository.java @@ -0,0 +1,103 @@ +package com.fowoco.server.airun.application.port; + +import com.fowoco.server.aiintegration.application.model.AiAnalysisPhase; +import com.fowoco.server.aiintegration.application.model.AiAnalysisResponse; +import com.fowoco.server.aiintegration.application.model.AnalysisInput; +import com.fowoco.server.airun.application.AiRunResult; +import java.time.Instant; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; + +public interface AiRunRepository { + + Optional findByIdempotencyKeyHash(UUID companyId, String keyHash); + + void insertPlan(PlanRun run); + + Optional findByIdAndCompanyId(UUID aiRunId, UUID companyId); + + Optional findExecutionState(UUID aiRunId, UUID companyId); + + Optional findExecutionStateByRequestId(UUID requestId); + + void insertAttempt(Attempt attempt); + + ExecutionState startContinuationAttempt( + UUID requestId, + UUID attemptId, + AiAnalysisPhase phase, + int contextRound, + AnalysisInput input, + Instant startedAt + ); + + void markAttemptSucceeded( + UUID aiRunId, + UUID companyId, + UUID attemptId, + AiAnalysisResponse response, + Instant completedAt + ); + + void markAttemptFailed( + UUID aiRunId, + UUID companyId, + UUID attemptId, + String errorCode, + Instant completedAt + ); + + ExecutionState startAnswerAttempt( + UUID aiRunId, + UUID companyId, + UUID actorId, + long expectedVersion, + Map answers, + UUID attemptId, + AnalysisInput input, + Instant startedAt + ); + + record IdempotentRun(UUID aiRunId, UUID requestId, String instructionHash) { + } + + record PlanRun( + UUID aiRunId, + UUID companyId, + UUID actorId, + UUID requestId, + UUID attemptId, + String instruction, + String instructionHash, + String idempotencyKeyHash, + AnalysisInput input, + Instant createdAt + ) { + } + + record Attempt( + UUID attemptId, + UUID aiRunId, + UUID companyId, + UUID requestId, + int sequenceNo, + AiAnalysisPhase phase, + int contextRound, + AnalysisInput input, + Instant startedAt + ) { + } + + record ExecutionState( + UUID aiRunId, + UUID companyId, + UUID requestId, + UUID latestAttemptId, + int attemptCount, + int contextRound, + long version, + AnalysisInput latestInput + ) { + } +} diff --git a/src/main/java/com/fowoco/server/airun/domain/AiRunStatus.java b/src/main/java/com/fowoco/server/airun/domain/AiRunStatus.java new file mode 100644 index 0000000..4750212 --- /dev/null +++ b/src/main/java/com/fowoco/server/airun/domain/AiRunStatus.java @@ -0,0 +1,8 @@ +package com.fowoco.server.airun.domain; + +public enum AiRunStatus { + QUEUED, + RUNNING, + SUCCEEDED, + FAILED +} diff --git a/src/main/java/com/fowoco/server/airun/infrastructure/persistence/JdbcAiRunRepository.java b/src/main/java/com/fowoco/server/airun/infrastructure/persistence/JdbcAiRunRepository.java new file mode 100644 index 0000000..ffdb0f3 --- /dev/null +++ b/src/main/java/com/fowoco/server/airun/infrastructure/persistence/JdbcAiRunRepository.java @@ -0,0 +1,661 @@ +package com.fowoco.server.airun.infrastructure.persistence; + +import com.fowoco.server.aiintegration.application.model.AiAnalysisOutcome; +import com.fowoco.server.aiintegration.application.model.AiAnalysisResponse; +import com.fowoco.server.aiintegration.application.model.AiCandidate; +import com.fowoco.server.aiintegration.application.model.AiQuestion; +import com.fowoco.server.aiintegration.application.model.AnalysisInput; +import com.fowoco.server.airun.application.AiRunCandidateResult; +import com.fowoco.server.airun.application.AiRunQuestionResult; +import com.fowoco.server.airun.application.AiRunResult; +import com.fowoco.server.airun.application.error.AiRunErrorCode; +import com.fowoco.server.airun.application.port.AiRunRepository; +import com.fowoco.server.airun.domain.AiRunStatus; +import com.fowoco.server.common.error.ApiException; +import com.fowoco.server.common.id.UuidGenerator; +import java.math.BigDecimal; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Timestamp; +import java.time.Instant; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; +import tools.jackson.core.JacksonException; +import tools.jackson.databind.ObjectMapper; + +@Repository +public class JdbcAiRunRepository implements AiRunRepository { + + private final JdbcTemplate jdbcTemplate; + private final ObjectMapper objectMapper; + private final UuidGenerator uuidGenerator; + + public JdbcAiRunRepository( + JdbcTemplate jdbcTemplate, + ObjectMapper objectMapper, + UuidGenerator uuidGenerator + ) { + this.jdbcTemplate = jdbcTemplate; + this.objectMapper = objectMapper; + this.uuidGenerator = uuidGenerator; + } + + @Override + public Optional findByIdempotencyKeyHash(UUID companyId, String keyHash) { + return jdbcTemplate.query( + """ + SELECT ai_run_id, request_id, instruction_hash + FROM ai_run + WHERE company_id = ? AND idempotency_key_hash = ? + """, + (resultSet, rowNum) -> new IdempotentRun( + uuid(resultSet, "ai_run_id"), + uuid(resultSet, "request_id"), + resultSet.getString("instruction_hash") + ), + companyId, + keyHash + ).stream().findFirst(); + } + + @Override + @Transactional + public void insertPlan(PlanRun run) { + jdbcTemplate.update( + """ + INSERT INTO ai_run ( + ai_run_id, company_id, requested_by, request_id, + instruction, instruction_hash, idempotency_key_hash, + status, attempt_count, created_at, updated_at, version + ) VALUES (?, ?, ?, ?, ?, ?, ?, 'RUNNING', 1, ?, ?, 0) + """, + run.aiRunId(), + run.companyId(), + run.actorId(), + run.requestId(), + run.instruction(), + run.instructionHash(), + run.idempotencyKeyHash(), + timestamp(run.createdAt()), + timestamp(run.createdAt()) + ); + insertAttempt(new Attempt( + run.attemptId(), + run.aiRunId(), + run.companyId(), + run.requestId(), + 1, + com.fowoco.server.aiintegration.application.model.AiAnalysisPhase.PLAN, + 0, + run.input(), + run.createdAt() + )); + } + + @Override + @Transactional(readOnly = true) + public Optional findByIdAndCompanyId(UUID aiRunId, UUID companyId) { + Optional run = findRunRow(aiRunId, companyId); + if (run.isEmpty()) { + return Optional.empty(); + } + UUID latestAttemptId = latestAttemptId(aiRunId, companyId).orElse(null); + List questions = latestAttemptId == null + ? List.of() + : findQuestions(latestAttemptId, companyId); + List candidates = latestAttemptId == null + ? List.of() + : findCandidates(latestAttemptId, companyId); + RunRow row = run.get(); + return Optional.of(new AiRunResult( + row.aiRunId(), + row.requestId(), + row.instruction(), + row.status(), + row.outcome(), + row.detectedIntent(), + row.errorCode(), + row.attemptCount(), + row.version(), + questions, + candidates, + row.createdAt(), + row.updatedAt() + )); + } + + @Override + @Transactional(readOnly = true) + public Optional findExecutionState(UUID aiRunId, UUID companyId) { + return findExecutionState("run.ai_run_id = ? AND run.company_id = ?", aiRunId, companyId); + } + + @Override + @Transactional(readOnly = true) + public Optional findExecutionStateByRequestId(UUID requestId) { + return findExecutionState("run.request_id = ?", requestId); + } + + @Override + public void insertAttempt(Attempt attempt) { + jdbcTemplate.update( + """ + INSERT INTO ai_attempt ( + ai_attempt_id, ai_run_id, company_id, request_id, + sequence_no, phase, context_round, status, + analysis_input_json, started_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, 'RUNNING', ?, ?) + """, + attempt.attemptId(), + attempt.aiRunId(), + attempt.companyId(), + attempt.requestId(), + attempt.sequenceNo(), + attempt.phase().name(), + attempt.contextRound(), + encode(attempt.input()), + timestamp(attempt.startedAt()) + ); + } + + @Override + @Transactional + public ExecutionState startContinuationAttempt( + UUID requestId, + UUID attemptId, + com.fowoco.server.aiintegration.application.model.AiAnalysisPhase phase, + int contextRound, + AnalysisInput input, + Instant startedAt + ) { + ExecutionState current = findExecutionStateByRequestId(requestId) + .orElseThrow(() -> new IllegalStateException("AI Run for request was not found")); + int nextSequence = current.attemptCount() + 1; + jdbcTemplate.update( + """ + UPDATE ai_run + SET status = 'RUNNING', analysis_outcome = NULL, last_error_code = NULL, + attempt_count = ?, updated_at = ?, version = version + 1 + WHERE ai_run_id = ? AND company_id = ? AND version = ? + """, + nextSequence, + timestamp(startedAt), + current.aiRunId(), + current.companyId(), + current.version() + ); + insertAttempt(new Attempt( + attemptId, + current.aiRunId(), + current.companyId(), + requestId, + nextSequence, + phase, + contextRound, + input, + startedAt + )); + return new ExecutionState( + current.aiRunId(), + current.companyId(), + requestId, + attemptId, + nextSequence, + contextRound, + current.version() + 1, + input + ); + } + + @Override + @Transactional + public void markAttemptSucceeded( + UUID aiRunId, + UUID companyId, + UUID attemptId, + AiAnalysisResponse response, + Instant completedAt + ) { + int attempts = jdbcTemplate.update( + """ + UPDATE ai_attempt + SET status = 'SUCCEEDED', latency_ms = ?, provider_attempt_count = ?, + agent_version = ?, model_provider = ?, model_name = ?, model_version = ?, + prompt_version = ?, context_pack_version = ?, workflow_catalog_version = ?, + contract_version = ?, knowledge_version = ?, completed_at = ? + WHERE ai_attempt_id = ? AND ai_run_id = ? AND company_id = ? AND status = 'RUNNING' + """, + response.latencyMs(), + response.providerAttemptCount(), + response.versions().agentVersion(), + response.versions().modelProvider(), + response.versions().modelName(), + response.versions().modelVersion(), + response.versions().promptVersion(), + response.versions().contextPackVersion(), + response.versions().workflowCatalogVersion(), + response.versions().contractVersion(), + response.versions().workflowCatalogVersion(), + timestamp(completedAt), + attemptId, + aiRunId, + companyId + ); + if (attempts != 1) { + throw new IllegalStateException("running AI attempt was not found"); + } + String detectedIntent = detectedIntent(response); + jdbcTemplate.update( + """ + UPDATE ai_run + SET status = 'SUCCEEDED', analysis_outcome = ?, + detected_intent = COALESCE(?, detected_intent), + last_error_code = NULL, updated_at = ?, version = version + 1 + WHERE ai_run_id = ? AND company_id = ? + """, + response.outcome().name(), + detectedIntent, + timestamp(completedAt), + aiRunId, + companyId + ); + insertQuestions(aiRunId, attemptId, companyId, response.questions(), completedAt); + insertCandidates(aiRunId, attemptId, companyId, response.candidates(), completedAt); + } + + @Override + @Transactional + public void markAttemptFailed( + UUID aiRunId, + UUID companyId, + UUID attemptId, + String errorCode, + Instant completedAt + ) { + jdbcTemplate.update( + """ + UPDATE ai_attempt + SET status = 'FAILED', error_code = ?, completed_at = ? + WHERE ai_attempt_id = ? AND ai_run_id = ? AND company_id = ? AND status = 'RUNNING' + """, + errorCode, + timestamp(completedAt), + attemptId, + aiRunId, + companyId + ); + jdbcTemplate.update( + """ + UPDATE ai_run + SET status = 'FAILED', analysis_outcome = NULL, last_error_code = ?, + updated_at = ?, version = version + 1 + WHERE ai_run_id = ? AND company_id = ? + """, + errorCode, + timestamp(completedAt), + aiRunId, + companyId + ); + } + + @Override + @Transactional + public ExecutionState startAnswerAttempt( + UUID aiRunId, + UUID companyId, + UUID actorId, + long expectedVersion, + Map answers, + UUID attemptId, + AnalysisInput input, + Instant startedAt + ) { + RunForUpdate run = jdbcTemplate.query( + """ + SELECT request_id, status, analysis_outcome, attempt_count, version + FROM ai_run + WHERE ai_run_id = ? AND company_id = ? + FOR UPDATE + """, + (resultSet, rowNum) -> new RunForUpdate( + uuid(resultSet, "request_id"), + AiRunStatus.valueOf(resultSet.getString("status")), + nullableOutcome(resultSet.getString("analysis_outcome")), + resultSet.getInt("attempt_count"), + resultSet.getLong("version") + ), + aiRunId, + companyId + ).stream().findFirst().orElseThrow(() -> new ApiException(AiRunErrorCode.AI_RUN_NOT_FOUND)); + if (run.version() != expectedVersion) { + throw new ApiException(AiRunErrorCode.AI_RUN_VERSION_CONFLICT); + } + if (run.status() != AiRunStatus.SUCCEEDED + || run.outcome() != AiAnalysisOutcome.NEEDS_INFO) { + throw new ApiException(AiRunErrorCode.AI_RUN_ANSWERS_NOT_ALLOWED); + } + UUID latestAttemptId = latestAttemptId(aiRunId, companyId) + .orElseThrow(() -> new IllegalStateException("AI attempt was not found")); + List allowedKeys = jdbcTemplate.query( + """ + SELECT slot_key + FROM ai_question + WHERE ai_attempt_id = ? AND company_id = ? + """, + (resultSet, rowNum) -> resultSet.getString("slot_key"), + latestAttemptId, + companyId + ); + if (answers.isEmpty() || !allowedKeys.containsAll(answers.keySet())) { + throw new ApiException(AiRunErrorCode.AI_RUN_INVALID_ANSWER); + } + answers.forEach((slotKey, value) -> jdbcTemplate.update( + """ + UPDATE ai_question + SET answer_value = ?, answered_by = ?, answered_at = ? + WHERE ai_attempt_id = ? AND company_id = ? AND slot_key = ? + """, + value, + actorId, + timestamp(startedAt), + latestAttemptId, + companyId, + slotKey + )); + int nextSequence = run.attemptCount() + 1; + jdbcTemplate.update( + """ + UPDATE ai_run + SET status = 'RUNNING', analysis_outcome = NULL, last_error_code = NULL, + attempt_count = ?, updated_at = ?, version = version + 1 + WHERE ai_run_id = ? AND company_id = ? AND version = ? + """, + nextSequence, + timestamp(startedAt), + aiRunId, + companyId, + expectedVersion + ); + insertAttempt(new Attempt( + attemptId, + aiRunId, + companyId, + run.requestId(), + nextSequence, + com.fowoco.server.aiintegration.application.model.AiAnalysisPhase.ANALYZE, + 0, + input, + startedAt + )); + return new ExecutionState( + aiRunId, + companyId, + run.requestId(), + attemptId, + nextSequence, + 0, + expectedVersion + 1, + input + ); + } + + private Optional findRunRow(UUID aiRunId, UUID companyId) { + return jdbcTemplate.query( + """ + SELECT ai_run_id, request_id, instruction, status, analysis_outcome, + detected_intent, last_error_code, attempt_count, version, + created_at, updated_at + FROM ai_run + WHERE ai_run_id = ? AND company_id = ? + """, + (resultSet, rowNum) -> new RunRow( + uuid(resultSet, "ai_run_id"), + uuid(resultSet, "request_id"), + resultSet.getString("instruction"), + AiRunStatus.valueOf(resultSet.getString("status")), + nullableOutcome(resultSet.getString("analysis_outcome")), + resultSet.getString("detected_intent"), + resultSet.getString("last_error_code"), + resultSet.getInt("attempt_count"), + resultSet.getLong("version"), + instant(resultSet, "created_at"), + instant(resultSet, "updated_at") + ), + aiRunId, + companyId + ).stream().findFirst(); + } + + private Optional findExecutionState(String predicate, Object... arguments) { + String sql = """ + SELECT run.ai_run_id, run.company_id, run.request_id, run.attempt_count, run.version, + attempt.ai_attempt_id, attempt.context_round, attempt.analysis_input_json + FROM ai_run run + JOIN ai_attempt attempt + ON attempt.ai_run_id = run.ai_run_id + AND attempt.company_id = run.company_id + AND attempt.sequence_no = run.attempt_count + WHERE %s + """.formatted(predicate); + return jdbcTemplate.query( + sql, + (resultSet, rowNum) -> new ExecutionState( + uuid(resultSet, "ai_run_id"), + uuid(resultSet, "company_id"), + uuid(resultSet, "request_id"), + uuid(resultSet, "ai_attempt_id"), + resultSet.getInt("attempt_count"), + resultSet.getInt("context_round"), + resultSet.getLong("version"), + decodeInput(resultSet.getString("analysis_input_json")) + ), + arguments + ).stream().findFirst(); + } + + private Optional latestAttemptId(UUID aiRunId, UUID companyId) { + return jdbcTemplate.query( + """ + SELECT ai_attempt_id + FROM ai_attempt + WHERE ai_run_id = ? AND company_id = ? + ORDER BY sequence_no DESC + FETCH FIRST 1 ROW ONLY + """, + (resultSet, rowNum) -> uuid(resultSet, "ai_attempt_id"), + aiRunId, + companyId + ).stream().findFirst(); + } + + private List findQuestions(UUID attemptId, UUID companyId) { + return jdbcTemplate.query( + """ + SELECT slot_key, label, input_type, required, answer_value + FROM ai_question + WHERE ai_attempt_id = ? AND company_id = ? + ORDER BY created_at, slot_key + """, + (resultSet, rowNum) -> new AiRunQuestionResult( + resultSet.getString("slot_key"), + resultSet.getString("label"), + resultSet.getString("input_type"), + resultSet.getBoolean("required"), + resultSet.getString("answer_value") + ), + attemptId, + companyId + ); + } + + private List findCandidates(UUID attemptId, UUID companyId) { + return jdbcTemplate.query( + """ + SELECT ai_candidate_id, candidate_ref, worker_id, workflow_id, + extracted_slots_json, missing_slots_json, confidence + FROM ai_candidate + WHERE ai_attempt_id = ? AND company_id = ? + ORDER BY created_at, candidate_ref + """, + (resultSet, rowNum) -> new AiRunCandidateResult( + uuid(resultSet, "ai_candidate_id"), + resultSet.getString("candidate_ref"), + uuid(resultSet, "worker_id"), + resultSet.getString("workflow_id"), + decodeStringMap(resultSet.getString("extracted_slots_json")), + decodeStringList(resultSet.getString("missing_slots_json")), + resultSet.getBigDecimal("confidence") + ), + attemptId, + companyId + ); + } + + private void insertQuestions( + UUID aiRunId, + UUID attemptId, + UUID companyId, + List questions, + Instant createdAt + ) { + questions.forEach(question -> jdbcTemplate.update( + """ + INSERT INTO ai_question ( + ai_question_id, ai_run_id, ai_attempt_id, company_id, + slot_key, label, input_type, required, created_at + ) VALUES (?, ?, ?, ?, ?, ?, 'TEXT', TRUE, ?) + """, + uuidGenerator.generate(), + aiRunId, + attemptId, + companyId, + question.slotKey(), + question.prompt(), + timestamp(createdAt) + )); + } + + private void insertCandidates( + UUID aiRunId, + UUID attemptId, + UUID companyId, + List candidates, + Instant createdAt + ) { + candidates.forEach(candidate -> jdbcTemplate.update( + """ + INSERT INTO ai_candidate ( + ai_candidate_id, ai_run_id, ai_attempt_id, company_id, + candidate_ref, worker_id, workflow_id, extracted_slots_json, + missing_slots_json, confidence, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + uuidGenerator.generate(), + aiRunId, + attemptId, + companyId, + candidate.candidateRef(), + candidate.workerRef(), + candidate.workflowId(), + encode(candidate.extractedSlots()), + encode(candidate.missingSlots()), + candidate.confidence(), + timestamp(createdAt) + )); + } + + private String detectedIntent(AiAnalysisResponse response) { + if (response.contextRequirement() != null) { + return response.contextRequirement().detectedIntent(); + } + return response.candidates().isEmpty() ? null : response.candidates().get(0).workflowId(); + } + + private String encode(Object value) { + try { + return objectMapper.writeValueAsString(value); + } catch (JacksonException exception) { + throw new IllegalArgumentException("AI Run data cannot be encoded", exception); + } + } + + private AnalysisInput decodeInput(String json) { + try { + return objectMapper.readValue(json, AnalysisInput.class); + } catch (JacksonException exception) { + throw new IllegalStateException("stored AI analysis input cannot be decoded", exception); + } + } + + @SuppressWarnings("unchecked") + private Map decodeStringMap(String json) { + try { + Map raw = objectMapper.readValue(json, Map.class); + Map result = new LinkedHashMap<>(); + raw.forEach((key, value) -> result.put(key, value == null ? null : value.toString())); + return result; + } catch (JacksonException exception) { + throw new IllegalStateException("stored candidate slots cannot be decoded", exception); + } + } + + @SuppressWarnings("unchecked") + private List decodeStringList(String json) { + try { + List raw = objectMapper.readValue(json, List.class); + List result = new ArrayList<>(); + raw.forEach(value -> result.add(value.toString())); + return result; + } catch (JacksonException exception) { + throw new IllegalStateException("stored candidate missing slots cannot be decoded", exception); + } + } + + private UUID uuid(ResultSet resultSet, String column) throws SQLException { + Object value = resultSet.getObject(column); + return value instanceof UUID uuid ? uuid : UUID.fromString(value.toString()); + } + + private Instant instant(ResultSet resultSet, String column) throws SQLException { + return resultSet.getTimestamp(column).toInstant(); + } + + private Timestamp timestamp(Instant instant) { + return Timestamp.from(instant); + } + + private AiAnalysisOutcome nullableOutcome(String value) { + return value == null ? null : AiAnalysisOutcome.valueOf(value); + } + + private record RunRow( + UUID aiRunId, + UUID requestId, + String instruction, + AiRunStatus status, + AiAnalysisOutcome outcome, + String detectedIntent, + String errorCode, + int attemptCount, + long version, + Instant createdAt, + Instant updatedAt + ) { + } + + private record RunForUpdate( + UUID requestId, + AiRunStatus status, + AiAnalysisOutcome outcome, + int attemptCount, + long version + ) { + } +} diff --git a/src/main/resources/db/migration-postgresql/V14__prepare_ai_run_rls.sql b/src/main/resources/db/migration-postgresql/V14__prepare_ai_run_rls.sql new file mode 100644 index 0000000..a2eb297 --- /dev/null +++ b/src/main/resources/db/migration-postgresql/V14__prepare_ai_run_rls.sql @@ -0,0 +1,51 @@ +CREATE POLICY pl_ai_run_tenant_isolation + ON public.ai_run + FOR ALL + TO PUBLIC + USING ( + company_id = + NULLIF(pg_catalog.current_setting('app.company_id', true), '')::UUID + ) + WITH CHECK ( + company_id = + NULLIF(pg_catalog.current_setting('app.company_id', true), '')::UUID + ); + +CREATE POLICY pl_ai_attempt_tenant_isolation + ON public.ai_attempt + FOR ALL + TO PUBLIC + USING ( + company_id = + NULLIF(pg_catalog.current_setting('app.company_id', true), '')::UUID + ) + WITH CHECK ( + company_id = + NULLIF(pg_catalog.current_setting('app.company_id', true), '')::UUID + ); + +CREATE POLICY pl_ai_question_tenant_isolation + ON public.ai_question + FOR ALL + TO PUBLIC + USING ( + company_id = + NULLIF(pg_catalog.current_setting('app.company_id', true), '')::UUID + ) + WITH CHECK ( + company_id = + NULLIF(pg_catalog.current_setting('app.company_id', true), '')::UUID + ); + +CREATE POLICY pl_ai_candidate_tenant_isolation + ON public.ai_candidate + FOR ALL + TO PUBLIC + USING ( + company_id = + NULLIF(pg_catalog.current_setting('app.company_id', true), '')::UUID + ) + WITH CHECK ( + company_id = + NULLIF(pg_catalog.current_setting('app.company_id', true), '')::UUID + ); diff --git a/src/main/resources/db/migration/V12__create_ai_run.sql b/src/main/resources/db/migration/V12__create_ai_run.sql new file mode 100644 index 0000000..8fb73c8 --- /dev/null +++ b/src/main/resources/db/migration/V12__create_ai_run.sql @@ -0,0 +1,158 @@ +CREATE TABLE ai_run ( + ai_run_id UUID NOT NULL, + company_id UUID NOT NULL, + requested_by UUID NOT NULL, + request_id UUID NOT NULL, + instruction TEXT NOT NULL, + instruction_hash VARCHAR(64) NOT NULL, + idempotency_key_hash VARCHAR(64) NOT NULL, + status VARCHAR(20) NOT NULL, + analysis_outcome VARCHAR(30), + detected_intent VARCHAR(80), + last_error_code VARCHAR(80), + attempt_count INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMP(6) WITH TIME ZONE NOT NULL, + updated_at TIMESTAMP(6) WITH TIME ZONE NOT NULL, + version BIGINT NOT NULL DEFAULT 0, + CONSTRAINT pk_ai_run PRIMARY KEY (ai_run_id), + CONSTRAINT uq_ai_run_id_company UNIQUE (ai_run_id, company_id), + CONSTRAINT uq_ai_run_request UNIQUE (request_id), + CONSTRAINT uq_ai_run_company_idempotency UNIQUE (company_id, idempotency_key_hash), + CONSTRAINT fk_ai_run_company + FOREIGN KEY (company_id) REFERENCES company (company_id) ON DELETE RESTRICT, + CONSTRAINT fk_ai_run_requester_company + FOREIGN KEY (requested_by, company_id) + REFERENCES user_account (user_id, company_id) ON DELETE RESTRICT, + CONSTRAINT ck_ai_run_instruction_not_blank CHECK (CHAR_LENGTH(TRIM(instruction)) > 0), + CONSTRAINT ck_ai_run_instruction_hash_length CHECK (CHAR_LENGTH(instruction_hash) = 64), + CONSTRAINT ck_ai_run_idempotency_hash_length CHECK (CHAR_LENGTH(idempotency_key_hash) = 64), + CONSTRAINT ck_ai_run_status CHECK ( + status IN ('QUEUED', 'RUNNING', 'SUCCEEDED', 'FAILED') + ), + CONSTRAINT ck_ai_run_outcome CHECK ( + analysis_outcome IS NULL OR analysis_outcome IN ( + 'CONTEXT_REQUIRED', 'NEEDS_INFO', 'REVIEW_REQUIRED' + ) + ), + CONSTRAINT ck_ai_run_attempt_count CHECK (attempt_count >= 0), + CONSTRAINT ck_ai_run_version CHECK (version >= 0), + CONSTRAINT ck_ai_run_updated_at CHECK (updated_at >= created_at) +); + +CREATE TABLE ai_attempt ( + ai_attempt_id UUID NOT NULL, + ai_run_id UUID NOT NULL, + company_id UUID NOT NULL, + request_id UUID NOT NULL, + sequence_no INTEGER NOT NULL, + phase VARCHAR(20) NOT NULL, + context_round INTEGER NOT NULL DEFAULT 0, + status VARCHAR(20) NOT NULL, + analysis_input_json TEXT NOT NULL, + agent_version VARCHAR(100), + model_provider VARCHAR(80), + model_name VARCHAR(120), + model_version VARCHAR(120), + prompt_version VARCHAR(100), + context_pack_version VARCHAR(100), + workflow_catalog_version VARCHAR(100), + contract_version VARCHAR(100), + knowledge_version VARCHAR(100), + provider_attempt_count INTEGER, + error_code VARCHAR(80), + latency_ms BIGINT, + started_at TIMESTAMP(6) WITH TIME ZONE NOT NULL, + completed_at TIMESTAMP(6) WITH TIME ZONE, + CONSTRAINT pk_ai_attempt PRIMARY KEY (ai_attempt_id), + CONSTRAINT uq_ai_attempt_id_company UNIQUE (ai_attempt_id, company_id), + CONSTRAINT uq_ai_attempt_run_sequence UNIQUE (ai_run_id, sequence_no), + CONSTRAINT fk_ai_attempt_run_company + FOREIGN KEY (ai_run_id, company_id) + REFERENCES ai_run (ai_run_id, company_id) ON DELETE CASCADE, + CONSTRAINT ck_ai_attempt_sequence CHECK (sequence_no > 0), + CONSTRAINT ck_ai_attempt_phase CHECK (phase IN ('PLAN', 'ANALYZE')), + CONSTRAINT ck_ai_attempt_context_round CHECK (context_round >= 0), + CONSTRAINT ck_ai_attempt_status CHECK (status IN ('RUNNING', 'SUCCEEDED', 'FAILED')), + CONSTRAINT ck_ai_attempt_input_not_blank CHECK (CHAR_LENGTH(TRIM(analysis_input_json)) > 0), + CONSTRAINT ck_ai_attempt_latency CHECK (latency_ms IS NULL OR latency_ms >= 0), + CONSTRAINT ck_ai_attempt_provider_count CHECK ( + provider_attempt_count IS NULL OR provider_attempt_count >= 0 + ), + CONSTRAINT ck_ai_attempt_completion CHECK ( + (status = 'RUNNING' AND completed_at IS NULL) + OR (status IN ('SUCCEEDED', 'FAILED') AND completed_at IS NOT NULL) + ) +); + +CREATE TABLE ai_question ( + ai_question_id UUID NOT NULL, + ai_run_id UUID NOT NULL, + ai_attempt_id UUID NOT NULL, + company_id UUID NOT NULL, + slot_key VARCHAR(100) NOT NULL, + label VARCHAR(500) NOT NULL, + input_type VARCHAR(30) NOT NULL DEFAULT 'TEXT', + required BOOLEAN NOT NULL DEFAULT TRUE, + answer_value VARCHAR(2000), + answered_by UUID, + answered_at TIMESTAMP(6) WITH TIME ZONE, + created_at TIMESTAMP(6) WITH TIME ZONE NOT NULL, + CONSTRAINT pk_ai_question PRIMARY KEY (ai_question_id), + CONSTRAINT uq_ai_question_attempt_slot UNIQUE (ai_attempt_id, slot_key), + CONSTRAINT fk_ai_question_run_company + FOREIGN KEY (ai_run_id, company_id) + REFERENCES ai_run (ai_run_id, company_id) ON DELETE CASCADE, + CONSTRAINT fk_ai_question_attempt_company + FOREIGN KEY (ai_attempt_id, company_id) + REFERENCES ai_attempt (ai_attempt_id, company_id) ON DELETE CASCADE, + CONSTRAINT fk_ai_question_answered_by_company + FOREIGN KEY (answered_by, company_id) + REFERENCES user_account (user_id, company_id) ON DELETE RESTRICT, + CONSTRAINT ck_ai_question_slot_not_blank CHECK (CHAR_LENGTH(TRIM(slot_key)) > 0), + CONSTRAINT ck_ai_question_label_not_blank CHECK (CHAR_LENGTH(TRIM(label)) > 0), + CONSTRAINT ck_ai_question_input_type CHECK ( + input_type IN ('TEXT', 'DATE', 'NUMBER', 'SELECT') + ), + CONSTRAINT ck_ai_question_answer CHECK ( + (answer_value IS NULL AND answered_by IS NULL AND answered_at IS NULL) + OR (answer_value IS NOT NULL AND answered_by IS NOT NULL AND answered_at IS NOT NULL) + ) +); + +CREATE TABLE ai_candidate ( + ai_candidate_id UUID NOT NULL, + ai_run_id UUID NOT NULL, + ai_attempt_id UUID NOT NULL, + company_id UUID NOT NULL, + candidate_ref VARCHAR(120) NOT NULL, + worker_id UUID NOT NULL, + workflow_id VARCHAR(100) NOT NULL, + extracted_slots_json TEXT NOT NULL, + missing_slots_json TEXT NOT NULL, + confidence DECIMAL(5,4) NOT NULL, + created_at TIMESTAMP(6) WITH TIME ZONE NOT NULL, + CONSTRAINT pk_ai_candidate PRIMARY KEY (ai_candidate_id), + CONSTRAINT uq_ai_candidate_attempt_ref UNIQUE (ai_attempt_id, candidate_ref), + CONSTRAINT fk_ai_candidate_run_company + FOREIGN KEY (ai_run_id, company_id) + REFERENCES ai_run (ai_run_id, company_id) ON DELETE CASCADE, + CONSTRAINT fk_ai_candidate_attempt_company + FOREIGN KEY (ai_attempt_id, company_id) + REFERENCES ai_attempt (ai_attempt_id, company_id) ON DELETE CASCADE, + CONSTRAINT fk_ai_candidate_worker_company + FOREIGN KEY (worker_id, company_id) + REFERENCES worker (worker_id, company_id) ON DELETE RESTRICT, + CONSTRAINT ck_ai_candidate_ref_not_blank CHECK (CHAR_LENGTH(TRIM(candidate_ref)) > 0), + CONSTRAINT ck_ai_candidate_workflow_not_blank CHECK (CHAR_LENGTH(TRIM(workflow_id)) > 0), + CONSTRAINT ck_ai_candidate_extracted_not_blank + CHECK (CHAR_LENGTH(TRIM(extracted_slots_json)) > 0), + CONSTRAINT ck_ai_candidate_missing_not_blank + CHECK (CHAR_LENGTH(TRIM(missing_slots_json)) > 0), + CONSTRAINT ck_ai_candidate_confidence CHECK (confidence >= 0 AND confidence <= 1) +); + +CREATE INDEX idx_ai_run_company_created ON ai_run (company_id, created_at); +CREATE INDEX idx_ai_run_company_status ON ai_run (company_id, status, updated_at); +CREATE INDEX idx_ai_attempt_run ON ai_attempt (company_id, ai_run_id, sequence_no); +CREATE INDEX idx_ai_question_run ON ai_question (company_id, ai_run_id); +CREATE INDEX idx_ai_candidate_run ON ai_candidate (company_id, ai_run_id); From 5f3200a42cd1e41f5d9b57980ef216be6491f6dc Mon Sep 17 00:00:00 2001 From: hywznn Date: Tue, 4 Aug 2026 10:39:57 +0900 Subject: [PATCH 2/5] =?UTF-8?q?feat(airun):=20=EB=B0=9C=ED=99=94=EB=AC=B8?= =?UTF-8?q?=20=EB=B6=84=EC=84=9D=EA=B3=BC=20=EB=88=84=EB=9D=BD=20=EC=A0=95?= =?UTF-8?q?=EB=B3=B4=20API=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST·GET·answers API를 추가하고 PLAN에서 사업장 범위 Slot을 해결한 뒤 ANALYZE로 이어지는 실행 흐름을 연결합니다. 요청과 답변 변경은 감사로그에 남기고 Runtime HTTP 호출 전후의 DB transaction을 분리합니다. --- .../application/model/AnalysisInput.java | 2 - .../airun/api/AiRunCandidateResponse.java | 29 + .../server/airun/api/AiRunController.java | 122 +++++ .../airun/api/AiRunQuestionResponse.java | 21 + .../server/airun/api/AiRunResponse.java | 42 ++ .../server/airun/api/CreateAiRunRequest.java | 11 + .../airun/api/SubmitAiRunAnswersRequest.java | 17 + .../AiAnalysisContinuationService.java | 11 +- .../airun/application/AiRunService.java | 516 ++++++++++++++++++ .../application/port/AiAttemptStarter.java | 9 +- .../server/audit/domain/AuditAction.java | 4 +- .../server/audit/domain/AuditTargetType.java | 3 +- 12 files changed, 780 insertions(+), 7 deletions(-) create mode 100644 src/main/java/com/fowoco/server/airun/api/AiRunCandidateResponse.java create mode 100644 src/main/java/com/fowoco/server/airun/api/AiRunController.java create mode 100644 src/main/java/com/fowoco/server/airun/api/AiRunQuestionResponse.java create mode 100644 src/main/java/com/fowoco/server/airun/api/AiRunResponse.java create mode 100644 src/main/java/com/fowoco/server/airun/api/CreateAiRunRequest.java create mode 100644 src/main/java/com/fowoco/server/airun/api/SubmitAiRunAnswersRequest.java create mode 100644 src/main/java/com/fowoco/server/airun/application/AiRunService.java diff --git a/src/main/java/com/fowoco/server/aiintegration/application/model/AnalysisInput.java b/src/main/java/com/fowoco/server/aiintegration/application/model/AnalysisInput.java index f9a6d34..78604a1 100644 --- a/src/main/java/com/fowoco/server/aiintegration/application/model/AnalysisInput.java +++ b/src/main/java/com/fowoco/server/aiintegration/application/model/AnalysisInput.java @@ -1,6 +1,5 @@ package com.fowoco.server.aiintegration.application.model; -import com.fasterxml.jackson.annotation.JsonInclude; import java.util.List; import java.util.Map; import java.util.Objects; @@ -11,7 +10,6 @@ *

PLAN keeps context collections empty. ANALYZE preserves the context needed for validation, * while the HTTP Adapter transmits only requested field keys and resolved Worker values.

*/ -@JsonInclude(JsonInclude.Include.NON_EMPTY) public record AnalysisInput( String instruction, Map extractedSlots, diff --git a/src/main/java/com/fowoco/server/airun/api/AiRunCandidateResponse.java b/src/main/java/com/fowoco/server/airun/api/AiRunCandidateResponse.java new file mode 100644 index 0000000..8fae56a --- /dev/null +++ b/src/main/java/com/fowoco/server/airun/api/AiRunCandidateResponse.java @@ -0,0 +1,29 @@ +package com.fowoco.server.airun.api; + +import com.fowoco.server.airun.application.AiRunCandidateResult; +import java.math.BigDecimal; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +public record AiRunCandidateResponse( + UUID candidateId, + String candidateRef, + UUID workerId, + String workflowId, + Map extractedSlots, + List missingSlots, + BigDecimal confidence +) { + static AiRunCandidateResponse from(AiRunCandidateResult result) { + return new AiRunCandidateResponse( + result.candidateId(), + result.candidateRef(), + result.workerId(), + result.workflowId(), + result.extractedSlots(), + result.missingSlots(), + result.confidence() + ); + } +} diff --git a/src/main/java/com/fowoco/server/airun/api/AiRunController.java b/src/main/java/com/fowoco/server/airun/api/AiRunController.java new file mode 100644 index 0000000..130b61d --- /dev/null +++ b/src/main/java/com/fowoco/server/airun/api/AiRunController.java @@ -0,0 +1,122 @@ +package com.fowoco.server.airun.api; + +import com.fowoco.server.airun.application.AiRunService; +import com.fowoco.server.auth.application.ActorContext; +import com.fowoco.server.auth.application.port.ActorContextProvider; +import com.fowoco.server.common.web.RequestMetadata; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import jakarta.servlet.http.HttpServletRequest; +import java.net.URI; +import java.util.UUID; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.servlet.support.ServletUriComponentsBuilder; + +@Tag(name = "AI Run", description = "자연어 업무 분석 실행·질문·답변") +@SecurityRequirement(name = "bearerAuth") +@RestController +@RequestMapping("/api/v1/ai-runs") +public class AiRunController { + + private final AiRunService aiRunService; + private final ActorContextProvider actorContextProvider; + + public AiRunController( + AiRunService aiRunService, + ActorContextProvider actorContextProvider + ) { + this.aiRunService = aiRunService; + this.actorContextProvider = actorContextProvider; + } + + @Operation( + operationId = "createAiRun", + summary = "AI 업무 분석 요청", + description = "발화문 하나를 저장한 뒤 AI Runtime 분석을 시작합니다." + ) + @ApiResponses({ + @ApiResponse(responseCode = "202", description = "분석 요청 접수"), + @ApiResponse(responseCode = "400", ref = "#/components/responses/BadRequest"), + @ApiResponse(responseCode = "409", ref = "#/components/responses/Conflict") + }) + @PreAuthorize("hasAnyRole('ADMIN', 'HR')") + @PostMapping( + consumes = MediaType.APPLICATION_JSON_VALUE, + produces = MediaType.APPLICATION_JSON_VALUE + ) + public ResponseEntity create( + @Parameter(description = "같은 화면 요청의 중복 생성을 막는 키", required = true) + @RequestHeader("Idempotency-Key") String idempotencyKey, + @Valid @RequestBody CreateAiRunRequest request, + HttpServletRequest servletRequest + ) { + AiRunResponse response = AiRunResponse.from(aiRunService.createAndExecute( + request.instruction(), + idempotencyKey, + actor(), + RequestMetadata.from(servletRequest) + )); + URI location = ServletUriComponentsBuilder.fromCurrentRequest() + .path("/{aiRunId}") + .buildAndExpand(response.aiRunId()) + .toUri(); + return ResponseEntity.accepted().location(location).body(response); + } + + @Operation(operationId = "getAiRun", summary = "AI 분석 상태·질문 조회") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "현재 실행·분석 상태"), + @ApiResponse(responseCode = "404", ref = "#/components/responses/NotFound") + }) + @PreAuthorize("hasAnyRole('ADMIN', 'HR', 'VIEWER')") + @GetMapping(path = "/{aiRunId}", produces = MediaType.APPLICATION_JSON_VALUE) + public AiRunResponse findById(@PathVariable UUID aiRunId) { + return AiRunResponse.from(aiRunService.requireRun(aiRunId, actor())); + } + + @Operation(operationId = "answerAiRunQuestions", summary = "누락 Slot 답변 제출") + @ApiResponses({ + @ApiResponse(responseCode = "202", description = "답변 저장 및 새 분석 시도"), + @ApiResponse(responseCode = "400", ref = "#/components/responses/BadRequest"), + @ApiResponse(responseCode = "404", ref = "#/components/responses/NotFound"), + @ApiResponse(responseCode = "409", ref = "#/components/responses/Conflict"), + @ApiResponse(responseCode = "422", ref = "#/components/responses/UnprocessableEntity") + }) + @PreAuthorize("hasAnyRole('ADMIN', 'HR')") + @PostMapping( + path = "/{aiRunId}/answers", + consumes = MediaType.APPLICATION_JSON_VALUE, + produces = MediaType.APPLICATION_JSON_VALUE + ) + public ResponseEntity answer( + @PathVariable UUID aiRunId, + @Valid @RequestBody SubmitAiRunAnswersRequest request, + HttpServletRequest servletRequest + ) { + return ResponseEntity.accepted().body(AiRunResponse.from(aiRunService.answerAndExecute( + aiRunId, + request.expectedVersion(), + request.answers(), + actor(), + RequestMetadata.from(servletRequest) + ))); + } + + private ActorContext actor() { + return actorContextProvider.requireCurrentActor(); + } +} diff --git a/src/main/java/com/fowoco/server/airun/api/AiRunQuestionResponse.java b/src/main/java/com/fowoco/server/airun/api/AiRunQuestionResponse.java new file mode 100644 index 0000000..191d7bd --- /dev/null +++ b/src/main/java/com/fowoco/server/airun/api/AiRunQuestionResponse.java @@ -0,0 +1,21 @@ +package com.fowoco.server.airun.api; + +import com.fowoco.server.airun.application.AiRunQuestionResult; + +public record AiRunQuestionResponse( + String slotKey, + String label, + String inputType, + boolean required, + String answer +) { + static AiRunQuestionResponse from(AiRunQuestionResult result) { + return new AiRunQuestionResponse( + result.slotKey(), + result.label(), + result.inputType(), + result.required(), + result.answer() + ); + } +} diff --git a/src/main/java/com/fowoco/server/airun/api/AiRunResponse.java b/src/main/java/com/fowoco/server/airun/api/AiRunResponse.java new file mode 100644 index 0000000..9db3936 --- /dev/null +++ b/src/main/java/com/fowoco/server/airun/api/AiRunResponse.java @@ -0,0 +1,42 @@ +package com.fowoco.server.airun.api; + +import com.fowoco.server.aiintegration.application.model.AiAnalysisOutcome; +import com.fowoco.server.airun.application.AiRunResult; +import com.fowoco.server.airun.domain.AiRunStatus; +import java.time.Instant; +import java.util.List; +import java.util.UUID; + +public record AiRunResponse( + UUID aiRunId, + UUID requestId, + String instruction, + AiRunStatus status, + AiAnalysisOutcome analysisOutcome, + String detectedIntent, + String errorCode, + int attemptCount, + long version, + List questions, + List candidates, + Instant createdAt, + Instant updatedAt +) { + public static AiRunResponse from(AiRunResult result) { + return new AiRunResponse( + result.aiRunId(), + result.requestId(), + result.instruction(), + result.status(), + result.analysisOutcome(), + result.detectedIntent(), + result.errorCode(), + result.attemptCount(), + result.version(), + result.questions().stream().map(AiRunQuestionResponse::from).toList(), + result.candidates().stream().map(AiRunCandidateResponse::from).toList(), + result.createdAt(), + result.updatedAt() + ); + } +} diff --git a/src/main/java/com/fowoco/server/airun/api/CreateAiRunRequest.java b/src/main/java/com/fowoco/server/airun/api/CreateAiRunRequest.java new file mode 100644 index 0000000..fb91400 --- /dev/null +++ b/src/main/java/com/fowoco/server/airun/api/CreateAiRunRequest.java @@ -0,0 +1,11 @@ +package com.fowoco.server.airun.api; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record CreateAiRunRequest( + @NotBlank + @Size(max = 10_000) + String instruction +) { +} diff --git a/src/main/java/com/fowoco/server/airun/api/SubmitAiRunAnswersRequest.java b/src/main/java/com/fowoco/server/airun/api/SubmitAiRunAnswersRequest.java new file mode 100644 index 0000000..8a0963e --- /dev/null +++ b/src/main/java/com/fowoco/server/airun/api/SubmitAiRunAnswersRequest.java @@ -0,0 +1,17 @@ +package com.fowoco.server.airun.api; + +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import java.util.Map; + +public record SubmitAiRunAnswersRequest( + @Min(0) + long expectedVersion, + @NotNull + @NotEmpty + @Size(max = 50) + Map answers +) { +} diff --git a/src/main/java/com/fowoco/server/airun/application/AiAnalysisContinuationService.java b/src/main/java/com/fowoco/server/airun/application/AiAnalysisContinuationService.java index e02a1da..ad2603d 100644 --- a/src/main/java/com/fowoco/server/airun/application/AiAnalysisContinuationService.java +++ b/src/main/java/com/fowoco/server/airun/application/AiAnalysisContinuationService.java @@ -66,10 +66,17 @@ public AiAnalysisContinuationResult continueAnalysis( validateSameWorker(previousRequest, resolution.worker()); int nextContextRound = completedContextRounds + 1; + AnalysisInput analyzeInput = buildAnalyzeInput( + previousRequest.analysisInput(), + previousResponse, + resolution + ); UUID attemptId = attemptStarter.startAttempt( + companyId, previousRequest.requestId(), AiAnalysisPhase.ANALYZE, - nextContextRound + nextContextRound, + analyzeInput ); AiAnalysisRequest analyzeRequest = new AiAnalysisRequest( previousRequest.requestId(), @@ -78,7 +85,7 @@ public AiAnalysisContinuationResult continueAnalysis( previousRequest.contractVersion(), previousRequest.requiredKnowledgeVersion(), remainingDeadlineMs, - buildAnalyzeInput(previousRequest.analysisInput(), previousResponse, resolution) + analyzeInput ); AiAnalysisResponse response = runtimeClient.analyze(analyzeRequest, callContext); return new AiAnalysisContinuationResult( diff --git a/src/main/java/com/fowoco/server/airun/application/AiRunService.java b/src/main/java/com/fowoco/server/airun/application/AiRunService.java new file mode 100644 index 0000000..38dba65 --- /dev/null +++ b/src/main/java/com/fowoco/server/airun/application/AiRunService.java @@ -0,0 +1,516 @@ +package com.fowoco.server.airun.application; + +import com.fowoco.server.aiintegration.application.error.AiRuntimeCallException; +import com.fowoco.server.aiintegration.application.error.AiRuntimeContractException; +import com.fowoco.server.aiintegration.application.model.AiAnalysisOutcome; +import com.fowoco.server.aiintegration.application.model.AiAnalysisPhase; +import com.fowoco.server.aiintegration.application.model.AiAnalysisRequest; +import com.fowoco.server.aiintegration.application.model.AiAnalysisResponse; +import com.fowoco.server.aiintegration.application.model.AiRuntimeCallContext; +import com.fowoco.server.aiintegration.application.model.AnalysisInput; +import com.fowoco.server.aiintegration.application.model.WorkerContext; +import com.fowoco.server.aiintegration.application.port.AiRuntimeClient; +import com.fowoco.server.airun.application.error.AiContextResolutionException; +import com.fowoco.server.airun.application.error.AiRunErrorCode; +import com.fowoco.server.airun.application.port.AiAttemptStarter; +import com.fowoco.server.airun.application.port.AiRunRepository; +import com.fowoco.server.airun.application.port.AiRunRepository.ExecutionState; +import com.fowoco.server.auth.application.ActorAuthorizer; +import com.fowoco.server.auth.application.ActorContext; +import com.fowoco.server.auth.domain.UserRole; +import com.fowoco.server.audit.application.port.AuditEventRepository; +import com.fowoco.server.audit.domain.ActorType; +import com.fowoco.server.audit.domain.AuditAction; +import com.fowoco.server.audit.domain.AuditEvent; +import com.fowoco.server.audit.domain.AuditTargetType; +import com.fowoco.server.common.error.ApiException; +import com.fowoco.server.common.id.UuidGenerator; +import com.fowoco.server.common.security.TenantDatabaseContext; +import com.fowoco.server.common.web.RequestMetadata; +import com.fowoco.server.workflow.application.WorkflowCatalogService; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.Clock; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.UUID; +import java.util.function.Supplier; +import java.util.regex.Pattern; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.support.TransactionTemplate; + +/** + * Owns the demo vertical slice: persist first, call Runtime without a database transaction, + * and persist the validated result in a new transaction. + */ +@Service +public class AiRunService implements AiAttemptStarter { + + private static final String CONTRACT_VERSION = "1.0.0"; + private static final long ATTEMPT_DEADLINE_MS = 10_000; + private static final int MAX_INSTRUCTION_LENGTH = 10_000; + private static final Pattern SLOT_KEY = Pattern.compile("[A-Za-z][A-Za-z0-9._-]{0,127}"); + private static final String AUDIT_EVENT_VERSION = "1"; + + private final ActorAuthorizer actorAuthorizer; + private final TenantDatabaseContext tenantDatabaseContext; + private final AiRunRepository repository; + private final AiRuntimeClient runtimeClient; + private final AiSlotResolutionTransaction slotResolutionTransaction; + private final WorkflowCatalogService workflowCatalogService; + private final UuidGenerator uuidGenerator; + private final Clock clock; + private final TransactionTemplate transactionTemplate; + private final AuditEventRepository auditEventRepository; + + public AiRunService( + ActorAuthorizer actorAuthorizer, + TenantDatabaseContext tenantDatabaseContext, + AiRunRepository repository, + AiRuntimeClient runtimeClient, + AiSlotResolutionTransaction slotResolutionTransaction, + WorkflowCatalogService workflowCatalogService, + UuidGenerator uuidGenerator, + Clock clock, + TransactionTemplate transactionTemplate, + AuditEventRepository auditEventRepository + ) { + this.actorAuthorizer = actorAuthorizer; + this.tenantDatabaseContext = tenantDatabaseContext; + this.repository = repository; + this.runtimeClient = runtimeClient; + this.slotResolutionTransaction = slotResolutionTransaction; + this.workflowCatalogService = workflowCatalogService; + this.uuidGenerator = uuidGenerator; + this.clock = clock; + this.transactionTemplate = transactionTemplate; + this.auditEventRepository = auditEventRepository; + } + + public AiRunResult createAndExecute( + String instruction, + String idempotencyKey, + ActorContext actor, + RequestMetadata metadata + ) { + actorAuthorizer.requireHrWrite(actor); + String normalizedInstruction = normalizeInstruction(instruction); + String instructionHash = sha256(normalizedInstruction); + String keyHash = sha256(normalizeIdempotencyKey(idempotencyKey)); + + AiRunCreation creation = createPlan( + normalizedInstruction, + instructionHash, + keyHash, + actor, + metadata + ); + if (creation.newlyCreated()) { + executePlan(creation); + } + return requireRun(creation.aiRunId(), actor); + } + + public AiRunResult requireRun(UUID aiRunId, ActorContext actor) { + actorAuthorizer.requireAnyRole(actor, UserRole.ADMIN, UserRole.HR, UserRole.VIEWER); + return inTenant(actor.companyId(), () -> repository + .findByIdAndCompanyId(aiRunId, actor.companyId()) + .orElseThrow(() -> new ApiException(AiRunErrorCode.AI_RUN_NOT_FOUND))); + } + + public AiRunResult answerAndExecute( + UUID aiRunId, + long expectedVersion, + Map answers, + ActorContext actor, + RequestMetadata metadata + ) { + actorAuthorizer.requireHrWrite(actor); + Map normalizedAnswers = normalizeAnswers(answers); + ExecutionState current = requireExecution(aiRunId, actor); + AnalysisInput nextInput = mergeAnswers(current.latestInput(), normalizedAnswers); + UUID attemptId = uuidGenerator.generate(); + Instant now = clock.instant(); + ExecutionState started = inTenant(actor.companyId(), () -> { + ExecutionState state = repository.startAnswerAttempt( + aiRunId, + actor.companyId(), + actor.actorId(), + expectedVersion, + normalizedAnswers, + attemptId, + nextInput, + now + ); + appendAudit( + aiRunId, + actor, + AuditAction.AI_RUN_ANSWERS_SUBMITTED, + "AI 분석에 필요한 추가 정보를 제출함", + metadata, + now + ); + return state; + }); + executeOne(started, request( + started.requestId(), + attemptId, + AiAnalysisPhase.ANALYZE, + nextInput + )); + return requireRun(aiRunId, actor); + } + + @Override + public UUID startAttempt( + UUID companyId, + UUID requestId, + AiAnalysisPhase phase, + int contextRound, + AnalysisInput analysisInput + ) { + UUID attemptId = uuidGenerator.generate(); + inTenant(companyId, () -> repository.startContinuationAttempt( + requestId, + attemptId, + phase, + contextRound, + analysisInput, + clock.instant() + )); + return attemptId; + } + + private AiRunCreation createPlan( + String instruction, + String instructionHash, + String keyHash, + ActorContext actor, + RequestMetadata metadata + ) { + UUID aiRunId = uuidGenerator.generate(); + UUID requestId = uuidGenerator.generate(); + UUID attemptId = uuidGenerator.generate(); + AnalysisInput input = new AnalysisInput( + instruction, + Map.of(), + List.of(), + List.of(), + List.of() + ); + AiAnalysisRequest request = request(requestId, attemptId, AiAnalysisPhase.PLAN, input); + try { + return inTenant(actor.companyId(), () -> createPlanInTransaction( + instruction, + instructionHash, + keyHash, + actor, + aiRunId, + requestId, + attemptId, + input, + request, + metadata + )); + } catch (DataIntegrityViolationException exception) { + return inTenant(actor.companyId(), () -> recoverIdempotentRace( + actor.companyId(), + instructionHash, + keyHash, + request, + exception + )); + } + } + + private AiRunCreation createPlanInTransaction( + String instruction, + String instructionHash, + String keyHash, + ActorContext actor, + UUID aiRunId, + UUID requestId, + UUID attemptId, + AnalysisInput input, + AiAnalysisRequest request, + RequestMetadata metadata + ) { + var existing = repository.findByIdempotencyKeyHash(actor.companyId(), keyHash); + if (existing.isPresent()) { + if (!existing.get().instructionHash().equals(instructionHash)) { + throw new ApiException(AiRunErrorCode.AI_RUN_IDEMPOTENCY_CONFLICT); + } + return new AiRunCreation(existing.get().aiRunId(), actor.companyId(), request, false); + } + repository.insertPlan(new AiRunRepository.PlanRun( + aiRunId, + actor.companyId(), + actor.actorId(), + requestId, + attemptId, + instruction, + instructionHash, + keyHash, + input, + clock.instant() + )); + appendAudit( + aiRunId, + actor, + AuditAction.AI_RUN_CREATED, + "AI 업무 분석 요청을 생성함", + metadata, + clock.instant() + ); + return new AiRunCreation(aiRunId, actor.companyId(), request, true); + } + + private AiRunCreation recoverIdempotentRace( + UUID companyId, + String instructionHash, + String keyHash, + AiAnalysisRequest request, + DataIntegrityViolationException originalFailure + ) { + var raced = repository.findByIdempotencyKeyHash(companyId, keyHash) + .orElseThrow(() -> originalFailure); + if (!raced.instructionHash().equals(instructionHash)) { + throw new ApiException(AiRunErrorCode.AI_RUN_IDEMPOTENCY_CONFLICT); + } + return new AiRunCreation(raced.aiRunId(), companyId, request, false); + } + + private void executePlan(AiRunCreation creation) { + ExecutionState initial = inTenant(creation.companyId(), () -> repository + .findExecutionState(creation.aiRunId(), creation.companyId()) + .orElseThrow(() -> new IllegalStateException("created AI Run has no attempt"))); + try { + AiAnalysisResponse planResponse = runtimeClient.analyze( + creation.request(), + AiRuntimeCallContext.withoutTrace() + ); + saveSuccess(creation.aiRunId(), creation.companyId(), creation.request().attemptId(), planResponse); + if (planResponse.outcome() == AiAnalysisOutcome.CONTEXT_REQUIRED) { + AiAnalysisContinuationResult result = new AiAnalysisContinuationService( + slotResolutionTransaction, + this, + runtimeClient + ).continueAnalysis( + creation.companyId(), + creation.request(), + planResponse, + 0, + ATTEMPT_DEADLINE_MS, + AiRuntimeCallContext.withoutTrace() + ); + saveSuccess( + creation.aiRunId(), + creation.companyId(), + result.attemptId(), + result.response() + ); + } + } catch (RuntimeException exception) { + markLatestFailed(initial, exception); + } + } + + private void executeOne(ExecutionState state, AiAnalysisRequest request) { + try { + AiAnalysisResponse response = runtimeClient.analyze( + request, + AiRuntimeCallContext.withoutTrace() + ); + saveSuccess(state.aiRunId(), state.companyId(), request.attemptId(), response); + } catch (RuntimeException exception) { + markLatestFailed(state, exception); + } + } + + private void saveSuccess( + UUID aiRunId, + UUID companyId, + UUID attemptId, + AiAnalysisResponse response + ) { + inTenant(companyId, () -> { + repository.markAttemptSucceeded(aiRunId, companyId, attemptId, response, clock.instant()); + return null; + }); + } + + private void markLatestFailed(ExecutionState fallback, RuntimeException failure) { + ExecutionState latest = inTenant(fallback.companyId(), () -> repository + .findExecutionState(fallback.aiRunId(), fallback.companyId()) + .orElse(fallback)); + inTenant(latest.companyId(), () -> { + repository.markAttemptFailed( + latest.aiRunId(), + latest.companyId(), + latest.latestAttemptId(), + failureCode(failure), + clock.instant() + ); + return null; + }); + } + + private ExecutionState requireExecution(UUID aiRunId, ActorContext actor) { + return inTenant(actor.companyId(), () -> repository + .findExecutionState(aiRunId, actor.companyId()) + .orElseThrow(() -> new ApiException(AiRunErrorCode.AI_RUN_NOT_FOUND))); + } + + private AiAnalysisRequest request( + UUID requestId, + UUID attemptId, + AiAnalysisPhase phase, + AnalysisInput input + ) { + return new AiAnalysisRequest( + requestId, + attemptId, + phase, + CONTRACT_VERSION, + workflowCatalogService.getActiveCatalog().bundleVersion(), + ATTEMPT_DEADLINE_MS, + input + ); + } + + private AnalysisInput mergeAnswers(AnalysisInput previous, Map answers) { + if (previous.workers().isEmpty()) { + throw new ApiException(AiRunErrorCode.AI_RUN_ANSWERS_NOT_ALLOWED); + } + WorkerContext worker = previous.workers().get(0); + Map fields = new LinkedHashMap<>(worker.requestedFields()); + fields.putAll(answers); + LinkedHashSet fieldKeys = new LinkedHashSet<>(previous.requestedFieldKeys()); + fieldKeys.addAll(answers.keySet()); + WorkerContext mergedWorker = new WorkerContext( + worker.workerRef(), + worker.displayName(), + worker.nationalityCode(), + worker.preferredLanguage(), + worker.workStatus(), + worker.stayExpiryDate(), + worker.contractStartDate(), + worker.contractEndDate(), + fields + ); + return new AnalysisInput( + previous.instruction(), + previous.extractedSlots(), + new ArrayList<>(fieldKeys), + List.of(mergedWorker), + previous.workflowConstraints() + ); + } + + private Map normalizeAnswers(Map answers) { + if (answers == null || answers.isEmpty() || answers.size() > 50) { + throw new ApiException(AiRunErrorCode.AI_RUN_INVALID_ANSWER); + } + Map normalized = new LinkedHashMap<>(); + answers.forEach((key, value) -> { + if (key == null || !SLOT_KEY.matcher(key).matches() + || value == null || value.isBlank() || value.length() > 2_000) { + throw new ApiException(AiRunErrorCode.AI_RUN_INVALID_ANSWER); + } + normalized.put(key, value.trim()); + }); + return Map.copyOf(normalized); + } + + private String normalizeInstruction(String instruction) { + if (instruction == null || instruction.isBlank()) { + throw new ApiException(AiRunErrorCode.AI_RUN_INVALID_INSTRUCTION); + } + String normalized = instruction.trim(); + if (normalized.length() > MAX_INSTRUCTION_LENGTH) { + throw new ApiException(AiRunErrorCode.AI_RUN_INVALID_INSTRUCTION); + } + return normalized; + } + + private String normalizeIdempotencyKey(String key) { + if (key == null || key.isBlank() || key.length() > 100) { + throw new ApiException(AiRunErrorCode.AI_RUN_INVALID_IDEMPOTENCY_KEY); + } + return key.trim(); + } + + private String failureCode(RuntimeException failure) { + if (failure instanceof AiRuntimeCallException runtimeFailure) { + return runtimeFailure.failureCode().name(); + } + if (failure instanceof AiRuntimeContractException contractFailure) { + return contractFailure.failureCode().name(); + } + if (failure instanceof AiContextResolutionException contextFailure) { + return contextFailure.failureCode().name(); + } + return "UNEXPECTED_AI_RUN_FAILURE"; + } + + private String sha256(String value) { + try { + byte[] digest = MessageDigest.getInstance("SHA-256") + .digest(value.getBytes(StandardCharsets.UTF_8)); + return java.util.HexFormat.of().formatHex(digest); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("SHA-256 must be available", exception); + } + } + + private T inTenant(UUID companyId, Supplier action) { + Objects.requireNonNull(companyId, "companyId must not be null"); + return transactionTemplate.execute(status -> { + tenantDatabaseContext.setCompanyIdForCurrentTransaction(companyId); + return action.get(); + }); + } + + private void appendAudit( + UUID aiRunId, + ActorContext actor, + AuditAction action, + String summary, + RequestMetadata metadata, + Instant now + ) { + auditEventRepository.append(new AuditEvent( + uuidGenerator.generate(), + actor.companyId(), + ActorType.HR_USER, + actor.actorId(), + effectiveRole(actor), + action, + AuditTargetType.AI_RUN, + aiRunId, + metadata.requestId(), + metadata.traceId(), + AUDIT_EVENT_VERSION, + summary, + now + )); + } + + private UserRole effectiveRole(ActorContext actor) { + return actor.roles().stream() + .min(Comparator.comparingInt(role -> switch (role) { + case ADMIN -> 0; + case HR -> 1; + case VIEWER -> 2; + })) + .orElseThrow(); + } +} diff --git a/src/main/java/com/fowoco/server/airun/application/port/AiAttemptStarter.java b/src/main/java/com/fowoco/server/airun/application/port/AiAttemptStarter.java index 616f409..df5eb78 100644 --- a/src/main/java/com/fowoco/server/airun/application/port/AiAttemptStarter.java +++ b/src/main/java/com/fowoco/server/airun/application/port/AiAttemptStarter.java @@ -1,6 +1,7 @@ package com.fowoco.server.airun.application.port; import com.fowoco.server.aiintegration.application.model.AiAnalysisPhase; +import com.fowoco.server.aiintegration.application.model.AnalysisInput; import java.util.UUID; /** @@ -9,5 +10,11 @@ */ public interface AiAttemptStarter { - UUID startAttempt(UUID requestId, AiAnalysisPhase phase, int contextRound); + UUID startAttempt( + UUID companyId, + UUID requestId, + AiAnalysisPhase phase, + int contextRound, + AnalysisInput analysisInput + ); } diff --git a/src/main/java/com/fowoco/server/audit/domain/AuditAction.java b/src/main/java/com/fowoco/server/audit/domain/AuditAction.java index b9001d0..f7a445d 100644 --- a/src/main/java/com/fowoco/server/audit/domain/AuditAction.java +++ b/src/main/java/com/fowoco/server/audit/domain/AuditAction.java @@ -14,5 +14,7 @@ public enum AuditAction { TASK_COMPLETED, FILE_UPLOADED, WORKER_DOCUMENT_FILE_LINKED, - DOCUMENT_REQUEST_DRAFT_SAVED + DOCUMENT_REQUEST_DRAFT_SAVED, + AI_RUN_CREATED, + AI_RUN_ANSWERS_SUBMITTED } diff --git a/src/main/java/com/fowoco/server/audit/domain/AuditTargetType.java b/src/main/java/com/fowoco/server/audit/domain/AuditTargetType.java index bd21915..67dab2a 100644 --- a/src/main/java/com/fowoco/server/audit/domain/AuditTargetType.java +++ b/src/main/java/com/fowoco/server/audit/domain/AuditTargetType.java @@ -7,5 +7,6 @@ public enum AuditTargetType { EVIDENCE, FILE, WORKER_DOCUMENT, - DOCUMENT_REQUEST_DRAFT + DOCUMENT_REQUEST_DRAFT, + AI_RUN } From 23de6c49ffb68ce29a4f29d42ae7c7875f549d94 Mon Sep 17 00:00:00 2001 From: hywznn Date: Tue, 4 Aug 2026 10:40:05 +0900 Subject: [PATCH 3/5] =?UTF-8?q?test(airun):=20=EB=8B=A8=EC=9D=BC=20?= =?UTF-8?q?=EA=B7=BC=EB=A1=9C=EC=9E=90=20=EB=B6=84=EC=84=9D=20=EC=99=95?= =?UTF-8?q?=EB=B3=B5=20=EC=8B=9C=EB=82=98=EB=A6=AC=EC=98=A4=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PLAN, Slot 조회, NEEDS_INFO, HR 답변, REVIEW_REQUIRED까지 세 Attempt가 남는 대표 흐름을 검증합니다. 멱등성 재요청, 타 사업장 404, 감사로그와 PostgreSQL schema 계약도 함께 확인합니다. --- .../server/PostgreSqlMigrationTests.java | 53 ++- .../server/airun/AiRunApiIntegrationTest.java | 348 ++++++++++++++++++ .../AiAnalysisContinuationServiceTest.java | 7 +- 3 files changed, 402 insertions(+), 6 deletions(-) create mode 100644 src/test/java/com/fowoco/server/airun/AiRunApiIntegrationTest.java diff --git a/src/test/java/com/fowoco/server/PostgreSqlMigrationTests.java b/src/test/java/com/fowoco/server/PostgreSqlMigrationTests.java index 67b839c..3c081db 100644 --- a/src/test/java/com/fowoco/server/PostgreSqlMigrationTests.java +++ b/src/test/java/com/fowoco/server/PostgreSqlMigrationTests.java @@ -78,7 +78,11 @@ private void assertSchemaContract(Connection connection) throws SQLException { "event_publication", "event_consumption", "document_request_draft", - "document_request_draft_type" + "document_request_draft_type", + "ai_run", + "ai_attempt", + "ai_question", + "ai_candidate" ); assertThat(columnSpecs(connection, "company")) @@ -172,6 +176,30 @@ private void assertSchemaContract(Connection connection) throws SQLException { .containsEntry("draft_id", new ColumnSpec("uuid", false)) .containsEntry("document_type", new ColumnSpec("varchar", false)) .doesNotContainKey("company_id"); + assertThat(columnSpecs(connection, "ai_run")) + .containsEntry("ai_run_id", new ColumnSpec("uuid", false)) + .containsEntry("company_id", new ColumnSpec("uuid", false)) + .containsEntry("instruction_hash", new ColumnSpec("varchar", false)) + .containsEntry("idempotency_key_hash", new ColumnSpec("varchar", false)) + .containsEntry("status", new ColumnSpec("varchar", false)) + .containsEntry("analysis_outcome", new ColumnSpec("varchar", true)) + .containsEntry("version", new ColumnSpec("int8", false)); + assertThat(columnSpecs(connection, "ai_attempt")) + .containsEntry("ai_attempt_id", new ColumnSpec("uuid", false)) + .containsEntry("ai_run_id", new ColumnSpec("uuid", false)) + .containsEntry("phase", new ColumnSpec("varchar", false)) + .containsEntry("analysis_input_json", new ColumnSpec("text", false)) + .containsEntry("latency_ms", new ColumnSpec("int8", true)); + assertThat(columnSpecs(connection, "ai_question")) + .containsEntry("ai_question_id", new ColumnSpec("uuid", false)) + .containsEntry("ai_attempt_id", new ColumnSpec("uuid", false)) + .containsEntry("slot_key", new ColumnSpec("varchar", false)) + .containsEntry("answer_value", new ColumnSpec("varchar", true)); + assertThat(columnSpecs(connection, "ai_candidate")) + .containsEntry("ai_candidate_id", new ColumnSpec("uuid", false)) + .containsEntry("ai_attempt_id", new ColumnSpec("uuid", false)) + .containsEntry("worker_id", new ColumnSpec("uuid", false)) + .containsEntry("confidence", new ColumnSpec("numeric", false)); assertThat(constraintNames(connection)) .contains( @@ -200,7 +228,16 @@ private void assertSchemaContract(Connection connection) throws SQLException { "fk_event_consumption_publication", "pk_document_request_draft", "fk_document_request_draft_task_company", - "fk_document_request_draft_type_draft" + "fk_document_request_draft_type_draft", + "pk_ai_run", + "uq_ai_run_company_idempotency", + "fk_ai_run_requester_company", + "pk_ai_attempt", + "fk_ai_attempt_run_company", + "pk_ai_question", + "fk_ai_question_attempt_company", + "pk_ai_candidate", + "fk_ai_candidate_worker_company" ); assertThat(indexNames(connection)) .contains( @@ -217,7 +254,11 @@ private void assertSchemaContract(Connection connection) throws SQLException { "idx_event_publication_claim", "idx_event_publication_company_time", "idx_event_consumption_company_event", - "idx_document_request_draft_company" + "idx_document_request_draft_company", + "idx_ai_run_company_created", + "idx_ai_attempt_run", + "idx_ai_question_run", + "idx_ai_candidate_run" ); assertThat(policyNames(connection)) .containsExactlyInAnyOrder( @@ -237,7 +278,11 @@ private void assertSchemaContract(Connection connection) throws SQLException { "pl_event_publication_tenant_isolation", "pl_event_consumption_tenant_isolation", "pl_document_request_draft_tenant_isolation", - "pl_document_request_draft_type_tenant_isolation" + "pl_document_request_draft_type_tenant_isolation", + "pl_ai_run_tenant_isolation", + "pl_ai_attempt_tenant_isolation", + "pl_ai_question_tenant_isolation", + "pl_ai_candidate_tenant_isolation" ); assertThat(rlsEnabledTables(connection)).isEmpty(); assertThat(securityDefinerFunctionNames(connection)) diff --git a/src/test/java/com/fowoco/server/airun/AiRunApiIntegrationTest.java b/src/test/java/com/fowoco/server/airun/AiRunApiIntegrationTest.java new file mode 100644 index 0000000..5f54d70 --- /dev/null +++ b/src/test/java/com/fowoco/server/airun/AiRunApiIntegrationTest.java @@ -0,0 +1,348 @@ +package com.fowoco.server.airun; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.reset; +import static org.mockito.Mockito.when; + +import com.fowoco.server.aiintegration.application.model.AiAnalysisOutcome; +import com.fowoco.server.aiintegration.application.model.AiAnalysisRequest; +import com.fowoco.server.aiintegration.application.model.AiAnalysisResponse; +import com.fowoco.server.aiintegration.application.model.AiCandidate; +import com.fowoco.server.aiintegration.application.model.AiContextRequirement; +import com.fowoco.server.aiintegration.application.model.AiQuestion; +import com.fowoco.server.aiintegration.application.model.AiRuntimeVersions; +import com.fowoco.server.aiintegration.application.port.AiRuntimeClient; +import com.jayway.jsonpath.JsonPath; +import java.math.BigDecimal; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.LocalDate; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.http.HttpHeaders; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +@ActiveProfiles("test") +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class AiRunApiIntegrationTest { + + private static final UUID COMPANY_A = UUID.fromString("81000000-0000-0000-0000-000000000001"); + private static final UUID COMPANY_B = UUID.fromString("81000000-0000-0000-0000-000000000002"); + private static final UUID HR_A = UUID.fromString("82000000-0000-0000-0000-000000000001"); + private static final UUID HR_B = UUID.fromString("82000000-0000-0000-0000-000000000002"); + private static final UUID WORKER_A = UUID.fromString("83000000-0000-0000-0000-000000000001"); + private static final String HR_A_EMAIL = "airun.hr.a@example.com"; + private static final String HR_B_EMAIL = "airun.hr.b@example.com"; + private static final String PASSWORD = "Test-password-1!"; + + @LocalServerPort + private int port; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @Autowired + private PasswordEncoder passwordEncoder; + + @MockitoBean + private AiRuntimeClient runtimeClient; + + private final HttpClient httpClient = HttpClient.newHttpClient(); + private AtomicInteger runtimeCalls; + + @BeforeEach + void resetAndSeed() { + reset(runtimeClient); + runtimeCalls = new AtomicInteger(); + when(runtimeClient.analyze(any(), any())).thenAnswer(invocation -> { + AiAnalysisRequest request = invocation.getArgument(0); + return scriptedResponse(request, runtimeCalls.incrementAndGet()); + }); + + jdbcTemplate.update("DELETE FROM ai_candidate"); + jdbcTemplate.update("DELETE FROM ai_question"); + jdbcTemplate.update("DELETE FROM ai_attempt"); + jdbcTemplate.update("DELETE FROM ai_run"); + jdbcTemplate.update("DELETE FROM event_consumption"); + jdbcTemplate.update("DELETE FROM event_publication"); + jdbcTemplate.update("DELETE FROM audit_event"); + jdbcTemplate.update("DELETE FROM task_evidence"); + jdbcTemplate.update("DELETE FROM external_submission"); + jdbcTemplate.update("DELETE FROM approval_request"); + jdbcTemplate.update("DELETE FROM task_transition_history"); + jdbcTemplate.update("DELETE FROM task_checklist_item"); + jdbcTemplate.update("DELETE FROM task"); + jdbcTemplate.update("DELETE FROM worker_document"); + jdbcTemplate.update("DELETE FROM worker"); + jdbcTemplate.update("DELETE FROM refresh_token"); + jdbcTemplate.update("DELETE FROM user_account"); + jdbcTemplate.update("DELETE FROM company"); + + insertCompany(COMPANY_A, "AI Run 사업장 A"); + insertCompany(COMPANY_B, "AI Run 사업장 B"); + String passwordHash = passwordEncoder.encode(PASSWORD); + insertUser(HR_A, COMPANY_A, HR_A_EMAIL, passwordHash); + insertUser(HR_B, COMPANY_B, HR_B_EMAIL, passwordHash); + insertWorker(); + } + + @Test + void createsQueriesAnswersAndFinishesOneWorkerAnalysis() throws Exception { + String token = login(HR_A_EMAIL); + HttpResponse created = post( + "/api/v1/ai-runs", + """ + {"instruction":"응웬반A 체류연장 준비해줘, EXPIRY_RENEWAL"} + """, + token, + "airun-demo-001" + ); + + assertThat(created.statusCode()).isEqualTo(202); + UUID aiRunId = UUID.fromString(JsonPath.read(created.body(), "$.ai_run_id")); + assertThat(JsonPath.read(created.body(), "$.analysis_outcome")) + .isEqualTo("NEEDS_INFO"); + assertThat(JsonPath.read(created.body(), "$.detected_intent")) + .isEqualTo("EXPIRY_RENEWAL"); + assertThat(JsonPath.>read(created.body(), "$.questions[*].slot_key")) + .containsExactly("due_at"); + assertThat(JsonPath.read(created.body(), "$.attempt_count").intValue()) + .isEqualTo(2); + long version = JsonPath.read(created.body(), "$.version").longValue(); + + HttpResponse detail = get("/api/v1/ai-runs/" + aiRunId, token); + assertThat(detail.statusCode()).isEqualTo(200); + assertThat(JsonPath.read(detail.body(), "$.status")).isEqualTo("SUCCEEDED"); + + HttpResponse answered = post( + "/api/v1/ai-runs/" + aiRunId + "/answers", + """ + {"expected_version":%d,"answers":{"due_at":"2026-08-31"}} + """.formatted(version), + token, + null + ); + assertThat(answered.statusCode()).isEqualTo(202); + assertThat(JsonPath.read(answered.body(), "$.analysis_outcome")) + .isEqualTo("REVIEW_REQUIRED"); + assertThat(JsonPath.>read(answered.body(), "$.candidates[*].workflow_id")) + .containsExactly("WF-STY-001"); + assertThat(JsonPath.read(answered.body(), "$.attempt_count").intValue()) + .isEqualTo(3); + assertThat(jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM ai_attempt WHERE ai_run_id = ?", + Integer.class, + aiRunId + )).isEqualTo(3); + assertThat(jdbcTemplate.queryForList( + "SELECT action FROM audit_event WHERE target_id = ? ORDER BY created_at", + String.class, + aiRunId + )).containsExactly("AI_RUN_CREATED", "AI_RUN_ANSWERS_SUBMITTED"); + } + + @Test + void idempotencyAndCompanyIsolationAreEnforced() throws Exception { + String tokenA = login(HR_A_EMAIL); + String tokenB = login(HR_B_EMAIL); + String body = """ + {"instruction":"응웬반A 체류연장 준비해줘, EXPIRY_RENEWAL"} + """; + HttpResponse first = post( + "/api/v1/ai-runs", + body, + tokenA, + "airun-demo-duplicate" + ); + HttpResponse repeated = post( + "/api/v1/ai-runs", + body, + tokenA, + "airun-demo-duplicate" + ); + UUID aiRunId = UUID.fromString(JsonPath.read(first.body(), "$.ai_run_id")); + + assertThat(repeated.statusCode()).isEqualTo(202); + assertThat(JsonPath.read(repeated.body(), "$.ai_run_id")) + .isEqualTo(aiRunId.toString()); + assertThat(runtimeCalls).hasValue(2); + assertThat(get("/api/v1/ai-runs/" + aiRunId, tokenB).statusCode()).isEqualTo(404); + + HttpResponse conflict = post( + "/api/v1/ai-runs", + """ + {"instruction":"다른 요청"} + """, + tokenA, + "airun-demo-duplicate" + ); + assertThat(conflict.statusCode()).isEqualTo(409); + } + + private AiAnalysisResponse scriptedResponse(AiAnalysisRequest request, int call) { + if (call == 1) { + return new AiAnalysisResponse( + request.requestId(), + AiAnalysisOutcome.CONTEXT_REQUIRED, + new AiContextRequirement( + "EXPIRY_RENEWAL", + new BigDecimal("0.96"), + "응웬반A", + Map.of(), + List.of("worker_id", "stay_expiry_date", "due_at") + ), + List.of(), + List.of(), + List.of(), + versions(), + 1, + 30 + ); + } + if (call == 2) { + return new AiAnalysisResponse( + request.requestId(), + AiAnalysisOutcome.NEEDS_INFO, + null, + List.of(new AiQuestion("due_at", "신청 목표일을 입력해 주세요.")), + List.of(), + List.of(), + versions(), + 1, + 20 + ); + } + return new AiAnalysisResponse( + request.requestId(), + AiAnalysisOutcome.REVIEW_REQUIRED, + null, + List.of(), + List.of(new AiCandidate( + "candidate-1", + WORKER_A, + "WF-STY-001", + Map.of("due_at", "2026-08-31"), + List.of(), + new BigDecimal("0.93") + )), + List.of(), + versions(), + 1, + 25 + ); + } + + private AiRuntimeVersions versions() { + return new AiRuntimeVersions( + "agent-demo-1", + "fixture", + "fixture-model", + "1", + "prompt-demo-1", + "context-demo-1", + "0.2.0", + "1.0.0" + ); + } + + private String login(String email) throws Exception { + HttpResponse response = post( + "/api/v1/auth/login", + """ + {"email":"%s","password":"%s"} + """.formatted(email, PASSWORD), + null, + null + ); + assertThat(response.statusCode()).isEqualTo(200); + return JsonPath.read(response.body(), "$.access_token"); + } + + private HttpResponse get(String path, String token) throws Exception { + HttpRequest request = HttpRequest.newBuilder(uri(path)) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + token) + .GET() + .build(); + return httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + } + + private HttpResponse post( + String path, + String body, + String token, + String idempotencyKey + ) throws Exception { + HttpRequest.Builder builder = HttpRequest.newBuilder(uri(path)) + .header(HttpHeaders.CONTENT_TYPE, "application/json") + .POST(HttpRequest.BodyPublishers.ofString(body)); + if (token != null) { + builder.header(HttpHeaders.AUTHORIZATION, "Bearer " + token); + } + if (idempotencyKey != null) { + builder.header("Idempotency-Key", idempotencyKey); + } + return httpClient.send(builder.build(), HttpResponse.BodyHandlers.ofString()); + } + + private URI uri(String path) { + return URI.create("http://localhost:" + port + path); + } + + private void insertCompany(UUID companyId, String name) { + jdbcTemplate.update( + """ + INSERT INTO company (company_id, name, status, created_at, updated_at, version) + VALUES (?, ?, 'ACTIVE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0) + """, + companyId, + name + ); + } + + private void insertUser(UUID userId, UUID companyId, String email, String passwordHash) { + jdbcTemplate.update( + """ + INSERT INTO user_account ( + user_id, company_id, email, normalized_email, password_hash, + role, status, created_at, updated_at, version + ) VALUES (?, ?, ?, ?, ?, 'HR', 'ACTIVE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0) + """, + userId, + companyId, + email, + email, + passwordHash + ); + } + + private void insertWorker() { + jdbcTemplate.update( + """ + INSERT INTO worker ( + worker_id, company_id, display_name, nationality_code, preferred_language, + work_status, stay_expiry_date, contract_start_date, contract_end_date, + created_at, updated_at, version + ) VALUES (?, ?, '응웬반A', 'VN', 'vi', 'ACTIVE', ?, ?, ?, + CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0) + """, + WORKER_A, + COMPANY_A, + LocalDate.of(2026, 9, 30), + LocalDate.of(2025, 9, 1), + LocalDate.of(2026, 9, 30) + ); + } +} diff --git a/src/test/java/com/fowoco/server/airun/application/AiAnalysisContinuationServiceTest.java b/src/test/java/com/fowoco/server/airun/application/AiAnalysisContinuationServiceTest.java index 162b7a3..3176f56 100644 --- a/src/test/java/com/fowoco/server/airun/application/AiAnalysisContinuationServiceTest.java +++ b/src/test/java/com/fowoco/server/airun/application/AiAnalysisContinuationServiceTest.java @@ -55,10 +55,13 @@ void recordsNewAttemptBeforeAnalyzeAndPreservesPlanContext() { ); AiAnalysisContinuationService service = new AiAnalysisContinuationService( resolutionTransaction(), - (requestId, phase, contextRound) -> { + (companyId, requestId, phase, contextRound, analysisInput) -> { + assertThat(companyId).isEqualTo(COMPANY_ID); assertThat(requestId).isEqualTo(REQUEST_ID); assertThat(phase).isEqualTo(AiAnalysisPhase.ANALYZE); assertThat(contextRound).isEqualTo(1); + assertThat(analysisInput.requestedFieldKeys()) + .containsExactly("worker_id", "stay_expiry_date", "due_at"); callOrder.add("attempt"); return NEXT_ATTEMPT_ID; }, @@ -104,7 +107,7 @@ void stopsAtRoundLimitBeforeDatabaseResolutionOrRuntimeCall() { AtomicReference attemptStarted = new AtomicReference<>(false); AiAnalysisContinuationService service = new AiAnalysisContinuationService( resolutionTransaction(), - (requestId, phase, contextRound) -> { + (companyId, requestId, phase, contextRound, analysisInput) -> { attemptStarted.set(true); return NEXT_ATTEMPT_ID; }, From f93173c7fed7ab0e461893118093ed2baa320c8b Mon Sep 17 00:00:00 2001 From: hywznn Date: Tue, 4 Aug 2026 11:07:48 +0900 Subject: [PATCH 4/5] =?UTF-8?q?fix(airun):=20=EB=B8=8C=EB=9D=BC=EC=9A=B0?= =?UTF-8?q?=EC=A0=80=20=EB=A9=B1=EB=93=B1=EC=84=B1=20=ED=97=A4=EB=8D=94=20?= =?UTF-8?q?CORS=20=ED=97=88=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../fowoco/server/common/config/CorsConfig.java | 7 ++++++- .../com/fowoco/server/ServerApplicationTests.java | 14 +++++++++++--- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/fowoco/server/common/config/CorsConfig.java b/src/main/java/com/fowoco/server/common/config/CorsConfig.java index 56c9bee..899109f 100644 --- a/src/main/java/com/fowoco/server/common/config/CorsConfig.java +++ b/src/main/java/com/fowoco/server/common/config/CorsConfig.java @@ -18,7 +18,12 @@ public CorsConfigurationSource corsConfigurationSource( CorsConfiguration configuration = new CorsConfiguration(); configuration.setAllowedOrigins(allowedOrigins); configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")); - configuration.setAllowedHeaders(List.of("Authorization", "Content-Type", "X-Request-Id")); + configuration.setAllowedHeaders(List.of( + "Authorization", + "Content-Type", + "X-Request-Id", + "Idempotency-Key" + )); configuration.setExposedHeaders(List.of("X-Request-Id")); configuration.setAllowCredentials(true); configuration.setMaxAge(3600L); diff --git a/src/test/java/com/fowoco/server/ServerApplicationTests.java b/src/test/java/com/fowoco/server/ServerApplicationTests.java index 290656c..0df640e 100644 --- a/src/test/java/com/fowoco/server/ServerApplicationTests.java +++ b/src/test/java/com/fowoco/server/ServerApplicationTests.java @@ -92,11 +92,15 @@ void openApiRequestSchemasUseCanonicalSnakeCaseProperties() throws Exception { } @Test - void reactDevelopmentOriginCanSendPreflightRequest() throws Exception { + void reactDevelopmentOriginCanSendAiRunPreflightRequest() throws Exception { HttpRequest request = HttpRequest.newBuilder() - .uri(URI.create("http://localhost:" + port + "/health")) + .uri(URI.create("http://localhost:" + port + "/api/v1/ai-runs")) .header("Origin", "http://localhost:5173") - .header("Access-Control-Request-Method", "GET") + .header("Access-Control-Request-Method", "POST") + .header( + "Access-Control-Request-Headers", + "authorization,content-type,idempotency-key" + ) .method("OPTIONS", HttpRequest.BodyPublishers.noBody()) .build(); @@ -105,6 +109,10 @@ void reactDevelopmentOriginCanSendPreflightRequest() throws Exception { assertThat(response.statusCode()).isEqualTo(200); assertThat(response.headers().firstValue("Access-Control-Allow-Origin")) .contains("http://localhost:5173"); + assertThat(response.headers().firstValue("Access-Control-Allow-Headers").orElseThrow()) + .containsIgnoringCase("authorization") + .containsIgnoringCase("content-type") + .containsIgnoringCase("idempotency-key"); } @Test From 952896f8a4a6da1535aa818611fc68e7ce6a4d57 Mon Sep 17 00:00:00 2001 From: hywznn Date: Tue, 4 Aug 2026 16:14:29 +0900 Subject: [PATCH 5/5] =?UTF-8?q?fix(airun):=20=EC=82=AC=EC=9A=A9=EC=9E=90?= =?UTF-8?q?=20=EC=9B=90=EB=AC=B8=20=EC=A0=84=EB=8B=AC=20=EA=B3=84=EC=95=BD?= =?UTF-8?q?=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 빠른 선택 태그와 Intent 코드를 instruction에 덧붙이지 않도록 문서와 테스트 Fixture를 수정합니다. 최종 Intent는 AI Runtime이 반환한 detectedIntent를 사용하는 흐름을 검증합니다. --- docs/ai-runtime-contract.md | 17 ++++++++++------- docs/ai-slot-resolution.md | 4 ++-- .../http/AiRuntimeHttpRequestTest.java | 2 +- .../http/RemoteAiRuntimeClientWireMockTest.java | 2 +- .../support/AiRuntimeContractFixture.java | 4 ++-- .../server/airun/AiRunApiIntegrationTest.java | 6 ++++-- .../AiAnalysisContinuationServiceTest.java | 2 +- 7 files changed, 21 insertions(+), 16 deletions(-) diff --git a/docs/ai-runtime-contract.md b/docs/ai-runtime-contract.md index 0475651..ff79639 100644 --- a/docs/ai-runtime-contract.md +++ b/docs/ai-runtime-contract.md @@ -32,9 +32,10 @@ Prompt, Agent Pipeline, Provider retry와 모델 선택은 `fowoco/ai` 책임입 ## PLAN 요청 계약 첫 호출은 HR 발화문을 이해하고 Server에 필요한 DB field를 요청하는 단계입니다. 화면의 -빠른 선택 태그는 별도 JSON 필드로 보내지 않고 `발화문, INTENT_TAG` 형식으로 -`instruction` 끝에 붙입니다. Runtime이 받는 업무 입력은 이 문자열 하나이며, 최종 분류 -결과는 Runtime이 `detectedIntent`로 반환합니다. 이 단계에는 Worker UUID나 DB 조회값을 +빠른 선택 태그는 입력 예시를 채우는 UI 기능일 뿐, API 데이터가 아닙니다. Client는 사용자가 +최종 작성한 발화문만 `instruction`으로 보내고, Server도 이를 그대로 Runtime에 전달합니다. +`intentHint`를 보내거나 `instruction` 뒤에 Intent 코드를 붙이지 않습니다. 최종 분류 결과는 +Runtime이 반환한 `detectedIntent`를 사용합니다. 이 단계에는 Worker UUID나 DB 조회값을 넣지 않습니다. ```json @@ -42,7 +43,7 @@ Prompt, Agent Pipeline, Provider retry와 모델 선택은 `fowoco/ai` 책임입 "requestId": "10000000-0000-0000-0000-000000000001", "phase": "PLAN", "analysisInput": { - "instruction": "응웬반안 체류연장 준비해줘, EXPIRY_RENEWAL" + "instruction": "응웬반안 체류연장 준비해줘" } } ``` @@ -97,7 +98,7 @@ Agent는 SQL을 만들거나 DB를 직접 조회하지 않고, canonical field k "requestId": "10000000-0000-0000-0000-000000000001", "phase": "ANALYZE", "analysisInput": { - "instruction": "응웬반안 체류연장 준비해줘, EXPIRY_RENEWAL", + "instruction": "응웬반안 체류연장 준비해줘", "requestedFieldKeys": [ "legal_name", "stay_expiry_date" @@ -117,8 +118,10 @@ Agent는 SQL을 만들거나 DB를 직접 조회하지 않고, canonical field k - `requestId`: Server 요청과 Runtime 응답을 같은 실행으로 연결합니다. - `phase`: 발화문을 해석하는 `PLAN`과 Server 보유정보로 결과를 만드는 `ANALYZE`를 구분합니다. -- `instruction`: HR 발화문에 선택한 태그가 있으면 `발화문, INTENT_TAG` 형식으로 붙인 - 단일 문자열입니다. 현재 데모에서는 가상 근로자 데이터만 사용합니다. +- `instruction`: 사용자가 최종 작성한 HR 발화문 원문입니다. 빠른 선택 태그나 Server가 + 추측한 Intent를 덧붙이지 않습니다. 현재 데모에서는 가상 근로자 데이터만 사용합니다. +- `detectedIntent`: Runtime 응답에서만 정해지는 최종 Intent입니다. Server가 발화문이나 + 화면 태그를 기준으로 별도 판정하지 않습니다. - `requestedFieldKeys`: Agent가 PLAN에서 요청했던 전체 key입니다. DB에 값이 없어도 목록에는 남습니다. - `requestedFields`: Agent가 요구한 field의 원본값입니다. Server가 가진 값만 넣습니다. diff --git a/docs/ai-slot-resolution.md b/docs/ai-slot-resolution.md index f31500f..fbd1a6f 100644 --- a/docs/ai-slot-resolution.md +++ b/docs/ai-slot-resolution.md @@ -67,7 +67,7 @@ Runtime JSON으로 변환합니다. Server 내부 요청은 다음 값을 잃어 - 동일한 `requestId` - 새로운 `attemptId`와 남은 deadline -- 선택한 태그까지 포함한 원래 `instruction` (`발화문, INTENT_TAG`) +- 사용자가 최종 작성한 원래 `instruction` (Intent 태그를 덧붙이지 않은 발화문) - PLAN이 추출한 `extractedSlots` - PLAN이 요청한 전체 `requestedFieldKeys` - 응답 검증에 필요한 Worker snapshot @@ -81,7 +81,7 @@ Runtime JSON으로 변환합니다. Server 내부 요청은 다음 값을 잃어 "requestId": "10000000-0000-0000-0000-000000000001", "phase": "ANALYZE", "analysisInput": { - "instruction": "응웬반안 체류연장 준비해줘, EXPIRY_RENEWAL", + "instruction": "응웬반안 체류연장 준비해줘", "requestedFieldKeys": ["worker_id", "stay_expiry_date", "due_at"], "workers": [{ "workerRef": "worker-uuid", diff --git a/src/test/java/com/fowoco/server/aiintegration/infrastructure/http/AiRuntimeHttpRequestTest.java b/src/test/java/com/fowoco/server/aiintegration/infrastructure/http/AiRuntimeHttpRequestTest.java index 8cca9bd..68f8e1e 100644 --- a/src/test/java/com/fowoco/server/aiintegration/infrastructure/http/AiRuntimeHttpRequestTest.java +++ b/src/test/java/com/fowoco/server/aiintegration/infrastructure/http/AiRuntimeHttpRequestTest.java @@ -23,7 +23,7 @@ void planJsonContainsOnlyRequestIdPhaseAndInstruction() { assertThat(json.get("phase").textValue()).isEqualTo("PLAN"); assertThat(fieldNames(input)).containsExactly("instruction"); assertThat(input.get("instruction").textValue()) - .isEqualTo("응웬반안 체류연장 준비해줘, EXPIRY_RENEWAL"); + .isEqualTo("응웬반안 체류연장 준비해줘"); } @Test diff --git a/src/test/java/com/fowoco/server/aiintegration/infrastructure/http/RemoteAiRuntimeClientWireMockTest.java b/src/test/java/com/fowoco/server/aiintegration/infrastructure/http/RemoteAiRuntimeClientWireMockTest.java index 9aafff5..79460c4 100644 --- a/src/test/java/com/fowoco/server/aiintegration/infrastructure/http/RemoteAiRuntimeClientWireMockTest.java +++ b/src/test/java/com/fowoco/server/aiintegration/infrastructure/http/RemoteAiRuntimeClientWireMockTest.java @@ -134,7 +134,7 @@ void sendsInstructionOnlyPlanAndParsesContextRequirement() throws Exception { )) .withRequestBody(com.github.tomakehurst.wiremock.client.WireMock.matchingJsonPath( "$.analysisInput.instruction", - equalTo("응웬반안 체류연장 준비해줘, EXPIRY_RENEWAL") + equalTo("응웬반안 체류연장 준비해줘") )) .withRequestBody(com.github.tomakehurst.wiremock.client.WireMock.matchingJsonPath( "$.analysisInput.intentHint", diff --git a/src/test/java/com/fowoco/server/aiintegration/support/AiRuntimeContractFixture.java b/src/test/java/com/fowoco/server/aiintegration/support/AiRuntimeContractFixture.java index 828cd59..e58602d 100644 --- a/src/test/java/com/fowoco/server/aiintegration/support/AiRuntimeContractFixture.java +++ b/src/test/java/com/fowoco/server/aiintegration/support/AiRuntimeContractFixture.java @@ -35,7 +35,7 @@ public static AiAnalysisRequest validRequest() { } public static AiAnalysisRequest validPlanRequest() { - return planRequestWithInstruction("응웬반안 체류연장 준비해줘, EXPIRY_RENEWAL"); + return planRequestWithInstruction("응웬반안 체류연장 준비해줘"); } public static AiAnalysisRequest planRequestWithInstruction(String instruction) { @@ -52,7 +52,7 @@ public static AiAnalysisRequest planRequestWithInstruction(String instruction) { public static AiAnalysisRequest validAnalyzeRequest() { return requestWithInstruction( - "가상 근로자 응웬반안(010-1234-5678)의 체류연장 준비, EXPIRY_RENEWAL" + "가상 근로자 응웬반안(010-1234-5678)의 체류연장 준비" ); } diff --git a/src/test/java/com/fowoco/server/airun/AiRunApiIntegrationTest.java b/src/test/java/com/fowoco/server/airun/AiRunApiIntegrationTest.java index 5f54d70..43d44a5 100644 --- a/src/test/java/com/fowoco/server/airun/AiRunApiIntegrationTest.java +++ b/src/test/java/com/fowoco/server/airun/AiRunApiIntegrationTest.java @@ -105,7 +105,7 @@ void createsQueriesAnswersAndFinishesOneWorkerAnalysis() throws Exception { HttpResponse created = post( "/api/v1/ai-runs", """ - {"instruction":"응웬반A 체류연장 준비해줘, EXPIRY_RENEWAL"} + {"instruction":"응웬반A 체류연장 준비해줘"} """, token, "airun-demo-001" @@ -117,6 +117,8 @@ void createsQueriesAnswersAndFinishesOneWorkerAnalysis() throws Exception { .isEqualTo("NEEDS_INFO"); assertThat(JsonPath.read(created.body(), "$.detected_intent")) .isEqualTo("EXPIRY_RENEWAL"); + assertThat(JsonPath.read(created.body(), "$.instruction")) + .isEqualTo("응웬반A 체류연장 준비해줘"); assertThat(JsonPath.>read(created.body(), "$.questions[*].slot_key")) .containsExactly("due_at"); assertThat(JsonPath.read(created.body(), "$.attempt_count").intValue()) @@ -159,7 +161,7 @@ void idempotencyAndCompanyIsolationAreEnforced() throws Exception { String tokenA = login(HR_A_EMAIL); String tokenB = login(HR_B_EMAIL); String body = """ - {"instruction":"응웬반A 체류연장 준비해줘, EXPIRY_RENEWAL"} + {"instruction":"응웬반A 체류연장 준비해줘"} """; HttpResponse first = post( "/api/v1/ai-runs", diff --git a/src/test/java/com/fowoco/server/airun/application/AiAnalysisContinuationServiceTest.java b/src/test/java/com/fowoco/server/airun/application/AiAnalysisContinuationServiceTest.java index 3176f56..720abd9 100644 --- a/src/test/java/com/fowoco/server/airun/application/AiAnalysisContinuationServiceTest.java +++ b/src/test/java/com/fowoco/server/airun/application/AiAnalysisContinuationServiceTest.java @@ -89,7 +89,7 @@ void recordsNewAttemptBeforeAnalyzeAndPreservesPlanContext() { assertThat(analyzeRequest.analysisInput().instruction()) .isEqualTo(validPlanRequest().analysisInput().instruction()); assertThat(analyzeRequest.analysisInput().instruction()) - .endsWith(", EXPIRY_RENEWAL"); + .isEqualTo("응웬반안 체류연장 준비해줘"); assertThat(analyzeRequest.analysisInput().extractedSlots()) .containsEntry("document_type", "STAY_EXTENSION"); assertThat(analyzeRequest.analysisInput().requestedFieldKeys())