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
22 changes: 15 additions & 7 deletions src/main/java/com/fowoco/server/task/api/TaskController.java
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import com.fowoco.server.common.web.RequestMetadata;
import com.fowoco.server.task.application.TaskResult;
import com.fowoco.server.task.application.TaskWorkflowService;
import com.fowoco.server.task.application.action.TaskAvailableActionResolver;
import com.fowoco.server.task.domain.TaskSource;
import com.fowoco.server.task.domain.TaskStatus;
import com.fowoco.server.task.domain.TaskTargetType;
Expand Down Expand Up @@ -45,13 +46,16 @@ public class TaskController {

private final TaskWorkflowService taskService;
private final ActorContextProvider actorContextProvider;
private final TaskAvailableActionResolver actionResolver;

public TaskController(
TaskWorkflowService taskService,
ActorContextProvider actorContextProvider
ActorContextProvider actorContextProvider,
TaskAvailableActionResolver actionResolver
) {
this.taskService = taskService;
this.actorContextProvider = actorContextProvider;
this.actionResolver = actionResolver;
}

@Operation(operationId = "listTasks", summary = "업무카드 목록 조회")
Expand Down Expand Up @@ -115,7 +119,7 @@ public ResponseEntity<TaskDetailResponse> create(
.path("/{taskId}")
.buildAndExpand(result.task().taskId())
.toUri();
return ResponseEntity.created(location).body(TaskDetailResponse.from(result));
return ResponseEntity.created(location).body(response(result));
}

@Operation(operationId = "getTask", summary = "업무카드 상세 조회")
Expand All @@ -126,7 +130,7 @@ public ResponseEntity<TaskDetailResponse> create(
@PreAuthorize("hasAnyRole('ADMIN', 'HR', 'VIEWER')")
@GetMapping(path = "/{taskId}", produces = MediaType.APPLICATION_JSON_VALUE)
public TaskDetailResponse findById(@PathVariable UUID taskId) {
return TaskDetailResponse.from(taskService.findById(taskId, actor()));
return response(taskService.findById(taskId, actor()));
}

@Operation(
Expand All @@ -151,7 +155,7 @@ public TaskDetailResponse update(
@Valid @RequestBody UpdateTaskRequest request,
HttpServletRequest servletRequest
) {
return TaskDetailResponse.from(taskService.update(
return response(taskService.update(
taskId,
request.toCommand(),
actor(),
Expand Down Expand Up @@ -183,7 +187,7 @@ public TaskDetailResponse changeAssignee(
@Valid @RequestBody ChangeTaskAssigneeRequest request,
HttpServletRequest servletRequest
) {
return TaskDetailResponse.from(taskService.changeAssignee(
return response(taskService.changeAssignee(
taskId,
request.toCommand(),
actor(),
Expand All @@ -210,7 +214,7 @@ public TaskDetailResponse updateChecklistItem(
@Valid @RequestBody UpdateChecklistItemRequest request,
HttpServletRequest servletRequest
) {
return TaskDetailResponse.from(taskService.updateChecklistItem(
return response(taskService.updateChecklistItem(
taskId,
itemId,
request.toCommand(),
Expand All @@ -237,7 +241,7 @@ public TaskDetailResponse cancel(
@Valid @RequestBody CancelTaskRequest request,
HttpServletRequest servletRequest
) {
return TaskDetailResponse.from(taskService.cancel(
return response(taskService.cancel(
taskId,
request.toCommand(),
actor(),
Expand All @@ -248,4 +252,8 @@ public TaskDetailResponse cancel(
private ActorContext actor() {
return actorContextProvider.requireCurrentActor();
}

private TaskDetailResponse response(TaskResult result) {
return TaskDetailResponse.from(result, actionResolver.resolve(result));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
import com.fasterxml.jackson.databind.PropertyNamingStrategies;
import com.fasterxml.jackson.databind.annotation.JsonNaming;
import com.fowoco.server.task.application.TaskResult;
import com.fowoco.server.task.application.action.TaskActionDecision;
import com.fowoco.server.task.application.action.TaskAvailableAction;
import com.fowoco.server.task.domain.Task;
import com.fowoco.server.task.domain.TaskSource;
import com.fowoco.server.task.domain.TaskStatus;
Expand Down Expand Up @@ -34,12 +36,15 @@ public record TaskDetailResponse(
long version,
List<String> missingRequiredSlots,
List<TaskChecklistItemResponse> checklistItems,
TaskAvailableAction nextAction,
List<TaskAvailableAction> availableActions,
String blockedReason,
UUID createdBy,
UUID updatedBy,
Instant createdAt,
Instant updatedAt
) {
static TaskDetailResponse from(TaskResult result) {
static TaskDetailResponse from(TaskResult result, TaskActionDecision actionDecision) {
Task task = result.task();
return new TaskDetailResponse(
task.taskId(),
Expand All @@ -60,6 +65,9 @@ static TaskDetailResponse from(TaskResult result) {
task.version(),
result.missingRequiredSlots(),
result.checklistItems().stream().map(TaskChecklistItemResponse::from).toList(),
actionDecision.nextAction(),
actionDecision.availableActions(),
actionDecision.blockedReason(),
task.createdBy(),
task.updatedBy(),
task.createdAt(),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package com.fowoco.server.task.application.action;

import java.util.List;

public record TaskActionDecision(
TaskAvailableAction nextAction,
List<TaskAvailableAction> availableActions,
String blockedReason
) {
public TaskActionDecision {
availableActions = List.copyOf(availableActions);
}

public static TaskActionDecision of(
TaskAvailableAction nextAction,
String blockedReason,
TaskAvailableAction... availableActions
) {
return new TaskActionDecision(nextAction, List.of(availableActions), blockedReason);
}

public static TaskActionDecision terminal() {
return new TaskActionDecision(null, List.of(), "TASK_TERMINAL");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.fowoco.server.task.application.action;

public enum TaskAvailableAction {
RUN_RENEWAL,
PROVIDE_REQUIRED_INFORMATION,
COMPLETE_CHECKLIST,
REVIEW_OCR,
REVIEW_WORKER_GUIDE,
REVIEW_GENERATED_DOCUMENT,
REQUEST_APPROVAL,
APPROVE,
ISSUE_WORKER_LINK,
REVIEW_WORKER_RESPONSE,
COMPLETE_TASK
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
package com.fowoco.server.task.application.action;

import com.fowoco.server.aiintegration.application.renewal.RenewalWorkflowPolicy;
import com.fowoco.server.task.application.TaskResult;
import com.fowoco.server.task.domain.Task;
import com.fowoco.server.task.domain.TaskChecklistItem;
import com.fowoco.server.task.domain.TaskTargetType;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import org.springframework.stereotype.Component;

@Component
public class TaskAvailableActionResolver {

private static final String DOCUMENT_OCR = "DOCUMENT_OCR";
private static final String USER_INPUT = "USER_INPUT";

public TaskActionDecision resolve(TaskResult result) {
Task task = result.task();
if (task.status().isTerminal()) {
return TaskActionDecision.terminal();
}

return switch (task.status()) {
case DRAFT, NEEDS_INFO -> resolvePreparation(result);
case READY_FOR_REVIEW -> TaskActionDecision.of(
TaskAvailableAction.APPROVE,
"HR_REVIEW_REQUIRED",
TaskAvailableAction.APPROVE
);
case APPROVED -> TaskActionDecision.of(
TaskAvailableAction.ISSUE_WORKER_LINK,
null,
TaskAvailableAction.ISSUE_WORKER_LINK
);
case WAITING_WORKER -> TaskActionDecision.of(
TaskAvailableAction.REVIEW_WORKER_RESPONSE,
"WORKER_RESPONSE_PENDING",
TaskAvailableAction.REVIEW_WORKER_RESPONSE
);
case WAITING_EXTERNAL -> TaskActionDecision.of(
TaskAvailableAction.COMPLETE_TASK,
"EXTERNAL_EVIDENCE_REQUIRED",
TaskAvailableAction.COMPLETE_TASK
);
case COMPLETED, CANCELLED -> TaskActionDecision.terminal();
};
}

private TaskActionDecision resolvePreparation(TaskResult result) {
Task task = result.task();
RenewalProgress renewal = RenewalProgress.from(result.businessData());
boolean renewalSupported = supportsRenewal(task);

if (renewalSupported && !renewal.executed()) {
return TaskActionDecision.of(
TaskAvailableAction.RUN_RENEWAL,
null,
TaskAvailableAction.RUN_RENEWAL
);
}
if (renewalSupported && renewal.hasMissingSource(DOCUMENT_OCR)) {
return TaskActionDecision.of(
TaskAvailableAction.REVIEW_OCR,
"OCR_REVIEW_REQUIRED",
TaskAvailableAction.REVIEW_OCR
);
}
if (renewalSupported && renewal.hasMissingSource(USER_INPUT)) {
return TaskActionDecision.of(
TaskAvailableAction.RUN_RENEWAL,
"RENEWAL_INPUT_REQUIRED",
TaskAvailableAction.RUN_RENEWAL
);
}
if (!result.missingRequiredSlots().isEmpty()) {
return TaskActionDecision.of(
TaskAvailableAction.PROVIDE_REQUIRED_INFORMATION,
"REQUIRED_INFORMATION_MISSING",
TaskAvailableAction.PROVIDE_REQUIRED_INFORMATION
);
}
if (hasIncompleteRequiredChecklist(result.checklistItems())) {
return TaskActionDecision.of(
TaskAvailableAction.COMPLETE_CHECKLIST,
"CHECKLIST_INCOMPLETE",
TaskAvailableAction.COMPLETE_CHECKLIST
);
}
if (renewal.guideReviewRequired()) {
return TaskActionDecision.of(
TaskAvailableAction.REVIEW_WORKER_GUIDE,
"WORKER_GUIDE_REVIEW_REQUIRED",
TaskAvailableAction.REVIEW_WORKER_GUIDE
);
}

List<TaskAvailableAction> available = new ArrayList<>();
if (renewal.generatedDocumentPresent()) {
available.add(TaskAvailableAction.REVIEW_GENERATED_DOCUMENT);
}
available.add(TaskAvailableAction.REQUEST_APPROVAL);
return new TaskActionDecision(
TaskAvailableAction.REQUEST_APPROVAL,
available,
"APPROVAL_REQUIRED_BEFORE_CONTINUATION"
);
}

private boolean supportsRenewal(Task task) {
return task.targetType() == TaskTargetType.WORKER
&& RenewalWorkflowPolicy.supports(task.taskType().name(), task.workflowId());
}

private boolean hasIncompleteRequiredChecklist(List<TaskChecklistItem> items) {
return items.stream().anyMatch(item -> item.required() && !item.completed());
}

private record RenewalProgress(
boolean executed,
Set<String> missingSlots,
Map<String, String> sourceByField,
boolean guideReviewRequired,
boolean generatedDocumentPresent
) {
private static RenewalProgress from(Map<String, Object> businessData) {
Object executionValue = businessData.get("renewal_execution");
if (!(executionValue instanceof Map<?, ?> execution)) {
return new RenewalProgress(false, Set.of(), Map.of(), false, false);
}

Set<String> missingSlots = stringSet(execution.get("missing_slots"));
Map<String, String> sources = requestedFieldSources(execution.get("requested_fields"));
boolean guideReviewRequired = Boolean.TRUE.equals(execution.get("guide_review_required"));
boolean generatedDocumentPresent = execution.get("generated_documents") instanceof List<?> documents
&& !documents.isEmpty();
return new RenewalProgress(
true,
missingSlots,
sources,
guideReviewRequired,
generatedDocumentPresent
);
}

private boolean hasMissingSource(String source) {
return missingSlots.stream().anyMatch(slot -> source.equals(sourceByField.get(slot)));
}

private static Set<String> stringSet(Object value) {
if (!(value instanceof List<?> values)) {
return Set.of();
}
Set<String> result = new LinkedHashSet<>();
values.forEach(candidate -> {
if (candidate instanceof String text && !text.isBlank()) {
result.add(text);
}
});
return Set.copyOf(result);
}

private static Map<String, String> requestedFieldSources(Object value) {
if (!(value instanceof List<?> fields)) {
return Map.of();
}
Map<String, String> result = new LinkedHashMap<>();
fields.forEach(candidate -> {
if (!(candidate instanceof Map<?, ?> field)) {
return;
}
Object key = field.get("key");
Object source = field.get("source_hint");
if (key instanceof String keyText && source instanceof String sourceText) {
result.put(keyText, sourceText);
}
});
return Map.copyOf(result);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,14 @@ public enum TaskErrorCode implements ApiErrorCode {
HttpStatus.UNPROCESSABLE_CONTENT,
"이 업무카드에서는 재계약·연장 Agent를 실행할 수 없습니다."
),
RENEWAL_REQUEST_CONTRACT_INVALID(
HttpStatus.UNPROCESSABLE_CONTENT,
"재계약·연장 Agent 요청 정보가 현재 계약과 일치하지 않습니다."
),
RENEWAL_WORKFLOW_MISMATCH(
HttpStatus.UNPROCESSABLE_CONTENT,
"업무카드와 Agent가 선택한 Workflow가 일치하지 않습니다."
),
INVALID_RENEWAL_SLOT_ANSWER(
HttpStatus.UNPROCESSABLE_CONTENT,
"재계약·연장 업무에 제출한 추가 정보를 확인해 주세요."
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -205,9 +205,13 @@ private RenewalExecutionResult executeMeasured(
}

private ApiException contractFailure(AiRuntimeFailureCode code) {
if (code == AiRuntimeFailureCode.INVALID_REQUEST_CONTRACT
|| code == AiRuntimeFailureCode.SENSITIVE_DATA_REJECTED
|| code == AiRuntimeFailureCode.UNEXPECTED_WORKFLOW) {
if (code == AiRuntimeFailureCode.INVALID_REQUEST_CONTRACT) {
return new ApiException(TaskErrorCode.RENEWAL_REQUEST_CONTRACT_INVALID);
}
if (code == AiRuntimeFailureCode.UNEXPECTED_WORKFLOW) {
return new ApiException(TaskErrorCode.RENEWAL_WORKFLOW_MISMATCH);
}
if (code == AiRuntimeFailureCode.SENSITIVE_DATA_REJECTED) {
return new ApiException(TaskErrorCode.RENEWAL_EXECUTION_NOT_ALLOWED);
}
return new ApiException(TaskErrorCode.RENEWAL_RUNTIME_UNAVAILABLE);
Expand Down
Loading
Loading