From e3287970ce30489b4c15c60cc70a94a20b81eaca Mon Sep 17 00:00:00 2001 From: hywznn Date: Mon, 3 Aug 2026 15:49:52 +0900 Subject: [PATCH 1/4] =?UTF-8?q?feat(ai):=20canonical=20Slot=20Resolver=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../airun/application/AiSlotResolution.java | 29 +++ .../AiSlotResolutionTransaction.java | 158 +++++++++++++++ .../error/AiContextResolutionException.java | 23 +++ .../error/AiContextResolutionFailureCode.java | 15 ++ .../com/fowoco/server/airun/package-info.java | 2 +- .../application/WorkerAiContextSnapshot.java | 28 +++ .../port/WorkerAiContextReader.java | 16 ++ .../persistence/JpaWorkerAiContextReader.java | 59 ++++++ .../api/WorkflowDefinitionResponse.java | 4 + .../workflow/domain/WorkflowCatalog.java | 6 + .../workflow/domain/WorkflowDefinition.java | 4 + .../ResourceWorkflowCatalogRepository.java | 12 ++ .../workflow/catalog-projection.local.json | 4 + .../AiSlotResolutionIntegrationTest.java | 106 ++++++++++ .../AiSlotResolutionTransactionTest.java | 191 ++++++++++++++++++ 15 files changed, 656 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/fowoco/server/airun/application/AiSlotResolution.java create mode 100644 src/main/java/com/fowoco/server/airun/application/AiSlotResolutionTransaction.java create mode 100644 src/main/java/com/fowoco/server/airun/application/error/AiContextResolutionException.java create mode 100644 src/main/java/com/fowoco/server/airun/application/error/AiContextResolutionFailureCode.java create mode 100644 src/main/java/com/fowoco/server/worker/application/WorkerAiContextSnapshot.java create mode 100644 src/main/java/com/fowoco/server/worker/application/port/WorkerAiContextReader.java create mode 100644 src/main/java/com/fowoco/server/worker/infrastructure/persistence/JpaWorkerAiContextReader.java create mode 100644 src/test/java/com/fowoco/server/airun/AiSlotResolutionIntegrationTest.java create mode 100644 src/test/java/com/fowoco/server/airun/application/AiSlotResolutionTransactionTest.java diff --git a/src/main/java/com/fowoco/server/airun/application/AiSlotResolution.java b/src/main/java/com/fowoco/server/airun/application/AiSlotResolution.java new file mode 100644 index 00000000..2ab3af76 --- /dev/null +++ b/src/main/java/com/fowoco/server/airun/application/AiSlotResolution.java @@ -0,0 +1,29 @@ +package com.fowoco.server.airun.application; + +import com.fowoco.server.aiintegration.application.model.WorkerContext; +import com.fowoco.server.aiintegration.application.model.WorkflowConstraint; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +/** + * Tenant-scoped values and Knowledge constraints prepared for an ANALYZE request. + */ +public record AiSlotResolution( + WorkerContext worker, + List workflowConstraints, + Map resolvedFields, + Set missingFieldKeys +) { + + public AiSlotResolution { + Objects.requireNonNull(worker, "worker must not be null"); + Objects.requireNonNull(workflowConstraints, "workflowConstraints must not be null"); + Objects.requireNonNull(resolvedFields, "resolvedFields must not be null"); + Objects.requireNonNull(missingFieldKeys, "missingFieldKeys must not be null"); + workflowConstraints = List.copyOf(workflowConstraints); + resolvedFields = Map.copyOf(resolvedFields); + missingFieldKeys = Set.copyOf(missingFieldKeys); + } +} diff --git a/src/main/java/com/fowoco/server/airun/application/AiSlotResolutionTransaction.java b/src/main/java/com/fowoco/server/airun/application/AiSlotResolutionTransaction.java new file mode 100644 index 00000000..7788e10a --- /dev/null +++ b/src/main/java/com/fowoco/server/airun/application/AiSlotResolutionTransaction.java @@ -0,0 +1,158 @@ +package com.fowoco.server.airun.application; + +import com.fowoco.server.aiintegration.application.model.AiContextRequirement; +import com.fowoco.server.aiintegration.application.model.WorkerContext; +import com.fowoco.server.aiintegration.application.model.WorkflowConstraint; +import com.fowoco.server.airun.application.error.AiContextResolutionException; +import com.fowoco.server.airun.application.error.AiContextResolutionFailureCode; +import com.fowoco.server.common.security.TenantDatabaseContext; +import com.fowoco.server.worker.application.WorkerAiContextSnapshot; +import com.fowoco.server.worker.application.port.WorkerAiContextReader; +import com.fowoco.server.workflow.application.WorkflowCatalogService; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.UUID; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * Resolves allow-listed Worker context in a short database transaction. + * The Runtime HTTP call happens after this transaction has completed. + */ +@Service +public class AiSlotResolutionTransaction { + + private final WorkflowCatalogService workflowCatalogService; + private final WorkerAiContextReader workerContextReader; + private final TenantDatabaseContext tenantDatabaseContext; + + public AiSlotResolutionTransaction( + WorkflowCatalogService workflowCatalogService, + WorkerAiContextReader workerContextReader, + TenantDatabaseContext tenantDatabaseContext + ) { + this.workflowCatalogService = workflowCatalogService; + this.workerContextReader = workerContextReader; + this.tenantDatabaseContext = tenantDatabaseContext; + } + + @Transactional(readOnly = true) + public AiSlotResolution resolve( + UUID companyId, + String requiredKnowledgeVersion, + AiContextRequirement requirement + ) { + Objects.requireNonNull(companyId, "companyId must not be null"); + Objects.requireNonNull(requiredKnowledgeVersion, "requiredKnowledgeVersion must not be null"); + Objects.requireNonNull(requirement, "requirement must not be null"); + tenantDatabaseContext.setCompanyIdForCurrentTransaction(companyId); + + var catalog = workflowCatalogService.getActiveCatalog(); + if (!requiredKnowledgeVersion.equals(catalog.bundleVersion())) { + reject( + AiContextResolutionFailureCode.KNOWLEDGE_VERSION_MISMATCH, + "The analysis and active Workflow Catalog versions do not match." + ); + } + var workflows = catalog.findByIntent(requirement.detectedIntent()); + if (workflows.isEmpty()) { + reject( + AiContextResolutionFailureCode.UNSUPPORTED_INTENT, + "The Runtime returned an Intent that is not in the active Workflow Catalog." + ); + } + + Set resolvableKeys = new LinkedHashSet<>(); + workflows.forEach(workflow -> resolvableKeys.addAll(workflow.resolvableSlotKeys())); + Set forbiddenKeys = new LinkedHashSet<>(requirement.requiredFieldKeys()); + forbiddenKeys.removeAll(resolvableKeys); + if (!forbiddenKeys.isEmpty()) { + reject( + AiContextResolutionFailureCode.FORBIDDEN_FIELD, + "The Runtime requested a field outside the active Knowledge allow-list." + ); + } + + List matches = workerContextReader.findByDisplayName( + companyId, + requirement.targetDisplayName() + ); + if (matches.isEmpty()) { + reject( + AiContextResolutionFailureCode.TARGET_NOT_FOUND, + "The requested Worker target was not found in the current company." + ); + } + if (matches.size() > 1) { + reject( + AiContextResolutionFailureCode.TARGET_AMBIGUOUS, + "The requested Worker target is ambiguous in the current company." + ); + } + + WorkerAiContextSnapshot worker = matches.get(0); + if (!companyId.equals(worker.companyId())) { + reject( + AiContextResolutionFailureCode.TARGET_NOT_FOUND, + "The requested Worker target was not found in the current company." + ); + } + + Map resolvedFields = new LinkedHashMap<>(); + Set missingFieldKeys = new LinkedHashSet<>(); + for (String fieldKey : requirement.requiredFieldKeys()) { + String value = resolveWorkerField(fieldKey, worker); + if (value == null) { + missingFieldKeys.add(fieldKey); + } else { + resolvedFields.put(fieldKey, value); + } + } + + WorkerContext workerContext = new WorkerContext( + worker.workerId(), + worker.displayName(), + worker.nationalityCode(), + worker.preferredLanguage(), + worker.workStatus(), + worker.stayExpiryDate(), + worker.contractStartDate(), + worker.contractEndDate(), + resolvedFields + ); + List constraints = workflows.stream() + .sorted(java.util.Comparator.comparing(workflow -> workflow.workflowId())) + .map(workflow -> new WorkflowConstraint( + workflow.workflowId(), + workflow.allowedSlotKeys() + )) + .toList(); + return new AiSlotResolution( + workerContext, + constraints, + resolvedFields, + missingFieldKeys + ); + } + + private String resolveWorkerField(String fieldKey, WorkerAiContextSnapshot worker) { + return switch (fieldKey) { + case "worker_id" -> worker.workerId().toString(); + case "stay_expiry_date" -> formatDate(worker.stayExpiryDate()); + case "contract_end_date" -> formatDate(worker.contractEndDate()); + default -> null; + }; + } + + private String formatDate(java.time.LocalDate date) { + return date == null ? null : date.toString(); + } + + private void reject(AiContextResolutionFailureCode failureCode, String safeMessage) { + throw new AiContextResolutionException(failureCode, safeMessage); + } +} diff --git a/src/main/java/com/fowoco/server/airun/application/error/AiContextResolutionException.java b/src/main/java/com/fowoco/server/airun/application/error/AiContextResolutionException.java new file mode 100644 index 00000000..549f4da7 --- /dev/null +++ b/src/main/java/com/fowoco/server/airun/application/error/AiContextResolutionException.java @@ -0,0 +1,23 @@ +package com.fowoco.server.airun.application.error; + +import java.util.Objects; + +/** + * Resolution failure whose message never includes a Worker name or field value. + */ +public final class AiContextResolutionException extends RuntimeException { + + private final AiContextResolutionFailureCode failureCode; + + public AiContextResolutionException( + AiContextResolutionFailureCode failureCode, + String safeMessage + ) { + super(safeMessage); + this.failureCode = Objects.requireNonNull(failureCode, "failureCode must not be null"); + } + + public AiContextResolutionFailureCode failureCode() { + return failureCode; + } +} diff --git a/src/main/java/com/fowoco/server/airun/application/error/AiContextResolutionFailureCode.java b/src/main/java/com/fowoco/server/airun/application/error/AiContextResolutionFailureCode.java new file mode 100644 index 00000000..a3e37c06 --- /dev/null +++ b/src/main/java/com/fowoco/server/airun/application/error/AiContextResolutionFailureCode.java @@ -0,0 +1,15 @@ +package com.fowoco.server.airun.application.error; + +/** + * Stable, non-sensitive reasons produced while resolving Runtime-requested context. + */ +public enum AiContextResolutionFailureCode { + INVALID_CONTEXT_RESPONSE, + KNOWLEDGE_VERSION_MISMATCH, + UNSUPPORTED_INTENT, + FORBIDDEN_FIELD, + TARGET_NOT_FOUND, + TARGET_AMBIGUOUS, + TARGET_CHANGED, + CONTEXT_ROUND_LIMIT +} diff --git a/src/main/java/com/fowoco/server/airun/package-info.java b/src/main/java/com/fowoco/server/airun/package-info.java index 39d4d3c3..2160b52c 100644 --- a/src/main/java/com/fowoco/server/airun/package-info.java +++ b/src/main/java/com/fowoco/server/airun/package-info.java @@ -1,4 +1,4 @@ /** - * Durable AI runs, candidate snapshots, idempotency, and server-owned retries. + * Durable AI execution orchestration, candidate state and explicit retry ownership. */ package com.fowoco.server.airun; diff --git a/src/main/java/com/fowoco/server/worker/application/WorkerAiContextSnapshot.java b/src/main/java/com/fowoco/server/worker/application/WorkerAiContextSnapshot.java new file mode 100644 index 00000000..051ee290 --- /dev/null +++ b/src/main/java/com/fowoco/server/worker/application/WorkerAiContextSnapshot.java @@ -0,0 +1,28 @@ +package com.fowoco.server.worker.application; + +import java.time.LocalDate; +import java.util.Objects; +import java.util.UUID; + +/** + * Immutable Worker data exposed to the AiRun application boundary. + */ +public record WorkerAiContextSnapshot( + UUID workerId, + UUID companyId, + String displayName, + String nationalityCode, + String preferredLanguage, + String workStatus, + LocalDate stayExpiryDate, + LocalDate contractStartDate, + LocalDate contractEndDate +) { + + public WorkerAiContextSnapshot { + Objects.requireNonNull(workerId, "workerId must not be null"); + Objects.requireNonNull(companyId, "companyId must not be null"); + Objects.requireNonNull(displayName, "displayName must not be null"); + Objects.requireNonNull(workStatus, "workStatus must not be null"); + } +} diff --git a/src/main/java/com/fowoco/server/worker/application/port/WorkerAiContextReader.java b/src/main/java/com/fowoco/server/worker/application/port/WorkerAiContextReader.java new file mode 100644 index 00000000..03de2ddb --- /dev/null +++ b/src/main/java/com/fowoco/server/worker/application/port/WorkerAiContextReader.java @@ -0,0 +1,16 @@ +package com.fowoco.server.worker.application.port; + +import com.fowoco.server.worker.application.WorkerAiContextSnapshot; +import java.util.List; +import java.util.UUID; + +/** + * Tenant-scoped Worker lookup used by the AiRun Slot Resolver. + */ +public interface WorkerAiContextReader { + + /** + * Returns at most two matches so the caller can distinguish not-found, unique and ambiguous targets. + */ + List findByDisplayName(UUID companyId, String displayName); +} diff --git a/src/main/java/com/fowoco/server/worker/infrastructure/persistence/JpaWorkerAiContextReader.java b/src/main/java/com/fowoco/server/worker/infrastructure/persistence/JpaWorkerAiContextReader.java new file mode 100644 index 00000000..51efe826 --- /dev/null +++ b/src/main/java/com/fowoco/server/worker/infrastructure/persistence/JpaWorkerAiContextReader.java @@ -0,0 +1,59 @@ +package com.fowoco.server.worker.infrastructure.persistence; + +import com.fowoco.server.worker.application.WorkerAiContextSnapshot; +import com.fowoco.server.worker.application.port.WorkerAiContextReader; +import jakarta.persistence.EntityManager; +import java.util.List; +import java.util.Objects; +import java.util.UUID; +import org.springframework.stereotype.Repository; + +@Repository +public class JpaWorkerAiContextReader implements WorkerAiContextReader { + + private final EntityManager entityManager; + + public JpaWorkerAiContextReader(EntityManager entityManager) { + this.entityManager = entityManager; + } + + @Override + public List findByDisplayName(UUID companyId, String displayName) { + Objects.requireNonNull(companyId, "companyId must not be null"); + if (displayName == null || displayName.isBlank()) { + throw new IllegalArgumentException("displayName must not be blank"); + } + return entityManager.createQuery( + """ + select worker + from WorkerJpaEntity worker + where worker.companyId = :companyId + and worker.displayName = :displayName + order by worker.workerId + """, + WorkerJpaEntity.class + ) + .setParameter("companyId", companyId) + .setParameter("displayName", displayName.strip()) + .setMaxResults(2) + .getResultList() + .stream() + .map(this::toSnapshot) + .toList(); + } + + private WorkerAiContextSnapshot toSnapshot(WorkerJpaEntity entity) { + var worker = entity.toDomain(); + return new WorkerAiContextSnapshot( + worker.workerId(), + worker.companyId(), + worker.displayName(), + worker.nationalityCode(), + worker.preferredLanguage(), + worker.workStatus().name(), + worker.stayExpiryDate(), + worker.contractStartDate(), + worker.contractEndDate() + ); + } +} diff --git a/src/main/java/com/fowoco/server/workflow/api/WorkflowDefinitionResponse.java b/src/main/java/com/fowoco/server/workflow/api/WorkflowDefinitionResponse.java index 4de27226..ec6fa698 100644 --- a/src/main/java/com/fowoco/server/workflow/api/WorkflowDefinitionResponse.java +++ b/src/main/java/com/fowoco/server/workflow/api/WorkflowDefinitionResponse.java @@ -15,6 +15,8 @@ public record WorkflowDefinitionResponse( String sensitivity, Set supportedTaskTypes, Set requiredSlots, + Set allowedSlotKeys, + Set resolvableSlotKeys, List checklistItems, List completionEvidence, List sourceIds @@ -27,6 +29,8 @@ static WorkflowDefinitionResponse from(WorkflowDefinition workflow) { workflow.sensitivity(), workflow.supportedTaskTypes(), workflow.requiredSlots(), + workflow.allowedSlotKeys(), + workflow.resolvableSlotKeys(), workflow.checklistItems().stream() .map(WorkflowChecklistResponse::from) .toList(), diff --git a/src/main/java/com/fowoco/server/workflow/domain/WorkflowCatalog.java b/src/main/java/com/fowoco/server/workflow/domain/WorkflowCatalog.java index dca47821..22b1793d 100644 --- a/src/main/java/com/fowoco/server/workflow/domain/WorkflowCatalog.java +++ b/src/main/java/com/fowoco/server/workflow/domain/WorkflowCatalog.java @@ -22,4 +22,10 @@ public Optional findWorkflow(String workflowId) { .filter(workflow -> workflow.workflowId().equals(workflowId)) .findFirst(); } + + public List findByIntent(String intent) { + return workflows.stream() + .filter(workflow -> workflow.intent().equals(intent)) + .toList(); + } } diff --git a/src/main/java/com/fowoco/server/workflow/domain/WorkflowDefinition.java b/src/main/java/com/fowoco/server/workflow/domain/WorkflowDefinition.java index 44089c7f..6bdda1de 100644 --- a/src/main/java/com/fowoco/server/workflow/domain/WorkflowDefinition.java +++ b/src/main/java/com/fowoco/server/workflow/domain/WorkflowDefinition.java @@ -11,6 +11,8 @@ public record WorkflowDefinition( String sensitivity, Set supportedTaskTypes, Set requiredSlots, + Set allowedSlotKeys, + Set resolvableSlotKeys, List checklistItems, List completionEvidence, List sourceIds @@ -19,6 +21,8 @@ public record WorkflowDefinition( public WorkflowDefinition { supportedTaskTypes = Set.copyOf(supportedTaskTypes); requiredSlots = Set.copyOf(requiredSlots); + allowedSlotKeys = Set.copyOf(allowedSlotKeys); + resolvableSlotKeys = Set.copyOf(resolvableSlotKeys); checklistItems = List.copyOf(checklistItems); completionEvidence = List.copyOf(completionEvidence); sourceIds = List.copyOf(sourceIds); diff --git a/src/main/java/com/fowoco/server/workflow/infrastructure/ResourceWorkflowCatalogRepository.java b/src/main/java/com/fowoco/server/workflow/infrastructure/ResourceWorkflowCatalogRepository.java index a4a25e24..c2f17593 100644 --- a/src/main/java/com/fowoco/server/workflow/infrastructure/ResourceWorkflowCatalogRepository.java +++ b/src/main/java/com/fowoco/server/workflow/infrastructure/ResourceWorkflowCatalogRepository.java @@ -83,6 +83,8 @@ private void validate(CatalogProjection projection) { throw new IllegalStateException("중복 workflow_id: " + workflow.workflowId()); } if (workflow.requiredSlots() == null + || workflow.allowedSlotKeys() == null + || workflow.resolvableSlotKeys() == null || workflow.supportedTaskTypes() == null || workflow.supportedTaskTypes().isEmpty() || workflow.checklistItems() == null @@ -90,6 +92,12 @@ private void validate(CatalogProjection projection) { || workflow.sourceIds() == null) { throw new IllegalStateException("Workflow projection collection은 null일 수 없습니다."); } + if (!workflow.allowedSlotKeys().containsAll(workflow.requiredSlots()) + || !workflow.allowedSlotKeys().containsAll(workflow.resolvableSlotKeys())) { + throw new IllegalStateException( + "required_slots와 resolvable_slot_keys는 allowed_slot_keys에 포함되어야 합니다." + ); + } Set itemCodes = new HashSet<>(); workflow.checklistItems().forEach(item -> { requireText(item.itemCode(), "checklist item_code"); @@ -137,6 +145,8 @@ private record WorkflowProjection( String sensitivity, Set supportedTaskTypes, Set requiredSlots, + Set allowedSlotKeys, + Set resolvableSlotKeys, List checklistItems, List completionEvidence, List sourceIds @@ -150,6 +160,8 @@ WorkflowDefinition toDomain() { sensitivity, supportedTaskTypes, requiredSlots, + allowedSlotKeys, + resolvableSlotKeys, checklistItems.stream().map(ChecklistProjection::toDomain).toList(), completionEvidence, sourceIds diff --git a/src/main/resources/workflow/catalog-projection.local.json b/src/main/resources/workflow/catalog-projection.local.json index 71aec212..e3b50d7a 100644 --- a/src/main/resources/workflow/catalog-projection.local.json +++ b/src/main/resources/workflow/catalog-projection.local.json @@ -12,6 +12,8 @@ "sensitivity": "high", "supported_task_types": ["RECONTRACT", "EMPLOYMENT_PERIOD_EXTENSION"], "required_slots": ["worker_id", "due_at"], + "allowed_slot_keys": ["worker_id", "due_at", "contract_end_date"], + "resolvable_slot_keys": ["worker_id", "due_at", "contract_end_date"], "checklist_items": [ { "item_code": "EMPLOYMENT_CONTRACT_USE_CURRENT_STANDARD_FORM", @@ -43,6 +45,8 @@ "sensitivity": "high", "supported_task_types": ["STAY_PERIOD_EXTENSION"], "required_slots": ["worker_id", "due_at"], + "allowed_slot_keys": ["worker_id", "due_at", "stay_expiry_date"], + "resolvable_slot_keys": ["worker_id", "due_at", "stay_expiry_date"], "checklist_items": [ { "item_code": "PASSPORT_COPY_VERIFY_CURRENT", diff --git a/src/test/java/com/fowoco/server/airun/AiSlotResolutionIntegrationTest.java b/src/test/java/com/fowoco/server/airun/AiSlotResolutionIntegrationTest.java new file mode 100644 index 00000000..86429147 --- /dev/null +++ b/src/test/java/com/fowoco/server/airun/AiSlotResolutionIntegrationTest.java @@ -0,0 +1,106 @@ +package com.fowoco.server.airun; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.fowoco.server.aiintegration.application.model.AiContextRequirement; +import com.fowoco.server.airun.application.AiSlotResolution; +import com.fowoco.server.airun.application.AiSlotResolutionTransaction; +import com.fowoco.server.airun.application.error.AiContextResolutionException; +import com.fowoco.server.airun.application.error.AiContextResolutionFailureCode; +import java.math.BigDecimal; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.annotation.Transactional; + +@ActiveProfiles("test") +@SpringBootTest +@Transactional +class AiSlotResolutionIntegrationTest { + + private static final UUID COMPANY_A = UUID.fromString("71000000-0000-0000-0000-000000000001"); + private static final UUID COMPANY_B = UUID.fromString("71000000-0000-0000-0000-000000000002"); + private static final UUID WORKER_A = UUID.fromString("72000000-0000-0000-0000-000000000001"); + private static final UUID WORKER_B = UUID.fromString("72000000-0000-0000-0000-000000000002"); + private static final UUID WORKER_A_DUPLICATE = UUID.fromString("72000000-0000-0000-0000-000000000003"); + + @Autowired + private JdbcTemplate jdbcTemplate; + + @Autowired + private AiSlotResolutionTransaction resolutionTransaction; + + @Test + void sameDisplayNameInAnotherCompanyDoesNotAffectTenantScopedResolution() { + insertCompany(COMPANY_A, "사업장 A"); + insertCompany(COMPANY_B, "사업장 B"); + insertWorker(WORKER_A, COMPANY_A, "같은이름", "2026-09-30"); + insertWorker(WORKER_B, COMPANY_B, "같은이름", "2099-12-31"); + + AiSlotResolution result = resolutionTransaction.resolve( + COMPANY_A, + "0.2.0", + requirement("같은이름") + ); + + assertThat(result.worker().workerRef()).isEqualTo(WORKER_A); + assertThat(result.resolvedFields()).containsEntry("stay_expiry_date", "2026-09-30"); + assertThat(result.resolvedFields()).doesNotContainValue("2099-12-31"); + } + + @Test + void duplicateDisplayNameInsideTheSameCompanyIsReportedAsAmbiguous() { + insertCompany(COMPANY_A, "사업장 A"); + insertWorker(WORKER_A, COMPANY_A, "동명이인", "2026-09-30"); + insertWorker(WORKER_A_DUPLICATE, COMPANY_A, "동명이인", "2027-09-30"); + + assertThatThrownBy(() -> resolutionTransaction.resolve( + COMPANY_A, + "0.2.0", + requirement("동명이인") + )) + .isInstanceOfSatisfying(AiContextResolutionException.class, exception -> + assertThat(exception.failureCode()).isEqualTo( + AiContextResolutionFailureCode.TARGET_AMBIGUOUS + ) + ); + } + + private AiContextRequirement requirement(String displayName) { + return new AiContextRequirement( + "EXPIRY_RENEWAL", + new BigDecimal("0.94"), + displayName, + Map.of(), + List.of("worker_id", "stay_expiry_date") + ); + } + + private void insertCompany(UUID companyId, String name) { + jdbcTemplate.update( + "INSERT INTO company (company_id, name, status) VALUES (?, ?, 'ACTIVE')", + companyId, + name + ); + } + + private void insertWorker(UUID workerId, UUID companyId, String displayName, String expiryDate) { + jdbcTemplate.update( + """ + INSERT INTO worker ( + worker_id, company_id, display_name, work_status, stay_expiry_date + ) VALUES (?, ?, ?, 'ACTIVE', CAST(? AS DATE)) + """, + workerId, + companyId, + displayName, + expiryDate + ); + } +} diff --git a/src/test/java/com/fowoco/server/airun/application/AiSlotResolutionTransactionTest.java b/src/test/java/com/fowoco/server/airun/application/AiSlotResolutionTransactionTest.java new file mode 100644 index 00000000..71b71ff9 --- /dev/null +++ b/src/test/java/com/fowoco/server/airun/application/AiSlotResolutionTransactionTest.java @@ -0,0 +1,191 @@ +package com.fowoco.server.airun.application; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.fowoco.server.aiintegration.application.model.AiContextRequirement; +import com.fowoco.server.airun.application.error.AiContextResolutionException; +import com.fowoco.server.airun.application.error.AiContextResolutionFailureCode; +import com.fowoco.server.task.domain.TaskType; +import com.fowoco.server.worker.application.WorkerAiContextSnapshot; +import com.fowoco.server.worker.application.port.WorkerAiContextReader; +import com.fowoco.server.workflow.application.WorkflowCatalogService; +import com.fowoco.server.workflow.domain.WorkflowCatalog; +import com.fowoco.server.workflow.domain.WorkflowDefinition; +import java.math.BigDecimal; +import java.time.Instant; +import java.time.LocalDate; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.Test; + +class AiSlotResolutionTransactionTest { + + private static final UUID COMPANY_A = UUID.fromString("10000000-0000-0000-0000-000000000001"); + private static final UUID COMPANY_B = UUID.fromString("10000000-0000-0000-0000-000000000002"); + private static final UUID WORKER_A = UUID.fromString("20000000-0000-0000-0000-000000000001"); + + @Test + void resolvesOnlyAllowListedWorkerFieldsAndReportsMissingValues() { + AtomicReference boundCompany = new AtomicReference<>(); + WorkerAiContextReader reader = (companyId, displayName) -> List.of(worker(COMPANY_A)); + AiSlotResolutionTransaction transaction = transaction(reader, boundCompany); + + AiSlotResolution result = transaction.resolve( + COMPANY_A, + "0.2.0", + requirement(List.of("worker_id", "stay_expiry_date", "due_at")) + ); + + assertThat(boundCompany.get()).isEqualTo(COMPANY_A); + assertThat(result.worker().workerRef()).isEqualTo(WORKER_A); + assertThat(result.resolvedFields()).containsExactlyInAnyOrderEntriesOf(Map.of( + "worker_id", WORKER_A.toString(), + "stay_expiry_date", "2026-09-30" + )); + assertThat(result.missingFieldKeys()).containsExactly("due_at"); + assertThat(result.workflowConstraints()) + .extracting(constraint -> constraint.workflowId()) + .containsExactly("WF-CON-001", "WF-STY-001"); + } + + @Test + void rejectsFieldOutsideKnowledgeAllowListBeforeReadingWorkerData() { + AtomicReference readerCalled = new AtomicReference<>(false); + WorkerAiContextReader reader = (companyId, displayName) -> { + readerCalled.set(true); + return List.of(worker(COMPANY_A)); + }; + + assertFailure( + () -> transaction(reader, new AtomicReference<>()).resolve( + COMPANY_A, + "0.2.0", + requirement(List.of("legal_name")) + ), + AiContextResolutionFailureCode.FORBIDDEN_FIELD + ); + assertThat(readerCalled.get()).isFalse(); + } + + @Test + void rejectsAnalysisPinnedToADifferentKnowledgeVersion() { + assertFailure( + () -> transaction( + (companyId, displayName) -> List.of(worker(COMPANY_A)), + new AtomicReference<>() + ).resolve(COMPANY_A, "9.9.9", requirement(List.of("worker_id"))), + AiContextResolutionFailureCode.KNOWLEDGE_VERSION_MISMATCH + ); + } + + @Test + void distinguishesMissingAmbiguousAndCrossCompanyTargetsWithoutLeakingNames() { + assertFailure( + () -> transaction((companyId, displayName) -> List.of(), new AtomicReference<>()) + .resolve(COMPANY_A, "0.2.0", requirement(List.of("worker_id"))), + AiContextResolutionFailureCode.TARGET_NOT_FOUND + ); + + assertFailure( + () -> transaction( + (companyId, displayName) -> List.of(worker(COMPANY_A), worker(COMPANY_A)), + new AtomicReference<>() + ).resolve(COMPANY_A, "0.2.0", requirement(List.of("worker_id"))), + AiContextResolutionFailureCode.TARGET_AMBIGUOUS + ); + + assertThatThrownBy(() -> transaction( + (companyId, displayName) -> List.of(worker(COMPANY_B)), + new AtomicReference<>() + ).resolve(COMPANY_A, "0.2.0", requirement(List.of("worker_id")))) + .isInstanceOfSatisfying(AiContextResolutionException.class, exception -> { + assertThat(exception.failureCode()).isEqualTo( + AiContextResolutionFailureCode.TARGET_NOT_FOUND + ); + assertThat(exception.getMessage()).doesNotContain("응웬반안"); + }); + } + + private AiSlotResolutionTransaction transaction( + WorkerAiContextReader reader, + AtomicReference boundCompany + ) { + WorkflowCatalogService workflowService = new WorkflowCatalogService(this::catalog); + return new AiSlotResolutionTransaction( + workflowService, + reader, + boundCompany::set + ); + } + + private WorkflowCatalog catalog() { + return new WorkflowCatalog( + "FOWOCO-KNOWLEDGE", + "0.2.0", + "DRAFT", + "fowoco/knowledge", + Instant.parse("2026-07-23T00:00:00Z"), + List.of( + workflow( + "WF-STY-001", + Set.of("worker_id", "due_at", "stay_expiry_date") + ), + workflow( + "WF-CON-001", + Set.of("worker_id", "due_at", "contract_end_date") + ) + ) + ); + } + + private WorkflowDefinition workflow(String workflowId, Set slots) { + return new WorkflowDefinition( + workflowId, + workflowId, + "EXPIRY_RENEWAL", + "high", + Set.of(TaskType.STAY_PERIOD_EXTENSION), + Set.of("worker_id", "due_at"), + slots, + slots, + List.of(), + List.of(), + List.of() + ); + } + + private AiContextRequirement requirement(List requiredFieldKeys) { + return new AiContextRequirement( + "EXPIRY_RENEWAL", + new BigDecimal("0.94"), + "응웬반안", + Map.of("document_type", "STAY_EXTENSION"), + requiredFieldKeys + ); + } + + private WorkerAiContextSnapshot worker(UUID companyId) { + return new WorkerAiContextSnapshot( + WORKER_A, + companyId, + "응웬반안", + "VN", + "vi", + "ACTIVE", + LocalDate.of(2026, 9, 30), + LocalDate.of(2026, 1, 1), + LocalDate.of(2026, 8, 31) + ); + } + + private void assertFailure(Runnable invocation, AiContextResolutionFailureCode expectedCode) { + assertThatThrownBy(invocation::run) + .isInstanceOfSatisfying(AiContextResolutionException.class, exception -> + assertThat(exception.failureCode()).isEqualTo(expectedCode) + ); + } +} From 4d6154f074342c200f73bc543d6bd1a89f341a98 Mon Sep 17 00:00:00 2001 From: hywznn Date: Mon, 3 Aug 2026 15:50:00 +0900 Subject: [PATCH 2/4] =?UTF-8?q?feat(ai):=20Agent=20=EB=AC=B8=EB=A7=A5=20?= =?UTF-8?q?=EB=B3=B4=EC=B6=A9=20=EC=9E=AC=ED=98=B8=EC=B6=9C=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 1 + docs/ai-slot-resolution.md | 100 +++++++++ .../AiAnalysisContinuationResult.java | 20 ++ .../AiAnalysisContinuationService.java | 166 ++++++++++++++ .../application/port/AiAttemptStarter.java | 13 ++ .../AiAnalysisContinuationServiceTest.java | 212 ++++++++++++++++++ 6 files changed, 512 insertions(+) create mode 100644 docs/ai-slot-resolution.md create mode 100644 src/main/java/com/fowoco/server/airun/application/AiAnalysisContinuationResult.java create mode 100644 src/main/java/com/fowoco/server/airun/application/AiAnalysisContinuationService.java create mode 100644 src/main/java/com/fowoco/server/airun/application/port/AiAttemptStarter.java create mode 100644 src/test/java/com/fowoco/server/airun/application/AiAnalysisContinuationServiceTest.java diff --git a/README.md b/README.md index 6b73af73..9cd01b81 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ FOWOCO는 단순 번역 서비스가 아닙니다. 해야 할 일을 업무카 | 패키지·모듈 경계 | [프로젝트 구조](docs/project-structure.md) | 코드를 어느 패키지에 구현해야 하는지 설명 | | 중요한 설계 결정 | [ADR 목록](docs/adr/README.md) | 저장소 경계, API·보안, Task·AiRun, RLS 결정 원본 | | Server ↔ AI 계약 | [AI Runtime 계약](docs/ai-runtime-contract.md) | Server가 AI에 보내고 받을 수 있는 값과 검증 기준 | +| Agent DB 정보 보충 | [Slot 조회·재호출](docs/ai-slot-resolution.md) | canonical key allow-list, tenant 조회와 ANALYZE 재호출 기준 | | 이벤트 유실·재처리 | [Outbox 운영 가이드](docs/reliability/transactional-outbox.md) | 이벤트 발행, lease, 재시도와 장애 복구 기준 | | 구현 계획·업무 상태 | [Server Roadmap](https://github.com/orgs/fowoco/projects/3) · [Issues](https://github.com/fowoco/server/issues) | 실제 담당자, 우선순위와 진행 상태 | | 전체 설명·운영 가이드 | [Server Wiki](https://github.com/fowoco/server/wiki) | 초보자용 아키텍처·API·배포 설명 | diff --git a/docs/ai-slot-resolution.md b/docs/ai-slot-resolution.md new file mode 100644 index 00000000..c6f0718a --- /dev/null +++ b/docs/ai-slot-resolution.md @@ -0,0 +1,100 @@ +# Agent 요청 Slot 조회와 재호출 + +이 문서는 AI Runtime이 `CONTEXT_REQUIRED`를 반환했을 때 Server가 Worker DB 값을 +안전하게 보충하고 ANALYZE를 다시 호출하는 #74 구현을 설명합니다. + +## 한 줄 설명 + +Agent가 DB를 직접 조회하는 대신 필요한 **canonical key**만 말하고, Server가 현재 +사업장과 Knowledge allow-list를 확인한 뒤 고정된 코드로 값을 찾아 줍니다. + +```text +PLAN response: CONTEXT_REQUIRED + → AiSlotResolutionTransaction + 1. company tenant context 설정 + 2. detectedIntent에 해당하는 Workflow projection 조회 + 3. AiRun의 requiredKnowledgeVersion과 활성 bundle version 확인 + 4. requiredFieldKeys allow-list 검사 + 5. 같은 company 안에서 targetDisplayName 조회 + 6. 고정 switch로 Worker 값 변환 + → AiAttemptStarter (#24가 PostgreSQL 구현) + → ANALYZE request + → AiRuntimeClient (#56) +``` + +DB transaction은 Slot 조회가 끝나면 닫습니다. 외부 Runtime 응답을 기다리는 동안 DB +transaction과 connection을 붙잡지 않습니다. + +## canonical key 기준 + +key의 원본은 `fowoco/knowledge`의 `required_slots.yaml`입니다. Server의 활성 Workflow +projection은 다음 세 집합을 구분합니다. + +- `requiredSlots`: Workflow 시작에 필요한 값 +- `allowedSlotKeys`: Agent candidate와 질문에서 사용할 수 있는 전체 Slot +- `resolvableSlotKeys`: Server context 조회를 요청할 수 있는 Slot + +분석이 고정한 `requiredKnowledgeVersion`과 현재 활성 projection의 `bundleVersion`이 다르면 +서로 다른 지식 기준을 섞지 않고 `KNOWLEDGE_VERSION_MISMATCH`로 중단합니다. + +MVP Worker DB Resolver가 실제 값으로 바꿀 수 있는 key는 다음과 같습니다. + +| canonical key | Server 값 | +| --- | --- | +| `worker_id` | 현재 사업장 Worker UUID | +| `stay_expiry_date` | Worker의 체류기간 만료일 | +| `contract_end_date` | Worker의 계약 종료일 | + +`due_at`처럼 Knowledge에서 context 조회 가능하지만 현재 Worker DB로 계산할 수 없는 값은 +`missingFieldKeys`로 반환합니다. `legal_name`처럼 활성 projection이 허용하지 않은 key는 +DB column을 추측하지 않고 `FORBIDDEN_FIELD`로 거부합니다. + +## 대상 근로자 확인 + +MVP는 한 요청에서 Worker 한 명만 처리합니다. + +- 현재 `companyId` 안에서 `targetDisplayName`이 정확히 한 명이면 계속 진행합니다. +- 없으면 `TARGET_NOT_FOUND`입니다. +- 같은 사업장에 동명이인이 두 명 이상이면 `TARGET_AMBIGUOUS`입니다. +- 다른 사업장에 같은 이름이 있어도 조회 결과에 포함하지 않습니다. + +오류 메시지에는 실제 이름이나 조회값을 넣지 않습니다. + +## 두 번째 요청에서 보존하는 값 + +ANALYZE 요청은 다음을 잃어버리면 안 됩니다. + +- 동일한 `requestId` +- 새로운 `attemptId` +- 원래 HR `instruction` +- 선택적 `intentHint` +- PLAN이 추출한 `extractedSlots` +- PLAN이 요청한 전체 `requestedFieldKeys` +- DB에서 찾은 값만 포함한 `workers[0].requestedFields` +- 활성 Knowledge의 `workflowConstraints` + +`requestedFieldKeys`에는 DB에 값이 없던 key도 남습니다. Runtime은 전체 요청 key와 실제로 +채워진 값의 차이를 보고 `NEEDS_INFO + questions`를 반환할 수 있습니다. + +## Attempt와 반복 제한 + +`AiAttemptStarter`는 Runtime HTTP 호출 전에 호출됩니다. 현재 #74에서는 Port만 정의하고, +#24가 V12 AiAttempt table과 transaction으로 구현합니다. + +- 같은 분석: `requestId` 유지 +- 매 Runtime 호출: 새 `attemptId` +- 자동 DB 보충: 최대 2회 +- Remote HTTP client의 투명 retry: 금지 +- Agent 결과만으로 Task 생성·승인·발송: 금지 + +2회를 초과하면 계속 자동 호출하지 않고 HR 확인 흐름으로 넘겨야 합니다. 실제 AiRun 상태와 +질문 저장은 #24와 #77에서 연결합니다. + +## 검증 + +- `AiSlotResolutionTransactionTest`: allow-list, 누락값, 금지 key, 대상 없음·동명이인·타사 방어 +- `AiSlotResolutionIntegrationTest`: 실제 JPA query가 `companyId + displayName`으로 격리되는지 확인 +- `AiAnalysisContinuationServiceTest`: Attempt 기록이 HTTP 호출보다 먼저이며 PLAN 문맥이 + ANALYZE에 보존되는지 확인 + +이 기능은 DB migration을 추가하지 않습니다. `V12__create_ai_run.sql`은 #24가 소유합니다. diff --git a/src/main/java/com/fowoco/server/airun/application/AiAnalysisContinuationResult.java b/src/main/java/com/fowoco/server/airun/application/AiAnalysisContinuationResult.java new file mode 100644 index 00000000..4708bc39 --- /dev/null +++ b/src/main/java/com/fowoco/server/airun/application/AiAnalysisContinuationResult.java @@ -0,0 +1,20 @@ +package com.fowoco.server.airun.application; + +import com.fowoco.server.aiintegration.application.model.AiAnalysisResponse; +import java.util.Objects; +import java.util.Set; +import java.util.UUID; + +public record AiAnalysisContinuationResult( + UUID attemptId, + AiAnalysisResponse response, + Set missingFieldKeys +) { + + public AiAnalysisContinuationResult { + Objects.requireNonNull(attemptId, "attemptId must not be null"); + Objects.requireNonNull(response, "response must not be null"); + Objects.requireNonNull(missingFieldKeys, "missingFieldKeys must not be null"); + missingFieldKeys = Set.copyOf(missingFieldKeys); + } +} diff --git a/src/main/java/com/fowoco/server/airun/application/AiAnalysisContinuationService.java b/src/main/java/com/fowoco/server/airun/application/AiAnalysisContinuationService.java new file mode 100644 index 00000000..6a5ae55f --- /dev/null +++ b/src/main/java/com/fowoco/server/airun/application/AiAnalysisContinuationService.java @@ -0,0 +1,166 @@ +package com.fowoco.server.airun.application; + +import com.fowoco.server.aiintegration.application.model.AiAnalysisOutcome; +import com.fowoco.server.aiintegration.application.model.AiAnalysisPhase; +import com.fowoco.server.aiintegration.application.model.AiAnalysisRequest; +import com.fowoco.server.aiintegration.application.model.AiAnalysisResponse; +import com.fowoco.server.aiintegration.application.model.AiRuntimeCallContext; +import com.fowoco.server.aiintegration.application.model.AnalysisInput; +import com.fowoco.server.aiintegration.application.model.WorkerContext; +import com.fowoco.server.aiintegration.application.port.AiRuntimeClient; +import com.fowoco.server.airun.application.error.AiContextResolutionException; +import com.fowoco.server.airun.application.error.AiContextResolutionFailureCode; +import com.fowoco.server.airun.application.port.AiAttemptStarter; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.UUID; + +/** + * Continues a validated CONTEXT_REQUIRED result without holding a database transaction open. + * #24 wires this service to a durable AiAttempt implementation. + */ +public final class AiAnalysisContinuationService { + + public static final int MAX_CONTEXT_ROUNDS = 2; + + private final AiSlotResolutionTransaction slotResolutionTransaction; + private final AiAttemptStarter attemptStarter; + private final AiRuntimeClient runtimeClient; + + public AiAnalysisContinuationService( + AiSlotResolutionTransaction slotResolutionTransaction, + AiAttemptStarter attemptStarter, + AiRuntimeClient runtimeClient + ) { + this.slotResolutionTransaction = Objects.requireNonNull( + slotResolutionTransaction, + "slotResolutionTransaction must not be null" + ); + this.attemptStarter = Objects.requireNonNull(attemptStarter, "attemptStarter must not be null"); + this.runtimeClient = Objects.requireNonNull(runtimeClient, "runtimeClient must not be null"); + } + + public AiAnalysisContinuationResult continueAnalysis( + UUID companyId, + AiAnalysisRequest previousRequest, + AiAnalysisResponse previousResponse, + int completedContextRounds, + long remainingDeadlineMs, + AiRuntimeCallContext callContext + ) { + Objects.requireNonNull(companyId, "companyId must not be null"); + Objects.requireNonNull(previousRequest, "previousRequest must not be null"); + Objects.requireNonNull(previousResponse, "previousResponse must not be null"); + Objects.requireNonNull(callContext, "callContext must not be null"); + validateContinuation(previousRequest, previousResponse, completedContextRounds); + + AiSlotResolution resolution = slotResolutionTransaction.resolve( + companyId, + previousRequest.requiredKnowledgeVersion(), + previousResponse.contextRequirement() + ); + validateSameWorker(previousRequest, resolution.worker()); + + int nextContextRound = completedContextRounds + 1; + UUID attemptId = attemptStarter.startAttempt( + previousRequest.requestId(), + AiAnalysisPhase.ANALYZE, + nextContextRound + ); + AiAnalysisRequest analyzeRequest = new AiAnalysisRequest( + previousRequest.requestId(), + attemptId, + AiAnalysisPhase.ANALYZE, + previousRequest.contractVersion(), + previousRequest.requiredKnowledgeVersion(), + remainingDeadlineMs, + buildAnalyzeInput(previousRequest.analysisInput(), previousResponse, resolution) + ); + AiAnalysisResponse response = runtimeClient.analyze(analyzeRequest, callContext); + return new AiAnalysisContinuationResult( + attemptId, + response, + resolution.missingFieldKeys() + ); + } + + private AnalysisInput buildAnalyzeInput( + AnalysisInput previousInput, + AiAnalysisResponse previousResponse, + AiSlotResolution resolution + ) { + Map extractedSlots = new LinkedHashMap<>(previousInput.extractedSlots()); + extractedSlots.putAll(previousResponse.contextRequirement().extractedSlots()); + + LinkedHashSet requestedFieldKeys = new LinkedHashSet<>(previousInput.requestedFieldKeys()); + requestedFieldKeys.addAll(previousResponse.contextRequirement().requiredFieldKeys()); + + Map requestedFields = new LinkedHashMap<>(); + if (!previousInput.workers().isEmpty()) { + requestedFields.putAll(previousInput.workers().get(0).requestedFields()); + } + requestedFields.putAll(resolution.resolvedFields()); + WorkerContext worker = resolution.worker(); + WorkerContext mergedWorker = new WorkerContext( + worker.workerRef(), + worker.displayName(), + worker.nationalityCode(), + worker.preferredLanguage(), + worker.workStatus(), + worker.stayExpiryDate(), + worker.contractStartDate(), + worker.contractEndDate(), + requestedFields + ); + return new AnalysisInput( + previousInput.instruction(), + previousInput.intentHint(), + extractedSlots, + new ArrayList<>(requestedFieldKeys), + List.of(mergedWorker), + resolution.workflowConstraints() + ); + } + + private void validateContinuation( + AiAnalysisRequest previousRequest, + AiAnalysisResponse previousResponse, + int completedContextRounds + ) { + if (!previousRequest.requestId().equals(previousResponse.requestId()) + || previousResponse.outcome() != AiAnalysisOutcome.CONTEXT_REQUIRED + || previousResponse.contextRequirement() == null) { + reject( + AiContextResolutionFailureCode.INVALID_CONTEXT_RESPONSE, + "Only a matching CONTEXT_REQUIRED response can continue analysis." + ); + } + if (completedContextRounds < 0 || completedContextRounds >= MAX_CONTEXT_ROUNDS) { + reject( + AiContextResolutionFailureCode.CONTEXT_ROUND_LIMIT, + "The automatic context resolution round limit was reached." + ); + } + } + + private void validateSameWorker(AiAnalysisRequest previousRequest, WorkerContext resolvedWorker) { + if (previousRequest.analysisInput().workers().isEmpty()) { + return; + } + UUID previousWorkerRef = previousRequest.analysisInput().workers().get(0).workerRef(); + if (!previousWorkerRef.equals(resolvedWorker.workerRef())) { + reject( + AiContextResolutionFailureCode.TARGET_CHANGED, + "The Runtime attempted to change the Worker target during one analysis." + ); + } + } + + private void reject(AiContextResolutionFailureCode failureCode, String safeMessage) { + throw new AiContextResolutionException(failureCode, safeMessage); + } +} diff --git a/src/main/java/com/fowoco/server/airun/application/port/AiAttemptStarter.java b/src/main/java/com/fowoco/server/airun/application/port/AiAttemptStarter.java new file mode 100644 index 00000000..616f4094 --- /dev/null +++ b/src/main/java/com/fowoco/server/airun/application/port/AiAttemptStarter.java @@ -0,0 +1,13 @@ +package com.fowoco.server.airun.application.port; + +import com.fowoco.server.aiintegration.application.model.AiAnalysisPhase; +import java.util.UUID; + +/** + * Starts a durable Attempt before one Runtime transport call. + * The PostgreSQL implementation is owned by #24. + */ +public interface AiAttemptStarter { + + UUID startAttempt(UUID requestId, AiAnalysisPhase phase, int contextRound); +} diff --git a/src/test/java/com/fowoco/server/airun/application/AiAnalysisContinuationServiceTest.java b/src/test/java/com/fowoco/server/airun/application/AiAnalysisContinuationServiceTest.java new file mode 100644 index 00000000..ad29401f --- /dev/null +++ b/src/test/java/com/fowoco/server/airun/application/AiAnalysisContinuationServiceTest.java @@ -0,0 +1,212 @@ +package com.fowoco.server.airun.application; + +import static com.fowoco.server.aiintegration.support.AiRuntimeContractFixture.REQUEST_ID; +import static com.fowoco.server.aiintegration.support.AiRuntimeContractFixture.validPlanRequest; +import static com.fowoco.server.aiintegration.support.AiRuntimeContractFixture.validVersions; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.fowoco.server.aiintegration.application.model.AiAnalysisOutcome; +import com.fowoco.server.aiintegration.application.model.AiAnalysisPhase; +import com.fowoco.server.aiintegration.application.model.AiAnalysisRequest; +import com.fowoco.server.aiintegration.application.model.AiAnalysisResponse; +import com.fowoco.server.aiintegration.application.model.AiContextRequirement; +import com.fowoco.server.aiintegration.application.model.AiQuestion; +import com.fowoco.server.aiintegration.application.port.AiRuntimeClient; +import com.fowoco.server.aiintegration.application.validation.AiRuntimeBoundaryPolicy; +import com.fowoco.server.aiintegration.application.validation.AiRuntimeContractValidator; +import com.fowoco.server.aiintegration.application.validation.ValidatingAiRuntimeClient; +import com.fowoco.server.airun.application.error.AiContextResolutionException; +import com.fowoco.server.airun.application.error.AiContextResolutionFailureCode; +import com.fowoco.server.task.domain.TaskType; +import com.fowoco.server.worker.application.WorkerAiContextSnapshot; +import com.fowoco.server.workflow.application.WorkflowCatalogService; +import com.fowoco.server.workflow.domain.WorkflowCatalog; +import com.fowoco.server.workflow.domain.WorkflowDefinition; +import java.math.BigDecimal; +import java.time.Instant; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.Test; + +class AiAnalysisContinuationServiceTest { + + private static final UUID COMPANY_ID = UUID.fromString("10000000-0000-0000-0000-000000000001"); + private static final UUID WORKER_ID = UUID.fromString("30000000-0000-0000-0000-000000000001"); + private static final UUID NEXT_ATTEMPT_ID = UUID.fromString("20000000-0000-0000-0000-000000000002"); + + @Test + void recordsNewAttemptBeforeAnalyzeAndPreservesPlanContext() { + List callOrder = new ArrayList<>(); + AtomicReference receivedRequest = new AtomicReference<>(); + AiRuntimeClient transport = (request, context) -> { + callOrder.add("runtime"); + receivedRequest.set(request); + return needsInfoResponse(request.requestId()); + }; + AiRuntimeClient validatingClient = new ValidatingAiRuntimeClient( + transport, + new AiRuntimeContractValidator(new AiRuntimeBoundaryPolicy()) + ); + AiAnalysisContinuationService service = new AiAnalysisContinuationService( + resolutionTransaction(), + (requestId, phase, contextRound) -> { + assertThat(requestId).isEqualTo(REQUEST_ID); + assertThat(phase).isEqualTo(AiAnalysisPhase.ANALYZE); + assertThat(contextRound).isEqualTo(1); + callOrder.add("attempt"); + return NEXT_ATTEMPT_ID; + }, + validatingClient + ); + + AiAnalysisContinuationResult result = service.continueAnalysis( + COMPANY_ID, + validPlanRequest(), + contextRequiredResponse(), + 0, + 9_000, + com.fowoco.server.aiintegration.application.model.AiRuntimeCallContext.withoutTrace() + ); + + assertThat(callOrder).containsExactly("attempt", "runtime"); + assertThat(result.attemptId()).isEqualTo(NEXT_ATTEMPT_ID); + assertThat(result.response().outcome()).isEqualTo(AiAnalysisOutcome.NEEDS_INFO); + assertThat(result.missingFieldKeys()).containsExactly("due_at"); + + AiAnalysisRequest analyzeRequest = receivedRequest.get(); + assertThat(analyzeRequest.requestId()).isEqualTo(REQUEST_ID); + assertThat(analyzeRequest.attemptId()).isEqualTo(NEXT_ATTEMPT_ID); + assertThat(analyzeRequest.phase()).isEqualTo(AiAnalysisPhase.ANALYZE); + assertThat(analyzeRequest.analysisInput().instruction()) + .isEqualTo(validPlanRequest().analysisInput().instruction()); + assertThat(analyzeRequest.analysisInput().intentHint()).isEqualTo("EXPIRY_RENEWAL"); + assertThat(analyzeRequest.analysisInput().extractedSlots()) + .containsEntry("document_type", "STAY_EXTENSION"); + assertThat(analyzeRequest.analysisInput().requestedFieldKeys()) + .containsExactly("worker_id", "stay_expiry_date", "due_at"); + assertThat(analyzeRequest.analysisInput().workers()).hasSize(1); + assertThat(analyzeRequest.analysisInput().workers().get(0).requestedFields()) + .containsExactlyInAnyOrderEntriesOf(Map.of( + "worker_id", WORKER_ID.toString(), + "stay_expiry_date", "2026-09-30" + )); + } + + @Test + void stopsAtRoundLimitBeforeDatabaseResolutionOrRuntimeCall() { + AtomicReference attemptStarted = new AtomicReference<>(false); + AiAnalysisContinuationService service = new AiAnalysisContinuationService( + resolutionTransaction(), + (requestId, phase, contextRound) -> { + attemptStarted.set(true); + return NEXT_ATTEMPT_ID; + }, + (request, context) -> { + throw new AssertionError("Runtime must not be called after the round limit."); + } + ); + + assertThatThrownBy(() -> service.continueAnalysis( + COMPANY_ID, + validPlanRequest(), + contextRequiredResponse(), + AiAnalysisContinuationService.MAX_CONTEXT_ROUNDS, + 9_000, + com.fowoco.server.aiintegration.application.model.AiRuntimeCallContext.withoutTrace() + )).isInstanceOfSatisfying(AiContextResolutionException.class, exception -> + assertThat(exception.failureCode()).isEqualTo( + AiContextResolutionFailureCode.CONTEXT_ROUND_LIMIT + ) + ); + assertThat(attemptStarted.get()).isFalse(); + } + + private AiSlotResolutionTransaction resolutionTransaction() { + WorkflowCatalogService workflowService = new WorkflowCatalogService(this::catalog); + return new AiSlotResolutionTransaction( + workflowService, + (companyId, displayName) -> List.of(worker()), + companyId -> { + } + ); + } + + private AiAnalysisResponse contextRequiredResponse() { + return new AiAnalysisResponse( + REQUEST_ID, + AiAnalysisOutcome.CONTEXT_REQUIRED, + new AiContextRequirement( + "EXPIRY_RENEWAL", + new BigDecimal("0.94"), + "응웬반안", + Map.of("document_type", "STAY_EXTENSION"), + List.of("worker_id", "stay_expiry_date", "due_at") + ), + List.of(), + List.of(), + List.of(), + validVersions(), + 1, + 120 + ); + } + + private AiAnalysisResponse needsInfoResponse(UUID requestId) { + return new AiAnalysisResponse( + requestId, + AiAnalysisOutcome.NEEDS_INFO, + null, + List.of(new AiQuestion("due_at", "내부 준비 마감일을 입력해 주세요.")), + List.of(), + List.of(), + validVersions(), + 1, + 180 + ); + } + + private WorkflowCatalog catalog() { + Set allowedSlots = Set.of("worker_id", "due_at", "stay_expiry_date"); + WorkflowDefinition workflow = new WorkflowDefinition( + "WF-STY-001", + "체류기간 연장", + "EXPIRY_RENEWAL", + "high", + Set.of(TaskType.STAY_PERIOD_EXTENSION), + Set.of("worker_id", "due_at"), + allowedSlots, + allowedSlots, + List.of(), + List.of(), + List.of() + ); + return new WorkflowCatalog( + "FOWOCO-KNOWLEDGE", + "0.2.0", + "DRAFT", + "fowoco/knowledge", + Instant.parse("2026-07-23T00:00:00Z"), + List.of(workflow) + ); + } + + private WorkerAiContextSnapshot worker() { + return new WorkerAiContextSnapshot( + WORKER_ID, + COMPANY_ID, + "응웬반안", + "VN", + "vi", + "ACTIVE", + LocalDate.of(2026, 9, 30), + LocalDate.of(2026, 1, 1), + LocalDate.of(2026, 8, 31) + ); + } +} From 67a04bb680c8b425b08c2446c512ebf89a803a7a Mon Sep 17 00:00:00 2001 From: hywznn Date: Mon, 3 Aug 2026 15:55:51 +0900 Subject: [PATCH 3/4] =?UTF-8?q?fix(ai):=20=EC=9E=AC=ED=98=B8=EC=B6=9C?= =?UTF-8?q?=EC=97=90=EC=84=9C=EB=8F=84=20=EB=8B=A8=EC=9D=BC=20=EB=B0=9C?= =?UTF-8?q?=ED=99=94=EB=AC=B8=20=EA=B3=84=EC=95=BD=20=EC=9C=A0=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/ai-slot-resolution.md | 3 +-- .../airun/application/AiAnalysisContinuationService.java | 1 - .../airun/application/AiAnalysisContinuationServiceTest.java | 3 ++- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/docs/ai-slot-resolution.md b/docs/ai-slot-resolution.md index c6f0718a..fbfa6fc6 100644 --- a/docs/ai-slot-resolution.md +++ b/docs/ai-slot-resolution.md @@ -66,8 +66,7 @@ ANALYZE 요청은 다음을 잃어버리면 안 됩니다. - 동일한 `requestId` - 새로운 `attemptId` -- 원래 HR `instruction` -- 선택적 `intentHint` +- 선택한 태그까지 포함한 원래 `instruction` (`발화문, INTENT_TAG`) - PLAN이 추출한 `extractedSlots` - PLAN이 요청한 전체 `requestedFieldKeys` - DB에서 찾은 값만 포함한 `workers[0].requestedFields` diff --git a/src/main/java/com/fowoco/server/airun/application/AiAnalysisContinuationService.java b/src/main/java/com/fowoco/server/airun/application/AiAnalysisContinuationService.java index 6a5ae55f..e02a1dae 100644 --- a/src/main/java/com/fowoco/server/airun/application/AiAnalysisContinuationService.java +++ b/src/main/java/com/fowoco/server/airun/application/AiAnalysisContinuationService.java @@ -118,7 +118,6 @@ private AnalysisInput buildAnalyzeInput( ); return new AnalysisInput( previousInput.instruction(), - previousInput.intentHint(), extractedSlots, new ArrayList<>(requestedFieldKeys), List.of(mergedWorker), diff --git a/src/test/java/com/fowoco/server/airun/application/AiAnalysisContinuationServiceTest.java b/src/test/java/com/fowoco/server/airun/application/AiAnalysisContinuationServiceTest.java index ad29401f..162b7a39 100644 --- a/src/test/java/com/fowoco/server/airun/application/AiAnalysisContinuationServiceTest.java +++ b/src/test/java/com/fowoco/server/airun/application/AiAnalysisContinuationServiceTest.java @@ -85,7 +85,8 @@ void recordsNewAttemptBeforeAnalyzeAndPreservesPlanContext() { assertThat(analyzeRequest.phase()).isEqualTo(AiAnalysisPhase.ANALYZE); assertThat(analyzeRequest.analysisInput().instruction()) .isEqualTo(validPlanRequest().analysisInput().instruction()); - assertThat(analyzeRequest.analysisInput().intentHint()).isEqualTo("EXPIRY_RENEWAL"); + assertThat(analyzeRequest.analysisInput().instruction()) + .endsWith(", EXPIRY_RENEWAL"); assertThat(analyzeRequest.analysisInput().extractedSlots()) .containsEntry("document_type", "STAY_EXTENSION"); assertThat(analyzeRequest.analysisInput().requestedFieldKeys()) From 746a8c7e9d1508910637f5a29285e16125092f08 Mon Sep 17 00:00:00 2001 From: hywznn Date: Tue, 4 Aug 2026 09:12:15 +0900 Subject: [PATCH 4/4] =?UTF-8?q?docs(ai):=20Slot=20=EC=9E=AC=ED=98=B8?= =?UTF-8?q?=EC=B6=9C=20=EC=B5=9C=EC=86=8C=20=EC=A0=84=EC=86=A1=20=EA=B3=84?= =?UTF-8?q?=EC=95=BD=20=EB=AA=85=ED=99=95=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/ai-slot-resolution.md | 38 +++++++++++++++++++++++++++++++------- 1 file changed, 31 insertions(+), 7 deletions(-) diff --git a/docs/ai-slot-resolution.md b/docs/ai-slot-resolution.md index fbfa6fc6..f31500f8 100644 --- a/docs/ai-slot-resolution.md +++ b/docs/ai-slot-resolution.md @@ -60,20 +60,44 @@ MVP는 한 요청에서 Worker 한 명만 처리합니다. 오류 메시지에는 실제 이름이나 조회값을 넣지 않습니다. -## 두 번째 요청에서 보존하는 값 +## 두 번째 호출에서 보존하는 값 -ANALYZE 요청은 다음을 잃어버리면 안 됩니다. +#74는 먼저 Server 내부 `AiAnalysisRequest`를 만들고, #56의 HTTP Adapter가 이를 최소 +Runtime JSON으로 변환합니다. Server 내부 요청은 다음 값을 잃어버리면 안 됩니다. - 동일한 `requestId` -- 새로운 `attemptId` +- 새로운 `attemptId`와 남은 deadline - 선택한 태그까지 포함한 원래 `instruction` (`발화문, INTENT_TAG`) - PLAN이 추출한 `extractedSlots` - PLAN이 요청한 전체 `requestedFieldKeys` -- DB에서 찾은 값만 포함한 `workers[0].requestedFields` +- 응답 검증에 필요한 Worker snapshot +- DB에서 찾은 값만 포함한 `requestedFields` - 활성 Knowledge의 `workflowConstraints` -`requestedFieldKeys`에는 DB에 값이 없던 key도 남습니다. Runtime은 전체 요청 key와 실제로 -채워진 값의 차이를 보고 `NEEDS_INFO + questions`를 반환할 수 있습니다. +실제 Runtime HTTP JSON에는 아래 값만 전송합니다. + +```json +{ + "requestId": "10000000-0000-0000-0000-000000000001", + "phase": "ANALYZE", + "analysisInput": { + "instruction": "응웬반안 체류연장 준비해줘, EXPIRY_RENEWAL", + "requestedFieldKeys": ["worker_id", "stay_expiry_date", "due_at"], + "workers": [{ + "workerRef": "worker-uuid", + "requestedFields": { + "worker_id": "worker-uuid", + "stay_expiry_date": "2026-09-30" + } + }] + } +} +``` + +`attemptId`, version, deadline, `extractedSlots`, `workflowConstraints`는 Server 내부 관리값이라 +Runtime JSON에는 넣지 않습니다. `requestedFieldKeys`에는 DB에 값이 없던 key도 남깁니다. +Runtime은 전체 요청 key와 실제로 채워진 값의 차이를 보고 `NEEDS_INFO + questions`를 +반환할 수 있습니다. ## Attempt와 반복 제한 @@ -81,7 +105,7 @@ ANALYZE 요청은 다음을 잃어버리면 안 됩니다. #24가 V12 AiAttempt table과 transaction으로 구현합니다. - 같은 분석: `requestId` 유지 -- 매 Runtime 호출: 새 `attemptId` +- 매 Runtime 호출: 새 `attemptId`를 Server 내부에 기록 - 자동 DB 보충: 최대 2회 - Remote HTTP client의 투명 retry: 금지 - Agent 결과만으로 Task 생성·승인·발송: 금지