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/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/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/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/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/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/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/java/com/fowoco/server/audit/domain/AuditAction.java b/src/main/java/com/fowoco/server/audit/domain/AuditAction.java
index fb2d1a9..ce49692 100644
--- a/src/main/java/com/fowoco/server/audit/domain/AuditAction.java
+++ b/src/main/java/com/fowoco/server/audit/domain/AuditAction.java
@@ -15,6 +15,8 @@ public enum AuditAction {
FILE_UPLOADED,
WORKER_DOCUMENT_FILE_LINKED,
DOCUMENT_REQUEST_DRAFT_SAVED,
+ AI_RUN_CREATED,
+ AI_RUN_ANSWERS_SUBMITTED,
WORKER_LINK_RESPONSE_SUBMITTED,
WORKER_LINK_ACCESSED
}
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
}
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 59858c1..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", "Idempotency-Key"));
+ 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/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);
diff --git a/src/test/java/com/fowoco/server/PostgreSqlMigrationTests.java b/src/test/java/com/fowoco/server/PostgreSqlMigrationTests.java
index b2ba513..0cc06a9 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(
@@ -238,6 +279,10 @@ private void assertSchemaContract(Connection connection) throws SQLException {
"pl_event_consumption_tenant_isolation",
"pl_document_request_draft_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",
"pl_worker_link_tenant_isolation",
"pl_worker_response_tenant_isolation",
"pl_worker_response_upload_tenant_isolation"
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
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
new file mode 100644
index 0000000..43d44a5
--- /dev/null
+++ b/src/test/java/com/fowoco/server/airun/AiRunApiIntegrationTest.java
@@ -0,0 +1,350 @@
+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 체류연장 준비해줘"}
+ """,
+ 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(), "$.instruction"))
+ .isEqualTo("응웬반A 체류연장 준비해줘");
+ 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 체류연장 준비해줘"}
+ """;
+ 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..720abd9 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;
},
@@ -86,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())
@@ -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;
},