diff --git a/README.md b/README.md index 2f7c00a2..aa50c290 100644 --- a/README.md +++ b/README.md @@ -154,8 +154,9 @@ HR이 승인된 Task의 Worker Link 발급 자동 발송하지 않으며 다시 HR 검토를 거칩니다. Language Assistant가 근로자 안내를 안전하게 만들지 못한 경우에도 임시 문장을 발송하지 -않습니다. Server는 실패 코드를 보존하고 Task를 HR 검토로 돌리며, 담당자가 안내문을 -작성하고 승인한 뒤에만 Worker Link와 SMS 전달을 허용합니다. +않습니다. Server는 생성된 한국어·쉬운 한국어·번역문을 `guide_review_draft`로 보존해 +HR이 검토할 수 있게 하고, 실패 코드도 함께 기록합니다. 이 제안문은 공식 안내 초안으로 +자동 확정되지 않으며 담당자가 수정·승인한 뒤에만 Worker Link와 SMS 전달을 허용합니다. ### 업무 이벤트가 알림으로 이어지는 흐름 diff --git a/docs/ai-runtime-contract.md b/docs/ai-runtime-contract.md index 70a5616c..89ce3af6 100644 --- a/docs/ai-runtime-contract.md +++ b/docs/ai-runtime-contract.md @@ -427,6 +427,27 @@ Server는 `guideFailureCode`를 안전한 허용 목록으로 검증하고 Task 상태로 회귀하지 않고 HR 검토 대상으로 남습니다. HR이 안전한 안내문을 작성하고 기존 승인 절차를 마친 뒤에만 Worker Link 발급과 전달 흐름을 진행합니다. +Runtime이 `languageAssistant`에 생성 결과를 함께 보낸 경우 Server는 임의 Provider 응답 +전체가 아니라 아래 검토 필드만 `guide_review_draft`로 선별해 Task 실행정보와 API 응답에 +보존합니다. + +```json +{ + "guide_review_draft": { + "target_language": "vi", + "generation_status": "warning", + "standard_korean_text": "여권 사본을 제출해 주세요.", + "easy_korean_text": "여권을 내 주세요.", + "translated_text": "Vui lòng nộp bản sao hộ chiếu.", + "warning_codes": ["SEMANTIC_VALIDATION_INCONCLUSIVE"] + } +} +``` + +이 값은 HR에게 보여 주는 수정 전 제안일 뿐 `document_request_draft`가 아닙니다. 검토 필요 +경로에서는 `worker_message_draft_id=null`을 유지하며, HR이 제안문을 확인·수정해 기존 +문서 요청 초안 API에 저장하고 승인을 끝내기 전에는 Worker Link나 SMS를 만들지 않습니다. + 기존 Runtime이 신규 필드를 보내지 않으면 `guideReviewRequired=false`, `guideFailureCode=null`로 해석하므로 정상 `ask_worker` 계약은 그대로 유지됩니다. diff --git a/src/main/java/com/fowoco/server/aiintegration/application/validation/RenewalRuntimeContractValidator.java b/src/main/java/com/fowoco/server/aiintegration/application/validation/RenewalRuntimeContractValidator.java index c2da5568..3ea3f72b 100644 --- a/src/main/java/com/fowoco/server/aiintegration/application/validation/RenewalRuntimeContractValidator.java +++ b/src/main/java/com/fowoco/server/aiintegration/application/validation/RenewalRuntimeContractValidator.java @@ -9,6 +9,8 @@ import com.fowoco.server.aiintegration.application.renewal.RenewalWorkflowPolicy; import java.math.BigDecimal; import java.util.HashSet; +import java.util.List; +import java.util.Map; import java.util.Set; import java.util.UUID; import java.util.regex.Pattern; @@ -167,14 +169,83 @@ private void validateLanguageAssistant(RenewalRunResponse response) { if (response.languageAssistant() == null) { return; } - Object language = response.languageAssistant().get("target_language"); + Map assistant = response.languageAssistant(); + Object language = assistant.get("target_language"); if (!(language instanceof String value) || value.isBlank() || value.length() > 20) { reject(AiRuntimeFailureCode.INVALID_RESPONSE_CONTRACT, "Language Assistant target is invalid."); } + Object generationStatus = assistant.get("generation_status"); + if (generationStatus != null + && (!(generationStatus instanceof String value) + || !Set.of("success", "warning", "failed").contains(value))) { + reject(AiRuntimeFailureCode.INVALID_RESPONSE_CONTRACT, "Language Assistant status is invalid."); + } + Object humanReview = assistant.get("requires_human_review"); + if (humanReview != null && !(humanReview instanceof Boolean)) { + reject(AiRuntimeFailureCode.INVALID_RESPONSE_CONTRACT, "Language Assistant review flag is invalid."); + } + if ((generationStatus == null) != (humanReview == null)) { + reject( + AiRuntimeFailureCode.INVALID_RESPONSE_CONTRACT, + "Language Assistant status and review flag must be provided together." + ); + } + if (generationStatus instanceof String status && humanReview instanceof Boolean review) { + boolean expectedReview = !"success".equals(status); + if (review != expectedReview || response.guideReviewRequired() != review) { + reject( + AiRuntimeFailureCode.INVALID_RESPONSE_CONTRACT, + "Language Assistant review state is inconsistent." + ); + } + } for (String key : Set.of("standard_korean_text", "easy_korean_text", "translated_text")) { - Object text = response.languageAssistant().get(key); - if (text instanceof String value) { - boundaryPolicy.validateText(value, 1_000, false); + Object text = assistant.get(key); + if (text == null) { + continue; + } + if (!(text instanceof String)) { + reject(AiRuntimeFailureCode.INVALID_RESPONSE_CONTRACT, "Language Assistant text is invalid."); + } + boundaryPolicy.validateText((String) text, 1_000, false); + } + validateLanguageWarnings(assistant.get("warnings")); + } + + private void validateLanguageWarnings(Object value) { + if (value == null) { + return; + } + if (!(value instanceof List)) { + reject(AiRuntimeFailureCode.INVALID_RESPONSE_CONTRACT, "Language Assistant warnings are invalid."); + } + List warnings = (List) value; + if (warnings.size() > 20) { + reject(AiRuntimeFailureCode.INVALID_RESPONSE_CONTRACT, "Language Assistant warnings are invalid."); + } + for (Object warning : warnings) { + if (!(warning instanceof Map)) { + reject(AiRuntimeFailureCode.INVALID_RESPONSE_CONTRACT, "Language Assistant warning is invalid."); + } + Map warningMap = (Map) warning; + if (!(warningMap.get("code") instanceof String)) { + reject(AiRuntimeFailureCode.INVALID_RESPONSE_CONTRACT, "Language Assistant warning is invalid."); + } + String code = (String) warningMap.get("code"); + validateIdentifier(code, AiRuntimeFailureCode.INVALID_RESPONSE_CONTRACT); + Object component = warningMap.get("component"); + if (component != null && !(component instanceof String)) { + reject(AiRuntimeFailureCode.INVALID_RESPONSE_CONTRACT, "Language Assistant warning is invalid."); + } + if (component instanceof String text) { + boundaryPolicy.validateText(text, 80, false); + } + Object message = warningMap.get("message"); + if (message != null && !(message instanceof String)) { + reject(AiRuntimeFailureCode.INVALID_RESPONSE_CONTRACT, "Language Assistant warning is invalid."); + } + if (message instanceof String text) { + boundaryPolicy.validateText(text, 500, false); } } } diff --git a/src/main/java/com/fowoco/server/task/api/RenewalExecutionResponse.java b/src/main/java/com/fowoco/server/task/api/RenewalExecutionResponse.java index b9db6bfa..e602aa7a 100644 --- a/src/main/java/com/fowoco/server/task/api/RenewalExecutionResponse.java +++ b/src/main/java/com/fowoco/server/task/api/RenewalExecutionResponse.java @@ -5,6 +5,7 @@ import com.fowoco.server.aiintegration.application.renewal.RenewalRequestedField; import com.fowoco.server.task.application.renewal.GeneratedDocumentResult; import com.fowoco.server.task.application.renewal.RenewalExecutionResult; +import com.fowoco.server.task.application.renewal.RenewalGuideReviewDraft; import com.fowoco.server.task.domain.TaskStatus; import java.math.BigDecimal; import java.util.List; @@ -29,6 +30,7 @@ public record RenewalExecutionResponse( Long workerMessageDraftVersion, boolean guideReviewRequired, String guideFailureCode, + GuideReviewDraftResponse guideReviewDraft, boolean humanReviewRequired ) { static RenewalExecutionResponse from(RenewalExecutionResult result) { @@ -50,8 +52,33 @@ static RenewalExecutionResponse from(RenewalExecutionResult result) { result.workerMessageDraft() == null ? null : result.workerMessageDraft().version(), result.agentResult().guideReviewRequired(), result.agentResult().guideFailureCode(), + GuideReviewDraftResponse.from(result.guideReviewDraft()), true ); } + @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) + public record GuideReviewDraftResponse( + String targetLanguage, + String generationStatus, + String standardKoreanText, + String easyKoreanText, + String translatedText, + List warningCodes + ) { + static GuideReviewDraftResponse from(RenewalGuideReviewDraft draft) { + if (draft == null) { + return null; + } + return new GuideReviewDraftResponse( + draft.targetLanguage(), + draft.generationStatus(), + draft.standardKoreanText(), + draft.easyKoreanText(), + draft.translatedText(), + draft.warningCodes() + ); + } + } + } diff --git a/src/main/java/com/fowoco/server/task/application/renewal/RenewalExecutionResult.java b/src/main/java/com/fowoco/server/task/application/renewal/RenewalExecutionResult.java index 453fde06..76930396 100644 --- a/src/main/java/com/fowoco/server/task/application/renewal/RenewalExecutionResult.java +++ b/src/main/java/com/fowoco/server/task/application/renewal/RenewalExecutionResult.java @@ -9,7 +9,8 @@ public record RenewalExecutionResult( Task task, RenewalRunResponse agentResult, List generatedDocuments, - DocumentRequestDraft workerMessageDraft + DocumentRequestDraft workerMessageDraft, + RenewalGuideReviewDraft guideReviewDraft ) { public RenewalExecutionResult { generatedDocuments = generatedDocuments == null ? List.of() : List.copyOf(generatedDocuments); diff --git a/src/main/java/com/fowoco/server/task/application/renewal/RenewalExecutionResultApplier.java b/src/main/java/com/fowoco/server/task/application/renewal/RenewalExecutionResultApplier.java index 4e96363e..28219fa1 100644 --- a/src/main/java/com/fowoco/server/task/application/renewal/RenewalExecutionResultApplier.java +++ b/src/main/java/com/fowoco/server/task/application/renewal/RenewalExecutionResultApplier.java @@ -103,11 +103,15 @@ RenewalExecutionResult apply( task.taskId(), task.workerId(), preparedDocuments, actor, metadata ); + RenewalGuideReviewDraft guideReviewDraft = RenewalGuideReviewDraft.from(agentResult); Map businessData = new LinkedHashMap<>( contentCodec.decodeBusinessData(task.businessDataJson()) ); mergeRenewalInputs(businessData, submittedSlotAnswers); - businessData.put("renewal_execution", executionMetadata(agentResult, generatedDocuments)); + businessData.put( + "renewal_execution", + executionMetadata(agentResult, generatedDocuments, guideReviewDraft) + ); EncodedTaskContent encoded = contentCodec.encode( task.targetType(), task.workerId(), @@ -155,7 +159,9 @@ RenewalExecutionResult apply( ); DocumentRequestDraft draft = saveWorkerMessageDraft(saved, agentResult, actor, metadata, now); - return new RenewalExecutionResult(saved, agentResult, generatedDocuments, draft); + return new RenewalExecutionResult( + saved, agentResult, generatedDocuments, draft, guideReviewDraft + ); } private void mergeRenewalInputs( @@ -180,7 +186,8 @@ private void mergeRenewalInputs( private Map executionMetadata( RenewalRunResponse result, - List generatedDocuments + List generatedDocuments, + RenewalGuideReviewDraft guideReviewDraft ) { Map metadata = new LinkedHashMap<>(); metadata.put("request_id", result.requestId().toString()); @@ -198,6 +205,9 @@ private Map executionMetadata( metadata.put("case_signals", result.caseSignals()); metadata.put("guide_review_required", result.guideReviewRequired()); putIfPresent(metadata, "guide_failure_code", result.guideFailureCode()); + if (guideReviewDraft != null) { + metadata.put("guide_review_draft", guideReviewDraft.toMetadata()); + } metadata.put("generated_documents", generatedDocuments.stream() .map(this::generatedDocumentMetadata) .toList()); diff --git a/src/main/java/com/fowoco/server/task/application/renewal/RenewalGuideReviewDraft.java b/src/main/java/com/fowoco/server/task/application/renewal/RenewalGuideReviewDraft.java new file mode 100644 index 00000000..469483fb --- /dev/null +++ b/src/main/java/com/fowoco/server/task/application/renewal/RenewalGuideReviewDraft.java @@ -0,0 +1,77 @@ +package com.fowoco.server.task.application.renewal; + +import com.fowoco.server.aiintegration.application.renewal.RenewalRunResponse; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; + +public record RenewalGuideReviewDraft( + String targetLanguage, + String generationStatus, + String standardKoreanText, + String easyKoreanText, + String translatedText, + List warningCodes +) { + public RenewalGuideReviewDraft { + warningCodes = warningCodes == null ? List.of() : List.copyOf(warningCodes); + } + + static RenewalGuideReviewDraft from(RenewalRunResponse response) { + if (!response.guideReviewRequired() || response.languageAssistant() == null) { + return null; + } + Map language = response.languageAssistant(); + String standardKorean = stringValue(language, "standard_korean_text"); + String easyKorean = stringValue(language, "easy_korean_text"); + String translated = stringValue(language, "translated_text"); + if (standardKorean == null && easyKorean == null && translated == null) { + return null; + } + return new RenewalGuideReviewDraft( + stringValue(language, "target_language"), + stringValue(language, "generation_status"), + standardKorean, + easyKorean, + translated, + warningCodes(language.get("warnings")) + ); + } + + Map toMetadata() { + Map metadata = new java.util.LinkedHashMap<>(); + putIfPresent(metadata, "target_language", targetLanguage); + putIfPresent(metadata, "generation_status", generationStatus); + putIfPresent(metadata, "standard_korean_text", standardKoreanText); + putIfPresent(metadata, "easy_korean_text", easyKoreanText); + putIfPresent(metadata, "translated_text", translatedText); + metadata.put("warning_codes", warningCodes); + return Map.copyOf(metadata); + } + + private static String stringValue(Map source, String key) { + Object value = source.get(key); + return value instanceof String text && !text.isBlank() ? text : null; + } + + private static List warningCodes(Object value) { + if (!(value instanceof List warnings)) { + return List.of(); + } + LinkedHashSet codes = new LinkedHashSet<>(); + for (Object warning : warnings) { + if (warning instanceof Map warningMap + && warningMap.get("code") instanceof String code + && !code.isBlank()) { + codes.add(code); + } + } + return List.copyOf(codes); + } + + private static void putIfPresent(Map target, String key, String value) { + if (value != null) { + target.put(key, value); + } + } +} diff --git a/src/test/java/com/fowoco/server/aiintegration/application/validation/RenewalRuntimeContractValidatorTest.java b/src/test/java/com/fowoco/server/aiintegration/application/validation/RenewalRuntimeContractValidatorTest.java index 7f73c00c..f5e6a09b 100644 --- a/src/test/java/com/fowoco/server/aiintegration/application/validation/RenewalRuntimeContractValidatorTest.java +++ b/src/test/java/com/fowoco/server/aiintegration/application/validation/RenewalRuntimeContractValidatorTest.java @@ -154,6 +154,66 @@ void acceptsAllWorkerGuideFailureCodesAsFailClosedReviewResponses() { } } + @Test + void rejectsMalformedLanguageAssistantWarnings() { + RenewalRunRequest request = request(); + RenewalRunResponse valid = response(request); + Map language = new LinkedHashMap<>(); + language.put("target_language", "vi"); + language.put("generation_status", "warning"); + language.put("warnings", List.of(Map.of("message", "code가 없습니다."))); + RenewalRunResponse invalid = new RenewalRunResponse( + valid.requestId(), valid.attemptId(), valid.taskId(), valid.intent(), + valid.workflowId(), valid.confidence(), valid.status(), valid.outcome(), + valid.scenario(), valid.phase(), valid.step(), valid.slots(), valid.missingSlots(), + valid.requestedFields(), valid.guideMessage(), valid.workerRequestMessage(), + valid.guideReviewRequired(), valid.guideFailureCode(), language, + valid.ocrResult(), valid.generatedDocuments(), valid.evidence(), + valid.documentValidation(), valid.caseSignals(), valid.progressEvents(), + valid.supervisorReason(), valid.supervisorSource(), valid.activeSubgraph(), valid.errors() + ); + + assertThatThrownBy(() -> validator.validateResponse(request, invalid)) + .isInstanceOf(AiRuntimeContractException.class); + } + + @Test + void rejectsNonTextLanguageAssistantContent() { + RenewalRunRequest request = request(); + Map language = reviewLanguageAssistant(); + language.put("translated_text", Map.of("raw", "검증되지 않은 Provider 응답")); + + assertThatThrownBy(() -> validator.validateResponse( + request, + workerGuideReviewResponse( + request, + "LANGUAGE_ASSISTANT_REVIEW_REQUIRED", + null, + true, + language + ) + )).isInstanceOf(AiRuntimeContractException.class); + } + + @Test + void rejectsInconsistentLanguageAssistantReviewState() { + RenewalRunRequest request = request(); + Map language = reviewLanguageAssistant(); + language.put("generation_status", "success"); + language.put("requires_human_review", false); + + assertThatThrownBy(() -> validator.validateResponse( + request, + workerGuideReviewResponse( + request, + "LANGUAGE_ASSISTANT_REVIEW_REQUIRED", + null, + true, + language + ) + )).isInstanceOf(AiRuntimeContractException.class); + } + @Test void rejectsAWorkerGuideReviewResponseThatContainsAnAutomaticDeliveryMessage() { RenewalRunRequest request = request(); @@ -298,6 +358,18 @@ private RenewalRunResponse workerGuideReviewResponse( String failureCode, String workerMessage, boolean reviewRequired + ) { + return workerGuideReviewResponse( + request, failureCode, workerMessage, reviewRequired, null + ); + } + + private RenewalRunResponse workerGuideReviewResponse( + RenewalRunRequest request, + String failureCode, + String workerMessage, + boolean reviewRequired, + Map languageAssistant ) { return new RenewalRunResponse( request.requestId(), request.attemptId(), request.taskId(), "EXPIRY_RENEWAL", @@ -305,9 +377,23 @@ private RenewalRunResponse workerGuideReviewResponse( "READY_FOR_REVIEW", "REVIEW_REQUIRED", "ask_worker", "PHASE_3", "STEP_5", Map.of(), List.of("passport_number"), List.of(new RenewalRequestedField("passport_number", "DOCUMENT_OCR")), - null, workerMessage, reviewRequired, failureCode, null, null, + null, workerMessage, reviewRequired, failureCode, languageAssistant, null, List.of(), List.of(), null, List.of("REVIEW_WORKER_GUIDE"), List.of(), null, "rules", "main", List.of() ); } + + private Map reviewLanguageAssistant() { + Map language = new LinkedHashMap<>(); + language.put("target_language", "vi"); + language.put("generation_status", "warning"); + language.put("requires_human_review", true); + language.put("standard_korean_text", "여권 사본을 제출해 주세요."); + language.put("easy_korean_text", "여권을 내 주세요."); + language.put("translated_text", "Vui lòng nộp bản sao hộ chiếu."); + language.put("warnings", List.of(Map.of( + "code", "SEMANTIC_VALIDATION_INCONCLUSIVE" + ))); + return language; + } } diff --git a/src/test/java/com/fowoco/server/task/RenewalExecutionIntegrationTest.java b/src/test/java/com/fowoco/server/task/RenewalExecutionIntegrationTest.java index 4e7b6c5d..65d23db0 100644 --- a/src/test/java/com/fowoco/server/task/RenewalExecutionIntegrationTest.java +++ b/src/test/java/com/fowoco/server/task/RenewalExecutionIntegrationTest.java @@ -218,7 +218,22 @@ void keepsAWorkerGuideFailureInHrReviewWithoutCreatingDeliveryResources() throws assertThat(JsonPath.read(response.body(), "$.task_status")).isEqualTo("DRAFT"); assertThat(JsonPath.read(response.body(), "$.guide_review_required")).isTrue(); assertThat(JsonPath.read(response.body(), "$.guide_failure_code")) - .isEqualTo("LANGUAGE_ASSISTANT_NOT_CONFIGURED"); + .isEqualTo("LANGUAGE_ASSISTANT_REVIEW_REQUIRED"); + assertThat(JsonPath.read( + response.body(), "$.guide_review_draft.target_language" + )).isEqualTo("vi"); + assertThat(JsonPath.read( + response.body(), "$.guide_review_draft.generation_status" + )).isEqualTo("warning"); + assertThat(JsonPath.read( + response.body(), "$.guide_review_draft.standard_korean_text" + )).isEqualTo("여권 사본을 제출해 주세요."); + assertThat(JsonPath.read( + response.body(), "$.guide_review_draft.translated_text" + )).isEqualTo("Vui lòng nộp bản sao hộ chiếu."); + assertThat(JsonPath.read( + response.body(), "$.guide_review_draft.warning_codes[0]" + )).isEqualTo("SEMANTIC_VALIDATION_INCONCLUSIVE"); assertThat(JsonPath.read(response.body(), "$.worker_message_draft_id")).isNull(); String businessData = jdbcTemplate.queryForObject( @@ -231,7 +246,12 @@ void keepsAWorkerGuideFailureInHrReviewWithoutCreatingDeliveryResources() throws assertThat(JsonPath.read( businessData, "$.renewal_execution.guide_failure_code" - )).isEqualTo("LANGUAGE_ASSISTANT_NOT_CONFIGURED"); + )).isEqualTo("LANGUAGE_ASSISTANT_REVIEW_REQUIRED"); + assertThat(JsonPath.read( + businessData, + "$.renewal_execution.guide_review_draft.easy_korean_text" + )).isEqualTo("여권을 내 주세요."); + assertThat(businessData).doesNotContain("provider_raw_secret", "do-not-persist"); assertThat(jdbcTemplate.queryForObject( "SELECT COUNT(*) FROM document_request_draft WHERE task_id = ?", Integer.class, @@ -246,7 +266,7 @@ void keepsAWorkerGuideFailureInHrReviewWithoutCreatingDeliveryResources() throws "SELECT change_summary FROM audit_event WHERE target_id = ? AND action = 'TASK_UPDATED'", String.class, TASK_A - )).contains("LANGUAGE_ASSISTANT_NOT_CONFIGURED"); + )).contains("LANGUAGE_ASSISTANT_REVIEW_REQUIRED"); } @Test @@ -548,13 +568,26 @@ private RenewalRunResponse generateResponse(RenewalRunRequest request) { } private RenewalRunResponse workerGuideReviewResponse(RenewalRunRequest request) { + Map languageAssistant = new java.util.LinkedHashMap<>(); + languageAssistant.put("target_language", "vi"); + languageAssistant.put("generation_status", "warning"); + languageAssistant.put("requires_human_review", true); + languageAssistant.put("standard_korean_text", "여권 사본을 제출해 주세요."); + languageAssistant.put("easy_korean_text", "여권을 내 주세요."); + languageAssistant.put("translated_text", "Vui lòng nộp bản sao hộ chiếu."); + languageAssistant.put("warnings", List.of(Map.of( + "component", "translation", + "code", "SEMANTIC_VALIDATION_INCONCLUSIVE", + "message", "번역 의미 검토가 필요합니다." + ))); + languageAssistant.put("provider_raw_secret", "do-not-persist"); return new RenewalRunResponse( request.requestId(), request.attemptId(), request.taskId(), "EXPIRY_RENEWAL", request.task().workflowId(), new BigDecimal("0.91"), "READY_FOR_REVIEW", "REVIEW_REQUIRED", "ask_worker", "PHASE_3", "STEP_5", Map.of(), List.of("passport_number"), List.of(new RenewalRequestedField("passport_number", "DOCUMENT_OCR")), - null, null, true, "LANGUAGE_ASSISTANT_NOT_CONFIGURED", null, null, + null, null, true, "LANGUAGE_ASSISTANT_REVIEW_REQUIRED", languageAssistant, null, List.of(), List.of(), null, List.of("REVIEW_WORKER_GUIDE"), List.of(), null, "rules", "main", List.of() );