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 index 179ba029..6df2f5e1 100644 --- a/src/main/java/com/fowoco/server/worker/infrastructure/persistence/JpaWorkerAiContextReader.java +++ b/src/main/java/com/fowoco/server/worker/infrastructure/persistence/JpaWorkerAiContextReader.java @@ -70,7 +70,7 @@ and not exists ( } catch (IllegalArgumentException ignored) { return List.of(); } - List workerIds = entityManager.createQuery( + List candidateRows = entityManager.createQuery( """ select worker.workerId, worker.displayName from WorkerJpaEntity worker @@ -86,12 +86,45 @@ and not exists ( Object[].class ) .setParameter("companyId", companyId) - .getResultList() + .getResultList(); + List workerIds = candidateRows .stream() .filter(row -> lookupKey.equals(displayNameNormalizer.normalize((String) row[1]))) .limit(2) .map(row -> (UUID) row[0]) .toList(); + if (workerIds.isEmpty() && lookupKey.codePointCount(0, lookupKey.length()) >= 3) { + workerIds = candidateRows + .stream() + .filter(row -> displayNameNormalizer.normalize((String) row[1]) + .startsWith(lookupKey)) + .limit(2) + .map(row -> (UUID) row[0]) + .toList(); + } + if (workerIds.isEmpty()) { + List sentencePrefixCandidates = candidateRows.stream() + .filter(row -> { + String candidateKey = displayNameNormalizer.normalize((String) row[1]); + return candidateKey.codePointCount(0, candidateKey.length()) >= 3 + && lookupKey.startsWith(candidateKey); + }) + .toList(); + int longestCandidateLength = sentencePrefixCandidates.stream() + .map(row -> displayNameNormalizer.normalize((String) row[1])) + .mapToInt(value -> value.codePointCount(0, value.length())) + .max() + .orElse(0); + workerIds = sentencePrefixCandidates.stream() + .filter(row -> { + String candidateKey = displayNameNormalizer.normalize((String) row[1]); + return candidateKey.codePointCount(0, candidateKey.length()) + == longestCandidateLength; + }) + .limit(2) + .map(row -> (UUID) row[0]) + .toList(); + } if (workerIds.isEmpty()) { return List.of(); } diff --git a/src/test/java/com/fowoco/server/airun/AiSlotResolutionIntegrationTest.java b/src/test/java/com/fowoco/server/airun/AiSlotResolutionIntegrationTest.java index 7a32a167..0c6a9862 100644 --- a/src/test/java/com/fowoco/server/airun/AiSlotResolutionIntegrationTest.java +++ b/src/test/java/com/fowoco/server/airun/AiSlotResolutionIntegrationTest.java @@ -148,6 +148,84 @@ void multipleNormalizedCandidatesAreReportedAsAmbiguous() { ); } + @Test + void resolvesUniqueNormalizedPrefixInsideTheCurrentCompany() { + insertCompany(COMPANY_A, "사업장 A"); + insertWorker(WORKER_A, COMPANY_A, "응웬반A", "2026-09-30"); + + AiSlotResolution result = resolutionTransaction.resolve( + COMPANY_A, + "0.3.1", + requirement("응웬반") + ); + + assertThat(result.worker().workerRef()).isEqualTo(WORKER_A); + } + + @Test + void resolvesWorkerWhenAgentReturnsSentenceStartingWithTheDisplayName() { + insertCompany(COMPANY_A, "사업장 A"); + insertWorker(WORKER_A, COMPANY_A, "응웬반A", "2026-09-30"); + + AiSlotResolution result = resolutionTransaction.resolve( + COMPANY_A, + "0.3.1", + requirement("응웬반A가 3년 만료 예정이야.") + ); + + assertThat(result.worker().workerRef()).isEqualTo(WORKER_A); + } + + @Test + void longestDisplayNameWinsWhenAgentReturnsANamePrefixedSentence() { + insertCompany(COMPANY_A, "사업장 A"); + insertWorker(WORKER_A, COMPANY_A, "응웬반", "2026-09-30"); + insertWorker(WORKER_A_DUPLICATE, COMPANY_A, "응웬반A", "2027-09-30"); + + AiSlotResolution result = resolutionTransaction.resolve( + COMPANY_A, + "0.3.1", + requirement("응웬반A가 3년 만료 예정이야.") + ); + + assertThat(result.worker().workerRef()).isEqualTo(WORKER_A_DUPLICATE); + } + + @Test + void multipleNormalizedPrefixCandidatesAreReportedAsAmbiguous() { + insertCompany(COMPANY_A, "사업장 A"); + insertWorker(WORKER_A, COMPANY_A, "응웬반A", "2026-09-30"); + insertWorker(WORKER_A_DUPLICATE, COMPANY_A, "응웬반B", "2027-09-30"); + + assertThatThrownBy(() -> resolutionTransaction.resolve( + COMPANY_A, + "0.3.1", + requirement("응웬반") + )) + .isInstanceOfSatisfying(AiContextResolutionException.class, exception -> + assertThat(exception.failureCode()).isEqualTo( + AiContextResolutionFailureCode.TARGET_AMBIGUOUS + ) + ); + } + + @Test + void shortPrefixIsNotUsedForWorkerResolution() { + insertCompany(COMPANY_A, "사업장 A"); + insertWorker(WORKER_A, COMPANY_A, "응웬반A", "2026-09-30"); + + assertThatThrownBy(() -> resolutionTransaction.resolve( + COMPANY_A, + "0.3.1", + requirement("응") + )) + .isInstanceOfSatisfying(AiContextResolutionException.class, exception -> + assertThat(exception.failureCode()).isEqualTo( + AiContextResolutionFailureCode.TARGET_NOT_FOUND + ) + ); + } + @Test void separatorOnlyTargetIsReportedAsNotFound() { insertCompany(COMPANY_A, "사업장 A");