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
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,10 @@ HR이 승인된 Task의 Worker Link 발급
덮어쓰지 않고, HR이 승인한 결과만 Renewal Context에서 사용합니다. 생성된 문서는
자동 발송하지 않으며 다시 HR 검토를 거칩니다.

Language Assistant가 근로자 안내를 안전하게 만들지 못한 경우에도 임시 문장을 발송하지
않습니다. Server는 실패 코드를 보존하고 Task를 HR 검토로 돌리며, 담당자가 안내문을
작성하고 승인한 뒤에만 Worker Link와 SMS 전달을 허용합니다.

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

```text
Expand Down
26 changes: 26 additions & 0 deletions docs/ai-runtime-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -404,6 +404,32 @@ Workflow로 바꾼 응답은 `UNEXPECTED_WORKFLOW`로 거부합니다.
`scenario=out_of_scope`, `intent=OUT_OF_SCOPE`인 종료 응답만 `workflowId`가 비어 있을 수
있습니다. 이는 Workflow 실행 결과가 아니라 지원 범위 밖 정상 종료 신호이기 때문입니다.

## 근로자 안내 실패와 HR 검토

Language Assistant가 비활성화됐거나 안내문을 안전하게 생성하지 못하면 Runtime은 임시
문장을 만들지 않고 다음과 같이 HR 검토를 요청합니다.

```json
{
"scenario": "ask_worker",
"status": "READY_FOR_REVIEW",
"outcome": "REVIEW_REQUIRED",
"workerRequestMessage": null,
"guideReviewRequired": true,
"guideFailureCode": "LANGUAGE_ASSISTANT_NOT_CONFIGURED",
"caseSignals": ["REVIEW_WORKER_GUIDE"]
}
```

Server는 `guideFailureCode`를 안전한 허용 목록으로 검증하고 Task의
`renewal_execution` 메타데이터와 감사로그에 보존합니다. 이 경로에서는 근로자 안내
초안, Worker Link, SMS를 자동으로 생성하거나 발송하지 않습니다. Task는 누락정보 입력
상태로 회귀하지 않고 HR 검토 대상으로 남습니다. HR이 안전한 안내문을 작성하고 기존
승인 절차를 마친 뒤에만 Worker Link 발급과 전달 흐름을 진행합니다.

기존 Runtime이 신규 필드를 보내지 않으면 `guideReviewRequired=false`,
`guideFailureCode=null`로 해석하므로 정상 `ask_worker` 계약은 그대로 유지됩니다.

## Renewal 생성 문서 연결

Renewal 응답의 `generatedDocuments[]`는 다음 세 값을 문서 생성 기준으로 사용합니다.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ public record RenewalRunResponse(
List<RenewalRequestedField> requestedFields,
String guideMessage,
String workerRequestMessage,
Boolean guideReviewRequired,
String guideFailureCode,
Map<String, Object> languageAssistant,
Map<String, Object> ocrResult,
List<RenewalGeneratedDocument> generatedDocuments,
Expand All @@ -37,6 +39,7 @@ public record RenewalRunResponse(
List<String> errors
) {
public RenewalRunResponse {
guideReviewRequired = Boolean.TRUE.equals(guideReviewRequired);
slots = copyMap(slots);
missingSlots = copyList(missingSlots);
requestedFields = requestedFields == null ? List.of() : List.copyOf(requestedFields);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,15 @@ public final class RenewalRuntimeContractValidator {
"GENERATE_DRAFTS",
"READY_FOR_REVIEW",
"OCR_SAVED",
"REVIEW_WORKER_GUIDE",
"CANCEL_OUT_OF_SCOPE"
);
private static final Set<String> GUIDE_FAILURE_CODES = Set.of(
"LANGUAGE_ASSISTANT_NOT_CONFIGURED",
"LANGUAGE_ASSISTANT_INVOCATION_FAILED",
"LANGUAGE_ASSISTANT_REVIEW_REQUIRED",
"WORKER_GUIDE_UNAVAILABLE"
);
private static final Pattern IDENTIFIER = Pattern.compile("[A-Za-z][A-Za-z0-9._-]{0,127}");
private static final Set<String> DOCUMENT_TEMPLATES = Set.of(
"standard_labor_contract_v6",
Expand Down Expand Up @@ -117,6 +124,7 @@ public void validateResponse(RenewalRunRequest request, RenewalRunResponse respo
boundaryPolicy.validateText(response.status(), 40, true);
boundaryPolicy.validateText(response.outcome(), 80, true);
boundaryPolicy.validateText(response.workerRequestMessage(), 1_000, false);
validateWorkerGuideReview(response);
validateLanguageAssistant(response);
if (response.missingSlots().size() > 100
|| response.requestedFields().size() > 100
Expand Down Expand Up @@ -176,6 +184,7 @@ private void validateScenario(RenewalRunResponse response) {
reject(AiRuntimeFailureCode.INVALID_RESPONSE_CONTRACT, "ask_hr requires missing slots.");
}
if ("ask_worker".equals(response.scenario())
&& !response.guideReviewRequired()
&& (response.workerRequestMessage() == null || response.workerRequestMessage().isBlank())) {
reject(AiRuntimeFailureCode.INVALID_RESPONSE_CONTRACT, "ask_worker requires a worker message.");
}
Expand All @@ -187,6 +196,37 @@ private void validateScenario(RenewalRunResponse response) {
}
}

private void validateWorkerGuideReview(RenewalRunResponse response) {
if (!response.guideReviewRequired()) {
if (response.guideFailureCode() != null && !response.guideFailureCode().isBlank()) {
reject(
AiRuntimeFailureCode.INVALID_RESPONSE_CONTRACT,
"Worker guide failure code requires review."
);
}
if (response.caseSignals().contains("REVIEW_WORKER_GUIDE")) {
reject(
AiRuntimeFailureCode.INVALID_RESPONSE_CONTRACT,
"Worker guide review signal requires review."
);
}
return;
}
if (!"ask_worker".equals(response.scenario())
|| !"READY_FOR_REVIEW".equals(response.status())
|| !"REVIEW_REQUIRED".equals(response.outcome())
|| (response.workerRequestMessage() != null
&& !response.workerRequestMessage().isBlank())
|| response.guideFailureCode() == null
|| !GUIDE_FAILURE_CODES.contains(response.guideFailureCode())
|| !response.caseSignals().contains("REVIEW_WORKER_GUIDE")) {
reject(
AiRuntimeFailureCode.INVALID_RESPONSE_CONTRACT,
"Worker guide review response is invalid."
);
}
}

private void validateGeneratedDocument(RenewalGeneratedDocument document) {
if (document == null
|| !DOCUMENT_TEMPLATES.contains(document.templateId())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ public record RenewalExecutionResponse(
List<GeneratedDocumentResult> generatedDocuments,
UUID workerMessageDraftId,
Long workerMessageDraftVersion,
boolean guideReviewRequired,
String guideFailureCode,
boolean humanReviewRequired
) {
static RenewalExecutionResponse from(RenewalExecutionResult result) {
Expand All @@ -46,6 +48,8 @@ static RenewalExecutionResponse from(RenewalExecutionResult result) {
result.generatedDocuments(),
result.workerMessageDraft() == null ? null : result.workerMessageDraft().draftId(),
result.workerMessageDraft() == null ? null : result.workerMessageDraft().version(),
result.agentResult().guideReviewRequired(),
result.agentResult().guideFailureCode(),
true
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -126,11 +126,7 @@ RenewalExecutionResult apply(
encoded.businessDataJson(),
encoded.criticalFingerprint(),
task.dueDate(),
!"out_of_scope".equals(agentResult.scenario())
&& agentResult.missingSlots().isEmpty()
&& !checklistStatusRepository.existsIncompleteRequiredItem(
task.taskId(), task.companyId()
),
requirementsSatisfied(task, agentResult),
expectedVersion,
actor.actorId(),
now
Expand All @@ -155,7 +151,7 @@ RenewalExecutionResult apply(
}
appendAudit(
actor, AuditAction.TASK_UPDATED, AuditTargetType.TASK, saved.taskId(),
"Renewal Agent 결과를 업무카드에 반영함", metadata, now
auditSummary(agentResult), metadata, now
);

DocumentRequestDraft draft = saveWorkerMessageDraft(saved, agentResult, actor, metadata, now);
Expand Down Expand Up @@ -200,6 +196,8 @@ private Map<String, Object> executionMetadata(
.map(this::requestedFieldMetadata)
.toList());
metadata.put("case_signals", result.caseSignals());
metadata.put("guide_review_required", result.guideReviewRequired());
putIfPresent(metadata, "guide_failure_code", result.guideFailureCode());
metadata.put("generated_documents", generatedDocuments.stream()
.map(this::generatedDocumentMetadata)
.toList());
Expand Down Expand Up @@ -233,7 +231,8 @@ private DocumentRequestDraft saveWorkerMessageDraft(
RequestMetadata metadata,
Instant now
) {
if (!"ask_worker".equals(result.scenario())
if (result.guideReviewRequired()
|| !"ask_worker".equals(result.scenario())
|| result.workerRequestMessage() == null
|| result.workerRequestMessage().isBlank()) {
return null;
Expand Down Expand Up @@ -261,6 +260,25 @@ private DocumentRequestDraft saveWorkerMessageDraft(
return draft;
}

private boolean requirementsSatisfied(Task task, RenewalRunResponse result) {
if (result.guideReviewRequired()) {
return true;
}
return !"out_of_scope".equals(result.scenario())
&& result.missingSlots().isEmpty()
&& !checklistStatusRepository.existsIncompleteRequiredItem(
task.taskId(), task.companyId()
);
}

private String auditSummary(RenewalRunResponse result) {
if (!result.guideReviewRequired()) {
return "Renewal Agent 결과를 업무카드에 반영함";
}
return "Renewal Agent 근로자 안내를 HR 검토로 전환함: "
+ result.guideFailureCode();
}

private String language(RenewalRunResponse result) {
Object target = result.languageAssistant() == null
? null
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ void rejectsAnIntentUsedAsAWorkflowId() {
"EXPIRY_RENEWAL", 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(),
valid.languageAssistant(), valid.ocrResult(), valid.generatedDocuments(), valid.evidence(),
valid.documentValidation(), valid.caseSignals(), valid.progressEvents(),
valid.supervisorReason(), valid.supervisorSource(), valid.activeSubgraph(), valid.errors()
Expand All @@ -93,6 +94,7 @@ void rejectsAResponseFromAnotherAttempt() {
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(),
valid.languageAssistant(), valid.ocrResult(), valid.generatedDocuments(), valid.evidence(),
valid.documentValidation(), valid.caseSignals(), valid.progressEvents(),
valid.supervisorReason(), valid.supervisorSource(), valid.activeSubgraph(), valid.errors()
Expand All @@ -119,7 +121,7 @@ void acceptsAgentIdentitySignalsAndNullableLanguageFields() {
new RenewalRequestedField("passport_number", "DOCUMENT_OCR"),
new RenewalRequestedField("alien_registration_number", "DOCUMENT_OCR")
),
valid.guideMessage(), "여권과 외국인등록증을 제출해 주세요.", language,
valid.guideMessage(), "여권과 외국인등록증을 제출해 주세요.", false, null, language,
valid.ocrResult(), valid.generatedDocuments(), valid.evidence(),
valid.documentValidation(),
List.of(
Expand All @@ -135,6 +137,63 @@ void acceptsAgentIdentitySignalsAndNullableLanguageFields() {
.doesNotThrowAnyException();
}

@Test
void acceptsAllWorkerGuideFailureCodesAsFailClosedReviewResponses() {
RenewalRunRequest request = request();

for (String failureCode : List.of(
"LANGUAGE_ASSISTANT_NOT_CONFIGURED",
"LANGUAGE_ASSISTANT_INVOCATION_FAILED",
"LANGUAGE_ASSISTANT_REVIEW_REQUIRED",
"WORKER_GUIDE_UNAVAILABLE"
)) {
assertThatCode(() -> validator.validateResponse(
request,
workerGuideReviewResponse(request, failureCode, null, true)
)).doesNotThrowAnyException();
}
}

@Test
void rejectsAWorkerGuideReviewResponseThatContainsAnAutomaticDeliveryMessage() {
RenewalRunRequest request = request();

assertThatThrownBy(() -> validator.validateResponse(
request,
workerGuideReviewResponse(
request,
"LANGUAGE_ASSISTANT_REVIEW_REQUIRED",
"검토하지 않은 안내입니다.",
true
)
)).isInstanceOf(AiRuntimeContractException.class);
}

@Test
void rejectsAWorkerGuideReviewSignalWithoutTheReviewFlag() {
RenewalRunRequest request = request();

assertThatThrownBy(() -> validator.validateResponse(
request,
workerGuideReviewResponse(
request,
"LANGUAGE_ASSISTANT_NOT_CONFIGURED",
null,
false
)
)).isInstanceOf(AiRuntimeContractException.class);
}

@Test
void rejectsAnUnknownWorkerGuideFailureCode() {
RenewalRunRequest request = request();

assertThatThrownBy(() -> validator.validateResponse(
request,
workerGuideReviewResponse(request, "RAW_PROVIDER_ERROR", null, true)
)).isInstanceOf(AiRuntimeContractException.class);
}

@Test
void acceptsTheAgentOutOfScopeResultWithoutTreatingItAsRenewal() {
RenewalRunRequest request = request();
Expand All @@ -143,7 +202,7 @@ void acceptsTheAgentOutOfScopeResultWithoutTreatingItAsRenewal() {
valid.requestId(), valid.attemptId(), valid.taskId(), "OUT_OF_SCOPE", "",
new BigDecimal("0.93"), "CANCELLED", "OUT_OF_SCOPE", "out_of_scope",
"PHASE_1", "STEP_2", Map.of(), List.of(), List.of(),
"지원 범위를 벗어난 요청입니다.", null, null, null, List.of(), List.of(),
"지원 범위를 벗어난 요청입니다.", null, false, null, null, null, List.of(), List.of(),
null, List.of("CANCEL_OUT_OF_SCOPE"), List.of(), null, "rules", "main",
List.of()
);
Expand Down Expand Up @@ -209,7 +268,7 @@ private RenewalRunResponse response(RenewalRunRequest request, String workflowId
workflowId, new BigDecimal("0.91"), "NEEDS_INFO", "NEEDS_INFO", "ask_hr",
"PHASE_2", "STEP_5", Map.of(), List.of("wage"),
List.of(new RenewalRequestedField("wage", "USER_INPUT")),
"임금을 확인해 주세요.", null, null, null, List.of(), List.of(), null,
"임금을 확인해 주세요.", null, false, null, null, null, List.of(), List.of(), null,
List.of("REQUEST_CONTRACT_SLOTS", "NEEDS_INFO"), List.of(), null, "rules", "main",
List.of()
);
Expand All @@ -224,7 +283,7 @@ private RenewalRunResponse generateResponse(
request.task().workflowId(), new BigDecimal("0.94"),
"READY_FOR_REVIEW", "REVIEW_REQUIRED",
"generate", "PHASE_4", "STEP_13", Map.of(), List.of(), List.of(),
null, null, null, null,
null, null, false, null, null, null,
List.of(new RenewalGeneratedDocument(
"standard_labor_contract_v6", "표준근로계약서", "hwp", "stub", null, null,
List.copyOf(values.keySet()), List.of(), values
Expand All @@ -233,4 +292,22 @@ private RenewalRunResponse generateResponse(
List.of(), null, "rules", "main", List.of()
);
}

private RenewalRunResponse workerGuideReviewResponse(
RenewalRunRequest request,
String failureCode,
String workerMessage,
boolean reviewRequired
) {
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, workerMessage, reviewRequired, failureCode, null, null,
List.of(), List.of(), null, List.of("REVIEW_WORKER_GUIDE"),
List.of(), null, "rules", "main", List.of()
);
}
}
Loading
Loading