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
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 전달을 허용합니다.

### 업무 이벤트가 알림으로 이어지는 흐름

Expand Down
21 changes: 21 additions & 0 deletions docs/ai-runtime-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` 계약은 그대로 유지됩니다.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -167,14 +169,83 @@ private void validateLanguageAssistant(RenewalRunResponse response) {
if (response.languageAssistant() == null) {
return;
}
Object language = response.languageAssistant().get("target_language");
Map<String, Object> 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);
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -29,6 +30,7 @@ public record RenewalExecutionResponse(
Long workerMessageDraftVersion,
boolean guideReviewRequired,
String guideFailureCode,
GuideReviewDraftResponse guideReviewDraft,
boolean humanReviewRequired
) {
static RenewalExecutionResponse from(RenewalExecutionResult result) {
Expand All @@ -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<String> 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()
);
}
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ public record RenewalExecutionResult(
Task task,
RenewalRunResponse agentResult,
List<GeneratedDocumentResult> generatedDocuments,
DocumentRequestDraft workerMessageDraft
DocumentRequestDraft workerMessageDraft,
RenewalGuideReviewDraft guideReviewDraft
) {
public RenewalExecutionResult {
generatedDocuments = generatedDocuments == null ? List.of() : List.copyOf(generatedDocuments);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -103,11 +103,15 @@ RenewalExecutionResult apply(
task.taskId(), task.workerId(), preparedDocuments, actor, metadata
);

RenewalGuideReviewDraft guideReviewDraft = RenewalGuideReviewDraft.from(agentResult);
Map<String, Object> 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(),
Expand Down Expand Up @@ -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(
Expand All @@ -180,7 +186,8 @@ private void mergeRenewalInputs(

private Map<String, Object> executionMetadata(
RenewalRunResponse result,
List<GeneratedDocumentResult> generatedDocuments
List<GeneratedDocumentResult> generatedDocuments,
RenewalGuideReviewDraft guideReviewDraft
) {
Map<String, Object> metadata = new LinkedHashMap<>();
metadata.put("request_id", result.requestId().toString());
Expand All @@ -198,6 +205,9 @@ private Map<String, Object> 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());
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String> 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<String, Object> 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<String, Object> toMetadata() {
Map<String, Object> 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<String, Object> source, String key) {
Object value = source.get(key);
return value instanceof String text && !text.isBlank() ? text : null;
}

private static List<String> warningCodes(Object value) {
if (!(value instanceof List<?> warnings)) {
return List.of();
}
LinkedHashSet<String> 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<String, Object> target, String key, String value) {
if (value != null) {
target.put(key, value);
}
}
}
Loading
Loading