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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 10 additions & 7 deletions docs/ai-runtime-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,17 +32,18 @@ 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
{
"requestId": "10000000-0000-0000-0000-000000000001",
"phase": "PLAN",
"analysisInput": {
"instruction": "응웬반안 체류연장 준비해줘, EXPIRY_RENEWAL"
"instruction": "응웬반안 체류연장 준비해줘"
}
}
```
Expand Down Expand Up @@ -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"
Expand All @@ -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가 가진 값만 넣습니다.

Expand Down
4 changes: 2 additions & 2 deletions docs/ai-slot-resolution.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ Runtime JSON으로 변환합니다. Server 내부 요청은 다음 값을 잃어

- 동일한 `requestId`
- 새로운 `attemptId`와 남은 deadline
- 선택한 태그까지 포함한 원래 `instruction` (`발화문, INTENT_TAG`)
- 사용자가 최종 작성한 원래 `instruction` (Intent 태그를 덧붙이지 않은 발화문)
- PLAN이 추출한 `extractedSlots`
- PLAN이 요청한 전체 `requestedFieldKeys`
- 응답 검증에 필요한 Worker snapshot
Expand All @@ -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",
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -11,7 +10,6 @@
* <p>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.</p>
*/
@JsonInclude(JsonInclude.Include.NON_EMPTY)
public record AnalysisInput(
String instruction,
Map<String, String> extractedSlots,
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String, String> extractedSlots,
List<String> 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()
);
}
}
122 changes: 122 additions & 0 deletions src/main/java/com/fowoco/server/airun/api/AiRunController.java
Original file line number Diff line number Diff line change
@@ -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<AiRunResponse> 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<AiRunResponse> 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();
}
}
Original file line number Diff line number Diff line change
@@ -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()
);
}
}
42 changes: 42 additions & 0 deletions src/main/java/com/fowoco/server/airun/api/AiRunResponse.java
Original file line number Diff line number Diff line change
@@ -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<AiRunQuestionResponse> questions,
List<AiRunCandidateResponse> 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()
);
}
}
11 changes: 11 additions & 0 deletions src/main/java/com/fowoco/server/airun/api/CreateAiRunRequest.java
Original file line number Diff line number Diff line change
@@ -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
) {
}
Original file line number Diff line number Diff line change
@@ -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<String, String> answers
) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -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(
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String, String> extractedSlots,
List<String> 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");
}
}
Loading
Loading