From 52c3ebd902bf0abd4aa7f64aa369aad9174e92ce Mon Sep 17 00:00:00 2001 From: rktclgh Date: Thu, 12 Mar 2026 13:54:18 +0900 Subject: [PATCH 1/8] =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EA=B4=80?= =?UTF-8?q?=EB=A0=A8=20=EC=BD=94=EB=93=9C=EB=A6=AC=EB=B7=B0=20=EB=B0=98?= =?UTF-8?q?=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/auth/service/AuthService.kt | 14 ++++- .../global/security/LoginSessionStore.kt | 58 +++++++++++++++++-- .../domain/auth/service/AuthServiceTests.kt | 36 +++++++++--- 3 files changed, 91 insertions(+), 17 deletions(-) diff --git a/src/main/kotlin/com/cw/vlainter/domain/auth/service/AuthService.kt b/src/main/kotlin/com/cw/vlainter/domain/auth/service/AuthService.kt index d6246df..00cb3dc 100644 --- a/src/main/kotlin/com/cw/vlainter/domain/auth/service/AuthService.kt +++ b/src/main/kotlin/com/cw/vlainter/domain/auth/service/AuthService.kt @@ -8,6 +8,7 @@ import com.cw.vlainter.domain.user.entity.UserStatus import com.cw.vlainter.domain.user.repository.UserRepository import com.cw.vlainter.global.security.JwtTokenProvider import com.cw.vlainter.global.security.LoginSessionStore +import com.cw.vlainter.global.security.RefreshTokenValidationResult import com.cw.vlainter.global.security.RedirectUriValidator import org.slf4j.LoggerFactory import org.springframework.dao.DataIntegrityViolationException @@ -15,6 +16,7 @@ import org.springframework.http.HttpStatus import org.springframework.security.crypto.password.PasswordEncoder import org.springframework.stereotype.Service import org.springframework.web.server.ResponseStatusException +import java.time.Duration import java.util.UUID /** @@ -37,6 +39,7 @@ class AuthService( ) { private val logger = LoggerFactory.getLogger(AuthService::class.java) private val passwordComplexityRegex = Regex("^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)(?=.*[^A-Za-z\\d]).{8,100}$") + private val refreshReuseGraceWindow = Duration.ofSeconds(5) /** * 로그인 처리. @@ -173,9 +176,14 @@ class AuthService( val userId = jwtTokenProvider.extractUserIdFromRefreshToken(refreshToken) val sessionId = jwtTokenProvider.extractSessionIdFromRefreshToken(refreshToken) - val validSession = loginSessionStore.validateRefreshToken(sessionId, userId, refreshToken) - if (!validSession) { - throw refreshUnauthorizedException() + when (loginSessionStore.inspectRefreshToken(sessionId, userId, refreshToken, refreshReuseGraceWindow)) { + RefreshTokenValidationResult.CURRENT_TOKEN -> Unit + RefreshTokenValidationResult.PREVIOUS_TOKEN_WITHIN_GRACE -> throw refreshUnauthorizedException() + RefreshTokenValidationResult.HASH_MISMATCH -> { + loginSessionStore.delete(sessionId) + throw refreshUnauthorizedException() + } + RefreshTokenValidationResult.SESSION_NOT_FOUND -> throw refreshUnauthorizedException() } val user = userRepository.findById(userId).orElseThrow { unauthorizedException() } diff --git a/src/main/kotlin/com/cw/vlainter/global/security/LoginSessionStore.kt b/src/main/kotlin/com/cw/vlainter/global/security/LoginSessionStore.kt index 1c99902..21c8220 100644 --- a/src/main/kotlin/com/cw/vlainter/global/security/LoginSessionStore.kt +++ b/src/main/kotlin/com/cw/vlainter/global/security/LoginSessionStore.kt @@ -35,6 +35,8 @@ class LoginSessionStore( mapOf( "userId" to userId.toString(), "refreshHash" to hash(refreshToken), + "previousRefreshHash" to "", + "rotatedAtEpochMs" to "0", "status" to "ACTIVE" ) ) @@ -56,19 +58,56 @@ class LoginSessionStore( * Refresh 요청 시 세션/사용자/토큰 해시 일치 여부를 검증한다. */ fun validateRefreshToken(sessionId: String, userId: Long, refreshToken: String): Boolean { + return inspectRefreshToken(sessionId, userId, refreshToken, Duration.ZERO) == RefreshTokenValidationResult.CURRENT_TOKEN + } + + fun inspectRefreshToken( + sessionId: String, + userId: Long, + refreshToken: String, + graceWindow: Duration + ): RefreshTokenValidationResult { val values = redisTemplate.opsForHash().entries(key(sessionId)) - if (values.isEmpty()) return false - if (values["status"] != "ACTIVE") return false - if (values["userId"]?.toLongOrNull() != userId) return false - return values["refreshHash"] == hash(refreshToken) + if (values.isEmpty()) return RefreshTokenValidationResult.SESSION_NOT_FOUND + if (values["status"] != "ACTIVE") return RefreshTokenValidationResult.SESSION_NOT_FOUND + if (values["userId"]?.toLongOrNull() != userId) return RefreshTokenValidationResult.SESSION_NOT_FOUND + + val tokenHash = hash(refreshToken) + if (values["refreshHash"] == tokenHash) { + return RefreshTokenValidationResult.CURRENT_TOKEN + } + + val previousHash = values["previousRefreshHash"] + if (previousHash != tokenHash) { + return RefreshTokenValidationResult.HASH_MISMATCH + } + + val rotatedAtEpochMs = values["rotatedAtEpochMs"]?.toLongOrNull() + ?: return RefreshTokenValidationResult.HASH_MISMATCH + val elapsedMillis = System.currentTimeMillis() - rotatedAtEpochMs + return if (elapsedMillis in 0..graceWindow.toMillis()) { + RefreshTokenValidationResult.PREVIOUS_TOKEN_WITHIN_GRACE + } else { + RefreshTokenValidationResult.HASH_MISMATCH + } } /** * Refresh Token 회전 시 새 해시로 교체하고 TTL을 갱신한다. */ fun rotateRefreshToken(sessionId: String, refreshToken: String) { - redisTemplate.opsForHash().put(key(sessionId), "refreshHash", hash(refreshToken)) - redisTemplate.expire(key(sessionId), Duration.ofSeconds(jwtProperties.refreshTokenExpSeconds)) + val sessionKey = key(sessionId) + val ops = redisTemplate.opsForHash() + val currentRefreshHash = ops.get(sessionKey, "refreshHash").orEmpty() + ops.putAll( + sessionKey, + mapOf( + "refreshHash" to hash(refreshToken), + "previousRefreshHash" to currentRefreshHash, + "rotatedAtEpochMs" to System.currentTimeMillis().toString() + ) + ) + redisTemplate.expire(sessionKey, Duration.ofSeconds(jwtProperties.refreshTokenExpSeconds)) } /** @@ -110,3 +149,10 @@ class LoginSessionStore( return digest.joinToString("") { "%02x".format(it) } } } + +enum class RefreshTokenValidationResult { + CURRENT_TOKEN, + PREVIOUS_TOKEN_WITHIN_GRACE, + HASH_MISMATCH, + SESSION_NOT_FOUND +} diff --git a/src/test/kotlin/com/cw/vlainter/domain/auth/service/AuthServiceTests.kt b/src/test/kotlin/com/cw/vlainter/domain/auth/service/AuthServiceTests.kt index a46dd9a..7038077 100644 --- a/src/test/kotlin/com/cw/vlainter/domain/auth/service/AuthServiceTests.kt +++ b/src/test/kotlin/com/cw/vlainter/domain/auth/service/AuthServiceTests.kt @@ -7,6 +7,7 @@ import com.cw.vlainter.domain.user.entity.UserStatus import com.cw.vlainter.domain.user.repository.UserRepository import com.cw.vlainter.global.security.JwtTokenProvider import com.cw.vlainter.global.security.LoginSessionStore +import com.cw.vlainter.global.security.RefreshTokenValidationResult import com.cw.vlainter.global.security.RedirectUriValidator import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.Test @@ -20,6 +21,7 @@ import org.mockito.junit.jupiter.MockitoExtension import org.springframework.http.HttpStatus import org.springframework.security.crypto.password.PasswordEncoder import org.springframework.web.server.ResponseStatusException +import java.time.Duration import java.util.Optional @ExtendWith(MockitoExtension::class) @@ -174,29 +176,45 @@ class AuthServiceTests { } @Test - fun `refresh does not delete session when token does not match stored session`() { + fun `refresh does not delete session when previous token is retried within grace window`() { val refreshToken = "refresh-token" given(jwtTokenProvider.isValidRefreshToken(refreshToken)).willReturn(true) given(jwtTokenProvider.extractUserIdFromRefreshToken(refreshToken)).willReturn(1L) given(jwtTokenProvider.extractSessionIdFromRefreshToken(refreshToken)).willReturn("sid-1") - given(loginSessionStore.validateRefreshToken("sid-1", 1L, refreshToken)).willReturn(false) + given(loginSessionStore.inspectRefreshToken("sid-1", 1L, refreshToken, Duration.ofSeconds(5))) + .willReturn(RefreshTokenValidationResult.PREVIOUS_TOKEN_WITHIN_GRACE) assertUnauthorized { authService().refresh(refreshToken) } - then(loginSessionStore).should().validateRefreshToken("sid-1", 1L, refreshToken) + then(loginSessionStore).should().inspectRefreshToken("sid-1", 1L, refreshToken, Duration.ofSeconds(5)) then(loginSessionStore).shouldHaveNoMoreInteractions() } + @Test + fun `refresh deletes session when token hash mismatches outside grace window`() { + val refreshToken = "refresh-token" + given(jwtTokenProvider.isValidRefreshToken(refreshToken)).willReturn(true) + given(jwtTokenProvider.extractUserIdFromRefreshToken(refreshToken)).willReturn(1L) + given(jwtTokenProvider.extractSessionIdFromRefreshToken(refreshToken)).willReturn("sid-1") + given(loginSessionStore.inspectRefreshToken("sid-1", 1L, refreshToken, Duration.ofSeconds(5))) + .willReturn(RefreshTokenValidationResult.HASH_MISMATCH) + + assertUnauthorized { authService().refresh(refreshToken) } + then(loginSessionStore).should().inspectRefreshToken("sid-1", 1L, refreshToken, Duration.ofSeconds(5)) + then(loginSessionStore).should().delete("sid-1") + } + @Test fun `refresh fails when user is missing`() { val refreshToken = "refresh-token" given(jwtTokenProvider.isValidRefreshToken(refreshToken)).willReturn(true) given(jwtTokenProvider.extractUserIdFromRefreshToken(refreshToken)).willReturn(1L) given(jwtTokenProvider.extractSessionIdFromRefreshToken(refreshToken)).willReturn("sid-1") - given(loginSessionStore.validateRefreshToken("sid-1", 1L, refreshToken)).willReturn(true) + given(loginSessionStore.inspectRefreshToken("sid-1", 1L, refreshToken, Duration.ofSeconds(5))) + .willReturn(RefreshTokenValidationResult.CURRENT_TOKEN) given(userRepository.findById(1L)).willReturn(Optional.empty()) assertUnauthorized { authService().refresh(refreshToken) } - then(loginSessionStore).should().validateRefreshToken("sid-1", 1L, refreshToken) + then(loginSessionStore).should().inspectRefreshToken("sid-1", 1L, refreshToken, Duration.ofSeconds(5)) then(loginSessionStore).shouldHaveNoMoreInteractions() } @@ -208,11 +226,12 @@ class AuthServiceTests { given(jwtTokenProvider.isValidRefreshToken(refreshToken)).willReturn(true) given(jwtTokenProvider.extractUserIdFromRefreshToken(refreshToken)).willReturn(blockedUser.id) given(jwtTokenProvider.extractSessionIdFromRefreshToken(refreshToken)).willReturn("sid-1") - given(loginSessionStore.validateRefreshToken("sid-1", blockedUser.id, refreshToken)).willReturn(true) + given(loginSessionStore.inspectRefreshToken("sid-1", blockedUser.id, refreshToken, Duration.ofSeconds(5))) + .willReturn(RefreshTokenValidationResult.CURRENT_TOKEN) given(userRepository.findById(blockedUser.id)).willReturn(Optional.of(blockedUser)) assertForbidden { authService().refresh(refreshToken) } - then(loginSessionStore).should().validateRefreshToken("sid-1", blockedUser.id, refreshToken) + then(loginSessionStore).should().inspectRefreshToken("sid-1", blockedUser.id, refreshToken, Duration.ofSeconds(5)) then(loginSessionStore).shouldHaveNoMoreInteractions() } @@ -224,7 +243,8 @@ class AuthServiceTests { given(jwtTokenProvider.isValidRefreshToken(refreshToken)).willReturn(true) given(jwtTokenProvider.extractUserIdFromRefreshToken(refreshToken)).willReturn(user.id) given(jwtTokenProvider.extractSessionIdFromRefreshToken(refreshToken)).willReturn("sid-1") - given(loginSessionStore.validateRefreshToken("sid-1", user.id, refreshToken)).willReturn(true) + given(loginSessionStore.inspectRefreshToken("sid-1", user.id, refreshToken, Duration.ofSeconds(5))) + .willReturn(RefreshTokenValidationResult.CURRENT_TOKEN) given(userRepository.findById(user.id)).willReturn(Optional.of(user)) given(jwtTokenProvider.createAccessToken(user.id, user.email, "sid-1", user.role)).willReturn("new-access-token") given(jwtTokenProvider.createRefreshToken(user.id, "sid-1")).willReturn("new-refresh-token") From c431af6d4f624c611105bb6a675d2604781e2a70 Mon Sep 17 00:00:00 2001 From: rktclgh Date: Thu, 12 Mar 2026 14:26:52 +0900 Subject: [PATCH 2/8] =?UTF-8?q?STAR=20=EB=B0=A9=ED=96=A5=EC=9D=98=20?= =?UTF-8?q?=EB=A9=B4=EC=A0=91=20=EB=8B=B5=EB=B3=80=20=EC=9A=94=EA=B5=AC=20?= =?UTF-8?q?=EB=94=94=ED=85=8C=EC=9D=BC=20=EB=B3=B4=EA=B0=95(=EC=84=9C?= =?UTF-8?q?=EB=A5=98=EB=A9=B4=EC=A0=91)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../interview/ai/InterviewAiOrchestrator.kt | 16 +- .../service/InterviewEvaluationService.kt | 202 ++++++++++++++++++ .../ai/InterviewAiOrchestratorTests.kt | 106 +++++++++ .../InterviewEvaluationServiceTests.kt | 188 ++++++++++++++++ 4 files changed, 508 insertions(+), 4 deletions(-) create mode 100644 src/test/kotlin/com/cw/vlainter/domain/interview/ai/InterviewAiOrchestratorTests.kt create mode 100644 src/test/kotlin/com/cw/vlainter/domain/interview/service/InterviewEvaluationServiceTests.kt diff --git a/src/main/kotlin/com/cw/vlainter/domain/interview/ai/InterviewAiOrchestrator.kt b/src/main/kotlin/com/cw/vlainter/domain/interview/ai/InterviewAiOrchestrator.kt index be58684..7a03546 100644 --- a/src/main/kotlin/com/cw/vlainter/domain/interview/ai/InterviewAiOrchestrator.kt +++ b/src/main/kotlin/com/cw/vlainter/domain/interview/ai/InterviewAiOrchestrator.kt @@ -232,7 +232,7 @@ class InterviewAiOrchestrator( [질문] $questionText - [참고 답안] + [STAR형 참고 답안] ${referenceAnswer?.takeIf { it.isNotBlank() } ?: "(참고 답안 없음)"} [근거 포인트] @@ -256,8 +256,14 @@ class InterviewAiOrchestrator( 규칙: - 반드시 JSON 객체만 반환 - - 답변이 문서 맥락과 어긋나면 낮은 점수를 부여 - - 사실 기반 근거와 전달력을 함께 평가 + - 평가는 반드시 사용자 답변 자체를 중심으로 수행 + - 참고 답안과 표현, 문장 순서, 단어 선택이 다르다는 이유만으로 감점하지 말 것 + - 참고 답안은 정답 매칭용이 아니라, 빠진 관점과 STAR 보강 포인트를 찾는 보조 자료로만 활용할 것 + - coverage는 질문 의도 적합성과 STAR 구조 완성도를 함께 평가한 점수로 산정 + - accuracy는 기술적 설명의 타당성, 논리, 근거, 성과 설명의 설득력을 중심으로 산정 + - communication은 답변 구조, 전달력, 면접 답변다운 정리 정도를 평가 + - 답변이 문서 맥락과 명확히 어긋나거나 주장 근거가 부족하면 낮은 점수를 부여 + - bestPractice에는 빠진 STAR 요소(Situation, Task, Action, Result)와 보강할 근거를 구체적으로 적을 것 """.trimIndent() return runCatching { @@ -416,7 +422,7 @@ class InterviewAiOrchestrator( { "questionText": "면접 질문", "questionType": "RESUME_EXPERIENCE | PORTFOLIO_PROJECT | INTRODUCE_MOTIVATION 등", - "referenceAnswer": "이 질문에 대한 이상적인 모범답안(면접 답변 형식, 4~8문장)", + "referenceAnswer": "이 질문에 대한 STAR형 모범답안(Situation, Task, Action, Result가 드러나는 면접 답변 형식, 4~8문장)", "evidence": ["질문의 근거가 된 문서 포인트", "..."] } ] @@ -426,6 +432,8 @@ class InterviewAiOrchestrator( - 총 ${questionCount}개 질문 생성 - 질문은 구체적이어야 하며 문서의 내용과 직접 연결되어야 함 - 단순 나열형 질문 대신 이유, 역할, 의사결정, 결과를 묻는 면접형 질문 우선 + - referenceAnswer는 질문의 의도에 맞는 STAR형 예시 답변이어야 하며, 상황/과제/행동/결과가 자연스럽게 드러나야 함 + - referenceAnswer는 사용자의 실제 경험을 단정하지 말고, 문서 맥락을 바탕으로 한 설득력 있는 예시 답변 형태로 작성할 것 - OCR 오류처럼 보이는 깨진 문자열, 무의미한 영문 대문자 나열, 문맥이 없는 잡음은 근거로 사용하지 말 것 - 말이 안 되는 발췌는 건너뛰고, 의미가 분명한 다른 발췌를 선택할 것 - 질문에 문서 발췌를 그대로 길게 인용하지 말고 자연스러운 면접 문장으로 바꿀 것 diff --git a/src/main/kotlin/com/cw/vlainter/domain/interview/service/InterviewEvaluationService.kt b/src/main/kotlin/com/cw/vlainter/domain/interview/service/InterviewEvaluationService.kt index 936015f..d36e0af 100644 --- a/src/main/kotlin/com/cw/vlainter/domain/interview/service/InterviewEvaluationService.kt +++ b/src/main/kotlin/com/cw/vlainter/domain/interview/service/InterviewEvaluationService.kt @@ -38,6 +38,12 @@ class InterviewEvaluationService( companion object { const val INTRO_CATEGORY = INTERVIEW_INTRO_CATEGORY const val INTRO_QUESTION_TEXT = INTERVIEW_INTRO_QUESTION_TEXT + val NUMBER_REGEX = Regex("""\d""") + val DOCUMENT_CONTEXT_SIGNALS = setOf("상황", "배경", "당시", "문제", "이슈", "프로젝트", "서비스", "요구사항") + val DOCUMENT_TASK_SIGNALS = setOf("역할", "담당", "책임", "목표", "과제", "요구", "목적") + val DOCUMENT_ACTION_SIGNALS = setOf("제가", "저는", "구현", "설계", "개선", "도입", "분석", "해결", "최적화", "리팩토링", "협업", "검증", "작성", "적용", "정리") + val DOCUMENT_RESULT_SIGNALS = setOf("결과", "성과", "개선", "향상", "감소", "증가", "단축", "완료", "달성", "안정화", "배포", "출시") + val DOCUMENT_REASONING_SIGNALS = setOf("이유", "근거", "왜냐", "그래서", "이를 위해", "때문에", "검증", "비교", "판단", "트레이드오프") } private val logger = LoggerFactory.getLogger(javaClass) @@ -183,6 +189,17 @@ class InterviewEvaluationService( ) } + if (documentQuestion != null) { + return buildDocumentHeuristicEvaluation( + questionText = documentQuestion.questionText, + referenceAnswer = documentQuestion.referenceAnswer, + evidence = parseJsonArray(documentQuestion.evidenceJson), + answer = answer, + userGeneratedQuestion = userGeneratedQuestion, + resolvedAnswer = resolvedAnswer + ) + } + val canonical = resolvedAnswer.modelAnswer.orEmpty() val score = if (isLowEffortAnswer(answer)) { BigDecimal.ZERO.setScale(2) @@ -215,6 +232,174 @@ class InterviewEvaluationService( ) } + private fun buildDocumentHeuristicEvaluation( + questionText: String, + referenceAnswer: String?, + evidence: List, + answer: String, + userGeneratedQuestion: Boolean, + resolvedAnswer: ResolvedAnswerContent + ): EvaluationResult { + if (isLowEffortAnswer(answer)) { + return EvaluationResult( + score = BigDecimal.ZERO.setScale(2), + feedback = "질문 의도에 맞는 핵심 경험과 본인의 행동이 거의 드러나지 않았습니다. 문서에 적은 경험을 기준으로 다시 답변해 보세요.", + bestPractice = if (userGeneratedQuestion) "" else "질문의 의도에 맞춰 당시 상황, 맡은 역할, 실제 행동, 결과를 STAR 순서로 다시 정리해 보세요.", + modelAnswer = resolvedAnswer.modelAnswer, + rubricScoresJson = """{"coverage":0,"accuracy":0,"communication":0}""", + evidenceJson = objectMapper.writeValueAsString(listOf("low_effort_answer")), + model = "heuristic", + modelVersion = "document-v2" + ) + } + + val answerTokens = tokenize(answer) + val questionTokens = tokenize(questionText) + val evidenceTokens = evidence.flatMap { tokenize(it) }.toSet() + val softBenchmarkTokens = extractDocumentSoftBenchmarkTokens(referenceAnswer, evidence) + + val intentHits = answerTokens.intersect(questionTokens).size + val evidenceHits = answerTokens.intersect(evidenceTokens).size + val benchmarkHits = answerTokens.intersect(softBenchmarkTokens.toSet()).size + val contextSignals = countSignalMatches(answer, DOCUMENT_CONTEXT_SIGNALS) + val taskSignals = countSignalMatches(answer, DOCUMENT_TASK_SIGNALS) + val actionSignals = countSignalMatches(answer, DOCUMENT_ACTION_SIGNALS) + val resultSignals = countSignalMatches(answer, DOCUMENT_RESULT_SIGNALS) + val hasNumber = NUMBER_REGEX.containsMatchIn(answer) + val hasReasoning = DOCUMENT_REASONING_SIGNALS.any { answer.contains(it) } + val sentenceCount = answer.split(Regex("[.!?。]|\\n")) + .map { it.trim() } + .count { it.isNotBlank() } + + val intentScore = ( + 25 + + min(35, intentHits * 12) + + min(20, evidenceHits * 6) + + if (answer.length >= 80) 10 else 0 + + if (actionSignals > 0) 10 else 0 + ).coerceIn(0, 100) + + val starScore = ( + min(25, contextSignals * 12) + + min(20, taskSignals * 10) + + min(30, actionSignals * 12) + + min(25, resultSignals * 12 + if (hasNumber) 8 else 0) + ).coerceIn(0, 100) + + val coverageScore = (intentScore * 0.6 + starScore * 0.4).toInt().coerceIn(0, 100) + + val accuracyScore = ( + 20 + + min(25, evidenceHits * 7) + + min(15, benchmarkHits * 4) + + if (hasNumber) 15 else 0 + + if (hasReasoning) 15 else 0 + + if (answer.length >= 120) 10 else 0 + ).coerceIn(0, 100) + + val communicationScore = ( + 25 + + min(20, sentenceCount * 6) + + if (answer.length in 80..600) 20 else 8 + + if (answer.contains("\n") || sentenceCount >= 3) 15 else 5 + + if (answer.trim().endsWith(".") || answer.trim().endsWith("다") || answer.trim().endsWith("요")) 10 else 0 + ).coerceIn(0, 100) + + val totalScore = BigDecimal( + ( + coverageScore * 0.45 + + accuracyScore * 0.35 + + communicationScore * 0.20 + ).coerceIn(0.0, 100.0) + ).setScale(2, RoundingMode.HALF_UP) + + val missingStarParts = buildList { + if (contextSignals == 0) add("상황") + if (taskSignals == 0) add("과제/역할") + if (actionSignals == 0) add("행동") + if (resultSignals == 0 && !hasNumber) add("결과") + } + val missingKeywords = softBenchmarkTokens + .filterNot { it in answerTokens } + .distinct() + .take(3) + val evidenceNotes = buildList { + add("질문 핵심 키워드 반영 ${intentHits}건") + add("문서 근거 포인트 반영 ${evidenceHits}건") + if (missingStarParts.isNotEmpty()) { + add("STAR 누락 요소: ${missingStarParts.joinToString(", ")}") + } + if (!hasNumber) { + add("수치/결과 근거 부족") + } + } + + val feedback = buildString { + append( + when { + coverageScore >= 80 -> "질문 의도에는 대체로 잘 맞게 답했습니다." + coverageScore >= 60 -> "질문 의도와 관련된 경험은 보이지만, 핵심 초점이 조금 더 선명해야 합니다." + else -> "질문이 묻는 핵심 경험과 답변 초점이 충분히 맞물리지 않았습니다." + } + ) + append(' ') + append( + when { + starScore >= 75 -> "상황, 역할, 행동, 결과 흐름도 비교적 자연스럽게 드러납니다." + starScore >= 50 -> "STAR 흐름은 일부 보이지만, 빠진 요소가 있어 설득력이 다소 약합니다." + else -> "STAR 구조가 약해 면접 답변으로 들었을 때 경험의 맥락과 본인 기여도가 충분히 드러나지 않습니다." + } + ) + append(' ') + append( + when { + accuracyScore >= 75 -> "기술적 설명과 근거도 비교적 설득력 있습니다." + accuracyScore >= 55 -> "기술적 설명은 가능하지만, 선택 이유나 성과 근거를 더 보강할 필요가 있습니다." + else -> "기술 선택 이유, 검증 근거, 성과 설명이 부족해 답변의 신뢰도가 떨어집니다." + } + ) + } + + val bestPractice = if (userGeneratedQuestion) { + "" + } else { + buildString { + if (missingStarParts.isNotEmpty()) { + append("다음 답변에서는 ${missingStarParts.joinToString(", ")} 요소를 더 분명히 넣어 STAR 흐름을 완성해 보세요. ") + } else { + append("현재 답변 흐름은 나쁘지 않으니, 행동과 결과를 더 압축적으로 연결해 전달력을 높여 보세요. ") + } + if (missingKeywords.isNotEmpty()) { + append("${missingKeywords.joinToString(", ")} 같은 문서 맥락의 구체 요소를 넣으면 질문과의 연결성이 더 선명해집니다. ") + } + if (!hasNumber) { + append("가능하면 수치, 사용자 영향, 성능 변화, 완료 결과처럼 확인 가능한 근거를 함께 제시하세요.") + } else if (!hasReasoning) { + append("결과를 말할 때는 왜 그런 선택을 했는지 판단 근거도 같이 설명해 주세요.") + } else { + append("특히 본인이 직접 판단하고 실행한 부분을 더 짧고 선명하게 강조하면 좋습니다.") + } + }.trim() + } + + return EvaluationResult( + score = totalScore, + feedback = feedback, + bestPractice = bestPractice, + modelAnswer = resolvedAnswer.modelAnswer, + rubricScoresJson = objectMapper.writeValueAsString( + mapOf( + "coverage" to coverageScore, + "accuracy" to accuracyScore, + "communication" to communicationScore + ) + ), + evidenceJson = objectMapper.writeValueAsString(evidenceNotes), + model = "heuristic", + modelVersion = "document-v2" + ) + } + private fun tokenize(text: String): Set { return text.lowercase() .split(Regex("[^a-zA-Z0-9가-힣]+")) @@ -222,6 +407,23 @@ class InterviewEvaluationService( .toSet() } + private fun countSignalMatches(answer: String, signals: Set): Int { + return signals.count { answer.contains(it, ignoreCase = true) } + } + + private fun extractDocumentSoftBenchmarkTokens(referenceAnswer: String?, evidence: List): List { + val stopwords = setOf( + "질문", "답변", "경험", "프로젝트", "서비스", "사용자", "당시", "이후", "정도", + "통해", "관련", "기반", "설명", "대한", "문서", "면접", "저는", "제가", "그리고" + ) + return buildList { + addAll(tokenize(referenceAnswer.orEmpty())) + evidence.forEach { addAll(tokenize(it)) } + }.filterNot { it in stopwords } + .distinct() + .take(8) + } + private fun isLowEffortAnswer(answer: String): Boolean { val normalized = answer.trim().lowercase() if (normalized.length <= 6) return true diff --git a/src/test/kotlin/com/cw/vlainter/domain/interview/ai/InterviewAiOrchestratorTests.kt b/src/test/kotlin/com/cw/vlainter/domain/interview/ai/InterviewAiOrchestratorTests.kt new file mode 100644 index 0000000..42e7e9b --- /dev/null +++ b/src/test/kotlin/com/cw/vlainter/domain/interview/ai/InterviewAiOrchestratorTests.kt @@ -0,0 +1,106 @@ +package com.cw.vlainter.domain.interview.ai + +import com.cw.vlainter.global.config.properties.AiProperties +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.mockito.ArgumentMatchers.anyString +import org.mockito.ArgumentMatchers.nullable +import org.mockito.BDDMockito.given +import org.mockito.Mock +import org.mockito.junit.jupiter.MockitoExtension + +@ExtendWith(MockitoExtension::class) +class InterviewAiOrchestratorTests { + + @Mock + private lateinit var llmProviderRouter: LlmProviderRouter + + private val objectMapper = jacksonObjectMapper() + private lateinit var orchestrator: InterviewAiOrchestrator + + @BeforeEach + fun setUp() { + orchestrator = InterviewAiOrchestrator( + aiProperties = AiProperties(), + llmProviderRouter = llmProviderRouter, + objectMapper = objectMapper + ) + } + + @Test + fun `문서 답변 평가 프롬프트는 참고 답안을 보조 자료로만 사용하도록 안내한다`() { + var capturedPrompt = "" + given(llmProviderRouter.generateJson(anyString(), nullable(Double::class.java))).willAnswer { invocation -> + capturedPrompt = invocation.getArgument(0) + LlmGenerationResult( + model = "gemini", + modelVersion = "v1", + text = """ + { + "score": 82, + "feedback": "질문 의도에 맞게 답했습니다.", + "bestPractice": "결과를 더 또렷하게 말해 보세요.", + "rubric": { + "coverage": 84, + "accuracy": 80, + "communication": 82 + }, + "evidence": ["질문 의도 적합", "STAR 일부 충족"] + } + """.trimIndent() + ) + } + + orchestrator.evaluateDocumentAnswer( + questionText = "포트폴리오 프로젝트에서 성능 병목을 어떻게 발견하고 개선하셨나요?", + referenceAnswer = "문제 상황과 담당 역할을 설명한 뒤, 원인 분석과 개선 결과를 STAR 구조로 답합니다.", + evidence = listOf("포트폴리오에 API 성능 개선 경험과 응답 속도 개선 내용이 기재되어 있음"), + userAnswer = "당시 서비스 응답 지연이 심해 직접 병목을 추적했고 캐시 전략을 조정해 성능을 개선했습니다." + ) + + assertThat(capturedPrompt).contains("[STAR형 참고 답안]") + assertThat(capturedPrompt).contains("평가는 반드시 사용자 답변 자체를 중심으로 수행") + assertThat(capturedPrompt).contains("표현, 문장 순서, 단어 선택이 다르다는 이유만으로 감점하지 말 것") + assertThat(capturedPrompt).contains("빠진 STAR 요소") + } + + @Test + fun `문서 질문 생성 프롬프트는 STAR형 referenceAnswer를 요구한다`() { + var capturedPrompt = "" + given(llmProviderRouter.generateJson(anyString(), nullable(Double::class.java))).willAnswer { invocation -> + capturedPrompt = invocation.getArgument(0) + LlmGenerationResult( + model = "gemini", + modelVersion = "v1", + text = """ + { + "questions": [ + { + "questionText": "프로젝트 성능 개선 과정에서 어떤 병목을 발견했고 어떻게 해결하셨나요?", + "questionType": "PORTFOLIO_PROJECT", + "referenceAnswer": "프로젝트 성능 병목을 발견한 상황을 먼저 설명합니다. 당시 제가 개선 역할을 맡아 병목 원인을 분석했습니다. 로그와 프로파일링으로 직렬화 비용 문제를 확인했습니다. 이후 캐시 전략을 조정하고 API 구조를 정리해 해결했습니다. 그 결과 초기 로딩 속도와 응답 안정성이 개선되었습니다.", + "evidence": [ + "포트폴리오에 성능 개선과 API 최적화 경험이 기재되어 있음" + ] + } + ] + } + """.trimIndent() + ) + } + + val generated = orchestrator.generateDocumentQuestions( + fileTypeLabel = "PORTFOLIO", + difficulty = null, + questionCount = 1, + contextSnippets = listOf("대시보드 초기 로딩 속도를 개선하기 위해 API 구조와 캐시 전략을 조정한 경험이 있다.") + ) + + assertThat(generated).hasSize(1) + assertThat(capturedPrompt).contains("STAR형 모범답안") + assertThat(capturedPrompt).contains("상황/과제/행동/결과가 자연스럽게 드러나야 함") + } +} diff --git a/src/test/kotlin/com/cw/vlainter/domain/interview/service/InterviewEvaluationServiceTests.kt b/src/test/kotlin/com/cw/vlainter/domain/interview/service/InterviewEvaluationServiceTests.kt new file mode 100644 index 0000000..8319951 --- /dev/null +++ b/src/test/kotlin/com/cw/vlainter/domain/interview/service/InterviewEvaluationServiceTests.kt @@ -0,0 +1,188 @@ +package com.cw.vlainter.domain.interview.service + +import com.cw.vlainter.domain.interview.ai.InterviewAiOrchestrator +import com.cw.vlainter.domain.interview.entity.DocumentQuestion +import com.cw.vlainter.domain.interview.entity.InterviewMode +import com.cw.vlainter.domain.interview.entity.InterviewSession +import com.cw.vlainter.domain.interview.entity.InterviewStatus +import com.cw.vlainter.domain.interview.entity.InterviewTurn +import com.cw.vlainter.domain.interview.entity.InterviewTurnEvaluation +import com.cw.vlainter.domain.interview.entity.RevealPolicy +import com.cw.vlainter.domain.interview.entity.TurnSourceTag +import com.cw.vlainter.domain.interview.repository.InterviewTurnEvaluationRepository +import com.cw.vlainter.domain.interview.repository.InterviewTurnRepository +import com.cw.vlainter.domain.interview.repository.UserQuestionAttemptRepository +import com.cw.vlainter.domain.user.entity.User +import com.cw.vlainter.domain.user.entity.UserRole +import com.cw.vlainter.domain.user.entity.UserStatus +import com.cw.vlainter.domain.user.service.UserGeminiApiKeyService +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.mockito.BDDMockito.given +import org.mockito.Mock +import org.mockito.junit.jupiter.MockitoExtension +import org.springframework.beans.factory.ObjectProvider +import java.math.BigDecimal +import java.time.OffsetDateTime + +@ExtendWith(MockitoExtension::class) +class InterviewEvaluationServiceTests { + + @Mock + private lateinit var interviewAiOrchestrator: InterviewAiOrchestrator + + @Mock + private lateinit var interviewTurnRepository: InterviewTurnRepository + + @Mock + private lateinit var interviewTurnEvaluationRepository: InterviewTurnEvaluationRepository + + @Mock + private lateinit var userQuestionAttemptRepository: UserQuestionAttemptRepository + + @Mock + private lateinit var userGeminiApiKeyService: UserGeminiApiKeyService + + @Mock + private lateinit var selfProvider: ObjectProvider + + private val objectMapper = jacksonObjectMapper() + private lateinit var service: InterviewEvaluationService + + @BeforeEach + fun setUp() { + service = InterviewEvaluationService( + interviewAiOrchestrator = interviewAiOrchestrator, + interviewTurnRepository = interviewTurnRepository, + interviewTurnEvaluationRepository = interviewTurnEvaluationRepository, + userQuestionAttemptRepository = userQuestionAttemptRepository, + userGeminiApiKeyService = userGeminiApiKeyService, + objectMapper = objectMapper, + selfProvider = selfProvider + ) + } + + @Test + fun `문서 면접 fallback 평가는 참고 답안 유사도보다 질문 의도와 STAR 흐름을 우선한다`() { + val turn = createDocumentTurn( + answer = "당시 대시보드 초기 로딩이 느려 사용자 이탈이 생기고 있었습니다. 저는 성능 개선을 맡아 응답 시간을 직접 측정하고 병목 구간을 분석했습니다. 이후 직렬화 비용이 큰 응답 구조를 줄이고 캐시 전략을 다시 설계했습니다. 그 결과 체감 로딩 시간이 짧아졌고 관련 문의도 줄었습니다.", + referenceAnswer = "APM과 인덱스 재설계를 통해 p95 지연 시간을 45퍼센트 줄였다고 STAR 구조로 설명합니다." + ) + + given( + interviewAiOrchestrator.evaluateDocumentAnswer( + questionText = turn.documentQuestion!!.questionText, + referenceAnswer = turn.documentQuestion!!.referenceAnswer, + evidence = objectMapper.readValue(turn.documentQuestion!!.evidenceJson, Array::class.java).toList(), + userAnswer = turn.userAnswer!! + ) + ).willReturn(null) + + val result = invokeBuildEvaluation(turn, turn.userAnswer!!) + + assertThat(result.score.toInt()).isGreaterThanOrEqualTo(60) + assertThat(result.feedback).contains("질문 의도") + assertThat(result.bestPractice).isNotBlank() + assertThat(result.model).isEqualTo("heuristic") + } + + @Test + fun `문서 면접 fallback 평가는 질문에서 벗어난 답변에 낮은 점수를 준다`() { + val turn = createDocumentTurn( + answer = "팀원들과 협업을 열심히 했고 맡은 일도 성실히 수행했습니다.", + referenceAnswer = "문제 상황과 개선 행동, 결과를 STAR 구조로 설명합니다." + ) + + given( + interviewAiOrchestrator.evaluateDocumentAnswer( + questionText = turn.documentQuestion!!.questionText, + referenceAnswer = turn.documentQuestion!!.referenceAnswer, + evidence = objectMapper.readValue(turn.documentQuestion!!.evidenceJson, Array::class.java).toList(), + userAnswer = turn.userAnswer!! + ) + ).willReturn(null) + + val result = invokeBuildEvaluation(turn, turn.userAnswer!!) + + assertThat(result.score.toInt()).isLessThan(55) + assertThat(result.feedback).contains("질문이 묻는 핵심 경험") + assertThat(result.bestPractice).contains("STAR") + assertThat(result.model).isEqualTo("heuristic") + } + + private fun createDocumentTurn(answer: String, referenceAnswer: String?): InterviewTurn { + val user = User( + id = 1L, + email = "tester@vlainter.com", + password = "encoded", + name = "테스터", + status = UserStatus.ACTIVE, + role = UserRole.USER + ) + val session = InterviewSession( + id = 10L, + user = user, + mode = InterviewMode.DOC, + status = InterviewStatus.IN_PROGRESS, + revealPolicy = RevealPolicy.PER_TURN, + configJson = "{}", + startedAt = OffsetDateTime.now(), + createdAt = OffsetDateTime.now(), + updatedAt = OffsetDateTime.now() + ) + val documentQuestion = DocumentQuestion( + id = 100L, + setId = 99L, + userId = 1L, + documentFileId = 77L, + questionNo = 1, + questionText = "포트폴리오 프로젝트에서 성능 병목을 어떻게 발견하고 개선하셨나요?", + questionType = "PORTFOLIO_PROJECT", + referenceAnswer = referenceAnswer, + evidenceJson = objectMapper.writeValueAsString( + listOf( + "포트폴리오에 대시보드 초기 로딩 개선과 API 구조 조정 경험이 기재되어 있음", + "사용자 체감 속도 개선과 관련 문의 감소를 언급함" + ) + ) + ) + return InterviewTurn( + id = 200L, + session = session, + turnNo = 1, + sourceTag = TurnSourceTag.DOC_RAG, + documentQuestion = documentQuestion, + questionTextSnapshot = documentQuestion.questionText, + categorySnapshot = "문서 기반 모의면접", + userAnswer = answer, + answeredAt = OffsetDateTime.now() + ) + } + + private fun invokeBuildEvaluation(turn: InterviewTurn, answer: String): ReflectedEvaluationResult { + val method = InterviewEvaluationService::class.java.getDeclaredMethod( + "buildEvaluation", + InterviewTurn::class.java, + String::class.java + ) + method.isAccessible = true + val result = method.invoke(service, turn, answer) + val resultClass = result.javaClass + return ReflectedEvaluationResult( + score = resultClass.getMethod("getScore").invoke(result) as BigDecimal, + feedback = resultClass.getMethod("getFeedback").invoke(result) as String, + bestPractice = resultClass.getMethod("getBestPractice").invoke(result) as String, + model = resultClass.getMethod("getModel").invoke(result) as String? + ) + } + + private data class ReflectedEvaluationResult( + val score: BigDecimal, + val feedback: String, + val bestPractice: String, + val model: String? + ) +} From 64a42e14ddfb06934bc2a3a93f90a75ef19dc90a Mon Sep 17 00:00:00 2001 From: rktclgh Date: Thu, 12 Mar 2026 15:08:16 +0900 Subject: [PATCH 3/8] =?UTF-8?q?=EC=98=81=EC=96=B4=20=EB=A9=B4=EC=A0=91=20m?= =?UTF-8?q?vp=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../interview/ai/InterviewAiOrchestrator.kt | 199 +++++++++++--- .../interview/dto/DocumentInterviewDtos.kt | 2 + .../interview/dto/InterviewPracticeDtos.kt | 5 + .../domain/interview/entity/InterviewEnums.kt | 5 + .../service/DocumentInterviewService.kt | 61 ++++- .../service/InterviewEvaluationService.kt | 252 ++++++++++++++---- .../service/InterviewPracticeService.kt | 49 +++- .../ai/InterviewAiOrchestratorTests.kt | 74 +++++ .../InterviewEvaluationServiceTests.kt | 68 ++++- 9 files changed, 606 insertions(+), 109 deletions(-) diff --git a/src/main/kotlin/com/cw/vlainter/domain/interview/ai/InterviewAiOrchestrator.kt b/src/main/kotlin/com/cw/vlainter/domain/interview/ai/InterviewAiOrchestrator.kt index 7a03546..2ae4bb3 100644 --- a/src/main/kotlin/com/cw/vlainter/domain/interview/ai/InterviewAiOrchestrator.kt +++ b/src/main/kotlin/com/cw/vlainter/domain/interview/ai/InterviewAiOrchestrator.kt @@ -1,6 +1,7 @@ package com.cw.vlainter.domain.interview.ai import com.cw.vlainter.domain.interview.entity.QaQuestion +import com.cw.vlainter.domain.interview.entity.InterviewLanguage import com.cw.vlainter.domain.interview.entity.QuestionDifficulty import com.cw.vlainter.global.config.properties.AiProperties import com.fasterxml.jackson.databind.JsonNode @@ -18,10 +19,10 @@ class InterviewAiOrchestrator( ) { private val logger = LoggerFactory.getLogger(javaClass) - fun evaluateTechAnswer(question: QaQuestion?, userAnswer: String): AiTurnEvaluation? { + fun evaluateTechAnswer(question: QaQuestion?, userAnswer: String, language: InterviewLanguage = InterviewLanguage.KO): AiTurnEvaluation? { if (userAnswer.isBlank()) return null - val prompt = buildEvaluationPrompt(question, userAnswer) + val prompt = buildEvaluationPrompt(question, userAnswer, language) return runCatching { val generated = llmProviderRouter.generateJson(prompt) val parsed = parseEvaluationJson(generated.text) @@ -37,7 +38,8 @@ class InterviewAiOrchestrator( fileTypeLabel: String, difficulty: QuestionDifficulty?, questionCount: Int, - contextSnippets: List + contextSnippets: List, + language: InterviewLanguage = InterviewLanguage.KO ): List { require(questionCount > 0) { "questionCount must be positive." } val temperatures = listOf(0.40, 0.50, 0.60, 0.70, 0.80) @@ -49,7 +51,7 @@ class InterviewAiOrchestrator( while (collected.size < questionCount && round < maxRounds) { val remaining = questionCount - collected.size val temperature = temperatures[minOf(round, temperatures.lastIndex)] - val prompt = buildDocumentQuestionPrompt(fileTypeLabel, difficulty, remaining, contextSnippets) + val prompt = buildDocumentQuestionPrompt(fileTypeLabel, difficulty, remaining, contextSnippets, language) try { val generated = llmProviderRouter.generateJson(prompt, temperature = temperature) val parsed = parseGeneratedDocumentQuestions(generated.text, fileTypeLabel) @@ -95,7 +97,8 @@ class InterviewAiOrchestrator( jobName: String, skillName: String, difficulty: QuestionDifficulty?, - questionCount: Int + questionCount: Int, + language: InterviewLanguage = InterviewLanguage.KO ): List { require(questionCount > 0) { "questionCount must be positive." } val labels = CategoryLabels( @@ -115,7 +118,8 @@ class InterviewAiOrchestrator( jobName = labels.jobLabel, skillName = labels.skillLabel, difficulty = difficulty, - questionCount = remaining + questionCount = remaining, + language = language ) try { val generated = llmProviderRouter.generateJson(prompt, temperature = temperature) @@ -162,7 +166,8 @@ class InterviewAiOrchestrator( jobName: String, skillNames: List, difficulty: QuestionDifficulty?, - questionCountPerSkill: Int + questionCountPerSkill: Int, + language: InterviewLanguage = InterviewLanguage.KO ): List { require(questionCountPerSkill > 0) { "questionCountPerSkill must be positive." } val normalizedSkills = skillNames @@ -179,7 +184,8 @@ class InterviewAiOrchestrator( jobName = jobName.trim().ifBlank { "직무" }, skillNames = normalizedSkills, difficulty = difficulty, - questionCountPerSkill = questionCountPerSkill + questionCountPerSkill = questionCountPerSkill, + language = language ) try { val generated = llmProviderRouter.generateJson(prompt, temperature = temperature) @@ -221,22 +227,27 @@ class InterviewAiOrchestrator( questionText: String, referenceAnswer: String?, evidence: List, - userAnswer: String + userAnswer: String, + language: InterviewLanguage = InterviewLanguage.KO ): AiTurnEvaluation? { if (userAnswer.isBlank()) return null + val localizedQuestion = localizeInterviewText(questionText, language, "interview question") + val localizedReferenceAnswer = localizeInterviewText(referenceAnswer, language, "reference answer") + val localizedEvidence = evidence.map { localizeInterviewText(it, language, "document evidence") } + val prompt = """ - 당신은 문서 기반 모의면접 평가관입니다. - 아래 입력을 바탕으로 한국어 JSON만 출력하세요. + ${evaluationSystemRole(language, "document-based interview evaluator")} + ${jsonLanguageInstruction(language)} [질문] - $questionText + $localizedQuestion [STAR형 참고 답안] - ${referenceAnswer?.takeIf { it.isNotBlank() } ?: "(참고 답안 없음)"} + ${localizedReferenceAnswer?.takeIf { it.isNotBlank() } ?: emptyLocalizedPlaceholder(language, "reference answer")} [근거 포인트] - ${if (evidence.isEmpty()) "(근거 없음)" else evidence.joinToString("\n- ", prefix = "- ")} + ${if (localizedEvidence.isEmpty()) emptyLocalizedPlaceholder(language, "evidence") else localizedEvidence.joinToString("\n- ", prefix = "- ")} [사용자 답변] $userAnswer @@ -264,6 +275,7 @@ class InterviewAiOrchestrator( - communication은 답변 구조, 전달력, 면접 답변다운 정리 정도를 평가 - 답변이 문서 맥락과 명확히 어긋나거나 주장 근거가 부족하면 낮은 점수를 부여 - bestPractice에는 빠진 STAR 요소(Situation, Task, Action, Result)와 보강할 근거를 구체적으로 적을 것 + ${englishCommunicationRule(language)} """.trimIndent() return runCatching { @@ -277,15 +289,15 @@ class InterviewAiOrchestrator( } } - fun evaluateIntroductionAnswer(userAnswer: String): AiTurnEvaluation? { + fun evaluateIntroductionAnswer(userAnswer: String, language: InterviewLanguage = InterviewLanguage.KO): AiTurnEvaluation? { if (userAnswer.isBlank()) return null val prompt = """ - 당신은 실전 모의면접의 첫 자기소개 답변을 평가하는 면접관입니다. - 아래 사용자의 자기소개 답변을 읽고 한국어 JSON만 출력하세요. + ${evaluationSystemRole(language, "interviewer evaluating the first self-introduction answer")} + ${jsonLanguageInstruction(language)} [질문] - 자기소개 부탁드리겠습니다. + ${localizedIntroQuestion(language)} [사용자 답변] $userAnswer @@ -308,6 +320,7 @@ class InterviewAiOrchestrator( - 경력/역할/강점/지원 맥락이 드러나는지 본다 - 너무 길거나 핵심이 흐리면 감점한다 - 존댓말, 전달력, 구조적 답변 여부를 함께 평가한다 + ${englishCommunicationRule(language)} """.trimIndent() return runCatching { @@ -347,16 +360,20 @@ class InterviewAiOrchestrator( } } - private fun buildEvaluationPrompt(question: QaQuestion?, userAnswer: String): String { - val questionText = question?.questionText.orEmpty() - val canonicalAnswer = question?.canonicalAnswer?.takeIf { it.isNotBlank() } ?: "(모범답안 없음)" + private fun buildEvaluationPrompt(question: QaQuestion?, userAnswer: String, language: InterviewLanguage): String { + val questionText = localizeInterviewText(question?.questionText.orEmpty(), language, "interview question") + val canonicalAnswer = localizeInterviewText( + question?.canonicalAnswer?.takeIf { it.isNotBlank() }, + language, + "reference answer" + ) ?: emptyLocalizedPlaceholder(language, "reference answer") val category = question?.category?.name ?: "(카테고리 없음)" val difficulty = question?.difficulty?.name ?: "(난이도 없음)" val tags = question?.tagsJson ?: "[]" return """ - 당신은 기술면접 평가관입니다. - 아래 입력을 기반으로 한국어로 엄격한 JSON만 출력하세요. + ${evaluationSystemRole(language, "technical interview evaluator")} + ${jsonLanguageInstruction(language)} [질문] $questionText @@ -389,6 +406,7 @@ class InterviewAiOrchestrator( - 반드시 JSON 객체만 반환 (코드블록 금지) - 점수는 관대하지 않게, 근거 중심으로 산정 - 사용자 답변이 질문과 무관하면 낮은 점수 부여 + ${englishCommunicationRule(language)} """.trimIndent() } @@ -396,16 +414,17 @@ class InterviewAiOrchestrator( fileTypeLabel: String, difficulty: QuestionDifficulty?, questionCount: Int, - contextSnippets: List + contextSnippets: List, + language: InterviewLanguage ): String { val joinedContext = contextSnippets .filter { it.isNotBlank() } .joinToString("\n\n") { snippet -> "[문서 발췌]\n$snippet" } return """ - 당신은 채용 면접관입니다. - 아래 문서 발췌를 기반으로 지원자에게 물을 개인화 면접 질문을 생성하세요. - 질문은 반드시 면접관의 말투로 작성하세요. + ${generationSystemRole(language, "hiring interviewer")} + Generate personalized interview questions from the document snippets below. + Questions and reference answers must be written in ${language.displayLanguageName()}. [문서 유형] $fileTypeLabel @@ -437,6 +456,7 @@ class InterviewAiOrchestrator( - OCR 오류처럼 보이는 깨진 문자열, 무의미한 영문 대문자 나열, 문맥이 없는 잡음은 근거로 사용하지 말 것 - 말이 안 되는 발췌는 건너뛰고, 의미가 분명한 다른 발췌를 선택할 것 - 질문에 문서 발췌를 그대로 길게 인용하지 말고 자연스러운 면접 문장으로 바꿀 것 + - 모든 questionText, referenceAnswer, evidence는 ${language.displayLanguageName()}로 작성할 것 - 반드시 JSON만 출력 """.trimIndent() } @@ -445,7 +465,8 @@ class InterviewAiOrchestrator( jobName: String, skillName: String, difficulty: QuestionDifficulty?, - questionCount: Int + questionCount: Int, + language: InterviewLanguage ): String { val difficultyGuide = when (difficulty ?: QuestionDifficulty.MEDIUM) { QuestionDifficulty.EASY -> "기본 개념, 핵심 구성요소, 대표 사용 사례 중심으로 묻습니다." @@ -453,8 +474,8 @@ class InterviewAiOrchestrator( QuestionDifficulty.HARD -> "복합적인 문제 해결, 대안 비교, 의사결정 근거를 깊게 묻습니다." } return """ - 당신은 기술면접 질문 출제관입니다. - 아래 직무와 기술을 기준으로 실전형 기술면접 질문과 모범답안을 생성하세요. + ${generationSystemRole(language, "technical interview question author")} + Generate realistic technical interview questions and reference answers in ${language.displayLanguageName()}. [직무] $jobName @@ -489,6 +510,7 @@ class InterviewAiOrchestrator( - 자연스러운 한국어 면접 문장으로 작성할 것 - 너무 포괄적인 질문, 어느 기술에도 통할 법한 질문, 기술명이 빠진 질문은 금지 - 모범답안은 실제 면접에서 답하는 문장으로 4~8문장 작성하고, 핵심 근거와 실무 포인트를 포함할 것 + - questionText와 canonicalAnswer는 모두 ${language.displayLanguageName()}로 작성할 것 - 반드시 JSON만 출력 """.trimIndent() } @@ -497,7 +519,8 @@ class InterviewAiOrchestrator( jobName: String, skillNames: List, difficulty: QuestionDifficulty?, - questionCountPerSkill: Int + questionCountPerSkill: Int, + language: InterviewLanguage ): String { val difficultyGuide = when (difficulty ?: QuestionDifficulty.MEDIUM) { QuestionDifficulty.EASY -> "기본 개념, 핵심 구성요소, 대표 사용 사례 중심으로 묻습니다." @@ -506,8 +529,8 @@ class InterviewAiOrchestrator( } val skillList = skillNames.joinToString("\n") { "- $it" } return """ - 당신은 기술면접 질문 출제관입니다. - 아래 직무와 여러 기술 카테고리를 기준으로 실전형 기술면접 질문과 모범답안을 생성하세요. + ${generationSystemRole(language, "technical interview question author")} + Generate realistic technical interview questions and reference answers in ${language.displayLanguageName()}. [직무] $jobName @@ -546,6 +569,7 @@ class InterviewAiOrchestrator( - 자연스러운 한국어 면접 문장으로 작성할 것 - 너무 포괄적인 질문, 어느 기술에도 통할 법한 질문, 기술명이 빠진 질문은 금지 - 모범답안은 실제 면접에서 답하는 문장으로 4~8문장 작성하고 핵심 근거와 실무 포인트를 포함할 것 + - questionText와 canonicalAnswer는 모두 ${language.displayLanguageName()}로 작성할 것 - 반드시 JSON만 출력 """.trimIndent() } @@ -729,7 +753,13 @@ class InterviewAiOrchestrator( "전반적으로", "보통", "대체로", - "대부분" + "대부분", + "in general", + "generally", + "overall", + "broadly speaking", + "for any technology", + "most cases" ) if (banned.any { lowered.contains(it) }) return false val tokens = questionText @@ -738,7 +768,10 @@ class InterviewAiOrchestrator( if (tokens.size < 4) return false val domainHints = listOf( "프로젝트", "서비스", "사용자", "구현", "설계", "개선", "경험", "선택", "이유", - "협업", "성능", "트러블슈팅", "문제", "해결", "운영", "개발", "아키텍처" + "협업", "성능", "트러블슈팅", "문제", "해결", "운영", "개발", "아키텍처", + "project", "service", "user", "implementation", "design", "improve", "experience", + "decision", "reason", "collaboration", "performance", "troubleshooting", "problem", + "solution", "operation", "development", "architecture", "result", "outcome" ) if (domainHints.none { lowered.contains(it) }) return false if (!questionText.trim().endsWith("?")) return false @@ -797,10 +830,106 @@ class InterviewAiOrchestrator( return trimmed.startsWith("질문 의도") || trimmed.startsWith("좋은 답변은") || trimmed.startsWith("핵심 개념") || + trimmed.startsWith("Question intent") || + trimmed.startsWith("A strong answer") || + trimmed.startsWith("Key points") || trimmed.contains("답변해") || trimmed.contains("설명해야") } + fun localizeInterviewText( + text: String?, + language: InterviewLanguage, + contentType: String + ): String? { + val source = text?.trim()?.takeIf { it.isNotBlank() } ?: return null + if (language == InterviewLanguage.KO) return source + if (looksMostlyEnglish(source)) return source + + val prompt = """ + You are a localization assistant for interview sessions. + Translate the following $contentType into natural, professional English. + Preserve technical terms, product names, numbers, and factual meaning. + Return JSON only. + + { + "text": "translated text" + } + + [source] + $source + """.trimIndent() + + return runCatching { + val generated = llmProviderRouter.generateJson(prompt, temperature = 0.2) + objectMapper.readTree(generated.text)["text"]?.asText()?.trim().takeIf { !it.isNullOrBlank() } ?: source + }.onFailure { ex -> + logger.warn("인터뷰 텍스트 현지화 실패(language={}, type={}): {}", language, contentType, ex.message) + }.getOrDefault(source) + } + + fun localizedIntroQuestion(language: InterviewLanguage): String { + return when (language) { + InterviewLanguage.KO -> "자기소개 부탁드리겠습니다." + InterviewLanguage.EN -> "Please introduce yourself." + } + } + + private fun evaluationSystemRole(language: InterviewLanguage, englishRole: String): String { + return when (language) { + InterviewLanguage.KO -> "당신은 면접 답변을 평가하는 면접관입니다." + InterviewLanguage.EN -> "You are an $englishRole." + } + } + + private fun generationSystemRole(language: InterviewLanguage, englishRole: String): String { + return when (language) { + InterviewLanguage.KO -> "당신은 면접 질문을 생성하는 면접관입니다." + InterviewLanguage.EN -> "You are a $englishRole." + } + } + + private fun jsonLanguageInstruction(language: InterviewLanguage): String { + return when (language) { + InterviewLanguage.KO -> "아래 입력을 바탕으로 한국어 JSON만 출력하세요." + InterviewLanguage.EN -> "Read the input below and return JSON only. feedback, bestPractice, and evidence must be written in English." + } + } + + private fun englishCommunicationRule(language: InterviewLanguage): String { + return when (language) { + InterviewLanguage.KO -> "" + InterviewLanguage.EN -> "- communication 점수에는 grammar, sentence completeness, clarity, and natural professional English quality를 반영할 것" + } + } + + private fun emptyLocalizedPlaceholder(language: InterviewLanguage, contentType: String): String { + return when (language) { + InterviewLanguage.KO -> when (contentType) { + "reference answer" -> "(참고 답안 없음)" + "evidence" -> "(근거 없음)" + else -> "(없음)" + } + InterviewLanguage.EN -> when (contentType) { + "reference answer" -> "(no reference answer)" + "evidence" -> "(no evidence)" + else -> "(empty)" + } + } + } + + private fun InterviewLanguage.displayLanguageName(): String = when (this) { + InterviewLanguage.KO -> "Korean" + InterviewLanguage.EN -> "English" + } + + private fun looksMostlyEnglish(text: String): Boolean { + val letters = text.filter { it.isLetter() } + if (letters.isEmpty()) return false + val asciiLetters = letters.count { it.code in 65..90 || it.code in 97..122 } + return asciiLetters >= letters.length * 0.7 + } + private fun normalizeTechTags(tags: List, labels: CategoryLabels): List { val normalized = tags .map { it.trim() } diff --git a/src/main/kotlin/com/cw/vlainter/domain/interview/dto/DocumentInterviewDtos.kt b/src/main/kotlin/com/cw/vlainter/domain/interview/dto/DocumentInterviewDtos.kt index be41271..8003d15 100644 --- a/src/main/kotlin/com/cw/vlainter/domain/interview/dto/DocumentInterviewDtos.kt +++ b/src/main/kotlin/com/cw/vlainter/domain/interview/dto/DocumentInterviewDtos.kt @@ -1,6 +1,7 @@ package com.cw.vlainter.domain.interview.dto import com.cw.vlainter.domain.interview.entity.DocumentIngestionStatus +import com.cw.vlainter.domain.interview.entity.InterviewLanguage import com.cw.vlainter.domain.interview.entity.QuestionDifficulty import jakarta.validation.constraints.Max import jakarta.validation.constraints.Min @@ -41,6 +42,7 @@ data class StartMockInterviewRequest( val skillNames: List = emptyList(), val jobName: String? = null, val difficulty: QuestionDifficulty? = null, + val language: InterviewLanguage = InterviewLanguage.KO, val includeSelfIntroduction: Boolean = false, @field:Min(value = 5, message = "questionCount는 5 이상이어야 합니다.") @field:Max(value = 20, message = "questionCount는 20 이하여야 합니다.") diff --git a/src/main/kotlin/com/cw/vlainter/domain/interview/dto/InterviewPracticeDtos.kt b/src/main/kotlin/com/cw/vlainter/domain/interview/dto/InterviewPracticeDtos.kt index 79ce9b0..fa53c94 100644 --- a/src/main/kotlin/com/cw/vlainter/domain/interview/dto/InterviewPracticeDtos.kt +++ b/src/main/kotlin/com/cw/vlainter/domain/interview/dto/InterviewPracticeDtos.kt @@ -2,6 +2,7 @@ package com.cw.vlainter.domain.interview.dto import com.cw.vlainter.domain.interview.entity.QuestionDifficulty import com.cw.vlainter.domain.interview.entity.InterviewQuestionKind +import com.cw.vlainter.domain.interview.entity.InterviewLanguage import com.cw.vlainter.domain.interview.entity.QuestionSourceTag import com.cw.vlainter.domain.interview.entity.TurnSourceTag import jakarta.validation.constraints.Max @@ -17,6 +18,7 @@ data class StartTechInterviewRequest( val jobName: String? = null, val skillName: String? = null, val difficulty: QuestionDifficulty? = null, + val language: InterviewLanguage = InterviewLanguage.KO, val sourceTag: QuestionSourceTag? = null, val saveHistory: Boolean = true, @field:Min(value = 1, message = "questionCount는 1 이상이어야 합니다.") @@ -43,6 +45,7 @@ data class StartTechInterviewResponse( val status: String, val currentQuestion: InterviewQuestionResponse, val hasNext: Boolean, + val language: String = InterviewLanguage.KO.name, val providerUsed: String? = null, val fallbackDepth: Int = 0 ) @@ -143,6 +146,7 @@ data class InterviewSessionHistoryResponse( val sessionId: Long, val status: String, val mode: String, + val language: String?, val questionCount: Int, val difficulty: String?, val difficultyRating: Int?, @@ -158,6 +162,7 @@ data class ResumeInterviewSessionResponse( val sessionId: Long, val status: String, val mode: String, + val language: String?, val currentQuestion: InterviewQuestionResponse, val questionCount: Int, val difficulty: String?, diff --git a/src/main/kotlin/com/cw/vlainter/domain/interview/entity/InterviewEnums.kt b/src/main/kotlin/com/cw/vlainter/domain/interview/entity/InterviewEnums.kt index 4f51e62..ebcaef9 100644 --- a/src/main/kotlin/com/cw/vlainter/domain/interview/entity/InterviewEnums.kt +++ b/src/main/kotlin/com/cw/vlainter/domain/interview/entity/InterviewEnums.kt @@ -21,6 +21,10 @@ enum class QuestionSourceTag { SYSTEM, USER } +enum class InterviewLanguage { + KO, EN +} + enum class InterviewMode { DOC, TECH, MIXED, QUESTION_SET_PRACTICE } @@ -38,6 +42,7 @@ enum class TurnSourceTag { SYSTEM, USER, DOC_RAG, INTRO } +@Suppress("unused") enum class TurnEvaluationStatus { PENDING, DONE, FAILED } diff --git a/src/main/kotlin/com/cw/vlainter/domain/interview/service/DocumentInterviewService.kt b/src/main/kotlin/com/cw/vlainter/domain/interview/service/DocumentInterviewService.kt index b68770c..778b6ed 100644 --- a/src/main/kotlin/com/cw/vlainter/domain/interview/service/DocumentInterviewService.kt +++ b/src/main/kotlin/com/cw/vlainter/domain/interview/service/DocumentInterviewService.kt @@ -19,6 +19,7 @@ import com.cw.vlainter.domain.interview.entity.DocumentQuestion import com.cw.vlainter.domain.interview.entity.DocumentQuestionSet import com.cw.vlainter.domain.interview.entity.InterviewMode import com.cw.vlainter.domain.interview.entity.InterviewQuestionKind +import com.cw.vlainter.domain.interview.entity.InterviewLanguage import com.cw.vlainter.domain.interview.entity.InterviewSession import com.cw.vlainter.domain.interview.entity.InterviewStatus import com.cw.vlainter.domain.interview.entity.InterviewTurn @@ -344,7 +345,8 @@ class DocumentInterviewService( actor = actor, contexts = techContexts, difficulty = request.difficulty, - requestedCount = desiredTechTarget + requestedCount = desiredTechTarget, + language = request.language ) } val techTarget = if (resolvedTechCandidates.isNotEmpty()) { @@ -353,7 +355,13 @@ class DocumentInterviewService( 0 } val documentTarget = max(1, requestedCount - techTarget) - val resolvedDocumentQuestions = generateDocumentQuestions(actor, files, request.difficulty, documentTarget) + val resolvedDocumentQuestions = generateDocumentQuestions( + actor = actor, + files = files, + difficulty = request.difficulty, + questionCount = documentTarget, + language = request.language + ) resolvedTechCandidates to resolvedDocumentQuestions } @@ -419,6 +427,7 @@ class DocumentInterviewService( "includeSelfIntroduction" to request.includeSelfIntroduction, "difficulty" to request.difficulty?.name, "difficultyRating" to difficultyToRating(request.difficulty), + "language" to request.language.name, "categoryId" to primaryCategoryId, "categoryName" to techMetaSkillNames.joinToString(", "), "jobName" to techMetaJobName, @@ -453,6 +462,7 @@ class DocumentInterviewService( status = session.status.name, currentQuestion = toInterviewQuestionResponse(firstTurn), hasNext = queue.size > 1, + language = request.language.name, providerUsed = routingSnapshot.providerUsed?.name, fallbackDepth = routingSnapshot.fallbackDepth ) @@ -465,7 +475,8 @@ class DocumentInterviewService( actor: User, files: List, difficulty: QuestionDifficulty?, - questionCount: Int + questionCount: Int, + language: InterviewLanguage ): List { val allocation = distribute(questionCount, files.size) val results = mutableListOf() @@ -525,7 +536,8 @@ class DocumentInterviewService( fileTypeLabel = file.fileType.toPromptLabel(), difficulty = difficulty, questionCount = targetCount, - contextSnippets = relaxedValidatedSnippets + contextSnippets = relaxedValidatedSnippets, + language = language ) }.onFailure { ex -> if (ex is GeminiTransientException) { @@ -641,6 +653,7 @@ class DocumentInterviewService( sessionId = session.id, status = session.status.name, mode = session.mode.name, + language = meta?.get("language")?.asText()?.takeIf { it.isNotBlank() } ?: InterviewLanguage.KO.name, questionCount = meta?.get("questionCount")?.asInt() ?: max(queueSize, turns.size), difficulty = meta?.get("difficulty")?.asText() ?: turns.firstOrNull()?.difficulty, difficultyRating = meta?.get("difficultyRating")?.asInt() @@ -692,6 +705,7 @@ class DocumentInterviewService( sessionId = latestSession.id, status = latestSession.status.name, mode = latestSession.mode.name, + language = meta?.get("language")?.asText()?.takeIf { it.isNotBlank() } ?: InterviewLanguage.KO.name, currentQuestion = toInterviewQuestionResponse(currentTurn), questionCount = meta?.get("questionCount")?.asInt() ?: max(queueSize, 1), difficulty = meta?.get("difficulty")?.asText(), @@ -907,7 +921,8 @@ class DocumentInterviewService( actor: User, contexts: List, difficulty: QuestionDifficulty?, - requestedCount: Int + requestedCount: Int, + language: InterviewLanguage ): List { val distinctContexts = contexts.distinctBy { it.category.id } if (distinctContexts.isEmpty()) return emptyList() @@ -936,7 +951,8 @@ class DocumentInterviewService( actor = actor, contexts = contextsToGenerate, difficulty = difficulty, - requestedPerSkill = requestedPerSkill + requestedPerSkill = requestedPerSkill, + language = language ) return (existing + generated).distinctBy { it.id } } @@ -945,7 +961,8 @@ class DocumentInterviewService( actor: User, contexts: List, difficulty: QuestionDifficulty?, - requestedPerSkill: Int + requestedPerSkill: Int, + language: InterviewLanguage ): List { val distinctContexts = contexts.distinctBy { it.category.id } if (distinctContexts.isEmpty()) return emptyList() @@ -956,7 +973,8 @@ class DocumentInterviewService( jobName = jobName, skillNames = distinctContexts.map { it.skillName }, difficulty = difficulty, - questionCountPerSkill = requestedPerSkill + questionCountPerSkill = requestedPerSkill, + language = language ) } catch (ex: GeminiTransientException) { throw toGeminiOverloadException(ex) @@ -1174,6 +1192,7 @@ class DocumentInterviewService( } private fun createTurnFromRef(session: InterviewSession, ref: InterviewPracticeService.QuestionRef): InterviewTurn { + val language = resolveInterviewLanguage(session.configJson) val turn = when (ref.kind) { InterviewQuestionKind.TECH -> { val question = questionRepository.findByIdAndDeletedAtIsNull(ref.id) @@ -1190,7 +1209,11 @@ class DocumentInterviewService( } }, question = question, - questionTextSnapshot = question.questionText, + questionTextSnapshot = interviewAiOrchestrator.localizeInterviewText( + question.questionText, + language, + "interview question" + ) ?: question.questionText, categorySnapshot = question.category.name, jobSnapshot = question.jobName ?: question.category.parent?.name?.trim(), skillSnapshot = question.skillName ?: question.category.name.trim(), @@ -1208,7 +1231,11 @@ class DocumentInterviewService( turnNo = 1, sourceTag = TurnSourceTag.DOC_RAG, documentQuestion = question, - questionTextSnapshot = question.questionText, + questionTextSnapshot = interviewAiOrchestrator.localizeInterviewText( + question.questionText, + language, + "interview question" + ) ?: question.questionText, categorySnapshot = question.questionType, difficulty = question.difficulty, tagsJson = "[]", @@ -1221,7 +1248,7 @@ class DocumentInterviewService( session = session, turnNo = 1, sourceTag = TurnSourceTag.DOC_RAG, - questionTextSnapshot = INTRO_QUESTION_TEXT, + questionTextSnapshot = interviewAiOrchestrator.localizedIntroQuestion(language), categorySnapshot = INTRO_CATEGORY, tagsJson = "[]" ) @@ -1255,7 +1282,17 @@ class DocumentInterviewService( return turn.question == null && turn.documentQuestion == null && turn.categorySnapshot == INTRO_CATEGORY && - turn.questionTextSnapshot == INTRO_QUESTION_TEXT + turn.questionTextSnapshot in setOf( + INTRO_QUESTION_TEXT, + interviewAiOrchestrator.localizedIntroQuestion(InterviewLanguage.EN) + ) + } + + private fun resolveInterviewLanguage(configJson: String?): InterviewLanguage { + if (configJson.isNullOrBlank()) return InterviewLanguage.KO + val root = runCatching { objectMapper.readTree(configJson) }.getOrNull() ?: return InterviewLanguage.KO + val raw = root.path("meta").path("language").asText().trim().uppercase() + return runCatching { InterviewLanguage.valueOf(raw) }.getOrDefault(InterviewLanguage.KO) } private fun extractPdfText(file: UserFile): ExtractedDocumentText { diff --git a/src/main/kotlin/com/cw/vlainter/domain/interview/service/InterviewEvaluationService.kt b/src/main/kotlin/com/cw/vlainter/domain/interview/service/InterviewEvaluationService.kt index d36e0af..b2f9bcc 100644 --- a/src/main/kotlin/com/cw/vlainter/domain/interview/service/InterviewEvaluationService.kt +++ b/src/main/kotlin/com/cw/vlainter/domain/interview/service/InterviewEvaluationService.kt @@ -4,6 +4,7 @@ import com.cw.vlainter.domain.interview.ai.InterviewAiOrchestrator import com.cw.vlainter.domain.interview.dto.TurnEvaluationResponse import com.cw.vlainter.domain.interview.entity.InterviewTurn import com.cw.vlainter.domain.interview.entity.InterviewTurnEvaluation +import com.cw.vlainter.domain.interview.entity.InterviewLanguage import com.cw.vlainter.domain.interview.entity.TurnSourceTag import com.cw.vlainter.domain.interview.entity.TurnEvaluationStatus import com.cw.vlainter.domain.interview.repository.InterviewTurnEvaluationRepository @@ -44,6 +45,14 @@ class InterviewEvaluationService( val DOCUMENT_ACTION_SIGNALS = setOf("제가", "저는", "구현", "설계", "개선", "도입", "분석", "해결", "최적화", "리팩토링", "협업", "검증", "작성", "적용", "정리") val DOCUMENT_RESULT_SIGNALS = setOf("결과", "성과", "개선", "향상", "감소", "증가", "단축", "완료", "달성", "안정화", "배포", "출시") val DOCUMENT_REASONING_SIGNALS = setOf("이유", "근거", "왜냐", "그래서", "이를 위해", "때문에", "검증", "비교", "판단", "트레이드오프") + val DOCUMENT_CONTEXT_SIGNALS_EN = setOf("situation", "background", "at the time", "problem", "issue", "project", "service", "requirement") + val DOCUMENT_TASK_SIGNALS_EN = setOf("role", "responsibility", "goal", "task", "objective", "ownership") + val DOCUMENT_ACTION_SIGNALS_EN = setOf("implemented", "designed", "improved", "introduced", "analyzed", "resolved", "optimized", "refactored", "collaborated", "validated", "built", "led") + val DOCUMENT_RESULT_SIGNALS_EN = setOf("result", "outcome", "improved", "reduced", "increased", "shortened", "completed", "achieved", "stabilized", "launched") + val DOCUMENT_REASONING_SIGNALS_EN = setOf("because", "therefore", "so that", "in order to", "reason", "evidence", "validated", "compared", "trade-off") + val ENGLISH_WORD_REGEX = Regex("""\b[A-Za-z]{2,}\b""") + val ENGLISH_LETTER_REGEX = Regex("""[A-Za-z]""") + val HANGUL_REGEX = Regex("""[가-힣]""") } private val logger = LoggerFactory.getLogger(javaClass) @@ -140,6 +149,7 @@ class InterviewEvaluationService( } private fun buildEvaluation(turn: InterviewTurn, answer: String): EvaluationResult { + val sessionLanguage = resolveInterviewLanguage(turn) val userGeneratedQuestion = turn.sourceTag == TurnSourceTag.USER val resolvedAnswer = resolveAnswerContent( rawModelAnswer = turn.question?.canonicalAnswer ?: turn.documentQuestion?.referenceAnswer, @@ -149,7 +159,11 @@ class InterviewEvaluationService( if (answer.isBlank()) { return EvaluationResult( score = BigDecimal.ZERO.setScale(2), - feedback = "답변이 비어 있습니다. 핵심 내용을 포함해 다시 작성해 주세요.", + feedback = if (sessionLanguage == InterviewLanguage.EN) { + "Your answer is empty. Please rewrite it with the key point included." + } else { + "답변이 비어 있습니다. 핵심 내용을 포함해 다시 작성해 주세요." + }, bestPractice = if (userGeneratedQuestion) "" else resolvedAnswer.guideText .orEmpty(), modelAnswer = resolvedAnswer.modelAnswer, @@ -163,15 +177,16 @@ class InterviewEvaluationService( val question = turn.question val documentQuestion = turn.documentQuestion val aiEvaluation = if (isIntroductionTurn(turn)) { - interviewAiOrchestrator.evaluateIntroductionAnswer(answer) + interviewAiOrchestrator.evaluateIntroductionAnswer(answer, sessionLanguage) } else if (question != null) { - interviewAiOrchestrator.evaluateTechAnswer(question, answer) + interviewAiOrchestrator.evaluateTechAnswer(question, answer, sessionLanguage) } else if (documentQuestion != null) { interviewAiOrchestrator.evaluateDocumentAnswer( questionText = documentQuestion.questionText, referenceAnswer = documentQuestion.referenceAnswer, evidence = parseJsonArray(documentQuestion.evidenceJson), - userAnswer = answer + userAnswer = answer, + language = sessionLanguage ) } else { null @@ -196,13 +211,30 @@ class InterviewEvaluationService( evidence = parseJsonArray(documentQuestion.evidenceJson), answer = answer, userGeneratedQuestion = userGeneratedQuestion, - resolvedAnswer = resolvedAnswer + resolvedAnswer = resolvedAnswer, + language = sessionLanguage ) } val canonical = resolvedAnswer.modelAnswer.orEmpty() + val questionText = if (sessionLanguage == InterviewLanguage.EN) { + turn.questionTextSnapshot + } else { + question?.questionText.orEmpty() + } val score = if (isLowEffortAnswer(answer)) { BigDecimal.ZERO.setScale(2) + } else if (sessionLanguage == InterviewLanguage.EN && (canonical.isBlank() || !looksMostlyEnglish(canonical))) { + val answerTokens = tokenize(answer) + val questionTokens = tokenize(questionText) + val overlap = if (questionTokens.isEmpty()) 0.0 else answerTokens.intersect(questionTokens).size.toDouble() / questionTokens.size + val communication = englishCommunicationHeuristic(answer) + val numeric = ( + 20 + + (overlap * 45) + + (communication * 0.35) + ).coerceIn(0.0, 100.0) + BigDecimal(numeric).setScale(2, RoundingMode.HALF_UP) } else if (canonical.isBlank()) { val lenScore = min(70, max(5, answer.length / 8)) BigDecimal(lenScore).setScale(2) @@ -215,11 +247,20 @@ class InterviewEvaluationService( BigDecimal(numeric).setScale(2, RoundingMode.HALF_UP) } - val feedback = when { - score <= BigDecimal("10.00") -> "질문에 대한 핵심 내용이 거의 제시되지 않았습니다. 최소한 개념 정의와 적용 맥락은 포함해서 다시 답해 보세요." - score >= BigDecimal("85.00") -> "핵심 개념을 잘 설명했습니다. 근거 사례를 한 줄 추가하면 더 좋습니다." - score >= BigDecimal("70.00") -> "핵심은 맞지만 설명 구조가 다소 약합니다. 결론을 먼저 말하고 근거를 붙여 보세요." - else -> "핵심 포인트 누락이 있습니다. 용어 정의와 문제 해결 흐름을 분리해 작성해 보세요." + val feedback = if (sessionLanguage == InterviewLanguage.EN) { + when { + score <= BigDecimal("10.00") -> "Your answer barely addresses the core of the question. Restate the concept clearly and explain where it applies." + score >= BigDecimal("85.00") -> "You explained the core idea well. Add one concrete example or trade-off to make it stronger." + score >= BigDecimal("70.00") -> "The main point is mostly correct, but the structure is still loose. Lead with the conclusion, then support it with reasoning." + else -> "Some key points are missing. Separate the concept, reasoning, and practical application more clearly." + } + } else { + when { + score <= BigDecimal("10.00") -> "질문에 대한 핵심 내용이 거의 제시되지 않았습니다. 최소한 개념 정의와 적용 맥락은 포함해서 다시 답해 보세요." + score >= BigDecimal("85.00") -> "핵심 개념을 잘 설명했습니다. 근거 사례를 한 줄 추가하면 더 좋습니다." + score >= BigDecimal("70.00") -> "핵심은 맞지만 설명 구조가 다소 약합니다. 결론을 먼저 말하고 근거를 붙여 보세요." + else -> "핵심 포인트 누락이 있습니다. 용어 정의와 문제 해결 흐름을 분리해 작성해 보세요." + } } val bestPractice = if (userGeneratedQuestion) "" else resolvedAnswer.guideText.orEmpty() @@ -238,13 +279,22 @@ class InterviewEvaluationService( evidence: List, answer: String, userGeneratedQuestion: Boolean, - resolvedAnswer: ResolvedAnswerContent + resolvedAnswer: ResolvedAnswerContent, + language: InterviewLanguage ): EvaluationResult { if (isLowEffortAnswer(answer)) { return EvaluationResult( score = BigDecimal.ZERO.setScale(2), - feedback = "질문 의도에 맞는 핵심 경험과 본인의 행동이 거의 드러나지 않았습니다. 문서에 적은 경험을 기준으로 다시 답변해 보세요.", - bestPractice = if (userGeneratedQuestion) "" else "질문의 의도에 맞춰 당시 상황, 맡은 역할, 실제 행동, 결과를 STAR 순서로 다시 정리해 보세요.", + feedback = if (language == InterviewLanguage.EN) { + "Your answer does not yet show the core experience or your own actions clearly enough. Rebuild it around the experience described in your document." + } else { + "질문 의도에 맞는 핵심 경험과 본인의 행동이 거의 드러나지 않았습니다. 문서에 적은 경험을 기준으로 다시 답변해 보세요." + }, + bestPractice = if (userGeneratedQuestion) "" else if (language == InterviewLanguage.EN) { + "Reorganize the answer in STAR order so the situation, responsibility, actions, and result are each explicit." + } else { + "질문의 의도에 맞춰 당시 상황, 맡은 역할, 실제 행동, 결과를 STAR 순서로 다시 정리해 보세요." + }, modelAnswer = resolvedAnswer.modelAnswer, rubricScoresJson = """{"coverage":0,"accuracy":0,"communication":0}""", evidenceJson = objectMapper.writeValueAsString(listOf("low_effort_answer")), @@ -257,16 +307,21 @@ class InterviewEvaluationService( val questionTokens = tokenize(questionText) val evidenceTokens = evidence.flatMap { tokenize(it) }.toSet() val softBenchmarkTokens = extractDocumentSoftBenchmarkTokens(referenceAnswer, evidence) + val contextSignalsSet = if (language == InterviewLanguage.EN) DOCUMENT_CONTEXT_SIGNALS_EN else DOCUMENT_CONTEXT_SIGNALS + val taskSignalsSet = if (language == InterviewLanguage.EN) DOCUMENT_TASK_SIGNALS_EN else DOCUMENT_TASK_SIGNALS + val actionSignalsSet = if (language == InterviewLanguage.EN) DOCUMENT_ACTION_SIGNALS_EN else DOCUMENT_ACTION_SIGNALS + val resultSignalsSet = if (language == InterviewLanguage.EN) DOCUMENT_RESULT_SIGNALS_EN else DOCUMENT_RESULT_SIGNALS + val reasoningSignalsSet = if (language == InterviewLanguage.EN) DOCUMENT_REASONING_SIGNALS_EN else DOCUMENT_REASONING_SIGNALS val intentHits = answerTokens.intersect(questionTokens).size val evidenceHits = answerTokens.intersect(evidenceTokens).size val benchmarkHits = answerTokens.intersect(softBenchmarkTokens.toSet()).size - val contextSignals = countSignalMatches(answer, DOCUMENT_CONTEXT_SIGNALS) - val taskSignals = countSignalMatches(answer, DOCUMENT_TASK_SIGNALS) - val actionSignals = countSignalMatches(answer, DOCUMENT_ACTION_SIGNALS) - val resultSignals = countSignalMatches(answer, DOCUMENT_RESULT_SIGNALS) + val contextSignals = countSignalMatches(answer, contextSignalsSet) + val taskSignals = countSignalMatches(answer, taskSignalsSet) + val actionSignals = countSignalMatches(answer, actionSignalsSet) + val resultSignals = countSignalMatches(answer, resultSignalsSet) val hasNumber = NUMBER_REGEX.containsMatchIn(answer) - val hasReasoning = DOCUMENT_REASONING_SIGNALS.any { answer.contains(it) } + val hasReasoning = reasoningSignalsSet.any { answer.contains(it, ignoreCase = true) } val sentenceCount = answer.split(Regex("[.!?。]|\\n")) .map { it.trim() } .count { it.isNotBlank() } @@ -297,13 +352,17 @@ class InterviewEvaluationService( if (answer.length >= 120) 10 else 0 ).coerceIn(0, 100) - val communicationScore = ( - 25 + - min(20, sentenceCount * 6) + - if (answer.length in 80..600) 20 else 8 + - if (answer.contains("\n") || sentenceCount >= 3) 15 else 5 + - if (answer.trim().endsWith(".") || answer.trim().endsWith("다") || answer.trim().endsWith("요")) 10 else 0 - ).coerceIn(0, 100) + val communicationScore = if (language == InterviewLanguage.EN) { + englishCommunicationHeuristic(answer).toInt().coerceIn(0, 100) + } else { + ( + 25 + + min(20, sentenceCount * 6) + + if (answer.length in 80..600) 20 else 8 + + if (answer.contains("\n") || sentenceCount >= 3) 15 else 5 + + if (answer.trim().endsWith(".") || answer.trim().endsWith("다") || answer.trim().endsWith("요")) 10 else 0 + ).coerceIn(0, 100) + } val totalScore = BigDecimal( ( @@ -314,48 +373,66 @@ class InterviewEvaluationService( ).setScale(2, RoundingMode.HALF_UP) val missingStarParts = buildList { - if (contextSignals == 0) add("상황") - if (taskSignals == 0) add("과제/역할") - if (actionSignals == 0) add("행동") - if (resultSignals == 0 && !hasNumber) add("결과") + if (contextSignals == 0) add(if (language == InterviewLanguage.EN) "Situation" else "상황") + if (taskSignals == 0) add(if (language == InterviewLanguage.EN) "Task/Role" else "과제/역할") + if (actionSignals == 0) add(if (language == InterviewLanguage.EN) "Action" else "행동") + if (resultSignals == 0 && !hasNumber) add(if (language == InterviewLanguage.EN) "Result" else "결과") } val missingKeywords = softBenchmarkTokens .filterNot { it in answerTokens } .distinct() .take(3) val evidenceNotes = buildList { - add("질문 핵심 키워드 반영 ${intentHits}건") - add("문서 근거 포인트 반영 ${evidenceHits}건") + add( + if (language == InterviewLanguage.EN) { + "Question-intent keyword coverage: $intentHits" + } else { + "질문 핵심 키워드 반영 ${intentHits}건" + } + ) + add( + if (language == InterviewLanguage.EN) { + "Document evidence coverage: $evidenceHits" + } else { + "문서 근거 포인트 반영 ${evidenceHits}건" + } + ) if (missingStarParts.isNotEmpty()) { - add("STAR 누락 요소: ${missingStarParts.joinToString(", ")}") + add( + if (language == InterviewLanguage.EN) { + "Missing STAR elements: ${missingStarParts.joinToString(", ")}" + } else { + "STAR 누락 요소: ${missingStarParts.joinToString(", ")}" + } + ) } if (!hasNumber) { - add("수치/결과 근거 부족") + add(if (language == InterviewLanguage.EN) "Missing measurable result evidence" else "수치/결과 근거 부족") } } val feedback = buildString { append( when { - coverageScore >= 80 -> "질문 의도에는 대체로 잘 맞게 답했습니다." - coverageScore >= 60 -> "질문 의도와 관련된 경험은 보이지만, 핵심 초점이 조금 더 선명해야 합니다." - else -> "질문이 묻는 핵심 경험과 답변 초점이 충분히 맞물리지 않았습니다." + coverageScore >= 80 -> if (language == InterviewLanguage.EN) "Your answer is largely aligned with the question intent." else "질문 의도에는 대체로 잘 맞게 답했습니다." + coverageScore >= 60 -> if (language == InterviewLanguage.EN) "The relevant experience is visible, but the answer still needs a sharper focus." else "질문 의도와 관련된 경험은 보이지만, 핵심 초점이 조금 더 선명해야 합니다." + else -> if (language == InterviewLanguage.EN) "The answer focus does not align tightly enough with the core experience the question is asking for." else "질문이 묻는 핵심 경험과 답변 초점이 충분히 맞물리지 않았습니다." } ) append(' ') append( when { - starScore >= 75 -> "상황, 역할, 행동, 결과 흐름도 비교적 자연스럽게 드러납니다." - starScore >= 50 -> "STAR 흐름은 일부 보이지만, 빠진 요소가 있어 설득력이 다소 약합니다." - else -> "STAR 구조가 약해 면접 답변으로 들었을 때 경험의 맥락과 본인 기여도가 충분히 드러나지 않습니다." + starScore >= 75 -> if (language == InterviewLanguage.EN) "The flow of situation, role, action, and result is fairly natural." else "상황, 역할, 행동, 결과 흐름도 비교적 자연스럽게 드러납니다." + starScore >= 50 -> if (language == InterviewLanguage.EN) "Some STAR structure is present, but missing pieces still reduce persuasiveness." else "STAR 흐름은 일부 보이지만, 빠진 요소가 있어 설득력이 다소 약합니다." + else -> if (language == InterviewLanguage.EN) "The STAR structure is weak, so the context and your contribution are not yet clear enough for an interview answer." else "STAR 구조가 약해 면접 답변으로 들었을 때 경험의 맥락과 본인 기여도가 충분히 드러나지 않습니다." } ) append(' ') append( when { - accuracyScore >= 75 -> "기술적 설명과 근거도 비교적 설득력 있습니다." - accuracyScore >= 55 -> "기술적 설명은 가능하지만, 선택 이유나 성과 근거를 더 보강할 필요가 있습니다." - else -> "기술 선택 이유, 검증 근거, 성과 설명이 부족해 답변의 신뢰도가 떨어집니다." + accuracyScore >= 75 -> if (language == InterviewLanguage.EN) "The technical explanation and supporting evidence are reasonably convincing." else "기술적 설명과 근거도 비교적 설득력 있습니다." + accuracyScore >= 55 -> if (language == InterviewLanguage.EN) "The technical explanation is understandable, but the reasoning and outcome evidence need reinforcement." else "기술적 설명은 가능하지만, 선택 이유나 성과 근거를 더 보강할 필요가 있습니다." + else -> if (language == InterviewLanguage.EN) "The answer lacks enough reasoning, validation, or result evidence to feel fully credible." else "기술 선택 이유, 검증 근거, 성과 설명이 부족해 답변의 신뢰도가 떨어집니다." } ) } @@ -365,19 +442,55 @@ class InterviewEvaluationService( } else { buildString { if (missingStarParts.isNotEmpty()) { - append("다음 답변에서는 ${missingStarParts.joinToString(", ")} 요소를 더 분명히 넣어 STAR 흐름을 완성해 보세요. ") + append( + if (language == InterviewLanguage.EN) { + "In your next answer, make ${missingStarParts.joinToString(", ")} more explicit so the STAR flow feels complete. " + } else { + "다음 답변에서는 ${missingStarParts.joinToString(", ")} 요소를 더 분명히 넣어 STAR 흐름을 완성해 보세요. " + } + ) } else { - append("현재 답변 흐름은 나쁘지 않으니, 행동과 결과를 더 압축적으로 연결해 전달력을 높여 보세요. ") + append( + if (language == InterviewLanguage.EN) { + "The overall flow is acceptable, so tighten the link between your actions and results to improve impact. " + } else { + "현재 답변 흐름은 나쁘지 않으니, 행동과 결과를 더 압축적으로 연결해 전달력을 높여 보세요. " + } + ) } if (missingKeywords.isNotEmpty()) { - append("${missingKeywords.joinToString(", ")} 같은 문서 맥락의 구체 요소를 넣으면 질문과의 연결성이 더 선명해집니다. ") + append( + if (language == InterviewLanguage.EN) { + "Adding concrete document-specific details such as ${missingKeywords.joinToString(", ")} will make the link to the question much clearer. " + } else { + "${missingKeywords.joinToString(", ")} 같은 문서 맥락의 구체 요소를 넣으면 질문과의 연결성이 더 선명해집니다. " + } + ) } if (!hasNumber) { - append("가능하면 수치, 사용자 영향, 성능 변화, 완료 결과처럼 확인 가능한 근거를 함께 제시하세요.") + append( + if (language == InterviewLanguage.EN) { + "If possible, add measurable evidence such as metrics, user impact, performance changes, or a concrete outcome." + } else { + "가능하면 수치, 사용자 영향, 성능 변화, 완료 결과처럼 확인 가능한 근거를 함께 제시하세요." + } + ) } else if (!hasReasoning) { - append("결과를 말할 때는 왜 그런 선택을 했는지 판단 근거도 같이 설명해 주세요.") + append( + if (language == InterviewLanguage.EN) { + "When you describe the result, also explain why you made that decision." + } else { + "결과를 말할 때는 왜 그런 선택을 했는지 판단 근거도 같이 설명해 주세요." + } + ) } else { - append("특히 본인이 직접 판단하고 실행한 부분을 더 짧고 선명하게 강조하면 좋습니다.") + append( + if (language == InterviewLanguage.EN) { + "Emphasize the part you personally decided and executed more directly and more concisely." + } else { + "특히 본인이 직접 판단하고 실행한 부분을 더 짧고 선명하게 강조하면 좋습니다." + } + ) } }.trim() } @@ -433,10 +546,46 @@ class InterviewEvaluationService( "기억이 안납니다", "생각이 안납니다", "잘 모르겠어요", - "모르겠습니다" + "모르겠습니다", + "i don't know", + "not sure", + "i am not sure", + "no idea" ) } + private fun resolveInterviewLanguage(turn: InterviewTurn): InterviewLanguage { + val rawConfig = turn.session.configJson + if (rawConfig.isBlank()) return InterviewLanguage.KO + val root = runCatching { objectMapper.readTree(rawConfig) }.getOrNull() ?: return InterviewLanguage.KO + val raw = root.path("meta").path("language").asText().trim().uppercase() + return runCatching { InterviewLanguage.valueOf(raw) }.getOrDefault(InterviewLanguage.KO) + } + + private fun englishCommunicationHeuristic(answer: String): Double { + val sentenceCount = answer.split(Regex("[.!?\\n]")) + .map { it.trim() } + .count { it.isNotBlank() } + val englishWords = ENGLISH_WORD_REGEX.findAll(answer).count() + val englishLetters = ENGLISH_LETTER_REGEX.findAll(answer).count() + val hangulLetters = HANGUL_REGEX.findAll(answer).count() + val punctuationEnding = answer.trim().lastOrNull()?.let { it == '.' || it == '!' || it == '?' } == true + return ( + 20 + + min(25, sentenceCount * 7) + + min(20, englishWords) + + if (englishLetters >= hangulLetters * 2) 15 else 4 + + if (punctuationEnding) 10 else 3 + + if (answer.length in 60..800) 10 else 4 + ).coerceIn(0, 100).toDouble() + } + + private fun looksMostlyEnglish(text: String): Boolean { + val englishLetters = ENGLISH_LETTER_REGEX.findAll(text).count() + val hangulLetters = HANGUL_REGEX.findAll(text).count() + return englishLetters >= max(8, hangulLetters * 2) + } + private fun parseJsonArray(raw: String?): List { if (raw.isNullOrBlank()) return emptyList() return runCatching { objectMapper.readValue(raw, Array::class.java).toList() } @@ -447,7 +596,10 @@ class InterviewEvaluationService( return turn.question == null && turn.documentQuestion == null && turn.categorySnapshot == INTRO_CATEGORY && - turn.questionTextSnapshot == INTRO_QUESTION_TEXT + turn.questionTextSnapshot in setOf( + INTRO_QUESTION_TEXT, + interviewAiOrchestrator.localizedIntroQuestion(InterviewLanguage.EN) + ) } private fun InterviewTurnEvaluation.toResponse(): TurnEvaluationResponse { diff --git a/src/main/kotlin/com/cw/vlainter/domain/interview/service/InterviewPracticeService.kt b/src/main/kotlin/com/cw/vlainter/domain/interview/service/InterviewPracticeService.kt index 8d2be56..c9adf96 100644 --- a/src/main/kotlin/com/cw/vlainter/domain/interview/service/InterviewPracticeService.kt +++ b/src/main/kotlin/com/cw/vlainter/domain/interview/service/InterviewPracticeService.kt @@ -14,6 +14,7 @@ import com.cw.vlainter.domain.interview.dto.SubmitInterviewAnswerRequest import com.cw.vlainter.domain.interview.dto.SubmitInterviewAnswerResponse import com.cw.vlainter.domain.interview.dto.TurnEvaluationResponse import com.cw.vlainter.domain.interview.entity.InterviewQuestionKind +import com.cw.vlainter.domain.interview.entity.InterviewLanguage import com.cw.vlainter.domain.interview.entity.InterviewMode import com.cw.vlainter.domain.interview.entity.InterviewSession import com.cw.vlainter.domain.interview.entity.InterviewStatus @@ -83,6 +84,9 @@ class InterviewPracticeService( ) { companion object { private const val AI_GENERATED_SET_DESCRIPTION = "카테고리 기반 자동 생성 문답" + private val ENGLISH_WORD_REGEX = Regex("""\b[A-Za-z]{2,}\b""") + private val ENGLISH_LETTER_REGEX = Regex("""[A-Za-z]""") + private val HANGUL_REGEX = Regex("""[가-힣]""") } @Transactional @@ -156,6 +160,7 @@ class InterviewPracticeService( "questionCount" to questionCount, "difficulty" to request.difficulty?.name, "difficultyRating" to difficultyToRating(request.difficulty), + "language" to request.language.name, "categoryId" to primaryCategory?.id, "categoryName" to resolvedSkillName, "jobName" to resolvedJobName, @@ -176,6 +181,7 @@ class InterviewPracticeService( status = session.status.name, currentQuestion = toInterviewQuestionResponse(firstTurn), hasNext = questionCount > 1, + language = request.language.name, providerUsed = routingSnapshot.providerUsed?.name, fallbackDepth = routingSnapshot.fallbackDepth ) @@ -201,6 +207,7 @@ class InterviewPracticeService( ?: throw ResponseStatusException(HttpStatus.CONFLICT, "답변할 질문이 없습니다.") val submittedAnswer = request.answer.trim() + assertInterviewAnswerLanguage(session, submittedAnswer) turn.userAnswer = submittedAnswer turn.answeredAt = OffsetDateTime.now() interviewTurnRepository.save(turn) @@ -247,6 +254,7 @@ class InterviewPracticeService( ?: throw ResponseStatusException(HttpStatus.CONFLICT, "답변할 질문이 없습니다.") val submittedAnswer = request.answer.trim() + assertInterviewAnswerLanguage(session, submittedAnswer) turn.userAnswer = submittedAnswer turn.answeredAt = OffsetDateTime.now() interviewTurnRepository.save(turn) @@ -554,7 +562,8 @@ class InterviewPracticeService( jobName = jobName, skillName = skillName, difficulty = request.difficulty, - questionCount = request.questionCount.coerceAtLeast(5) + questionCount = request.questionCount.coerceAtLeast(5), + language = request.language ) } catch (ex: GeminiTransientException) { throw toGeminiOverloadException(ex) @@ -675,6 +684,7 @@ class InterviewPracticeService( } private fun createTurnFromRef(session: InterviewSession, turnNo: Int, ref: QuestionRef): InterviewTurn { + val language = resolveInterviewLanguage(session.configJson) val turn = when (ref.kind) { InterviewQuestionKind.TECH -> { val question = questionRepository.findByIdAndDeletedAtIsNull(ref.id) @@ -684,7 +694,11 @@ class InterviewPracticeService( turnNo = turnNo, sourceTag = toTurnSource(question), question = question, - questionTextSnapshot = question.questionText, + questionTextSnapshot = interviewAiOrchestrator.localizeInterviewText( + question.questionText, + language, + "interview question" + ) ?: question.questionText, categorySnapshot = question.category.name, jobSnapshot = question.jobName ?: question.category.parent?.name?.trim(), skillSnapshot = question.skillName ?: question.category.name.trim(), @@ -702,7 +716,11 @@ class InterviewPracticeService( turnNo = turnNo, sourceTag = TurnSourceTag.DOC_RAG, documentQuestion = question, - questionTextSnapshot = question.questionText, + questionTextSnapshot = interviewAiOrchestrator.localizeInterviewText( + question.questionText, + language, + "interview question" + ) ?: question.questionText, categorySnapshot = question.questionType, difficulty = question.difficulty, tagsJson = "[]", @@ -917,6 +935,7 @@ class InterviewPracticeService( sessionId = session.id, status = session.status.name, mode = session.mode.name, + language = meta?.get("language")?.asText()?.takeIf { it.isNotBlank() } ?: InterviewLanguage.KO.name, questionCount = meta?.get("questionCount")?.asInt() ?: max(parsed.queueSize, turns.size), difficulty = meta?.get("difficulty")?.asText() ?: turns.firstOrNull()?.difficulty, difficultyRating = meta?.get("difficultyRating")?.asInt() @@ -942,6 +961,7 @@ class InterviewPracticeService( sessionId = session.id, status = session.status.name, mode = session.mode.name, + language = meta?.get("language")?.asText()?.takeIf { it.isNotBlank() } ?: InterviewLanguage.KO.name, currentQuestion = toInterviewQuestionResponse(currentTurn), questionCount = meta?.get("questionCount")?.asInt() ?: max(parsed.queueSize, turns.size), difficulty = meta?.get("difficulty")?.asText() ?: turns.firstOrNull()?.difficulty, @@ -980,6 +1000,29 @@ class InterviewPracticeService( return ParsedSessionMeta(meta = meta, queueSize = queueSize, selectedDocuments = selectedDocuments) } + private fun resolveInterviewLanguage(configJson: String?): InterviewLanguage { + if (configJson.isNullOrBlank()) return InterviewLanguage.KO + val root = runCatching { objectMapper.readTree(configJson) }.getOrNull() ?: return InterviewLanguage.KO + val raw = root.path("meta").path("language").asText().trim().uppercase() + return runCatching { InterviewLanguage.valueOf(raw) }.getOrDefault(InterviewLanguage.KO) + } + + private fun assertInterviewAnswerLanguage(session: InterviewSession, answer: String) { + if (resolveInterviewLanguage(session.configJson) != InterviewLanguage.EN) return + val englishLetters = ENGLISH_LETTER_REGEX.findAll(answer).count() + val hangulLetters = HANGUL_REGEX.findAll(answer).count() + val englishWords = ENGLISH_WORD_REGEX.findAll(answer).count() + val looksEnglishEnough = when { + englishWords >= 5 && englishLetters >= hangulLetters * 2 -> true + englishWords >= 3 && hangulLetters == 0 && englishLetters >= 12 -> true + englishWords >= 8 -> true + else -> false + } + if (!looksEnglishEnough) { + throw ResponseStatusException(HttpStatus.BAD_REQUEST, "영어 면접에서는 영어 답변으로 작성해 주세요.") + } + } + private fun JsonNode.toInterviewHistoryDocumentResponse(): InterviewHistoryDocumentResponse? { val label = this["label"]?.asText()?.trim().orEmpty() if (label.isBlank()) return null diff --git a/src/test/kotlin/com/cw/vlainter/domain/interview/ai/InterviewAiOrchestratorTests.kt b/src/test/kotlin/com/cw/vlainter/domain/interview/ai/InterviewAiOrchestratorTests.kt index 42e7e9b..ab2f64e 100644 --- a/src/test/kotlin/com/cw/vlainter/domain/interview/ai/InterviewAiOrchestratorTests.kt +++ b/src/test/kotlin/com/cw/vlainter/domain/interview/ai/InterviewAiOrchestratorTests.kt @@ -1,5 +1,8 @@ +@file:Suppress("NonAsciiCharacters") + package com.cw.vlainter.domain.interview.ai +import com.cw.vlainter.domain.interview.entity.InterviewLanguage import com.cw.vlainter.global.config.properties.AiProperties import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import org.assertj.core.api.Assertions.assertThat @@ -103,4 +106,75 @@ class InterviewAiOrchestratorTests { assertThat(capturedPrompt).contains("STAR형 모범답안") assertThat(capturedPrompt).contains("상황/과제/행동/결과가 자연스럽게 드러나야 함") } + + @Test + fun `영어 문서 답변 평가 프롬프트는 영어 응답과 grammar 기준을 명시한다`() { + var capturedPrompt = "" + given(llmProviderRouter.generateJson(anyString(), nullable(Double::class.java))).willAnswer { invocation -> + capturedPrompt = invocation.getArgument(0) + LlmGenerationResult( + model = "gemini", + modelVersion = "v1", + text = """ + { + "score": 79, + "feedback": "The answer is relevant.", + "bestPractice": "Make the result more specific.", + "rubric": { + "coverage": 80, + "accuracy": 76, + "communication": 81 + }, + "evidence": ["Relevant", "Needs clearer result"] + } + """.trimIndent() + ) + } + + orchestrator.evaluateDocumentAnswer( + questionText = "How did you diagnose and improve the performance bottleneck in your project?", + referenceAnswer = "I would explain the situation, my role, the actions I took, and the measurable result.", + evidence = listOf("The portfolio mentions reducing dashboard latency and restructuring the API response."), + userAnswer = "I traced the bottleneck with profiling and changed the cache strategy.", + language = InterviewLanguage.EN + ) + + assertThat(capturedPrompt).contains("feedback, bestPractice, and evidence must be written in English.") + assertThat(capturedPrompt).contains("grammar, sentence completeness, clarity, and natural professional English quality") + assertThat(capturedPrompt).contains("document-based interview evaluator") + } + + @Test + fun `영어 기술 질문 생성 프롬프트는 질문과 모범답안을 영어로 요구한다`() { + var capturedPrompt = "" + given(llmProviderRouter.generateJson(anyString(), nullable(Double::class.java))).willAnswer { invocation -> + capturedPrompt = invocation.getArgument(0) + LlmGenerationResult( + model = "gemini", + modelVersion = "v1", + text = """ + { + "questions": [ + { + "questionText": "How would you explain the trade-off between consistency and availability in Redis caching?", + "canonicalAnswer": "I would first define the trade-off, then explain the practical impact on cache design and invalidation.", + "tags": ["redis", "cache"] + } + ] + } + """.trimIndent() + ) + } + + orchestrator.generateTechQuestions( + jobName = "Backend Engineer", + skillName = "Redis", + difficulty = null, + questionCount = 1, + language = InterviewLanguage.EN + ) + + assertThat(capturedPrompt).contains("Generate realistic technical interview questions and reference answers in English.") + assertThat(capturedPrompt).contains("questionText와 canonicalAnswer는 모두 English로 작성할 것") + } } diff --git a/src/test/kotlin/com/cw/vlainter/domain/interview/service/InterviewEvaluationServiceTests.kt b/src/test/kotlin/com/cw/vlainter/domain/interview/service/InterviewEvaluationServiceTests.kt index 8319951..69120ee 100644 --- a/src/test/kotlin/com/cw/vlainter/domain/interview/service/InterviewEvaluationServiceTests.kt +++ b/src/test/kotlin/com/cw/vlainter/domain/interview/service/InterviewEvaluationServiceTests.kt @@ -1,12 +1,14 @@ +@file:Suppress("NonAsciiCharacters") + package com.cw.vlainter.domain.interview.service import com.cw.vlainter.domain.interview.ai.InterviewAiOrchestrator import com.cw.vlainter.domain.interview.entity.DocumentQuestion +import com.cw.vlainter.domain.interview.entity.InterviewLanguage import com.cw.vlainter.domain.interview.entity.InterviewMode import com.cw.vlainter.domain.interview.entity.InterviewSession import com.cw.vlainter.domain.interview.entity.InterviewStatus import com.cw.vlainter.domain.interview.entity.InterviewTurn -import com.cw.vlainter.domain.interview.entity.InterviewTurnEvaluation import com.cw.vlainter.domain.interview.entity.RevealPolicy import com.cw.vlainter.domain.interview.entity.TurnSourceTag import com.cw.vlainter.domain.interview.repository.InterviewTurnEvaluationRepository @@ -113,7 +115,38 @@ class InterviewEvaluationServiceTests { assertThat(result.model).isEqualTo("heuristic") } - private fun createDocumentTurn(answer: String, referenceAnswer: String?): InterviewTurn { + @Test + fun `영어 문서 면접 fallback 평가는 영어 피드백과 문장 완성도 기준을 사용한다`() { + val turn = createDocumentTurn( + answer = "In that project, I owned the performance investigation. I profiled the API response path, found excessive serialization overhead, and changed the cache policy. As a result, the dashboard loaded faster and support complaints decreased.", + referenceAnswer = "I would describe the situation, my responsibility, the actions I took, and the measurable outcome.", + language = InterviewLanguage.EN + ) + + given( + interviewAiOrchestrator.evaluateDocumentAnswer( + questionText = turn.documentQuestion!!.questionText, + referenceAnswer = turn.documentQuestion!!.referenceAnswer, + evidence = objectMapper.readValue(turn.documentQuestion!!.evidenceJson, Array::class.java).toList(), + userAnswer = turn.userAnswer!!, + language = InterviewLanguage.EN + ) + ).willReturn(null) + + val result = invokeBuildEvaluation(turn, turn.userAnswer!!) + + assertThat(result.score.toInt()).isGreaterThanOrEqualTo(60) + assertThat(result.feedback).doesNotContainPattern("[가-힣]") + assertThat(result.bestPractice).doesNotContainPattern("[가-힣]") + assertThat(result.bestPractice.lowercase()).containsAnyOf("star", "result", "document") + assertThat(result.model).isEqualTo("heuristic") + } + + private fun createDocumentTurn( + answer: String, + referenceAnswer: String?, + language: InterviewLanguage = InterviewLanguage.KO + ): InterviewTurn { val user = User( id = 1L, email = "tester@vlainter.com", @@ -128,7 +161,13 @@ class InterviewEvaluationServiceTests { mode = InterviewMode.DOC, status = InterviewStatus.IN_PROGRESS, revealPolicy = RevealPolicy.PER_TURN, - configJson = "{}", + configJson = objectMapper.writeValueAsString( + mapOf( + "meta" to mapOf( + "language" to language.name + ) + ) + ), startedAt = OffsetDateTime.now(), createdAt = OffsetDateTime.now(), updatedAt = OffsetDateTime.now() @@ -139,14 +178,25 @@ class InterviewEvaluationServiceTests { userId = 1L, documentFileId = 77L, questionNo = 1, - questionText = "포트폴리오 프로젝트에서 성능 병목을 어떻게 발견하고 개선하셨나요?", + questionText = if (language == InterviewLanguage.EN) { + "How did you identify and improve the performance bottleneck in your portfolio project?" + } else { + "포트폴리오 프로젝트에서 성능 병목을 어떻게 발견하고 개선하셨나요?" + }, questionType = "PORTFOLIO_PROJECT", referenceAnswer = referenceAnswer, evidenceJson = objectMapper.writeValueAsString( - listOf( - "포트폴리오에 대시보드 초기 로딩 개선과 API 구조 조정 경험이 기재되어 있음", - "사용자 체감 속도 개선과 관련 문의 감소를 언급함" - ) + if (language == InterviewLanguage.EN) { + listOf( + "The portfolio describes improving dashboard startup latency and restructuring the API response.", + "It also mentions faster perceived speed and fewer support complaints." + ) + } else { + listOf( + "포트폴리오에 대시보드 초기 로딩 개선과 API 구조 조정 경험이 기재되어 있음", + "사용자 체감 속도 개선과 관련 문의 감소를 언급함" + ) + } ) ) return InterviewTurn( @@ -156,7 +206,7 @@ class InterviewEvaluationServiceTests { sourceTag = TurnSourceTag.DOC_RAG, documentQuestion = documentQuestion, questionTextSnapshot = documentQuestion.questionText, - categorySnapshot = "문서 기반 모의면접", + categorySnapshot = if (language == InterviewLanguage.EN) "Document-based mock interview" else "문서 기반 모의면접", userAnswer = answer, answeredAt = OffsetDateTime.now() ) From 35ae838629aa38a268acef0aed5cab25f1f579a7 Mon Sep 17 00:00:00 2001 From: rktclgh Date: Thu, 12 Mar 2026 17:04:15 +0900 Subject: [PATCH 4/8] =?UTF-8?q?=EB=8B=B5=EB=B3=80,=20=EC=A7=88=EB=AC=B8?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20=ED=92=88=EC=A7=88=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../interview/ai/InterviewAiOrchestrator.kt | 548 ++++++++++++++++-- .../service/DocumentInterviewService.kt | 265 +++++++-- .../DocumentQuestionGenerationPolicy.kt | 154 +++++ .../service/InterviewEvaluationService.kt | 440 ++++++++++---- .../service/InterviewPracticeService.kt | 183 ++++-- .../service/TurnLocalizationSupport.kt | 117 ++++ .../ai/InterviewAiOrchestratorTests.kt | 136 ++++- .../DocumentQuestionGenerationPolicyTests.kt | 48 ++ .../InterviewEvaluationServiceTests.kt | 76 ++- 9 files changed, 1690 insertions(+), 277 deletions(-) create mode 100644 src/main/kotlin/com/cw/vlainter/domain/interview/service/DocumentQuestionGenerationPolicy.kt create mode 100644 src/main/kotlin/com/cw/vlainter/domain/interview/service/TurnLocalizationSupport.kt create mode 100644 src/test/kotlin/com/cw/vlainter/domain/interview/service/DocumentQuestionGenerationPolicyTests.kt diff --git a/src/main/kotlin/com/cw/vlainter/domain/interview/ai/InterviewAiOrchestrator.kt b/src/main/kotlin/com/cw/vlainter/domain/interview/ai/InterviewAiOrchestrator.kt index 2ae4bb3..f0e1c60 100644 --- a/src/main/kotlin/com/cw/vlainter/domain/interview/ai/InterviewAiOrchestrator.kt +++ b/src/main/kotlin/com/cw/vlainter/domain/interview/ai/InterviewAiOrchestrator.kt @@ -19,10 +19,24 @@ class InterviewAiOrchestrator( ) { private val logger = LoggerFactory.getLogger(javaClass) - fun evaluateTechAnswer(question: QaQuestion?, userAnswer: String, language: InterviewLanguage = InterviewLanguage.KO): AiTurnEvaluation? { + fun evaluateTechAnswer( + question: QaQuestion?, + userAnswer: String, + language: InterviewLanguage = InterviewLanguage.KO, + responseLanguage: InterviewLanguage = language, + questionTextOverride: String? = null, + canonicalAnswerOverride: String? = null + ): AiTurnEvaluation? { if (userAnswer.isBlank()) return null - val prompt = buildEvaluationPrompt(question, userAnswer, language) + val prompt = buildEvaluationPrompt( + question = question, + userAnswer = userAnswer, + answerLanguage = language, + responseLanguage = responseLanguage, + questionTextOverride = questionTextOverride, + canonicalAnswerOverride = canonicalAnswerOverride + ) return runCatching { val generated = llmProviderRouter.generateJson(prompt) val parsed = parseEvaluationJson(generated.text) @@ -42,7 +56,7 @@ class InterviewAiOrchestrator( language: InterviewLanguage = InterviewLanguage.KO ): List { require(questionCount > 0) { "questionCount must be positive." } - val temperatures = listOf(0.40, 0.50, 0.60, 0.70, 0.80) + val temperatures = listOf(0.45, 0.60, 0.75) val collected = linkedMapOf() val maxRounds = temperatures.size var round = 0 @@ -55,7 +69,7 @@ class InterviewAiOrchestrator( try { val generated = llmProviderRouter.generateJson(prompt, temperature = temperature) val parsed = parseGeneratedDocumentQuestions(generated.text, fileTypeLabel) - val validated = validateGeneratedDocumentQuestions(parsed) + val validated = validateGeneratedDocumentQuestions(parsed, fileTypeLabel) validated.forEach { item -> val key = item.questionText.trim().lowercase() if (key.isNotBlank() && !collected.containsKey(key)) { @@ -225,29 +239,35 @@ class InterviewAiOrchestrator( fun evaluateDocumentAnswer( questionText: String, + questionType: String? = null, referenceAnswer: String?, evidence: List, userAnswer: String, - language: InterviewLanguage = InterviewLanguage.KO + language: InterviewLanguage = InterviewLanguage.KO, + responseLanguage: InterviewLanguage = language ): AiTurnEvaluation? { if (userAnswer.isBlank()) return null - val localizedQuestion = localizeInterviewText(questionText, language, "interview question") - val localizedReferenceAnswer = localizeInterviewText(referenceAnswer, language, "reference answer") - val localizedEvidence = evidence.map { localizeInterviewText(it, language, "document evidence") } + val starRecommended = documentQuestionTypeRequiresStar(questionType) + val localizedQuestionType = questionType?.trim().orEmpty().ifBlank { + emptyLocalizedPlaceholder(responseLanguage, "question type") + } val prompt = """ - ${evaluationSystemRole(language, "document-based interview evaluator")} - ${jsonLanguageInstruction(language)} + ${evaluationSystemRole(responseLanguage, "document-based interview evaluator")} + ${jsonLanguageInstruction(responseLanguage)} [질문] - $localizedQuestion + $questionText + + [질문 유형] + $localizedQuestionType [STAR형 참고 답안] - ${localizedReferenceAnswer?.takeIf { it.isNotBlank() } ?: emptyLocalizedPlaceholder(language, "reference answer")} + ${referenceAnswer?.takeIf { it.isNotBlank() } ?: emptyLocalizedPlaceholder(responseLanguage, "reference answer")} [근거 포인트] - ${if (localizedEvidence.isEmpty()) emptyLocalizedPlaceholder(language, "evidence") else localizedEvidence.joinToString("\n- ", prefix = "- ")} + ${if (evidence.isEmpty()) emptyLocalizedPlaceholder(responseLanguage, "evidence") else evidence.joinToString("\n- ", prefix = "- ")} [사용자 답변] $userAnswer @@ -269,12 +289,13 @@ class InterviewAiOrchestrator( - 반드시 JSON 객체만 반환 - 평가는 반드시 사용자 답변 자체를 중심으로 수행 - 참고 답안과 표현, 문장 순서, 단어 선택이 다르다는 이유만으로 감점하지 말 것 - - 참고 답안은 정답 매칭용이 아니라, 빠진 관점과 STAR 보강 포인트를 찾는 보조 자료로만 활용할 것 - - coverage는 질문 의도 적합성과 STAR 구조 완성도를 함께 평가한 점수로 산정 + - 참고 답안은 정답 매칭용이 아니라, 빠진 관점과 ${if (starRecommended) "STAR 보강 포인트" else "답변 보강 포인트"}를 찾는 보조 자료로만 활용할 것 + - coverage는 질문 의도 적합성을 가장 우선으로 평가하고, ${if (starRecommended) "STAR 구조 완성도를 함께 반영" else "동기/가치관/실행 계획의 구체성을 함께 반영"}한 점수로 산정 - accuracy는 기술적 설명의 타당성, 논리, 근거, 성과 설명의 설득력을 중심으로 산정 - communication은 답변 구조, 전달력, 면접 답변다운 정리 정도를 평가 - 답변이 문서 맥락과 명확히 어긋나거나 주장 근거가 부족하면 낮은 점수를 부여 - - bestPractice에는 빠진 STAR 요소(Situation, Task, Action, Result)와 보강할 근거를 구체적으로 적을 것 + - ${if (starRecommended) "경험/성과형 질문이므로 Situation, Task, Action, Result 중 빠진 축을 함께 점검" else "동기/가치관형 질문이므로 지원 맥락, 판단 기준, 실제 적용 계획, 근거 경험의 연결성을 함께 점검"} + - bestPractice에는 ${if (starRecommended) "빠진 STAR 요소(Situation, Task, Action, Result)" else "빠진 동기/가치관/실행 계획 요소"}와 보강할 근거를 구체적으로 적을 것 ${englishCommunicationRule(language)} """.trimIndent() @@ -289,12 +310,16 @@ class InterviewAiOrchestrator( } } - fun evaluateIntroductionAnswer(userAnswer: String, language: InterviewLanguage = InterviewLanguage.KO): AiTurnEvaluation? { + fun evaluateIntroductionAnswer( + userAnswer: String, + language: InterviewLanguage = InterviewLanguage.KO, + responseLanguage: InterviewLanguage = language + ): AiTurnEvaluation? { if (userAnswer.isBlank()) return null val prompt = """ - ${evaluationSystemRole(language, "interviewer evaluating the first self-introduction answer")} - ${jsonLanguageInstruction(language)} + ${evaluationSystemRole(responseLanguage, "interviewer evaluating the first self-introduction answer")} + ${jsonLanguageInstruction(responseLanguage)} [질문] ${localizedIntroQuestion(language)} @@ -334,6 +359,63 @@ class InterviewAiOrchestrator( } } + fun evaluateTurnsBatch( + items: List, + responseLanguage: InterviewLanguage = InterviewLanguage.KO + ): Map { + if (items.isEmpty()) return emptyMap() + + val prompt = """ + ${evaluationSystemRole(responseLanguage, "interview evaluator")} + ${jsonLanguageInstruction(responseLanguage)} + + 아래 각 항목을 서로 독립적으로 평가하고 반드시 JSON만 반환하세요. + + 출력 JSON 스키마: + { + "items": [ + { + "key": "stable key", + "score": 0~100 숫자(소수점 2자리까지), + "feedback": "총평(2~4문장)", + "bestPractice": "개선 가이드(2~4문장)", + "rubric": { + "coverage": 0~100, + "accuracy": 0~100, + "communication": 0~100 + }, + "evidence": ["평가 근거", "..."] + } + ] + } + + 공통 규칙: + - 항목별 평가는 서로 섞지 말고 독립적으로 수행 + - 반드시 모든 입력 key를 유지해서 반환 + - feedback, bestPractice, evidence는 모두 ${responseLanguage.displayLanguageName()}로 작성 + - kind=TECH: 질문 의도 적합성, 기술 정확성, 실무 근거를 중심으로 평가 + - kind=DOCUMENT: 사용자 답변 자체를 중심으로 평가하고 referenceAnswer는 정답 매칭이 아니라 보조 힌트로만 활용 + - questionType이 INTRODUCE_MOTIVATION, INTRODUCE_VALUE, INTRODUCE_FUTURE_PLAN 인 DOCUMENT 항목은 STAR를 과도하게 강제하지 말고 동기, 판단 기준, 실제 적용 계획, 근거 연결성을 평가 + - 그 외 DOCUMENT 항목은 질문 의도와 STAR 흐름(Situation, Task, Action, Result)을 함께 평가 + - kind=INTRO: 자기소개 답변으로서 역할, 강점, 지원 맥락, 전달력을 평가 + - answerLanguage=EN 이면 communication 점수에 grammar, sentence completeness, clarity, and natural professional English quality를 반영 + + [items] + ${objectMapper.writeValueAsString(items)} + """.trimIndent() + + return runCatching { + val generated = llmProviderRouter.generateJson(prompt) + parseBatchEvaluationJson(generated.text).mapValues { (_, value) -> + value.copy(model = generated.model, modelVersion = generated.modelVersion) + } + }.onFailure { ex -> + logger.warn("배치 면접 평가 실패(provider={}, count={}): {}", aiProperties.provider, items.size, ex.message) + }.getOrElse { ex -> + if (aiProperties.fallbackToHeuristic) emptyMap() else throw ex + } + } + fun validateEvidenceSnippets(fileTypeLabel: String, snippets: List): SnippetValidationResult { if (snippets.isEmpty()) { return SnippetValidationResult(emptyList(), emptyList()) @@ -360,20 +442,30 @@ class InterviewAiOrchestrator( } } - private fun buildEvaluationPrompt(question: QaQuestion?, userAnswer: String, language: InterviewLanguage): String { - val questionText = localizeInterviewText(question?.questionText.orEmpty(), language, "interview question") - val canonicalAnswer = localizeInterviewText( - question?.canonicalAnswer?.takeIf { it.isNotBlank() }, - language, - "reference answer" - ) ?: emptyLocalizedPlaceholder(language, "reference answer") + private fun buildEvaluationPrompt( + question: QaQuestion?, + userAnswer: String, + answerLanguage: InterviewLanguage, + responseLanguage: InterviewLanguage, + questionTextOverride: String?, + canonicalAnswerOverride: String? + ): String { + val questionText = questionTextOverride + ?: localizeInterviewText(question?.questionText.orEmpty(), answerLanguage, "interview question") + val canonicalAnswer = canonicalAnswerOverride + ?: localizeInterviewText( + question?.canonicalAnswer?.takeIf { it.isNotBlank() }, + answerLanguage, + "reference answer" + ) + ?: emptyLocalizedPlaceholder(responseLanguage, "reference answer") val category = question?.category?.name ?: "(카테고리 없음)" val difficulty = question?.difficulty?.name ?: "(난이도 없음)" val tags = question?.tagsJson ?: "[]" return """ - ${evaluationSystemRole(language, "technical interview evaluator")} - ${jsonLanguageInstruction(language)} + ${evaluationSystemRole(responseLanguage, "technical interview evaluator")} + ${jsonLanguageInstruction(responseLanguage)} [질문] $questionText @@ -406,7 +498,7 @@ class InterviewAiOrchestrator( - 반드시 JSON 객체만 반환 (코드블록 금지) - 점수는 관대하지 않게, 근거 중심으로 산정 - 사용자 답변이 질문과 무관하면 낮은 점수 부여 - ${englishCommunicationRule(language)} + ${englishCommunicationRule(answerLanguage)} """.trimIndent() } @@ -417,9 +509,24 @@ class InterviewAiOrchestrator( contextSnippets: List, language: InterviewLanguage ): String { + val normalizedFileType = normalizeDocumentFileType(fileTypeLabel) val joinedContext = contextSnippets .filter { it.isNotBlank() } - .joinToString("\n\n") { snippet -> "[문서 발췌]\n$snippet" } + .joinToString("\n\n") + + val rules = buildList { + add("- 총 ${questionCount}개 질문 생성") + add("- 질문은 구체적이어야 하며 문서의 내용과 직접 연결되어야 함") + add("- 각 문서 발췌에는 kind=ACTUAL_EXPERIENCE | PROJECT_OR_RESULT | MOTIVATION_OR_ASPIRATION | VALUE_OR_ATTITUDE 라벨이 붙어 있으므로 반드시 이를 해석해 사용할 것") + add("- questionType은 문서 유형과 발췌 kind에 맞는 값만 사용") + addAll(documentQuestionTypeRules(normalizedFileType)) + addAll(documentQuestionPatternRules(normalizedFileType)) + add("- 질문에 문서 발췌를 그대로 길게 인용하지 말고 자연스러운 면접 문장으로 바꿀 것") + add("- OCR 오류처럼 보이는 깨진 문자열, 무의미한 영문 대문자 나열, 문맥이 없는 잡음은 근거로 사용하지 말 것") + add("- 말이 안 되는 발췌는 건너뛰고, 의미가 분명한 다른 발췌를 선택할 것") + add("- 모든 questionText, referenceAnswer, evidence는 ${language.displayLanguageName()}로 작성할 것") + add("- 반드시 JSON만 출력") + } return """ ${generationSystemRole(language, "hiring interviewer")} @@ -440,24 +547,16 @@ class InterviewAiOrchestrator( "questions": [ { "questionText": "면접 질문", - "questionType": "RESUME_EXPERIENCE | PORTFOLIO_PROJECT | INTRODUCE_MOTIVATION 등", - "referenceAnswer": "이 질문에 대한 STAR형 모범답안(Situation, Task, Action, Result가 드러나는 면접 답변 형식, 4~8문장)", + "questionType": "RESUME_EXPERIENCE | RESUME_RESULT | PORTFOLIO_PROJECT | PORTFOLIO_RESULT | INTRODUCE_MOTIVATION | INTRODUCE_VALUE | INTRODUCE_FUTURE_PLAN | INTRODUCE_EXPERIENCE", + "evidenceKind": "ACTUAL_EXPERIENCE | PROJECT_OR_RESULT | MOTIVATION_OR_ASPIRATION | VALUE_OR_ATTITUDE", + "referenceAnswer": "경험/성과형 질문이면 STAR형 예시 답변, 동기/가치관형 질문이면 동기와 실행 계획이 드러나는 예시 답변", "evidence": ["질문의 근거가 된 문서 포인트", "..."] } ] } 규칙: - - 총 ${questionCount}개 질문 생성 - - 질문은 구체적이어야 하며 문서의 내용과 직접 연결되어야 함 - - 단순 나열형 질문 대신 이유, 역할, 의사결정, 결과를 묻는 면접형 질문 우선 - - referenceAnswer는 질문의 의도에 맞는 STAR형 예시 답변이어야 하며, 상황/과제/행동/결과가 자연스럽게 드러나야 함 - - referenceAnswer는 사용자의 실제 경험을 단정하지 말고, 문서 맥락을 바탕으로 한 설득력 있는 예시 답변 형태로 작성할 것 - - OCR 오류처럼 보이는 깨진 문자열, 무의미한 영문 대문자 나열, 문맥이 없는 잡음은 근거로 사용하지 말 것 - - 말이 안 되는 발췌는 건너뛰고, 의미가 분명한 다른 발췌를 선택할 것 - - 질문에 문서 발췌를 그대로 길게 인용하지 말고 자연스러운 면접 문장으로 바꿀 것 - - 모든 questionText, referenceAnswer, evidence는 ${language.displayLanguageName()}로 작성할 것 - - 반드시 JSON만 출력 + ${rules.joinToString("\n")} """.trimIndent() } @@ -584,6 +683,7 @@ class InterviewAiOrchestrator( questionNo = index + 1, questionText = questionText, questionType = item.text("questionType").ifBlank { toDocumentQuestionType(fileTypeLabel) }, + evidenceKind = item.text("evidenceKind").ifBlank { defaultEvidenceKind(fileTypeLabel) }, referenceAnswer = item.text("referenceAnswer").ifBlank { null }, evidence = item["evidence"] ?.takeIf { it.isArray } @@ -595,22 +695,33 @@ class InterviewAiOrchestrator( } private fun validateGeneratedDocumentQuestions( - generated: List + generated: List, + fileTypeLabel: String ): List { val seen = linkedSetOf() return generated.mapNotNull { item -> + val normalizedQuestionType = normalizeDocumentQuestionType(item.questionType, fileTypeLabel) + val normalizedEvidenceKind = normalizeEvidenceKind(item.evidenceKind) val normalizedQuestion = item.questionText.replace(Regex("\\s+"), " ").trim() - if (!isUsableDocumentQuestion(normalizedQuestion)) return@mapNotNull null + if (!isUsableDocumentQuestion(normalizedQuestion, normalizedQuestionType, normalizedEvidenceKind)) return@mapNotNull null val fingerprint = normalizedQuestion .lowercase() .replace(Regex("[^a-z0-9가-힣]+"), "") if (!seen.add(fingerprint)) return@mapNotNull null + if (!isAllowedDocumentQuestionType(normalizedQuestionType, fileTypeLabel, normalizedEvidenceKind)) return@mapNotNull null + if (!isCompatibleQuestionForEvidenceKind(normalizedQuestion, normalizedQuestionType, normalizedEvidenceKind)) return@mapNotNull null + val normalizedAnswer = item.referenceAnswer ?.replace(Regex("\\s+"), " ") ?.trim() - ?.takeIf { it.isNotBlank() && !isGuideLikeModelAnswer(it) && isDocumentAnswerLinkedToQuestion(it, normalizedQuestion) } + ?.takeIf { + it.isNotBlank() && + !isGuideLikeModelAnswer(it) && + isDocumentAnswerLinkedToQuestion(it, normalizedQuestion, normalizedQuestionType) && + isCompatibleReferenceAnswer(it, normalizedQuestionType, normalizedEvidenceKind) + } ?: return@mapNotNull null val normalizedEvidence = item.evidence @@ -622,6 +733,8 @@ class InterviewAiOrchestrator( item.copy( questionText = normalizedQuestion, + questionType = normalizedQuestionType, + evidenceKind = normalizedEvidenceKind, referenceAnswer = normalizedAnswer, evidence = normalizedEvidence ) @@ -738,9 +851,10 @@ class InterviewAiOrchestrator( } } - private fun isUsableDocumentQuestion(questionText: String): Boolean { + private fun isUsableDocumentQuestion(questionText: String, questionType: String, evidenceKind: String): Boolean { if (questionText.isBlank()) return false - if (questionText.length < 16) return false + val introQuestion = questionType.startsWith("INTRODUCE_") + if (questionText.length < if (introQuestion) 12 else 16) return false if (Regex("\\b(BACKEND|FRONTEND|SYSTEM_ARCH|EMBEDDED|DEVOPS|DATA|AI|ML|CLOUD|SECURITY)\\b").containsMatchIn(questionText)) return false val lowered = questionText.lowercase() val banned = listOf( @@ -765,20 +879,160 @@ class InterviewAiOrchestrator( val tokens = questionText .split(Regex("[^0-9a-zA-Z가-힣]+")) .filter { it.length >= 2 } - if (tokens.size < 4) return false - val domainHints = listOf( + if (tokens.size < if (introQuestion) 3 else 4) return false + val commonDomainHints = listOf( "프로젝트", "서비스", "사용자", "구현", "설계", "개선", "경험", "선택", "이유", "협업", "성능", "트러블슈팅", "문제", "해결", "운영", "개발", "아키텍처", "project", "service", "user", "implementation", "design", "improve", "experience", "decision", "reason", "collaboration", "performance", "troubleshooting", "problem", "solution", "operation", "development", "architecture", "result", "outcome" ) + val introduceHints = listOf( + "지원", "동기", "포부", "가치", "가치관", "기준", "관점", "태도", "실천", "계획", "입사", + "motivation", "value", "principle", "mindset", "plan", "goal", "future", "join", "apply" + ) + val domainHints = if (introQuestion || evidenceKind in setOf("MOTIVATION_OR_ASPIRATION", "VALUE_OR_ATTITUDE")) { + commonDomainHints + introduceHints + } else { + commonDomainHints + } if (domainHints.none { lowered.contains(it) }) return false if (!questionText.trim().endsWith("?")) return false return true } - private fun isDocumentAnswerLinkedToQuestion(answer: String, questionText: String): Boolean { + private fun documentQuestionTypeRules(fileTypeKey: String): List { + return when (fileTypeKey) { + "RESUME" -> listOf( + "- RESUME 문서는 questionType으로 RESUME_EXPERIENCE 또는 RESUME_RESULT만 사용", + "- 이력서 발췌가 실제 업무/프로젝트/성과를 말하지 않으면 과거형 경험 검증 질문으로 비약하지 말 것" + ) + "PORTFOLIO" -> listOf( + "- PORTFOLIO 문서는 questionType으로 PORTFOLIO_PROJECT, PORTFOLIO_RESULT, PORTFOLIO_DECISION 중 하나만 사용", + "- 포트폴리오 질문은 문제 해결, 기술 선택, 구현 책임, 결과를 묻되 문서에 없는 리더십/운영 범위를 지어내지 말 것" + ) + "INTRODUCE" -> listOf( + "- INTRODUCE 문서는 questionType으로 INTRODUCE_MOTIVATION, INTRODUCE_VALUE, INTRODUCE_FUTURE_PLAN, INTRODUCE_EXPERIENCE 중 하나만 사용", + "- MOTIVATION_OR_ASPIRATION 또는 VALUE_OR_ATTITUDE 발췌에서는 INTRODUCE_MOTIVATION, INTRODUCE_VALUE, INTRODUCE_FUTURE_PLAN만 사용", + "- ACTUAL_EXPERIENCE 또는 PROJECT_OR_RESULT 발췌에서만 INTRODUCE_EXPERIENCE를 사용할 수 있음" + ) + else -> listOf("- 문서 유형과 일치하는 questionType만 사용") + } + } + + private fun documentQuestionPatternRules(fileTypeKey: String): List { + val commonRules = mutableListOf( + "- 단순 나열형 질문 대신 이유, 역할, 의사결정, 결과를 묻는 면접형 질문 우선", + "- 사용자의 실제 경험을 단정하지 말고, 문서 맥락을 바탕으로 한 설득력 있는 질문과 예시 답변을 작성할 것" + ) + + return when (fileTypeKey) { + "INTRODUCE" -> commonRules + listOf( + "- 자기소개서의 미래지향적 문장, 포부, 마음가짐, 가치관을 이미 수행한 경험처럼 단정하여 질문하지 말 것", + "- MOTIVATION_OR_ASPIRATION 발췌에서는 왜 그런 관점을 갖게 되었는지, 입사 후 어떻게 적용할지, 어떤 기준을 중요하게 보는지 묻는 질문을 우선", + "- VALUE_OR_ATTITUDE 발췌에서는 판단 기준, 협업 원칙, 일하는 방식, 우선순위 기준을 묻는 질문을 우선", + "- ACTUAL_EXPERIENCE 또는 PROJECT_OR_RESULT 발췌가 명시적으로 있을 때만 어떻게 개선했는지, 어떤 기준으로 판단했는지, 결과와 리스크를 어떻게 관리했는지 묻는 행동형 질문 허용", + "- 동기/가치관형 referenceAnswer는 STAR를 억지로 맞추지 말고 동기, 근거 경험, 실제 적용 계획이 자연스럽게 드러나게 작성" + ) + else -> commonRules + listOf( + "- ACTUAL_EXPERIENCE와 PROJECT_OR_RESULT 발췌에서는 referenceAnswer를 STAR형 예시 답변으로 작성하고 상황/과제/행동/결과가 자연스럽게 드러나게 할 것" + ) + } + } + + private fun normalizeDocumentFileType(fileTypeLabel: String): String { + return when (fileTypeLabel.trim().uppercase()) { + "RESUME", "이력서" -> "RESUME" + "PORTFOLIO", "포트폴리오" -> "PORTFOLIO" + "INTRODUCE", "자기소개서" -> "INTRODUCE" + else -> fileTypeLabel.trim().uppercase() + } + } + + private fun normalizeDocumentQuestionType(questionType: String, fileTypeLabel: String): String { + val normalized = questionType.trim().uppercase() + if (normalized.isBlank()) return toDocumentQuestionType(fileTypeLabel) + return normalized + } + + private fun normalizeEvidenceKind(evidenceKind: String): String { + return when (evidenceKind.trim().uppercase()) { + "ACTUAL_EXPERIENCE", + "PROJECT_OR_RESULT", + "MOTIVATION_OR_ASPIRATION", + "VALUE_OR_ATTITUDE" -> evidenceKind.trim().uppercase() + else -> "ACTUAL_EXPERIENCE" + } + } + + private fun defaultEvidenceKind(fileTypeLabel: String): String { + return when (normalizeDocumentFileType(fileTypeLabel)) { + "INTRODUCE" -> "MOTIVATION_OR_ASPIRATION" + "PORTFOLIO" -> "PROJECT_OR_RESULT" + else -> "ACTUAL_EXPERIENCE" + } + } + + private fun isAllowedDocumentQuestionType(questionType: String, fileTypeLabel: String, evidenceKind: String): Boolean { + val fileTypeKey = normalizeDocumentFileType(fileTypeLabel) + if (!questionType.startsWith("${fileTypeKey}_")) return false + + return when (fileTypeKey) { + "INTRODUCE" -> when (evidenceKind) { + "MOTIVATION_OR_ASPIRATION" -> questionType in setOf("INTRODUCE_MOTIVATION", "INTRODUCE_FUTURE_PLAN") + "VALUE_OR_ATTITUDE" -> questionType in setOf("INTRODUCE_VALUE", "INTRODUCE_MOTIVATION") + "ACTUAL_EXPERIENCE", "PROJECT_OR_RESULT" -> questionType in setOf("INTRODUCE_EXPERIENCE", "INTRODUCE_MOTIVATION") + else -> false + } + "RESUME" -> questionType in setOf("RESUME_EXPERIENCE", "RESUME_RESULT") + "PORTFOLIO" -> questionType in setOf("PORTFOLIO_PROJECT", "PORTFOLIO_RESULT", "PORTFOLIO_DECISION") + else -> true + } + } + + private fun isCompatibleQuestionForEvidenceKind(questionText: String, questionType: String, evidenceKind: String): Boolean { + if (evidenceKind !in setOf("MOTIVATION_OR_ASPIRATION", "VALUE_OR_ATTITUDE")) return true + if (questionType.endsWith("_EXPERIENCE") || questionType.endsWith("_PROJECT") || questionType.endsWith("_RESULT") || questionType.endsWith("_DECISION")) { + return false + } + return !looksLikePastExecutionAssumption(questionText) + } + + private fun isCompatibleReferenceAnswer(referenceAnswer: String, questionType: String, evidenceKind: String): Boolean { + return if (evidenceKind in setOf("MOTIVATION_OR_ASPIRATION", "VALUE_OR_ATTITUDE")) { + !looksLikePastExecutionAssumption(referenceAnswer) && !questionType.endsWith("_EXPERIENCE") + } else { + true + } + } + + private fun looksLikePastExecutionAssumption(text: String): Boolean { + val lowered = text.lowercase() + val patterns = listOf( + "어떻게 개선", + "어떻게 관리", + "어떻게 해결", + "어떻게 줄였", + "어떤 리스크", + "how did you improve", + "how did you manage", + "what risks did you face", + "how did you reduce", + "그 과정에서", + "당시 발생할 수 있는 리스크", + "what happened during", + "during that process" + ) + return patterns.any { lowered.contains(it) } + } + + private fun documentQuestionTypeRequiresStar(questionType: String?): Boolean { + val normalized = questionType?.trim()?.uppercase().orEmpty() + if (normalized.isBlank()) return true + return normalized !in setOf("INTRODUCE_MOTIVATION", "INTRODUCE_VALUE", "INTRODUCE_FUTURE_PLAN") + } + + private fun isDocumentAnswerLinkedToQuestion(answer: String, questionText: String, questionType: String): Boolean { val answerTokens = answer.lowercase() .split(Regex("[^0-9a-zA-Z가-힣]+")) .filter { it.length >= 2 } @@ -790,7 +1044,15 @@ class InterviewAiOrchestrator( .filter { it.length >= 2 } .toSet() - return questionTokens.intersect(answerTokens).size >= 2 + val overlap = questionTokens.intersect(answerTokens).size + if (questionType in setOf("INTRODUCE_MOTIVATION", "INTRODUCE_VALUE", "INTRODUCE_FUTURE_PLAN")) { + if (overlap >= 1) return true + val motivationMarkers = listOf("동기", "가치", "기준", "계획", "포부", "motivation", "value", "plan", "goal") + return motivationMarkers.any { marker -> + questionText.contains(marker, ignoreCase = true) && answer.contains(marker, ignoreCase = true) + } + } + return overlap >= 2 } private fun isRateLimitError(ex: Exception): Boolean { @@ -868,6 +1130,148 @@ class InterviewAiOrchestrator( }.getOrDefault(source) } + fun localizeTurnContent( + questionText: String, + modelAnswer: String?, + evidence: List, + language: InterviewLanguage + ): LocalizedInterviewContent { + val normalizedQuestion = questionText.trim() + val normalizedModelAnswer = modelAnswer?.trim()?.takeIf { it.isNotBlank() } + val normalizedEvidence = evidence.mapNotNull { it.trim().takeIf(String::isNotBlank) } + if (language == InterviewLanguage.KO) { + return LocalizedInterviewContent(normalizedQuestion, normalizedModelAnswer, normalizedEvidence) + } + if (looksMostlyEnglish(normalizedQuestion) && + (normalizedModelAnswer == null || looksMostlyEnglish(normalizedModelAnswer)) && + normalizedEvidence.all(::looksMostlyEnglish) + ) { + return LocalizedInterviewContent(normalizedQuestion, normalizedModelAnswer, normalizedEvidence) + } + + val prompt = """ + You are a localization assistant for interview sessions. + Translate the following interview content into natural, professional English. + Preserve technical terms, product names, numbers, and factual meaning. + Return JSON only. + + { + "questionText": "translated question", + "modelAnswer": "translated model answer or empty string", + "evidence": ["translated evidence", "..."] + } + + [question] + $normalizedQuestion + + [modelAnswer] + ${normalizedModelAnswer ?: ""} + + [evidence] + ${if (normalizedEvidence.isEmpty()) "(empty)" else normalizedEvidence.joinToString("\n- ", prefix = "- ")} + """.trimIndent() + + return runCatching { + val generated = llmProviderRouter.generateJson(prompt, temperature = 0.2) + val node = objectMapper.readTree(generated.text) + LocalizedInterviewContent( + questionText = node["questionText"]?.asText()?.trim().takeIf { !it.isNullOrBlank() } ?: normalizedQuestion, + modelAnswer = node["modelAnswer"]?.asText()?.trim().takeIf { !it.isNullOrBlank() } ?: normalizedModelAnswer, + evidence = node["evidence"] + ?.takeIf { it.isArray } + ?.mapNotNull { it.asText().trim().takeIf(String::isNotBlank) } + ?: normalizedEvidence + ) + }.onFailure { ex -> + logger.warn("인터뷰 턴 현지화 실패(language={}): {}", language, ex.message) + }.getOrDefault( + LocalizedInterviewContent( + questionText = normalizedQuestion, + modelAnswer = normalizedModelAnswer, + evidence = normalizedEvidence + ) + ) + } + + fun localizeTurnContents( + items: List, + language: InterviewLanguage + ): Map { + if (items.isEmpty()) return emptyMap() + val normalized = items.associate { item -> + item.key to LocalizedInterviewContent( + questionText = item.questionText.trim(), + modelAnswer = item.modelAnswer?.trim()?.takeIf { it.isNotBlank() }, + evidence = item.evidence.mapNotNull { it.trim().takeIf(String::isNotBlank) } + ) + } + if (language == InterviewLanguage.KO) return normalized + + val pending = normalized.filterValues { content -> + !looksMostlyEnglish(content.questionText) || + (content.modelAnswer != null && !looksMostlyEnglish(content.modelAnswer)) || + content.evidence.any { !looksMostlyEnglish(it) } + } + if (pending.isEmpty()) return normalized + + val prompt = """ + You are a localization assistant for interview sessions. + Translate each interview turn item into natural, professional English. + Preserve technical terms, product names, numbers, and factual meaning. + Return JSON only. + + { + "items": [ + { + "key": "stable key", + "questionText": "translated question", + "modelAnswer": "translated model answer or empty string", + "evidence": ["translated evidence", "..."] + } + ] + } + + [items] + ${objectMapper.writeValueAsString( + pending.map { (key, content) -> + mapOf( + "key" to key, + "questionText" to content.questionText, + "modelAnswer" to content.modelAnswer.orEmpty(), + "evidence" to content.evidence + ) + } + )} + """.trimIndent() + + val localized = runCatching { + val generated = llmProviderRouter.generateJson(prompt, temperature = 0.2) + objectMapper.readTree(generated.text)["items"] + ?.takeIf { it.isArray } + ?.mapNotNull { item -> + val key = item["key"]?.asText()?.trim()?.takeIf { it.isNotBlank() } ?: return@mapNotNull null + key to LocalizedInterviewContent( + questionText = item["questionText"]?.asText()?.trim().takeIf { !it.isNullOrBlank() } + ?: normalized[key]?.questionText + ?: return@mapNotNull null, + modelAnswer = item["modelAnswer"]?.asText()?.trim().takeIf { !it.isNullOrBlank() } + ?: normalized[key]?.modelAnswer, + evidence = item["evidence"] + ?.takeIf { it.isArray } + ?.mapNotNull { evidenceItem -> evidenceItem.asText().trim().takeIf(String::isNotBlank) } + ?: normalized[key]?.evidence + ?: emptyList() + ) + } + ?.toMap() + .orEmpty() + }.onFailure { ex -> + logger.warn("인터뷰 턴 배치 현지화 실패(language={}, count={}): {}", language, pending.size, ex.message) + }.getOrDefault(emptyMap()) + + return normalized + localized + } + fun localizedIntroQuestion(language: InterviewLanguage): String { return when (language) { InterviewLanguage.KO -> "자기소개 부탁드리겠습니다." @@ -1031,7 +1435,22 @@ class InterviewAiOrchestrator( } private fun parseEvaluationJson(raw: String): AiTurnEvaluation { - val node = objectMapper.readTree(raw) + return parseEvaluationNode(objectMapper.readTree(raw)) + } + + private fun parseBatchEvaluationJson(raw: String): Map { + val root = objectMapper.readTree(raw) + return root["items"] + ?.takeIf { it.isArray } + ?.mapNotNull { item -> + val key = item["key"]?.asText()?.trim()?.takeIf { it.isNotBlank() } ?: return@mapNotNull null + key to parseEvaluationNode(item) + } + ?.toMap() + .orEmpty() + } + + private fun parseEvaluationNode(node: JsonNode): AiTurnEvaluation { val score = node.decimal("score").coerceIn(BigDecimal.ZERO, BigDecimal("100.00")) val feedback = node.text("feedback").ifBlank { "AI 피드백 생성에 실패했습니다." } val bestPractice = node.text("bestPractice").ifBlank { "" } @@ -1085,10 +1504,35 @@ data class AiTurnEvaluation( val modelVersion: String? = null ) +data class LocalizedInterviewContent( + val questionText: String, + val modelAnswer: String?, + val evidence: List = emptyList() +) + +data class TurnContentLocalizationRequest( + val key: String, + val questionText: String, + val modelAnswer: String?, + val evidence: List = emptyList() +) + +data class BatchTurnEvaluationInput( + val key: String, + val kind: String, + val answerLanguage: String, + val questionText: String, + val questionType: String? = null, + val referenceAnswer: String? = null, + val evidence: List = emptyList(), + val userAnswer: String +) + data class GeneratedDocumentQuestion( val questionNo: Int, val questionText: String, val questionType: String, + val evidenceKind: String, val referenceAnswer: String?, val evidence: List ) diff --git a/src/main/kotlin/com/cw/vlainter/domain/interview/service/DocumentInterviewService.kt b/src/main/kotlin/com/cw/vlainter/domain/interview/service/DocumentInterviewService.kt index 778b6ed..1651921 100644 --- a/src/main/kotlin/com/cw/vlainter/domain/interview/service/DocumentInterviewService.kt +++ b/src/main/kotlin/com/cw/vlainter/domain/interview/service/DocumentInterviewService.kt @@ -338,7 +338,7 @@ class DocumentInterviewService( val requestedCount = request.questionCount.coerceAtLeast(5).coerceAtMost(20) val desiredTechTarget = max(1, (requestedCount * 0.4).roundToInt()) val (techCandidates, generatedQuestions) = userGeminiApiKeyService.withUserApiKey(actor.id) { - val resolvedTechCandidates = if (selectedSetQuestions.isNotEmpty()) { + var resolvedTechCandidates = if (selectedSetQuestions.isNotEmpty()) { selectedSetQuestions } else { resolveOrGenerateTechCandidates( @@ -362,15 +362,31 @@ class DocumentInterviewService( questionCount = documentTarget, language = request.language ) + val requiredTechCount = (requestedCount - resolvedDocumentQuestions.size).coerceAtLeast(0) + if (requiredTechCount > resolvedTechCandidates.size) { + resolvedTechCandidates = if (selectedSetQuestions.isNotEmpty()) { + selectedSetQuestions + } else { + resolveOrGenerateTechCandidates( + actor = actor, + contexts = techContexts, + difficulty = request.difficulty, + requestedCount = requiredTechCount, + language = request.language + ) + } + } resolvedTechCandidates to resolvedDocumentQuestions } val techTarget = if (techCandidates.isNotEmpty()) { - min(techCandidates.size, max(1, (requestedCount * 0.4).roundToInt())) + min(techCandidates.size, (requestedCount - generatedQuestions.size).coerceAtLeast(0)) } else { 0 } val selectedTech = techCandidates.shuffled().take(techTarget) + val localizedQueue = buildLocalizedDocumentQueueEntries(actor.id, request.language, generatedQuestions) + + buildLocalizedTechQueueEntries(actor.id, request.language, selectedTech) val techMetaJobName = selectedQuestionSet?.jobName ?: techContexts.firstOrNull()?.jobName ?: selectedTech.firstOrNull()?.jobName @@ -432,6 +448,7 @@ class DocumentInterviewService( "categoryName" to techMetaSkillNames.joinToString(", "), "jobName" to techMetaJobName, "questionSetId" to selectedQuestionSet?.id, + "localizedQueue" to localizedQueue, "providerUsed" to aiRoutingContextHolder.snapshot().providerUsed?.name, "fallbackDepth" to aiRoutingContextHolder.snapshot().fallbackDepth, "selectedDocuments" to files.map { file -> @@ -478,7 +495,10 @@ class DocumentInterviewService( questionCount: Int, language: InterviewLanguage ): List { - val allocation = distribute(questionCount, files.size) + val allocation = DocumentQuestionGenerationPolicy.allocateQuestionCounts( + total = questionCount, + fileTypes = files.map { it.fileType } + ) val results = mutableListOf() val skippedReasons = mutableListOf() var lastGeminiTransient: GeminiTransientException? = null @@ -486,6 +506,7 @@ class DocumentInterviewService( files.forEachIndexed { index, file -> val targetCount = allocation[index] if (targetCount <= 0) return@forEachIndexed + val snippetBudget = DocumentQuestionGenerationPolicy.snippetBudget(file.fileType, targetCount) val chunks = docChunkEmbeddingRepository.findAllByUserIdAndUserFileIdOrderByChunkNoAsc(actor.id, file.id) .map { it.chunkText } @@ -501,20 +522,25 @@ class DocumentInterviewService( fileTypeLabel = file.fileType.toPromptLabel(), snippets = snippets ) - val strictValidatedSnippets = snippetValidation.acceptedSnippets + val heuristicValidatedSnippets = snippets + .map(::sanitizePromptSnippet) + .filter { isUsablePromptSnippet(file.fileType, it) } + .distinct() + .take(snippetBudget) + val strictValidatedSnippets = (snippetValidation.acceptedSnippets .map(::sanitizePromptSnippet) - .filter(::isUsablePromptSnippet) + .filter { isUsablePromptSnippet(file.fileType, it) } + heuristicValidatedSnippets) .distinct() - .take(max(2, min(targetCount + 1, 5))) + .take(snippetBudget) val relaxedValidatedSnippets = if (strictValidatedSnippets.isNotEmpty()) { strictValidatedSnippets } else { - (snippets + fallbackPromptSnippets(chunks)) + (snippets + fallbackPromptSnippets(chunks, snippetBudget, file.fileType)) .map(::sanitizePromptSnippet) - .filter(::isUsablePromptSnippet) + .filter { isUsablePromptSnippet(file.fileType, it) } .distinct() - .take(max(2, min(targetCount + 1, 5))) + .take(snippetBudget) } if (relaxedValidatedSnippets.isEmpty()) { val reason = "발췌 검증/완화 모두 실패(fileId=${file.id}, name=${file.originalFileName})" @@ -530,13 +556,19 @@ class DocumentInterviewService( file.originalFileName ) } + val classifiedSnippets = DocumentQuestionGenerationPolicy.classifySnippets( + fileType = file.fileType, + snippets = relaxedValidatedSnippets + ) val generated = runCatching { interviewAiOrchestrator.generateDocumentQuestions( fileTypeLabel = file.fileType.toPromptLabel(), difficulty = difficulty, questionCount = targetCount, - contextSnippets = relaxedValidatedSnippets, + contextSnippets = classifiedSnippets.mapIndexed { snippetIndex, snippet -> + snippet.toPromptBlock(snippetIndex + 1) + }, language = language ) }.onFailure { ex -> @@ -576,6 +608,13 @@ class DocumentInterviewService( "acceptedCount" to relaxedValidatedSnippets.size, "strictAcceptedCount" to strictValidatedSnippets.size, "usedRelaxedFallback" to usedRelaxedFallback, + "snippetBudget" to snippetBudget, + "classifiedKinds" to classifiedSnippets.map { + mapOf( + "kind" to it.kind.name, + "snippet" to it.text.take(160) + ) + }, "details" to snippetValidation.details.map { mapOf( "index" to it.index, @@ -751,7 +790,12 @@ class DocumentInterviewService( difficulty: QuestionDifficulty?, questionCount: Int ): List { - val retrievalQueries = buildRetrievalQueries(file.fileType, difficulty, questionCount) + val snippetBudget = DocumentQuestionGenerationPolicy.snippetBudget(file.fileType, questionCount) + val retrievalQueries = buildRetrievalQueries( + fileType = file.fileType, + difficulty = difficulty, + queryLimit = DocumentQuestionGenerationPolicy.retrievalQueryLimit(file.fileType, questionCount) + ) val allChunks = docChunkEmbeddingRepository.findAllByUserIdAndUserFileIdOrderByChunkNoAsc(userId, file.id) val seenChunkNos = linkedSetOf() val results = mutableListOf() @@ -776,13 +820,13 @@ class DocumentInterviewService( if (results.isNotEmpty()) { return results .map(::sanitizePromptSnippet) - .filter(::isUsablePromptSnippet) + .filter { isUsablePromptSnippet(file.fileType, it) } .map { it.take(420) } - .take(max(2, min(questionCount + 1, 5))) + .take(snippetBudget) } val chunks = allChunks.map { it.chunkText } - return fallbackPromptSnippets(chunks) + return fallbackPromptSnippets(chunks, snippetBudget, file.fileType) } private fun semanticRetrieve(chunks: List, queryVector: List): List> { @@ -855,7 +899,7 @@ class DocumentInterviewService( private fun buildRetrievalQueries( fileType: FileType, difficulty: QuestionDifficulty?, - questionCount: Int + queryLimit: Int ): List { val difficultyHint = when (difficulty) { QuestionDifficulty.HARD -> "의사결정 근거, 트레이드오프, 수치 성과" @@ -877,28 +921,39 @@ class DocumentInterviewService( FileType.INTRODUCE -> listOf( "자기소개서에서 지원 동기와 직무 적합성이 드러나는 내용", "자기소개서에서 강점과 근거 경험이 드러나는 내용", - "자기소개서에서 어려움 극복과 성장 과정이 드러나는 내용" + "자기소개서에서 어려움 극복과 성장 과정이 드러나는 내용", + "자기소개서에서 미래 계획이나 포부가 드러나는 내용", + "자기소개서에서 중요하게 여기는 가치관과 일하는 기준이 드러나는 내용" ) else -> listOf("문서에서 면접 질문으로 발전시킬 수 있는 핵심 경험") } return base - .take(max(2, min(questionCount, 3))) + .take(queryLimit) .map { "$it, 중점: $difficultyHint" } } - private fun fallbackPromptSnippets(chunks: List): List { + private fun fallbackPromptSnippets(chunks: List, snippetBudget: Int, fileType: FileType): List { val normalized = chunks .map(::sanitizePromptSnippet) - .filter(::isUsablePromptSnippet) + .filter { isUsablePromptSnippet(fileType, it) } if (normalized.isEmpty()) { - return chunks.take(1).map { it.take(500) } + return chunks.take(min(1, snippetBudget)).map { it.take(500) } } val indexes = when { - normalized.size <= 3 -> normalized.indices.toList() - else -> listOf(0, normalized.size / 2, normalized.lastIndex) + normalized.size <= snippetBudget -> normalized.indices.toList() + else -> buildList { + add(0) + val step = max(1, normalized.lastIndex / max(1, snippetBudget - 1)) + var current = step + while (size < snippetBudget - 1 && current < normalized.lastIndex) { + add(current) + current += step + } + add(normalized.lastIndex) + } }.distinct() return indexes.map { normalized[it].take(500) } @@ -1197,6 +1252,20 @@ class DocumentInterviewService( InterviewQuestionKind.TECH -> { val question = questionRepository.findByIdAndDeletedAtIsNull(ref.id) ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "질문을 찾을 수 없습니다: ${ref.id}") + val storedLocalized = findSessionLocalizedQueueContent(objectMapper, session.configJson, language, ref.kind, ref.id) + val localized = storedLocalized?.let { + com.cw.vlainter.domain.interview.ai.LocalizedInterviewContent( + questionText = it.questionText ?: question.questionText, + modelAnswer = it.modelAnswer, + evidence = it.evidence + ) + } ?: localizeTurnContentIfNeeded( + userId = session.user.id, + language = language, + questionText = question.questionText, + modelAnswer = question.canonicalAnswer, + evidence = emptyList() + ) InterviewTurn( session = session, turnNo = 1, @@ -1209,37 +1278,68 @@ class DocumentInterviewService( } }, question = question, - questionTextSnapshot = interviewAiOrchestrator.localizeInterviewText( - question.questionText, - language, - "interview question" - ) ?: question.questionText, + questionTextSnapshot = localized?.questionText ?: question.questionText, categorySnapshot = question.category.name, jobSnapshot = question.jobName ?: question.category.parent?.name?.trim(), skillSnapshot = question.skillName ?: question.category.name.trim(), category = question.category, difficulty = question.difficulty.name, - tagsJson = question.tagsJson + tagsJson = question.tagsJson, + ragContextJson = buildTurnRagContextJson( + objectMapper = objectMapper, + evidence = emptyList(), + language = language, + localized = localized?.let { + StoredLocalizedTurnContent( + questionText = it.questionText, + modelAnswer = it.modelAnswer, + evidence = it.evidence + ) + } + ) ) } InterviewQuestionKind.DOCUMENT -> { val question = documentQuestionRepository.findByIdAndUserId(ref.id, session.user.id) ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "문서 질문을 찾을 수 없습니다: ${ref.id}") + val evidence = runCatching { objectMapper.readValue(question.evidenceJson, Array::class.java).toList() } + .getOrDefault(emptyList()) + val storedLocalized = findSessionLocalizedQueueContent(objectMapper, session.configJson, language, ref.kind, ref.id) + val localized = storedLocalized?.let { + com.cw.vlainter.domain.interview.ai.LocalizedInterviewContent( + questionText = it.questionText ?: question.questionText, + modelAnswer = it.modelAnswer ?: question.referenceAnswer, + evidence = it.evidence.ifEmpty { evidence } + ) + } ?: localizeTurnContentIfNeeded( + userId = session.user.id, + language = language, + questionText = question.questionText, + modelAnswer = question.referenceAnswer, + evidence = evidence + ) InterviewTurn( session = session, turnNo = 1, sourceTag = TurnSourceTag.DOC_RAG, documentQuestion = question, - questionTextSnapshot = interviewAiOrchestrator.localizeInterviewText( - question.questionText, - language, - "interview question" - ) ?: question.questionText, + questionTextSnapshot = localized?.questionText ?: question.questionText, categorySnapshot = question.questionType, difficulty = question.difficulty, tagsJson = "[]", - ragContextJson = question.evidenceJson + ragContextJson = buildTurnRagContextJson( + objectMapper = objectMapper, + evidence = evidence, + language = language, + localized = localized?.let { + StoredLocalizedTurnContent( + questionText = it.questionText, + modelAnswer = it.modelAnswer, + evidence = it.evidence + ) + } + ) ) } @@ -1278,6 +1378,88 @@ class DocumentInterviewService( ) } + private fun localizeTurnContentIfNeeded( + userId: Long, + language: InterviewLanguage, + questionText: String, + modelAnswer: String?, + evidence: List + ): com.cw.vlainter.domain.interview.ai.LocalizedInterviewContent? { + if (language != InterviewLanguage.EN) return null + return userGeminiApiKeyService.withUserApiKey(userId) { + interviewAiOrchestrator.localizeTurnContent( + questionText = questionText, + modelAnswer = modelAnswer, + evidence = evidence, + language = language + ) + } + } + + private fun buildLocalizedTechQueueEntries( + userId: Long, + language: InterviewLanguage, + questions: List + ): List> { + if (language != InterviewLanguage.EN || questions.isEmpty()) return emptyList() + val localized = userGeminiApiKeyService.withUserApiKey(userId) { + interviewAiOrchestrator.localizeTurnContents( + questions.map { question -> + com.cw.vlainter.domain.interview.ai.TurnContentLocalizationRequest( + key = question.id.toString(), + questionText = question.questionText, + modelAnswer = question.canonicalAnswer, + evidence = emptyList() + ) + }, + language + ) + } + return buildSessionLocalizedQueueEntries( + kind = InterviewQuestionKind.TECH, + entries = localized.entries.associate { (key, value) -> + key.toLong() to StoredLocalizedTurnContent( + questionText = value.questionText, + modelAnswer = value.modelAnswer, + evidence = value.evidence + ) + } + ) + } + + private fun buildLocalizedDocumentQueueEntries( + userId: Long, + language: InterviewLanguage, + questions: List + ): List> { + if (language != InterviewLanguage.EN || questions.isEmpty()) return emptyList() + val localized = userGeminiApiKeyService.withUserApiKey(userId) { + interviewAiOrchestrator.localizeTurnContents( + questions.map { question -> + com.cw.vlainter.domain.interview.ai.TurnContentLocalizationRequest( + key = question.id.toString(), + questionText = question.questionText, + modelAnswer = question.referenceAnswer, + evidence = runCatching { + objectMapper.readValue(question.evidenceJson, Array::class.java).toList() + }.getOrDefault(emptyList()) + ) + }, + language + ) + } + return buildSessionLocalizedQueueEntries( + kind = InterviewQuestionKind.DOCUMENT, + entries = localized.entries.associate { (key, value) -> + key.toLong() to StoredLocalizedTurnContent( + questionText = value.questionText, + modelAnswer = value.modelAnswer, + evidence = value.evidence + ) + } + ) + } + private fun isIntroductionTurn(turn: InterviewTurn): Boolean { return turn.question == null && turn.documentQuestion == null && @@ -1333,8 +1515,10 @@ class DocumentInterviewService( private fun sanitizePromptSnippet(text: String): String = text.replace(Regex("\\s+"), " ").trim() - private fun isUsablePromptSnippet(text: String): Boolean { - if (text.length < 40) return false + private fun isUsablePromptSnippet(fileType: FileType, text: String): Boolean { + val minimumLength = if (fileType == FileType.INTRODUCE) 28 else 40 + val minimumLongTokens = if (fileType == FileType.INTRODUCE) 3 else 4 + if (text.length < minimumLength) return false val lettersOnly = text.filter { it.isLetter() } if (lettersOnly.isEmpty()) return false val uppercaseRatio = lettersOnly.count { it.isUpperCase() }.toDouble() / lettersOnly.length.toDouble() @@ -1342,7 +1526,7 @@ class DocumentInterviewService( val weirdTokenCount = text.split(" ").count { token -> token.length >= 6 && token.count(Char::isUpperCase) >= 4 } - return uppercaseRatio < 0.72 && longTokens >= 4 && weirdTokenCount <= 3 + return uppercaseRatio < 0.72 && longTokens >= minimumLongTokens && weirdTokenCount <= 3 } private fun fingerprintFor(questionText: String, categoryPath: String, difficulty: String): String { @@ -1382,13 +1566,6 @@ class DocumentInterviewService( private fun estimateTokenCount(text: String): Int = max(1, text.length / 4) - private fun distribute(total: Int, bucketCount: Int): List { - if (bucketCount <= 0) return emptyList() - val base = total / bucketCount - val remainder = total % bucketCount - return List(bucketCount) { index -> base + if (index < remainder) 1 else 0 } - } - private fun loadActiveUser(userId: Long): User { val user = userRepository.findById(userId) .orElseThrow { ResponseStatusException(HttpStatus.UNAUTHORIZED, "인증이 필요합니다.") } diff --git a/src/main/kotlin/com/cw/vlainter/domain/interview/service/DocumentQuestionGenerationPolicy.kt b/src/main/kotlin/com/cw/vlainter/domain/interview/service/DocumentQuestionGenerationPolicy.kt new file mode 100644 index 0000000..bb809d7 --- /dev/null +++ b/src/main/kotlin/com/cw/vlainter/domain/interview/service/DocumentQuestionGenerationPolicy.kt @@ -0,0 +1,154 @@ +package com.cw.vlainter.domain.interview.service + +import com.cw.vlainter.domain.userFile.entity.FileType +import kotlin.math.floor +import kotlin.math.max +import kotlin.math.min + +internal enum class DocumentSnippetKind { + ACTUAL_EXPERIENCE, + PROJECT_OR_RESULT, + MOTIVATION_OR_ASPIRATION, + VALUE_OR_ATTITUDE +} + +internal data class ClassifiedPromptSnippet( + val text: String, + val kind: DocumentSnippetKind +) { + fun toPromptBlock(index: Int): String = """ + [문서 발췌 $index] + kind=${kind.name} + text=$text + """.trimIndent() +} + +internal object DocumentQuestionGenerationPolicy { + private val aspirationMarkers = listOf( + "싶", "하고자", "하겠습니다", "가지겠습니다", "희망", "포부", "지원 동기", "지원동기", + "기여", "성장", "배우", "목표", "관심", "꿈", "되고자", "되겠습니다", + "want to", "hope to", "aspire", "would like to", "aim to", "looking forward" + ) + private val valueMarkers = listOf( + "가치", "가치관", "원칙", "태도", "관점", "생각", "중요", "신념", "마음가짐", "철학", + "value", "principle", "attitude", "belief", "perspective", "mindset", "important" + ) + private val experienceMarkers = listOf( + "인턴", "프로젝트", "실습", "근무", "경험", "개발", "운영", "담당", "수행", "개선", + "구현", "설계", "주도", "참여", "분석", "해결", "협업", "작성", "테스트", "관리", + "intern", "project", "worked", "built", "implemented", "designed", "led", "owned", + "improved", "managed", "analyzed", "solved", "delivered", "launched" + ) + private val resultMarkers = listOf( + "성과", "결과", "향상", "개선", "증가", "감소", "달성", "절감", "완료", "출시", "리스크 관리", + "%", "배", "명", "건", "ms", "초", "퍼센트", + "result", "outcome", "impact", "reduced", "increased", "improved", "decreased", + "faster", "latency", "throughput", "conversion", "retention", "%" + ) + private val completedExperienceMarkers = listOf( + "했습니다", "하였다", "해냈", "맡아", "주도해", "개선해", "구현해", "설계해", "운영해", + "worked on", "was responsible for", "took ownership", "handled", "delivered" + ) + + fun allocateQuestionCounts(total: Int, fileTypes: List): List { + if (total <= 0 || fileTypes.isEmpty()) return List(fileTypes.size) { 0 } + + val weights = fileTypes.map(::questionWeight) + val totalWeight = weights.sum() + val counts = MutableList(fileTypes.size) { 0 } + val remainders = mutableListOf>() + var assigned = 0 + + fileTypes.indices.forEach { index -> + val exact = total * (weights[index] / totalWeight) + val base = floor(exact).toInt() + counts[index] = base + assigned += base + remainders += index to (exact - base) + } + + remainders + .sortedWith(compareByDescending> { it.second }.thenBy { it.first }) + .take(total - assigned) + .forEach { (index, _) -> + counts[index] = counts[index] + 1 + } + + return counts + } + + fun snippetBudget(fileType: FileType, questionCount: Int): Int = when (fileType) { + FileType.INTRODUCE -> max(4, min(questionCount + 3, 8)) + FileType.PORTFOLIO -> max(3, min(questionCount + 2, 6)) + FileType.RESUME -> max(3, min(questionCount + 1, 5)) + FileType.PROFILE_IMAGE -> max(2, min(questionCount + 1, 4)) + } + + fun retrievalQueryLimit(fileType: FileType, questionCount: Int): Int = when (fileType) { + FileType.INTRODUCE -> max(3, min(questionCount + 1, 5)) + FileType.PORTFOLIO -> max(3, min(questionCount + 1, 4)) + FileType.RESUME -> max(2, min(questionCount, 3)) + FileType.PROFILE_IMAGE -> 2 + } + + fun classifySnippets(fileType: FileType, snippets: List): List { + return snippets.map { snippet -> + ClassifiedPromptSnippet( + text = snippet, + kind = classifySnippet(fileType, snippet) + ) + } + } + + private fun classifySnippet(fileType: FileType, snippet: String): DocumentSnippetKind { + val normalized = snippet.lowercase() + val aspirationScore = countMarkerHits(normalized, aspirationMarkers) + val valueScore = countMarkerHits(normalized, valueMarkers) + val experienceScore = countMarkerHits(normalized, experienceMarkers) + val resultScore = countMarkerHits(normalized, resultMarkers) + val completedScore = countMarkerHits(normalized, completedExperienceMarkers) + + if (fileType == FileType.INTRODUCE) { + if (resultScore >= 1 || completedScore >= 1 || (experienceScore >= 2 && aspirationScore == 0)) { + return if (resultScore >= 1) { + DocumentSnippetKind.PROJECT_OR_RESULT + } else { + DocumentSnippetKind.ACTUAL_EXPERIENCE + } + } + if (aspirationScore >= 1 && aspirationScore >= experienceScore) { + return DocumentSnippetKind.MOTIVATION_OR_ASPIRATION + } + if (valueScore >= 1) { + return DocumentSnippetKind.VALUE_OR_ATTITUDE + } + return if (experienceScore >= 1) { + DocumentSnippetKind.ACTUAL_EXPERIENCE + } else { + DocumentSnippetKind.MOTIVATION_OR_ASPIRATION + } + } + + if (resultScore >= 1) return DocumentSnippetKind.PROJECT_OR_RESULT + if (experienceScore >= 1 || completedScore >= 1) return DocumentSnippetKind.ACTUAL_EXPERIENCE + if (aspirationScore >= 1) return DocumentSnippetKind.MOTIVATION_OR_ASPIRATION + if (valueScore >= 1) return DocumentSnippetKind.VALUE_OR_ATTITUDE + + return if (fileType == FileType.PROFILE_IMAGE) { + DocumentSnippetKind.VALUE_OR_ATTITUDE + } else { + DocumentSnippetKind.ACTUAL_EXPERIENCE + } + } + + private fun questionWeight(fileType: FileType): Double = when (fileType) { + FileType.INTRODUCE -> 1.6 + FileType.PORTFOLIO -> 1.3 + FileType.RESUME -> 1.0 + FileType.PROFILE_IMAGE -> 0.5 + } + + private fun countMarkerHits(text: String, markers: List): Int { + return markers.count { marker -> text.contains(marker.lowercase()) } + } +} diff --git a/src/main/kotlin/com/cw/vlainter/domain/interview/service/InterviewEvaluationService.kt b/src/main/kotlin/com/cw/vlainter/domain/interview/service/InterviewEvaluationService.kt index b2f9bcc..392150c 100644 --- a/src/main/kotlin/com/cw/vlainter/domain/interview/service/InterviewEvaluationService.kt +++ b/src/main/kotlin/com/cw/vlainter/domain/interview/service/InterviewEvaluationService.kt @@ -1,5 +1,6 @@ package com.cw.vlainter.domain.interview.service +import com.cw.vlainter.domain.interview.ai.BatchTurnEvaluationInput import com.cw.vlainter.domain.interview.ai.InterviewAiOrchestrator import com.cw.vlainter.domain.interview.dto.TurnEvaluationResponse import com.cw.vlainter.domain.interview.entity.InterviewTurn @@ -50,6 +51,11 @@ class InterviewEvaluationService( val DOCUMENT_ACTION_SIGNALS_EN = setOf("implemented", "designed", "improved", "introduced", "analyzed", "resolved", "optimized", "refactored", "collaborated", "validated", "built", "led") val DOCUMENT_RESULT_SIGNALS_EN = setOf("result", "outcome", "improved", "reduced", "increased", "shortened", "completed", "achieved", "stabilized", "launched") val DOCUMENT_REASONING_SIGNALS_EN = setOf("because", "therefore", "so that", "in order to", "reason", "evidence", "validated", "compared", "trade-off") + val MOTIVATION_SIGNALS = setOf("지원", "동기", "가치", "가치관", "중요", "이유", "관심", "기준", "태도", "관점", "포부") + val FUTURE_PLAN_SIGNALS = setOf( + "하겠습니다", "하고자", "적용", "실천", "기여", "앞으로", "입사 후", "향후", + "will", "would", "plan to", "apply", "contribute", "going forward" + ) val ENGLISH_WORD_REGEX = Regex("""\b[A-Za-z]{2,}\b""") val ENGLISH_LETTER_REGEX = Regex("""[A-Za-z]""") val HANGUL_REGEX = Regex("""[가-힣]""") @@ -76,43 +82,7 @@ class InterviewEvaluationService( buildEvaluation(turn, answer) } - val candidate = existing?.apply { - totalScore = evaluation.score - feedback = evaluation.feedback - bestPractice = evaluation.bestPractice - rubricScoresJson = evaluation.rubricScoresJson - evidenceJson = evaluation.evidenceJson - model = evaluation.model - modelVersion = evaluation.modelVersion - } ?: InterviewTurnEvaluation( - turn = turn, - totalScore = evaluation.score, - feedback = evaluation.feedback, - bestPractice = evaluation.bestPractice, - rubricScoresJson = evaluation.rubricScoresJson, - evidenceJson = evaluation.evidenceJson, - model = evaluation.model, - modelVersion = evaluation.modelVersion - ) - - val saved = saveTurnEvaluationWithConflictRecovery(turn.id, candidate) - turn.evaluationStatus = TurnEvaluationStatus.DONE - - if (turn.question != null && shouldStoreHistory(turn.session.configJson) && !userQuestionAttemptRepository.existsByTurn_Id(turn.id)) { - userQuestionAttemptRepository.save( - UserQuestionAttempt( - user = turn.session.user, - question = turn.question, - session = turn.session, - turn = turn, - answerText = answer, - totalScore = evaluation.score, - feedbackSummary = evaluation.feedback - ) - ) - } - - saved.toResponse() + persistEvaluation(turn, answer, evaluation).toResponse() } } finally { if (!lock.hasQueuedThreads()) { @@ -143,27 +113,164 @@ class InterviewEvaluationService( @Transactional fun evaluateOutstandingTurnsSync(sessionId: Long) { - interviewTurnRepository.findAllBySession_IdOrderByTurnNoAsc(sessionId) + val pendingTurns = interviewTurnRepository.findAllBySession_IdOrderByTurnNoAsc(sessionId) .filter { it.answeredAt != null && it.evaluationStatus != TurnEvaluationStatus.DONE } - .forEach { selfProvider.getObject().evaluateTurnSync(it.id) } + if (pendingTurns.isEmpty()) return + if (pendingTurns.size == 1) { + selfProvider.getObject().evaluateTurnSync(pendingTurns.first().id) + return + } + + val batchEvaluations = userGeminiApiKeyService.withUserApiKey(pendingTurns.first().session.user.id) { + buildBatchEvaluations(pendingTurns) + } + pendingTurns.forEach { turn -> + val answer = turn.userAnswer?.trim().orEmpty() + val evaluation = batchEvaluations[turn.id] + if (evaluation != null) { + persistEvaluation(turn, answer, evaluation) + } else { + selfProvider.getObject().evaluateTurnSync(turn.id) + } + } + } + + private fun persistEvaluation( + turn: InterviewTurn, + answer: String, + evaluation: EvaluationResult + ): InterviewTurnEvaluation { + val existing = interviewTurnEvaluationRepository.findByTurn_Id(turn.id) + val candidate = existing?.apply { + totalScore = evaluation.score + feedback = evaluation.feedback + bestPractice = evaluation.bestPractice + rubricScoresJson = evaluation.rubricScoresJson + evidenceJson = evaluation.evidenceJson + model = evaluation.model + modelVersion = evaluation.modelVersion + } ?: InterviewTurnEvaluation( + turn = turn, + totalScore = evaluation.score, + feedback = evaluation.feedback, + bestPractice = evaluation.bestPractice, + rubricScoresJson = evaluation.rubricScoresJson, + evidenceJson = evaluation.evidenceJson, + model = evaluation.model, + modelVersion = evaluation.modelVersion + ) + + val saved = saveTurnEvaluationWithConflictRecovery(turn.id, candidate) + turn.evaluationStatus = TurnEvaluationStatus.DONE + + if (turn.question != null && shouldStoreHistory(turn.session.configJson) && !userQuestionAttemptRepository.existsByTurn_Id(turn.id)) { + userQuestionAttemptRepository.save( + UserQuestionAttempt( + user = turn.session.user, + question = turn.question, + session = turn.session, + turn = turn, + answerText = answer, + totalScore = evaluation.score, + feedbackSummary = evaluation.feedback + ) + ) + } + + return saved + } + + private fun buildBatchEvaluations(turns: List): Map { + val responseLanguage = InterviewLanguage.KO + val prepared = turns.mapNotNull { turn -> + val answer = turn.userAnswer?.trim().orEmpty() + answer.takeIf { it.isNotBlank() }?.let { prepareBatchEvaluationInput(turn, it) } + } + if (prepared.isEmpty()) return emptyMap() + + val aiResults = interviewAiOrchestrator.evaluateTurnsBatch( + items = prepared.map { it.input }, + responseLanguage = responseLanguage + ) + return prepared.mapNotNull { item -> + aiResults[item.turn.id.toString()]?.let { ai -> + item.turn.id to EvaluationResult( + score = ai.score, + feedback = ai.feedback, + bestPractice = if (item.userGeneratedQuestion) "" else ai.bestPractice.ifBlank { item.resolvedAnswer.guideText.orEmpty() }, + modelAnswer = item.resolvedAnswer.modelAnswer, + rubricScoresJson = ai.rubricScoresJson, + evidenceJson = ai.evidenceJson, + model = ai.model, + modelVersion = ai.modelVersion + ) + } + }.toMap() + } + + private fun prepareBatchEvaluationInput( + turn: InterviewTurn, + answer: String + ): PreparedBatchEvaluation { + val sessionLanguage = resolveInterviewLanguage(turn) + val userGeneratedQuestion = turn.sourceTag == TurnSourceTag.USER + val turnContext = parseTurnRagContext(objectMapper, turn.ragContextJson) + val localizedQuestionText = turnContext.localizedQuestionTextFor(sessionLanguage) ?: turn.questionTextSnapshot + val localizedModelAnswer = turnContext.localizedModelAnswerFor(sessionLanguage) + ?: turn.question?.canonicalAnswer + ?: turn.documentQuestion?.referenceAnswer + val localizedEvidence = turnContext.localizedEvidenceFor(sessionLanguage) + val documentQuestion = turn.documentQuestion + val resolvedAnswer = resolveAnswerContent( + rawModelAnswer = localizedModelAnswer, + rawGuideText = null + ) + val evidence = when { + localizedEvidence.isNotEmpty() -> localizedEvidence + documentQuestion != null -> parseJsonArray(documentQuestion.evidenceJson) + else -> emptyList() + } + val kind = when { + isIntroductionTurn(turn) -> "INTRO" + turn.question != null -> "TECH" + else -> "DOCUMENT" + } + return PreparedBatchEvaluation( + turn = turn, + userGeneratedQuestion = userGeneratedQuestion, + resolvedAnswer = resolvedAnswer, + input = BatchTurnEvaluationInput( + key = turn.id.toString(), + kind = kind, + answerLanguage = sessionLanguage.name, + questionText = localizedQuestionText, + questionType = documentQuestion?.questionType, + referenceAnswer = resolvedAnswer.modelAnswer, + evidence = evidence, + userAnswer = answer + ) + ) } private fun buildEvaluation(turn: InterviewTurn, answer: String): EvaluationResult { val sessionLanguage = resolveInterviewLanguage(turn) + val responseLanguage = InterviewLanguage.KO val userGeneratedQuestion = turn.sourceTag == TurnSourceTag.USER + val turnContext = parseTurnRagContext(objectMapper, turn.ragContextJson) + val localizedQuestionText = turnContext.localizedQuestionTextFor(sessionLanguage) ?: turn.questionTextSnapshot + val localizedModelAnswer = turnContext.localizedModelAnswerFor(sessionLanguage) + ?: turn.question?.canonicalAnswer + ?: turn.documentQuestion?.referenceAnswer + val localizedEvidence = turnContext.localizedEvidenceFor(sessionLanguage) val resolvedAnswer = resolveAnswerContent( - rawModelAnswer = turn.question?.canonicalAnswer ?: turn.documentQuestion?.referenceAnswer, + rawModelAnswer = localizedModelAnswer, rawGuideText = null ) if (answer.isBlank()) { return EvaluationResult( score = BigDecimal.ZERO.setScale(2), - feedback = if (sessionLanguage == InterviewLanguage.EN) { - "Your answer is empty. Please rewrite it with the key point included." - } else { - "답변이 비어 있습니다. 핵심 내용을 포함해 다시 작성해 주세요." - }, + feedback = "답변이 비어 있습니다. 핵심 내용을 포함해 다시 작성해 주세요.", bestPractice = if (userGeneratedQuestion) "" else resolvedAnswer.guideText .orEmpty(), modelAnswer = resolvedAnswer.modelAnswer, @@ -177,16 +284,25 @@ class InterviewEvaluationService( val question = turn.question val documentQuestion = turn.documentQuestion val aiEvaluation = if (isIntroductionTurn(turn)) { - interviewAiOrchestrator.evaluateIntroductionAnswer(answer, sessionLanguage) + interviewAiOrchestrator.evaluateIntroductionAnswer(answer, sessionLanguage, responseLanguage) } else if (question != null) { - interviewAiOrchestrator.evaluateTechAnswer(question, answer, sessionLanguage) + interviewAiOrchestrator.evaluateTechAnswer( + question = question, + userAnswer = answer, + language = sessionLanguage, + responseLanguage = responseLanguage, + questionTextOverride = localizedQuestionText, + canonicalAnswerOverride = resolvedAnswer.modelAnswer + ) } else if (documentQuestion != null) { interviewAiOrchestrator.evaluateDocumentAnswer( - questionText = documentQuestion.questionText, - referenceAnswer = documentQuestion.referenceAnswer, - evidence = parseJsonArray(documentQuestion.evidenceJson), + questionText = localizedQuestionText, + questionType = documentQuestion.questionType, + referenceAnswer = resolvedAnswer.modelAnswer, + evidence = if (localizedEvidence.isNotEmpty()) localizedEvidence else parseJsonArray(documentQuestion.evidenceJson), userAnswer = answer, - language = sessionLanguage + language = sessionLanguage, + responseLanguage = responseLanguage ) } else { null @@ -206,22 +322,20 @@ class InterviewEvaluationService( if (documentQuestion != null) { return buildDocumentHeuristicEvaluation( - questionText = documentQuestion.questionText, - referenceAnswer = documentQuestion.referenceAnswer, - evidence = parseJsonArray(documentQuestion.evidenceJson), + questionText = localizedQuestionText, + questionType = documentQuestion.questionType, + referenceAnswer = resolvedAnswer.modelAnswer, + evidence = if (localizedEvidence.isNotEmpty()) localizedEvidence else parseJsonArray(documentQuestion.evidenceJson), answer = answer, userGeneratedQuestion = userGeneratedQuestion, resolvedAnswer = resolvedAnswer, - language = sessionLanguage + answerLanguage = sessionLanguage, + responseLanguage = responseLanguage ) } val canonical = resolvedAnswer.modelAnswer.orEmpty() - val questionText = if (sessionLanguage == InterviewLanguage.EN) { - turn.questionTextSnapshot - } else { - question?.questionText.orEmpty() - } + val questionText = localizedQuestionText val score = if (isLowEffortAnswer(answer)) { BigDecimal.ZERO.setScale(2) } else if (sessionLanguage == InterviewLanguage.EN && (canonical.isBlank() || !looksMostlyEnglish(canonical))) { @@ -247,20 +361,11 @@ class InterviewEvaluationService( BigDecimal(numeric).setScale(2, RoundingMode.HALF_UP) } - val feedback = if (sessionLanguage == InterviewLanguage.EN) { - when { - score <= BigDecimal("10.00") -> "Your answer barely addresses the core of the question. Restate the concept clearly and explain where it applies." - score >= BigDecimal("85.00") -> "You explained the core idea well. Add one concrete example or trade-off to make it stronger." - score >= BigDecimal("70.00") -> "The main point is mostly correct, but the structure is still loose. Lead with the conclusion, then support it with reasoning." - else -> "Some key points are missing. Separate the concept, reasoning, and practical application more clearly." - } - } else { - when { - score <= BigDecimal("10.00") -> "질문에 대한 핵심 내용이 거의 제시되지 않았습니다. 최소한 개념 정의와 적용 맥락은 포함해서 다시 답해 보세요." - score >= BigDecimal("85.00") -> "핵심 개념을 잘 설명했습니다. 근거 사례를 한 줄 추가하면 더 좋습니다." - score >= BigDecimal("70.00") -> "핵심은 맞지만 설명 구조가 다소 약합니다. 결론을 먼저 말하고 근거를 붙여 보세요." - else -> "핵심 포인트 누락이 있습니다. 용어 정의와 문제 해결 흐름을 분리해 작성해 보세요." - } + val feedback = when { + score <= BigDecimal("10.00") -> "질문에 대한 핵심 내용이 거의 제시되지 않았습니다. 최소한 개념 정의와 적용 맥락은 포함해서 다시 답해 보세요." + score >= BigDecimal("85.00") -> "핵심 개념을 잘 설명했습니다. 근거 사례를 한 줄 추가하면 더 좋습니다." + score >= BigDecimal("70.00") -> "핵심은 맞지만 설명 구조가 다소 약합니다. 결론을 먼저 말하고 근거를 붙여 보세요." + else -> "핵심 포인트 누락이 있습니다. 용어 정의와 문제 해결 흐름을 분리해 작성해 보세요." } val bestPractice = if (userGeneratedQuestion) "" else resolvedAnswer.guideText.orEmpty() @@ -275,25 +380,45 @@ class InterviewEvaluationService( private fun buildDocumentHeuristicEvaluation( questionText: String, + questionType: String?, referenceAnswer: String?, evidence: List, answer: String, userGeneratedQuestion: Boolean, resolvedAnswer: ResolvedAnswerContent, - language: InterviewLanguage + answerLanguage: InterviewLanguage, + responseLanguage: InterviewLanguage ): EvaluationResult { + val starRecommended = questionTypeRequiresStar(questionType) + val koreanResponse = responseLanguage == InterviewLanguage.KO if (isLowEffortAnswer(answer)) { return EvaluationResult( score = BigDecimal.ZERO.setScale(2), - feedback = if (language == InterviewLanguage.EN) { - "Your answer does not yet show the core experience or your own actions clearly enough. Rebuild it around the experience described in your document." + feedback = if (!koreanResponse) { + if (starRecommended) { + "Your answer does not yet show the core experience or your own actions clearly enough. Rebuild it around the experience described in your document." + } else { + "Your answer does not yet explain your motivation, judgment criteria, or practical plan clearly enough. Rebuild it around the point described in your document." + } } else { - "질문 의도에 맞는 핵심 경험과 본인의 행동이 거의 드러나지 않았습니다. 문서에 적은 경험을 기준으로 다시 답변해 보세요." + if (starRecommended) { + "질문 의도에 맞는 핵심 경험과 본인의 행동이 거의 드러나지 않았습니다. 문서에 적은 경험을 기준으로 다시 답변해 보세요." + } else { + "질문 의도에 맞는 동기, 판단 기준, 실제 적용 계획이 거의 드러나지 않았습니다. 문서에 적은 맥락을 기준으로 다시 답변해 보세요." + } }, - bestPractice = if (userGeneratedQuestion) "" else if (language == InterviewLanguage.EN) { - "Reorganize the answer in STAR order so the situation, responsibility, actions, and result are each explicit." + bestPractice = if (userGeneratedQuestion) "" else if (!koreanResponse) { + if (starRecommended) { + "Reorganize the answer in STAR order so the situation, responsibility, actions, and result are each explicit." + } else { + "State your motivation or principle first, then connect it to a concrete reason and how you would apply it in practice." + } } else { - "질문의 의도에 맞춰 당시 상황, 맡은 역할, 실제 행동, 결과를 STAR 순서로 다시 정리해 보세요." + if (starRecommended) { + "질문의 의도에 맞춰 당시 상황, 맡은 역할, 실제 행동, 결과를 STAR 순서로 다시 정리해 보세요." + } else { + "질문 의도에 맞춰 지원 동기나 판단 기준을 먼저 말하고, 그 근거와 실제 적용 계획을 함께 정리해 보세요." + } }, modelAnswer = resolvedAnswer.modelAnswer, rubricScoresJson = """{"coverage":0,"accuracy":0,"communication":0}""", @@ -307,11 +432,11 @@ class InterviewEvaluationService( val questionTokens = tokenize(questionText) val evidenceTokens = evidence.flatMap { tokenize(it) }.toSet() val softBenchmarkTokens = extractDocumentSoftBenchmarkTokens(referenceAnswer, evidence) - val contextSignalsSet = if (language == InterviewLanguage.EN) DOCUMENT_CONTEXT_SIGNALS_EN else DOCUMENT_CONTEXT_SIGNALS - val taskSignalsSet = if (language == InterviewLanguage.EN) DOCUMENT_TASK_SIGNALS_EN else DOCUMENT_TASK_SIGNALS - val actionSignalsSet = if (language == InterviewLanguage.EN) DOCUMENT_ACTION_SIGNALS_EN else DOCUMENT_ACTION_SIGNALS - val resultSignalsSet = if (language == InterviewLanguage.EN) DOCUMENT_RESULT_SIGNALS_EN else DOCUMENT_RESULT_SIGNALS - val reasoningSignalsSet = if (language == InterviewLanguage.EN) DOCUMENT_REASONING_SIGNALS_EN else DOCUMENT_REASONING_SIGNALS + val contextSignalsSet = if (answerLanguage == InterviewLanguage.EN) DOCUMENT_CONTEXT_SIGNALS_EN else DOCUMENT_CONTEXT_SIGNALS + val taskSignalsSet = if (answerLanguage == InterviewLanguage.EN) DOCUMENT_TASK_SIGNALS_EN else DOCUMENT_TASK_SIGNALS + val actionSignalsSet = if (answerLanguage == InterviewLanguage.EN) DOCUMENT_ACTION_SIGNALS_EN else DOCUMENT_ACTION_SIGNALS + val resultSignalsSet = if (answerLanguage == InterviewLanguage.EN) DOCUMENT_RESULT_SIGNALS_EN else DOCUMENT_RESULT_SIGNALS + val reasoningSignalsSet = if (answerLanguage == InterviewLanguage.EN) DOCUMENT_REASONING_SIGNALS_EN else DOCUMENT_REASONING_SIGNALS val intentHits = answerTokens.intersect(questionTokens).size val evidenceHits = answerTokens.intersect(evidenceTokens).size @@ -322,6 +447,8 @@ class InterviewEvaluationService( val resultSignals = countSignalMatches(answer, resultSignalsSet) val hasNumber = NUMBER_REGEX.containsMatchIn(answer) val hasReasoning = reasoningSignalsSet.any { answer.contains(it, ignoreCase = true) } + val hasFuturePlan = FUTURE_PLAN_SIGNALS.any { answer.contains(it, ignoreCase = true) } + val hasMotivationSignal = MOTIVATION_SIGNALS.any { answer.contains(it, ignoreCase = true) } val sentenceCount = answer.split(Regex("[.!?。]|\\n")) .map { it.trim() } .count { it.isNotBlank() } @@ -341,7 +468,20 @@ class InterviewEvaluationService( min(25, resultSignals * 12 + if (hasNumber) 8 else 0) ).coerceIn(0, 100) - val coverageScore = (intentScore * 0.6 + starScore * 0.4).toInt().coerceIn(0, 100) + val motivationScore = ( + 25 + + min(25, intentHits * 10) + + min(20, evidenceHits * 6) + + if (hasReasoning) 15 else 0 + + if (hasMotivationSignal) 15 else 0 + + if (hasFuturePlan) 15 else 0 + ).coerceIn(0, 100) + + val coverageScore = if (starRecommended) { + (intentScore * 0.6 + starScore * 0.4).toInt().coerceIn(0, 100) + } else { + (intentScore * 0.7 + motivationScore * 0.3).toInt().coerceIn(0, 100) + } val accuracyScore = ( 20 + @@ -352,7 +492,7 @@ class InterviewEvaluationService( if (answer.length >= 120) 10 else 0 ).coerceIn(0, 100) - val communicationScore = if (language == InterviewLanguage.EN) { + val communicationScore = if (answerLanguage == InterviewLanguage.EN) { englishCommunicationHeuristic(answer).toInt().coerceIn(0, 100) } else { ( @@ -373,10 +513,15 @@ class InterviewEvaluationService( ).setScale(2, RoundingMode.HALF_UP) val missingStarParts = buildList { - if (contextSignals == 0) add(if (language == InterviewLanguage.EN) "Situation" else "상황") - if (taskSignals == 0) add(if (language == InterviewLanguage.EN) "Task/Role" else "과제/역할") - if (actionSignals == 0) add(if (language == InterviewLanguage.EN) "Action" else "행동") - if (resultSignals == 0 && !hasNumber) add(if (language == InterviewLanguage.EN) "Result" else "결과") + if (contextSignals == 0) add(if (!koreanResponse) "Situation" else "상황") + if (taskSignals == 0) add(if (!koreanResponse) "Task/Role" else "과제/역할") + if (actionSignals == 0) add(if (!koreanResponse) "Action" else "행동") + if (resultSignals == 0 && !hasNumber) add(if (!koreanResponse) "Result" else "결과") + } + val missingMotivationParts = buildList { + if (!hasMotivationSignal) add(if (!koreanResponse) "motivation or principle" else "동기/판단 기준") + if (!hasReasoning) add(if (!koreanResponse) "supporting reason" else "근거 설명") + if (!hasFuturePlan) add(if (!koreanResponse) "practical application plan" else "실행 계획") } val missingKeywords = softBenchmarkTokens .filterNot { it in answerTokens } @@ -384,55 +529,69 @@ class InterviewEvaluationService( .take(3) val evidenceNotes = buildList { add( - if (language == InterviewLanguage.EN) { + if (!koreanResponse) { "Question-intent keyword coverage: $intentHits" } else { "질문 핵심 키워드 반영 ${intentHits}건" } ) add( - if (language == InterviewLanguage.EN) { + if (!koreanResponse) { "Document evidence coverage: $evidenceHits" } else { "문서 근거 포인트 반영 ${evidenceHits}건" } ) - if (missingStarParts.isNotEmpty()) { + if (starRecommended && missingStarParts.isNotEmpty()) { add( - if (language == InterviewLanguage.EN) { + if (!koreanResponse) { "Missing STAR elements: ${missingStarParts.joinToString(", ")}" } else { "STAR 누락 요소: ${missingStarParts.joinToString(", ")}" } ) } - if (!hasNumber) { - add(if (language == InterviewLanguage.EN) "Missing measurable result evidence" else "수치/결과 근거 부족") + if (!starRecommended && missingMotivationParts.isNotEmpty()) { + add( + if (!koreanResponse) { + "Missing motivation components: ${missingMotivationParts.joinToString(", ")}" + } else { + "동기형 답변 보강 요소: ${missingMotivationParts.joinToString(", ")}" + } + ) + } + if (starRecommended && !hasNumber) { + add(if (!koreanResponse) "Missing measurable result evidence" else "수치/결과 근거 부족") } } val feedback = buildString { append( when { - coverageScore >= 80 -> if (language == InterviewLanguage.EN) "Your answer is largely aligned with the question intent." else "질문 의도에는 대체로 잘 맞게 답했습니다." - coverageScore >= 60 -> if (language == InterviewLanguage.EN) "The relevant experience is visible, but the answer still needs a sharper focus." else "질문 의도와 관련된 경험은 보이지만, 핵심 초점이 조금 더 선명해야 합니다." - else -> if (language == InterviewLanguage.EN) "The answer focus does not align tightly enough with the core experience the question is asking for." else "질문이 묻는 핵심 경험과 답변 초점이 충분히 맞물리지 않았습니다." + coverageScore >= 80 -> if (!koreanResponse) "Your answer is largely aligned with the question intent." else "질문 의도에는 대체로 잘 맞게 답했습니다." + coverageScore >= 60 && starRecommended -> if (!koreanResponse) "The relevant experience is visible, but the answer still needs a sharper focus." else "질문 의도와 관련된 경험은 보이지만, 핵심 초점이 조금 더 선명해야 합니다." + coverageScore >= 60 -> if (!koreanResponse) "Your motivation and reasoning are visible, but the answer still needs a sharper focus." else "질문 의도와 관련된 동기와 이유는 보이지만, 핵심 초점이 조금 더 선명해야 합니다." + starRecommended -> if (!koreanResponse) "The answer focus does not align tightly enough with the core experience the question is asking for." else "질문이 묻는 핵심 경험과 답변 초점이 충분히 맞물리지 않았습니다." + else -> if (!koreanResponse) "The answer focus does not align tightly enough with the motivation or judgment point the question is asking for." else "질문이 묻는 동기나 판단 기준과 답변 초점이 충분히 맞물리지 않았습니다." } ) append(' ') append( when { - starScore >= 75 -> if (language == InterviewLanguage.EN) "The flow of situation, role, action, and result is fairly natural." else "상황, 역할, 행동, 결과 흐름도 비교적 자연스럽게 드러납니다." - starScore >= 50 -> if (language == InterviewLanguage.EN) "Some STAR structure is present, but missing pieces still reduce persuasiveness." else "STAR 흐름은 일부 보이지만, 빠진 요소가 있어 설득력이 다소 약합니다." - else -> if (language == InterviewLanguage.EN) "The STAR structure is weak, so the context and your contribution are not yet clear enough for an interview answer." else "STAR 구조가 약해 면접 답변으로 들었을 때 경험의 맥락과 본인 기여도가 충분히 드러나지 않습니다." + starRecommended && starScore >= 75 -> if (!koreanResponse) "The flow of situation, role, action, and result is fairly natural." else "상황, 역할, 행동, 결과 흐름도 비교적 자연스럽게 드러납니다." + starRecommended && starScore >= 50 -> if (!koreanResponse) "Some STAR structure is present, but missing pieces still reduce persuasiveness." else "STAR 흐름은 일부 보이지만, 빠진 요소가 있어 설득력이 다소 약합니다." + starRecommended -> if (!koreanResponse) "The STAR structure is weak, so the context and your contribution are not yet clear enough for an interview answer." else "STAR 구조가 약해 면접 답변으로 들었을 때 경험의 맥락과 본인 기여도가 충분히 드러나지 않습니다." + motivationScore >= 75 -> if (!koreanResponse) "Your motivation, judgment criteria, and practical application are connected fairly clearly." else "동기, 판단 기준, 실제 적용 계획의 연결도 비교적 분명합니다." + motivationScore >= 50 -> if (!koreanResponse) "Your motivation is visible, but the reasoning or practical application still feels thin." else "동기 자체는 보이지만, 이유나 실제 적용 계획은 조금 더 구체화할 필요가 있습니다." + else -> if (!koreanResponse) "The answer mentions an intention, but the concrete reasoning and practical application are still too vague." else "의도나 포부는 보이지만, 구체적 근거와 실제 적용 계획은 아직 모호합니다." } ) append(' ') append( when { - accuracyScore >= 75 -> if (language == InterviewLanguage.EN) "The technical explanation and supporting evidence are reasonably convincing." else "기술적 설명과 근거도 비교적 설득력 있습니다." - accuracyScore >= 55 -> if (language == InterviewLanguage.EN) "The technical explanation is understandable, but the reasoning and outcome evidence need reinforcement." else "기술적 설명은 가능하지만, 선택 이유나 성과 근거를 더 보강할 필요가 있습니다." - else -> if (language == InterviewLanguage.EN) "The answer lacks enough reasoning, validation, or result evidence to feel fully credible." else "기술 선택 이유, 검증 근거, 성과 설명이 부족해 답변의 신뢰도가 떨어집니다." + accuracyScore >= 75 -> if (!koreanResponse) "The technical explanation and supporting evidence are reasonably convincing." else "기술적 설명과 근거도 비교적 설득력 있습니다." + accuracyScore >= 55 -> if (!koreanResponse) "The technical explanation is understandable, but the reasoning and outcome evidence need reinforcement." else "기술적 설명은 가능하지만, 선택 이유나 성과 근거를 더 보강할 필요가 있습니다." + else -> if (!koreanResponse) "The answer lacks enough reasoning, validation, or result evidence to feel fully credible." else "기술 선택 이유, 검증 근거, 성과 설명이 부족해 답변의 신뢰도가 떨어집니다." } ) } @@ -441,35 +600,51 @@ class InterviewEvaluationService( "" } else { buildString { - if (missingStarParts.isNotEmpty()) { + if (starRecommended && missingStarParts.isNotEmpty()) { append( - if (language == InterviewLanguage.EN) { + if (!koreanResponse) { "In your next answer, make ${missingStarParts.joinToString(", ")} more explicit so the STAR flow feels complete. " } else { "다음 답변에서는 ${missingStarParts.joinToString(", ")} 요소를 더 분명히 넣어 STAR 흐름을 완성해 보세요. " } ) - } else { + } else if (starRecommended) { append( - if (language == InterviewLanguage.EN) { + if (!koreanResponse) { "The overall flow is acceptable, so tighten the link between your actions and results to improve impact. " } else { "현재 답변 흐름은 나쁘지 않으니, 행동과 결과를 더 압축적으로 연결해 전달력을 높여 보세요. " } ) + } else if (missingMotivationParts.isNotEmpty()) { + append( + if (!koreanResponse) { + "In your next answer, make ${missingMotivationParts.joinToString(", ")} more explicit so your motivation and practical fit are easier to trust. " + } else { + "다음 답변에서는 ${missingMotivationParts.joinToString(", ")}를 더 분명히 넣어 동기와 실제 적합성을 설득력 있게 보여 주세요. " + } + ) + } else { + append( + if (!koreanResponse) { + "The overall flow is acceptable, so tighten the link between your motivation, reason, and practical application. " + } else { + "현재 답변 흐름은 나쁘지 않으니, 동기와 이유, 실제 적용 계획의 연결을 조금 더 압축적으로 정리해 보세요. " + } + ) } if (missingKeywords.isNotEmpty()) { append( - if (language == InterviewLanguage.EN) { + if (!koreanResponse) { "Adding concrete document-specific details such as ${missingKeywords.joinToString(", ")} will make the link to the question much clearer. " } else { "${missingKeywords.joinToString(", ")} 같은 문서 맥락의 구체 요소를 넣으면 질문과의 연결성이 더 선명해집니다. " } ) } - if (!hasNumber) { + if (starRecommended && !hasNumber) { append( - if (language == InterviewLanguage.EN) { + if (!koreanResponse) { "If possible, add measurable evidence such as metrics, user impact, performance changes, or a concrete outcome." } else { "가능하면 수치, 사용자 영향, 성능 변화, 완료 결과처럼 확인 가능한 근거를 함께 제시하세요." @@ -477,7 +652,7 @@ class InterviewEvaluationService( ) } else if (!hasReasoning) { append( - if (language == InterviewLanguage.EN) { + if (!koreanResponse) { "When you describe the result, also explain why you made that decision." } else { "결과를 말할 때는 왜 그런 선택을 했는지 판단 근거도 같이 설명해 주세요." @@ -485,10 +660,18 @@ class InterviewEvaluationService( ) } else { append( - if (language == InterviewLanguage.EN) { - "Emphasize the part you personally decided and executed more directly and more concisely." + if (!koreanResponse) { + if (starRecommended) { + "Emphasize the part you personally decided and executed more directly and more concisely." + } else { + "Tie the principle you mentioned to one concrete example or execution plan more directly." + } } else { - "특히 본인이 직접 판단하고 실행한 부분을 더 짧고 선명하게 강조하면 좋습니다." + if (starRecommended) { + "특히 본인이 직접 판단하고 실행한 부분을 더 짧고 선명하게 강조하면 좋습니다." + } else { + "언급한 기준이나 가치관을 실제 예시나 실행 계획과 더 직접적으로 연결해 보세요." + } } ) } @@ -513,6 +696,14 @@ class InterviewEvaluationService( ) } + private fun questionTypeRequiresStar(questionType: String?): Boolean { + return questionType?.trim()?.uppercase() !in setOf( + "INTRODUCE_MOTIVATION", + "INTRODUCE_VALUE", + "INTRODUCE_FUTURE_PLAN" + ) + } + private fun tokenize(text: String): Set { return text.lowercase() .split(Regex("[^a-zA-Z0-9가-힣]+")) @@ -603,8 +794,12 @@ class InterviewEvaluationService( } private fun InterviewTurnEvaluation.toResponse(): TurnEvaluationResponse { + val sessionLanguage = resolveInterviewLanguage(turn) + val turnContext = parseTurnRagContext(objectMapper, turn.ragContextJson) val resolved = resolveAnswerContent( - rawModelAnswer = turn.question?.canonicalAnswer ?: turn.documentQuestion?.referenceAnswer, + rawModelAnswer = turnContext.localizedModelAnswerFor(sessionLanguage) + ?: turn.question?.canonicalAnswer + ?: turn.documentQuestion?.referenceAnswer, rawGuideText = bestPractice ) return TurnEvaluationResponse( @@ -644,4 +839,11 @@ class InterviewEvaluationService( val model: String? = null, val modelVersion: String? = null ) + + private data class PreparedBatchEvaluation( + val turn: InterviewTurn, + val userGeneratedQuestion: Boolean, + val resolvedAnswer: ResolvedAnswerContent, + val input: BatchTurnEvaluationInput + ) } diff --git a/src/main/kotlin/com/cw/vlainter/domain/interview/service/InterviewPracticeService.kt b/src/main/kotlin/com/cw/vlainter/domain/interview/service/InterviewPracticeService.kt index c9adf96..37f904e 100644 --- a/src/main/kotlin/com/cw/vlainter/domain/interview/service/InterviewPracticeService.kt +++ b/src/main/kotlin/com/cw/vlainter/domain/interview/service/InterviewPracticeService.kt @@ -126,6 +126,7 @@ class InterviewPracticeService( val questionCount = request.questionCount.coerceAtMost(candidates.size) val selected = candidates.shuffled().take(questionCount) val questionRefs = selected.map { QuestionRef(InterviewQuestionKind.TECH, it.id) } + val localizedQueue = buildLocalizedTechQueueEntries(actor.id, request.language, selected) val questionSet = request.setId?.let { questionSetRepository.findByIdAndDeletedAtIsNull(it) } val primaryCategory = categoryContext?.category ?: request.categoryId?.let { categoryRepository.findByIdAndDeletedAtIsNull(it) } @@ -166,6 +167,7 @@ class InterviewPracticeService( "jobName" to resolvedJobName, "practiceMode" to practiceMode.name, "selectedDocuments" to emptyList>(), + "localizedQueue" to localizedQueue, "providerUsed" to aiRoutingContextHolder.snapshot().providerUsed?.name, "fallbackDepth" to aiRoutingContextHolder.snapshot().fallbackDepth ) @@ -439,8 +441,12 @@ class InterviewPracticeService( tags = parseTags(turn.tagsJson), bookmarked = turn.isBookmarked, evaluation = evaluation?.let { + val sessionLanguage = resolveInterviewLanguage(session.configJson) + val turnContext = parseTurnRagContext(objectMapper, turn.ragContextJson) val resolved = resolveAnswerContent( - rawModelAnswer = turn.question?.canonicalAnswer ?: turn.documentQuestion?.referenceAnswer, + rawModelAnswer = turnContext.localizedModelAnswerFor(sessionLanguage) + ?: turn.question?.canonicalAnswer + ?: turn.documentQuestion?.referenceAnswer, rawGuideText = it.bestPractice ) TurnEvaluationResponse( @@ -687,46 +693,92 @@ class InterviewPracticeService( val language = resolveInterviewLanguage(session.configJson) val turn = when (ref.kind) { InterviewQuestionKind.TECH -> { - val question = questionRepository.findByIdAndDeletedAtIsNull(ref.id) - ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "질문을 찾을 수 없습니다: ${ref.id}") - InterviewTurn( - session = session, - turnNo = turnNo, - sourceTag = toTurnSource(question), - question = question, - questionTextSnapshot = interviewAiOrchestrator.localizeInterviewText( - question.questionText, - language, - "interview question" - ) ?: question.questionText, - categorySnapshot = question.category.name, - jobSnapshot = question.jobName ?: question.category.parent?.name?.trim(), - skillSnapshot = question.skillName ?: question.category.name.trim(), - category = question.category, - difficulty = question.difficulty.name, - tagsJson = question.tagsJson + val question = questionRepository.findByIdAndDeletedAtIsNull(ref.id) + ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "질문을 찾을 수 없습니다: ${ref.id}") + val storedLocalized = findSessionLocalizedQueueContent(objectMapper, session.configJson, language, ref.kind, ref.id) + val localized = storedLocalized?.let { + com.cw.vlainter.domain.interview.ai.LocalizedInterviewContent( + questionText = it.questionText ?: question.questionText, + modelAnswer = it.modelAnswer, + evidence = it.evidence ) - } + } ?: localizeTurnContentIfNeeded( + userId = session.user.id, + language = language, + questionText = question.questionText, + modelAnswer = question.canonicalAnswer, + evidence = emptyList() + ) + InterviewTurn( + session = session, + turnNo = turnNo, + sourceTag = toTurnSource(question), + question = question, + questionTextSnapshot = localized?.questionText ?: question.questionText, + categorySnapshot = question.category.name, + jobSnapshot = question.jobName ?: question.category.parent?.name?.trim(), + skillSnapshot = question.skillName ?: question.category.name.trim(), + category = question.category, + difficulty = question.difficulty.name, + tagsJson = question.tagsJson + , + ragContextJson = buildTurnRagContextJson( + objectMapper = objectMapper, + evidence = emptyList(), + language = language, + localized = localized?.let { + StoredLocalizedTurnContent( + questionText = it.questionText, + modelAnswer = it.modelAnswer, + evidence = it.evidence + ) + } + ) + ) + } - InterviewQuestionKind.DOCUMENT -> { - val question = documentQuestionRepository.findByIdAndUserId(ref.id, session.user.id) - ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "문서 질문을 찾을 수 없습니다: ${ref.id}") - InterviewTurn( - session = session, - turnNo = turnNo, - sourceTag = TurnSourceTag.DOC_RAG, - documentQuestion = question, - questionTextSnapshot = interviewAiOrchestrator.localizeInterviewText( - question.questionText, - language, - "interview question" - ) ?: question.questionText, - categorySnapshot = question.questionType, - difficulty = question.difficulty, - tagsJson = "[]", - ragContextJson = question.evidenceJson + InterviewQuestionKind.DOCUMENT -> { + val question = documentQuestionRepository.findByIdAndUserId(ref.id, session.user.id) + ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "문서 질문을 찾을 수 없습니다: ${ref.id}") + val evidence = runCatching { objectMapper.readValue(question.evidenceJson, Array::class.java).toList() } + .getOrDefault(emptyList()) + val storedLocalized = findSessionLocalizedQueueContent(objectMapper, session.configJson, language, ref.kind, ref.id) + val localized = storedLocalized?.let { + com.cw.vlainter.domain.interview.ai.LocalizedInterviewContent( + questionText = it.questionText ?: question.questionText, + modelAnswer = it.modelAnswer ?: question.referenceAnswer, + evidence = it.evidence.ifEmpty { evidence } ) - } + } ?: localizeTurnContentIfNeeded( + userId = session.user.id, + language = language, + questionText = question.questionText, + modelAnswer = question.referenceAnswer, + evidence = evidence + ) + InterviewTurn( + session = session, + turnNo = turnNo, + sourceTag = TurnSourceTag.DOC_RAG, + documentQuestion = question, + questionTextSnapshot = localized?.questionText ?: question.questionText, + categorySnapshot = question.questionType, + difficulty = question.difficulty, + tagsJson = "[]", + ragContextJson = buildTurnRagContextJson( + objectMapper = objectMapper, + evidence = evidence, + language = language, + localized = localized?.let { + StoredLocalizedTurnContent( + questionText = it.questionText, + modelAnswer = it.modelAnswer, + evidence = it.evidence + ) + } + ) + ) + } InterviewQuestionKind.INTRO -> { throw ResponseStatusException(HttpStatus.BAD_REQUEST, "자기소개 문항은 실전 모의면접에서만 사용할 수 있습니다.") @@ -754,8 +806,12 @@ class InterviewPracticeService( private fun toSavedQuestionResponse(saved: SavedQuestion): SavedQuestionResponse { val evaluation = saved.sourceTurn?.id?.let { interviewTurnEvaluationRepository.findByTurn_Id(it) } + val sessionLanguage = saved.sourceTurn?.session?.let { resolveInterviewLanguage(it.configJson) } ?: InterviewLanguage.KO + val turnContext = saved.sourceTurn?.let { parseTurnRagContext(objectMapper, it.ragContextJson) } ?: TurnRagContext() val resolved = resolveAnswerContent( - rawModelAnswer = saved.question?.canonicalAnswer ?: saved.documentQuestion?.referenceAnswer, + rawModelAnswer = turnContext.localizedModelAnswerFor(sessionLanguage) + ?: saved.question?.canonicalAnswer + ?: saved.documentQuestion?.referenceAnswer, rawGuideText = evaluation?.bestPractice ) return SavedQuestionResponse( @@ -786,6 +842,55 @@ class InterviewPracticeService( ) } + private fun localizeTurnContentIfNeeded( + userId: Long, + language: InterviewLanguage, + questionText: String, + modelAnswer: String?, + evidence: List + ): com.cw.vlainter.domain.interview.ai.LocalizedInterviewContent? { + if (language != InterviewLanguage.EN) return null + return userGeminiApiKeyService.withUserApiKey(userId) { + interviewAiOrchestrator.localizeTurnContent( + questionText = questionText, + modelAnswer = modelAnswer, + evidence = evidence, + language = language + ) + } + } + + private fun buildLocalizedTechQueueEntries( + userId: Long, + language: InterviewLanguage, + questions: List + ): List> { + if (language != InterviewLanguage.EN || questions.isEmpty()) return emptyList() + val localized = userGeminiApiKeyService.withUserApiKey(userId) { + interviewAiOrchestrator.localizeTurnContents( + questions.map { question -> + com.cw.vlainter.domain.interview.ai.TurnContentLocalizationRequest( + key = question.id.toString(), + questionText = question.questionText, + modelAnswer = question.canonicalAnswer, + evidence = emptyList() + ) + }, + language + ) + } + return buildSessionLocalizedQueueEntries( + kind = InterviewQuestionKind.TECH, + entries = localized.entries.associate { (key, value) -> + key.toLong() to StoredLocalizedTurnContent( + questionText = value.questionText, + modelAnswer = value.modelAnswer, + evidence = value.evidence + ) + } + ) + } + private fun normalizedTurnSourceTag(turn: InterviewTurn): TurnSourceTag { if (turn.sourceTag == TurnSourceTag.USER && turn.question?.id?.let { questionSetItemRepository.existsInAiGeneratedSetByQuestionId(it) } == true) { return TurnSourceTag.SYSTEM diff --git a/src/main/kotlin/com/cw/vlainter/domain/interview/service/TurnLocalizationSupport.kt b/src/main/kotlin/com/cw/vlainter/domain/interview/service/TurnLocalizationSupport.kt new file mode 100644 index 0000000..17be21d --- /dev/null +++ b/src/main/kotlin/com/cw/vlainter/domain/interview/service/TurnLocalizationSupport.kt @@ -0,0 +1,117 @@ +package com.cw.vlainter.domain.interview.service + +import com.cw.vlainter.domain.interview.entity.InterviewLanguage +import com.cw.vlainter.domain.interview.entity.InterviewQuestionKind +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.ObjectMapper + +data class StoredLocalizedTurnContent( + val questionText: String?, + val modelAnswer: String?, + val evidence: List = emptyList() +) + +data class TurnRagContext( + val evidence: List = emptyList(), + val localizedLanguage: String? = null, + val localizedQuestionText: String? = null, + val localizedModelAnswer: String? = null, + val localizedEvidence: List = emptyList() +) { + fun localizedQuestionTextFor(language: InterviewLanguage): String? { + return if (localizedLanguage == language.name) localizedQuestionText?.takeIf { it.isNotBlank() } else null + } + + fun localizedModelAnswerFor(language: InterviewLanguage): String? { + return if (localizedLanguage == language.name) localizedModelAnswer?.takeIf { it.isNotBlank() } else null + } + + fun localizedEvidenceFor(language: InterviewLanguage): List { + return if (localizedLanguage == language.name) localizedEvidence.filter { it.isNotBlank() } else emptyList() + } +} + +fun parseTurnRagContext(objectMapper: ObjectMapper, raw: String?): TurnRagContext { + if (raw.isNullOrBlank()) return TurnRagContext() + val root = runCatching { objectMapper.readTree(raw) }.getOrNull() ?: return TurnRagContext() + return when { + root.isArray -> TurnRagContext(evidence = root.stringArray()) + root.isObject -> { + val localized = root.path("localized") + TurnRagContext( + evidence = root.path("evidence").stringArray(), + localizedLanguage = localized.path("language").asText().trim().ifBlank { null }, + localizedQuestionText = localized.path("questionText").asText().trim().ifBlank { null }, + localizedModelAnswer = localized.path("modelAnswer").asText().trim().ifBlank { null }, + localizedEvidence = localized.path("evidence").stringArray() + ) + } + else -> TurnRagContext() + } +} + +fun buildTurnRagContextJson( + objectMapper: ObjectMapper, + evidence: List, + language: InterviewLanguage, + localized: StoredLocalizedTurnContent? +): String { + val payload = linkedMapOf( + "evidence" to evidence + ) + val localizedBlock = if (language == InterviewLanguage.EN && localized != null) { + mapOf( + "language" to language.name, + "questionText" to localized.questionText, + "modelAnswer" to localized.modelAnswer, + "evidence" to localized.evidence + ) + } else { + null + } + payload["localized"] = localizedBlock + return objectMapper.writeValueAsString(payload) +} + +fun buildSessionLocalizedQueueEntries( + kind: InterviewQuestionKind, + entries: Map +): List> { + return entries.map { (id, localized) -> + mapOf( + "kind" to kind.name, + "id" to id, + "questionText" to localized.questionText, + "modelAnswer" to localized.modelAnswer, + "evidence" to localized.evidence + ) + } +} + +fun findSessionLocalizedQueueContent( + objectMapper: ObjectMapper, + configJson: String?, + language: InterviewLanguage, + kind: InterviewQuestionKind, + id: Long +): StoredLocalizedTurnContent? { + if (language != InterviewLanguage.EN || configJson.isNullOrBlank()) return null + val root = runCatching { objectMapper.readTree(configJson) }.getOrNull() ?: return null + val localizedQueue = root.path("meta").path("localizedQueue") + if (!localizedQueue.isArray) return null + val matched = localizedQueue.firstOrNull { item -> + item.path("kind").asText().trim().uppercase() == kind.name && + item.path("id").asLong() == id + } ?: return null + return StoredLocalizedTurnContent( + questionText = matched.path("questionText").asText().trim().ifBlank { null }, + modelAnswer = matched.path("modelAnswer").asText().trim().ifBlank { null }, + evidence = matched.path("evidence").stringArray() + ) +} + +private fun JsonNode.stringArray(): List { + return takeIf { isArray } + ?.mapNotNull { it.asText().trim().takeIf(String::isNotBlank) } + .orEmpty() +} diff --git a/src/test/kotlin/com/cw/vlainter/domain/interview/ai/InterviewAiOrchestratorTests.kt b/src/test/kotlin/com/cw/vlainter/domain/interview/ai/InterviewAiOrchestratorTests.kt index ab2f64e..48986f0 100644 --- a/src/test/kotlin/com/cw/vlainter/domain/interview/ai/InterviewAiOrchestratorTests.kt +++ b/src/test/kotlin/com/cw/vlainter/domain/interview/ai/InterviewAiOrchestratorTests.kt @@ -103,12 +103,56 @@ class InterviewAiOrchestratorTests { ) assertThat(generated).hasSize(1) - assertThat(capturedPrompt).contains("STAR형 모범답안") - assertThat(capturedPrompt).contains("상황/과제/행동/결과가 자연스럽게 드러나야 함") + assertThat(capturedPrompt).contains("evidenceKind") + assertThat(capturedPrompt).contains("ACTUAL_EXPERIENCE와 PROJECT_OR_RESULT 발췌에서는 referenceAnswer를 STAR형 예시 답변으로 작성") } @Test - fun `영어 문서 답변 평가 프롬프트는 영어 응답과 grammar 기준을 명시한다`() { + fun `자기소개서 질문 생성 프롬프트는 포부성 문장을 경험처럼 단정하지 않도록 제한한다`() { + var capturedPrompt = "" + given(llmProviderRouter.generateJson(anyString(), nullable(Double::class.java))).willAnswer { invocation -> + capturedPrompt = invocation.getArgument(0) + LlmGenerationResult( + model = "gemini", + modelVersion = "v1", + text = """ + { + "questions": [ + { + "questionText": "후보자 경험을 더 좋게 만들고 싶다고 했는데, 입사 후 어떤 기준으로 그 관점을 실천하고 싶나요?", + "questionType": "INTRODUCE_FUTURE_PLAN", + "evidenceKind": "MOTIVATION_OR_ASPIRATION", + "referenceAnswer": "후보자 경험을 중요하게 보는 이유와 그 기준을 입사 후 어떻게 적용할지 차례로 설명합니다.", + "evidence": [ + "후보자 경험을 더 좋게 만들고 싶고 불필요한 업무를 줄이겠다는 포부를 언급함" + ] + } + ] + } + """.trimIndent() + ) + } + + val generated = orchestrator.generateDocumentQuestions( + fileTypeLabel = "INTRODUCE", + difficulty = null, + questionCount = 1, + contextSnippets = listOf( + """ + [문서 발췌 1] + kind=MOTIVATION_OR_ASPIRATION + text=인턴으로서 단순히 채용만 하는 것이 아니라 후보자에게 더 좋은 경험을 주고 싶고, 불필요한 업무를 과감히 줄이겠다는 마음가짐을 가지고 있습니다. + """.trimIndent() + ) + ) + + assertThat(generated).hasSize(1) + assertThat(capturedPrompt).contains("자기소개서의 미래지향적 문장, 포부, 마음가짐, 가치관을 이미 수행한 경험처럼 단정하여 질문하지 말 것") + assertThat(capturedPrompt).contains("INTRODUCE_MOTIVATION, INTRODUCE_VALUE, INTRODUCE_FUTURE_PLAN, INTRODUCE_EXPERIENCE") + } + + @Test + fun `영어 문서 답변 평가 프롬프트는 평가 출력은 한국어로 유지하고 영어 문법 기준을 명시한다`() { var capturedPrompt = "" given(llmProviderRouter.generateJson(anyString(), nullable(Double::class.java))).willAnswer { invocation -> capturedPrompt = invocation.getArgument(0) @@ -118,14 +162,14 @@ class InterviewAiOrchestratorTests { text = """ { "score": 79, - "feedback": "The answer is relevant.", - "bestPractice": "Make the result more specific.", + "feedback": "답변은 질문과 관련이 있습니다.", + "bestPractice": "결과를 더 구체적으로 말해 보세요.", "rubric": { "coverage": 80, "accuracy": 76, "communication": 81 }, - "evidence": ["Relevant", "Needs clearer result"] + "evidence": ["질문 관련성 있음", "결과 보강 필요"] } """.trimIndent() ) @@ -136,12 +180,13 @@ class InterviewAiOrchestratorTests { referenceAnswer = "I would explain the situation, my role, the actions I took, and the measurable result.", evidence = listOf("The portfolio mentions reducing dashboard latency and restructuring the API response."), userAnswer = "I traced the bottleneck with profiling and changed the cache strategy.", - language = InterviewLanguage.EN + language = InterviewLanguage.EN, + responseLanguage = InterviewLanguage.KO ) - assertThat(capturedPrompt).contains("feedback, bestPractice, and evidence must be written in English.") + assertThat(capturedPrompt).contains("아래 입력을 바탕으로 한국어 JSON만 출력하세요.") assertThat(capturedPrompt).contains("grammar, sentence completeness, clarity, and natural professional English quality") - assertThat(capturedPrompt).contains("document-based interview evaluator") + assertThat(capturedPrompt).contains("당신은 면접 답변을 평가하는 면접관입니다.") } @Test @@ -177,4 +222,77 @@ class InterviewAiOrchestratorTests { assertThat(capturedPrompt).contains("Generate realistic technical interview questions and reference answers in English.") assertThat(capturedPrompt).contains("questionText와 canonicalAnswer는 모두 English로 작성할 것") } + + @Test + fun `배치 평가 프롬프트는 모든 항목을 한 번에 평가하고 key를 유지한다`() { + var capturedPrompt = "" + given(llmProviderRouter.generateJson(anyString(), nullable(Double::class.java))).willAnswer { invocation -> + capturedPrompt = invocation.getArgument(0) + LlmGenerationResult( + model = "gemini", + modelVersion = "v1", + text = """ + { + "items": [ + { + "key": "101", + "score": 78, + "feedback": "질문 의도에는 대체로 맞습니다.", + "bestPractice": "결과를 조금 더 구체화해 보세요.", + "rubric": { + "coverage": 80, + "accuracy": 74, + "communication": 79 + }, + "evidence": ["질문 의도 적합", "결과 근거 보강 필요"] + }, + { + "key": "102", + "score": 65, + "feedback": "동기 설명은 있으나 근거가 조금 약합니다.", + "bestPractice": "판단 기준과 실제 적용 계획을 함께 설명해 보세요.", + "rubric": { + "coverage": 68, + "accuracy": 60, + "communication": 67 + }, + "evidence": ["동기 설명 존재", "근거 부족"] + } + ] + } + """.trimIndent() + ) + } + + val result = orchestrator.evaluateTurnsBatch( + listOf( + BatchTurnEvaluationInput( + key = "101", + kind = "TECH", + answerLanguage = "EN", + questionText = "How would you explain JWT refresh token rotation?", + referenceAnswer = "I would explain why rotation reduces replay risk and how session revocation works.", + userAnswer = "I would rotate refresh tokens to reduce replay risk and revoke sessions on mismatch." + ), + BatchTurnEvaluationInput( + key = "102", + kind = "DOCUMENT", + answerLanguage = "KO", + questionText = "후보자 경험을 중요하게 보는 이유와 입사 후 적용 계획을 설명해 주세요.", + questionType = "INTRODUCE_FUTURE_PLAN", + referenceAnswer = "지원 동기와 실제 적용 계획을 차례로 설명합니다.", + evidence = listOf("후보자 경험을 더 좋게 만들고 싶다는 포부를 기재함"), + userAnswer = "지원자가 채용 과정에서 가장 먼저 체감하는 것이 안내 품질이라고 생각해 이 관점을 중요하게 봅니다." + ) + ), + responseLanguage = InterviewLanguage.KO + ) + + assertThat(result).hasSize(2) + assertThat(result).containsKeys("101", "102") + assertThat(capturedPrompt).contains("반드시 모든 입력 key를 유지해서 반환") + assertThat(capturedPrompt).contains("\"kind\":\"TECH\"") + assertThat(capturedPrompt).contains("\"kind\":\"DOCUMENT\"") + assertThat(capturedPrompt).contains("answerLanguage=EN 이면 communication 점수에 grammar") + } } diff --git a/src/test/kotlin/com/cw/vlainter/domain/interview/service/DocumentQuestionGenerationPolicyTests.kt b/src/test/kotlin/com/cw/vlainter/domain/interview/service/DocumentQuestionGenerationPolicyTests.kt new file mode 100644 index 0000000..7d69cdf --- /dev/null +++ b/src/test/kotlin/com/cw/vlainter/domain/interview/service/DocumentQuestionGenerationPolicyTests.kt @@ -0,0 +1,48 @@ +@file:Suppress("NonAsciiCharacters") + +package com.cw.vlainter.domain.interview.service + +import com.cw.vlainter.domain.userFile.entity.FileType +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test + +class DocumentQuestionGenerationPolicyTests { + + @Test + fun `자기소개서는 이력서보다 더 많은 질문 예산을 받는다`() { + val allocation = DocumentQuestionGenerationPolicy.allocateQuestionCounts( + total = 5, + fileTypes = listOf(FileType.RESUME, FileType.INTRODUCE) + ) + + assertThat(allocation).containsExactly(2, 3) + } + + @Test + fun `자기소개서 snippet budget은 이력서보다 크다`() { + val resumeBudget = DocumentQuestionGenerationPolicy.snippetBudget(FileType.RESUME, 2) + val introduceBudget = DocumentQuestionGenerationPolicy.snippetBudget(FileType.INTRODUCE, 2) + + assertThat(introduceBudget).isGreaterThan(resumeBudget) + } + + @Test + fun `자기소개서의 포부 문장은 motivation kind로 분류된다`() { + val classified = DocumentQuestionGenerationPolicy.classifySnippets( + fileType = FileType.INTRODUCE, + snippets = listOf("인턴으로서 후보자 경험을 더 좋게 만들고 싶고, 불필요한 업무를 줄이겠다는 마음가짐을 가지겠습니다.") + ) + + assertThat(classified.single().kind).isEqualTo(DocumentSnippetKind.MOTIVATION_OR_ASPIRATION) + } + + @Test + fun `포트폴리오의 성과 문장은 result kind로 분류된다`() { + val classified = DocumentQuestionGenerationPolicy.classifySnippets( + fileType = FileType.PORTFOLIO, + snippets = listOf("API 응답 구조를 개선해 초기 로딩 시간을 35퍼센트 줄였고 관련 문의도 감소했습니다.") + ) + + assertThat(classified.single().kind).isEqualTo(DocumentSnippetKind.PROJECT_OR_RESULT) + } +} diff --git a/src/test/kotlin/com/cw/vlainter/domain/interview/service/InterviewEvaluationServiceTests.kt b/src/test/kotlin/com/cw/vlainter/domain/interview/service/InterviewEvaluationServiceTests.kt index 69120ee..02fb58a 100644 --- a/src/test/kotlin/com/cw/vlainter/domain/interview/service/InterviewEvaluationServiceTests.kt +++ b/src/test/kotlin/com/cw/vlainter/domain/interview/service/InterviewEvaluationServiceTests.kt @@ -77,9 +77,12 @@ class InterviewEvaluationServiceTests { given( interviewAiOrchestrator.evaluateDocumentAnswer( questionText = turn.documentQuestion!!.questionText, + questionType = turn.documentQuestion!!.questionType, referenceAnswer = turn.documentQuestion!!.referenceAnswer, evidence = objectMapper.readValue(turn.documentQuestion!!.evidenceJson, Array::class.java).toList(), - userAnswer = turn.userAnswer!! + userAnswer = turn.userAnswer!!, + language = InterviewLanguage.KO, + responseLanguage = InterviewLanguage.KO ) ).willReturn(null) @@ -101,9 +104,12 @@ class InterviewEvaluationServiceTests { given( interviewAiOrchestrator.evaluateDocumentAnswer( questionText = turn.documentQuestion!!.questionText, + questionType = turn.documentQuestion!!.questionType, referenceAnswer = turn.documentQuestion!!.referenceAnswer, evidence = objectMapper.readValue(turn.documentQuestion!!.evidenceJson, Array::class.java).toList(), - userAnswer = turn.userAnswer!! + userAnswer = turn.userAnswer!!, + language = InterviewLanguage.KO, + responseLanguage = InterviewLanguage.KO ) ).willReturn(null) @@ -116,7 +122,7 @@ class InterviewEvaluationServiceTests { } @Test - fun `영어 문서 면접 fallback 평가는 영어 피드백과 문장 완성도 기준을 사용한다`() { + fun `영어 문서 면접 fallback 평가는 한국어 피드백으로 반환하되 영어 문장 완성도 기준을 사용한다`() { val turn = createDocumentTurn( answer = "In that project, I owned the performance investigation. I profiled the API response path, found excessive serialization overhead, and changed the cache policy. As a result, the dashboard loaded faster and support complaints decreased.", referenceAnswer = "I would describe the situation, my responsibility, the actions I took, and the measurable outcome.", @@ -126,26 +132,57 @@ class InterviewEvaluationServiceTests { given( interviewAiOrchestrator.evaluateDocumentAnswer( questionText = turn.documentQuestion!!.questionText, + questionType = turn.documentQuestion!!.questionType, referenceAnswer = turn.documentQuestion!!.referenceAnswer, evidence = objectMapper.readValue(turn.documentQuestion!!.evidenceJson, Array::class.java).toList(), userAnswer = turn.userAnswer!!, - language = InterviewLanguage.EN + language = InterviewLanguage.EN, + responseLanguage = InterviewLanguage.KO ) ).willReturn(null) val result = invokeBuildEvaluation(turn, turn.userAnswer!!) assertThat(result.score.toInt()).isGreaterThanOrEqualTo(60) - assertThat(result.feedback).doesNotContainPattern("[가-힣]") - assertThat(result.bestPractice).doesNotContainPattern("[가-힣]") - assertThat(result.bestPractice.lowercase()).containsAnyOf("star", "result", "document") + assertThat(result.feedback).containsPattern("[가-힣]") + assertThat(result.bestPractice).containsPattern("[가-힣]") + assertThat(result.bestPractice).containsAnyOf("STAR", "수치", "결과", "근거") + assertThat(result.model).isEqualTo("heuristic") + } + + @Test + fun `동기형 자기소개 질문 fallback 평가는 STAR를 강제하지 않는다`() { + val turn = createDocumentTurn( + answer = "후보자 경험을 중요하게 생각한 이유는 지원자가 가장 먼저 체감하는 것이 채용 과정의 일관성과 안내 품질이라고 봤기 때문입니다. 입사 후에는 불필요한 안내 중복을 줄이고, 지원자가 다음 단계를 명확히 알 수 있게 만드는 방식으로 이 관점을 적용하고 싶습니다.", + referenceAnswer = "지원 동기와 중요하게 보는 기준을 먼저 설명하고, 실제 업무에서 어떻게 적용할지 구체적으로 답합니다.", + questionType = "INTRODUCE_FUTURE_PLAN" + ) + + given( + interviewAiOrchestrator.evaluateDocumentAnswer( + questionText = turn.documentQuestion!!.questionText, + questionType = turn.documentQuestion!!.questionType, + referenceAnswer = turn.documentQuestion!!.referenceAnswer, + evidence = objectMapper.readValue(turn.documentQuestion!!.evidenceJson, Array::class.java).toList(), + userAnswer = turn.userAnswer!!, + language = InterviewLanguage.KO, + responseLanguage = InterviewLanguage.KO + ) + ).willReturn(null) + + val result = invokeBuildEvaluation(turn, turn.userAnswer!!) + + assertThat(result.score.toInt()).isGreaterThanOrEqualTo(60) + assertThat(result.feedback).containsAnyOf("동기", "판단 기준") + assertThat(result.bestPractice).doesNotContain("STAR") assertThat(result.model).isEqualTo("heuristic") } private fun createDocumentTurn( answer: String, referenceAnswer: String?, - language: InterviewLanguage = InterviewLanguage.KO + language: InterviewLanguage = InterviewLanguage.KO, + questionType: String = "PORTFOLIO_PROJECT" ): InterviewTurn { val user = User( id = 1L, @@ -181,9 +218,13 @@ class InterviewEvaluationServiceTests { questionText = if (language == InterviewLanguage.EN) { "How did you identify and improve the performance bottleneck in your portfolio project?" } else { - "포트폴리오 프로젝트에서 성능 병목을 어떻게 발견하고 개선하셨나요?" + if (questionType == "INTRODUCE_FUTURE_PLAN") { + "후보자 경험을 더 좋게 만들고 싶다고 했는데, 입사 후 어떤 기준으로 그 관점을 실천하고 싶나요?" + } else { + "포트폴리오 프로젝트에서 성능 병목을 어떻게 발견하고 개선하셨나요?" + } }, - questionType = "PORTFOLIO_PROJECT", + questionType = questionType, referenceAnswer = referenceAnswer, evidenceJson = objectMapper.writeValueAsString( if (language == InterviewLanguage.EN) { @@ -192,10 +233,17 @@ class InterviewEvaluationServiceTests { "It also mentions faster perceived speed and fewer support complaints." ) } else { - listOf( - "포트폴리오에 대시보드 초기 로딩 개선과 API 구조 조정 경험이 기재되어 있음", - "사용자 체감 속도 개선과 관련 문의 감소를 언급함" - ) + if (questionType == "INTRODUCE_FUTURE_PLAN") { + listOf( + "자기소개서에서 후보자 경험을 더 좋게 만들고 싶다는 포부를 언급함", + "불필요한 업무를 줄이겠다는 기준과 마음가짐을 설명함" + ) + } else { + listOf( + "포트폴리오에 대시보드 초기 로딩 개선과 API 구조 조정 경험이 기재되어 있음", + "사용자 체감 속도 개선과 관련 문의 감소를 언급함" + ) + } } ) ) From bf040ede268d3106b27986e500d6d37d4698ecf4 Mon Sep 17 00:00:00 2001 From: rktclgh Date: Thu, 12 Mar 2026 17:13:34 +0900 Subject: [PATCH 5/8] =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20=EC=97=85=EB=A1=9C?= =?UTF-8?q?=EB=93=9C=20=ED=98=95=EC=8B=9D=20=EC=B6=94=EA=B0=80(docx,=20ppt?= =?UTF-8?q?x)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle.kts | 3 + .../service/DocumentInterviewService.kt | 92 ++++++++++++++++++- .../userFile/service/UserFileService.kt | 16 +++- 3 files changed, 105 insertions(+), 6 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index 81f1af4..4483f48 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -37,6 +37,9 @@ dependencies { implementation("org.apache.pdfbox:pdfbox:2.0.32") { exclude(group = "commons-logging", module = "commons-logging") } + implementation("org.apache.poi:poi-ooxml:5.4.1") { + exclude(group = "commons-logging", module = "commons-logging") + } implementation("com.fasterxml.jackson.module:jackson-module-kotlin") implementation("org.apache.commons:commons-lang3:3.18.0") implementation("io.jsonwebtoken:jjwt-api:0.12.6") diff --git a/src/main/kotlin/com/cw/vlainter/domain/interview/service/DocumentInterviewService.kt b/src/main/kotlin/com/cw/vlainter/domain/interview/service/DocumentInterviewService.kt index 1651921..13d65c9 100644 --- a/src/main/kotlin/com/cw/vlainter/domain/interview/service/DocumentInterviewService.kt +++ b/src/main/kotlin/com/cw/vlainter/domain/interview/service/DocumentInterviewService.kt @@ -56,6 +56,10 @@ import com.cw.vlainter.global.config.properties.S3Properties import com.cw.vlainter.global.security.AuthPrincipal import com.fasterxml.jackson.databind.JsonNode import com.fasterxml.jackson.databind.ObjectMapper +import org.apache.poi.xslf.usermodel.XMLSlideShow +import org.apache.poi.xslf.usermodel.XSLFTable +import org.apache.poi.xslf.usermodel.XSLFTextShape +import org.apache.poi.xwpf.usermodel.XWPFDocument import org.apache.pdfbox.pdmodel.PDDocument import org.apache.pdfbox.rendering.PDFRenderer import org.apache.pdfbox.text.PDFTextStripper @@ -218,7 +222,7 @@ class DocumentInterviewService( val firstEmbedding = embeddings.first() job.status = DocumentIngestionStatus.READY job.errorMessage = null - job.parserName = "pdfbox" + job.parserName = parserNameForExtractionMethod(extractionMethod) job.embeddingModel = firstEmbedding.model job.embeddingVersion = firstEmbedding.modelVersion job.chunkCount = embeddings.size @@ -248,7 +252,7 @@ class DocumentInterviewService( val file = loadOwnedInterviewDocument(job.userId, job.documentFileId) try { - val extracted = extractPdfText(file) + val extracted = extractDocumentText(file) val text = extracted.text val chunks = splitIntoChunks(text) if (chunks.isEmpty()) { @@ -1477,7 +1481,7 @@ class DocumentInterviewService( return runCatching { InterviewLanguage.valueOf(raw) }.getOrDefault(InterviewLanguage.KO) } - private fun extractPdfText(file: UserFile): ExtractedDocumentText { + private fun extractDocumentText(file: UserFile): ExtractedDocumentText { if (s3Properties.bucket.isBlank()) { throw ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "S3 버킷 설정이 누락되었습니다.") } @@ -1492,6 +1496,15 @@ class DocumentInterviewService( throw ResponseStatusException(HttpStatus.BAD_GATEWAY, "문서 파일을 불러오지 못했습니다.") } + return when (resolveDocumentFormat(file)) { + "pdf" -> extractPdfText(bytes) + "docx" -> extractDocxText(bytes) + "pptx" -> extractPptxText(bytes) + else -> throw ResponseStatusException(HttpStatus.BAD_REQUEST, "지원하지 않는 문서 형식입니다.") + } + } + + private fun extractPdfText(bytes: ByteArray): ExtractedDocumentText { return PDDocument.load(ByteArrayInputStream(bytes)).use { document -> val pdfText = normalizeText(PDFTextStripper().getText(document)) if (!ocrProperties.enabled || pdfText.length >= ocrProperties.fallbackMinTextLength) { @@ -1512,6 +1525,79 @@ class DocumentInterviewService( } } + private fun extractDocxText(bytes: ByteArray): ExtractedDocumentText { + val text = XWPFDocument(ByteArrayInputStream(bytes)).use { document -> + buildString { + document.paragraphs + .mapNotNull { it.text?.trim()?.takeIf(String::isNotBlank) } + .forEach { appendLine(it) } + document.tables.forEach { table -> + table.rows.forEach { row -> + row.tableCells + .mapNotNull { it.text?.trim()?.takeIf(String::isNotBlank) } + .forEach { appendLine(it) } + } + } + } + } + + return ExtractedDocumentText( + text = normalizeText(text), + method = "DOCX_POI", + ocrLanguages = null + ) + } + + private fun extractPptxText(bytes: ByteArray): ExtractedDocumentText { + val text = XMLSlideShow(ByteArrayInputStream(bytes)).use { slideShow -> + buildString { + slideShow.slides.forEach { slide -> + slide.shapes.forEach { shape -> + when (shape) { + is XSLFTextShape -> { + val shapeText = shape.text?.trim() + if (!shapeText.isNullOrBlank()) appendLine(shapeText) + } + is XSLFTable -> { + shape.rows.forEach { row -> + row.cells + .mapNotNull { it.text?.trim()?.takeIf(String::isNotBlank) } + .forEach { appendLine(it) } + } + } + } + } + } + } + } + + return ExtractedDocumentText( + text = normalizeText(text), + method = "PPTX_POI", + ocrLanguages = null + ) + } + + private fun resolveDocumentFormat(file: UserFile): String { + val fromName = file.originalFileName.substringAfterLast('.', "").trim().lowercase() + if (fromName.isNotBlank()) return fromName + + return when (file.contentType?.trim()?.lowercase()) { + "application/pdf" -> "pdf" + "application/vnd.openxmlformats-officedocument.wordprocessingml.document" -> "docx" + "application/vnd.openxmlformats-officedocument.presentationml.presentation" -> "pptx" + else -> "" + } + } + + private fun parserNameForExtractionMethod(extractionMethod: String): String = when (extractionMethod) { + "PDFBOX" -> "pdfbox" + "OCR_TESSERACT" -> "tesseract" + "DOCX_POI" -> "poi-xwpf" + "PPTX_POI" -> "poi-xslf" + else -> "unknown" + } + private fun sanitizePromptSnippet(text: String): String = text.replace(Regex("\\s+"), " ").trim() diff --git a/src/main/kotlin/com/cw/vlainter/domain/userFile/service/UserFileService.kt b/src/main/kotlin/com/cw/vlainter/domain/userFile/service/UserFileService.kt index 5bc1532..92d34bc 100644 --- a/src/main/kotlin/com/cw/vlainter/domain/userFile/service/UserFileService.kt +++ b/src/main/kotlin/com/cw/vlainter/domain/userFile/service/UserFileService.kt @@ -46,6 +46,13 @@ class UserFileService( private companion object { val ALLOWED_PROFILE_IMAGE_EXTENSIONS = setOf("png", "jpg", "jpeg", "webp") val ALLOWED_PROFILE_IMAGE_CONTENT_TYPES = setOf("image/png", "image/jpeg", "image/webp") + val ALLOWED_INTERVIEW_DOCUMENT_EXTENSIONS = setOf("pdf", "docx", "pptx") + val ALLOWED_INTERVIEW_DOCUMENT_CONTENT_TYPES = setOf( + "application/pdf", + "application/octet-stream", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "application/vnd.openxmlformats-officedocument.presentationml.presentation" + ) const val MAX_DOCUMENT_FILES_PER_TYPE = 5L } @@ -217,10 +224,13 @@ class UserFileService( val extension = lowerName.substringAfterLast('.', "") if (fileType == FileType.RESUME || fileType == FileType.INTRODUCE || fileType == FileType.PORTFOLIO) { - val extensionValid = extension == "pdf" - val contentTypeValid = contentType.isBlank() || contentType == "application/pdf" + val extensionValid = extension in ALLOWED_INTERVIEW_DOCUMENT_EXTENSIONS + val contentTypeValid = contentType.isBlank() || contentType in ALLOWED_INTERVIEW_DOCUMENT_CONTENT_TYPES if (!extensionValid || !contentTypeValid) { - throw ResponseStatusException(HttpStatus.BAD_REQUEST, "이력서/자기소개서/포트폴리오는 PDF 파일만 업로드할 수 있습니다.") + throw ResponseStatusException( + HttpStatus.BAD_REQUEST, + "이력서/자기소개서/포트폴리오는 PDF, DOCX, PPTX 파일만 업로드할 수 있습니다." + ) } } From 608e5cbbe231ab066f66a948e6c85c993ed13c66 Mon Sep 17 00:00:00 2001 From: rktclgh Date: Thu, 12 Mar 2026 17:40:47 +0900 Subject: [PATCH 6/8] =?UTF-8?q?=EA=B0=80=EC=9E=85,=20=ED=83=88=ED=87=B4?= =?UTF-8?q?=EC=8B=9C=20=EC=9D=B4=EB=A9=94=EC=9D=BC=20=EC=A0=84=EC=86=A1,?= =?UTF-8?q?=20=EC=B9=B4=EC=B9=B4=EC=98=A4=ED=86=A1=EC=9D=B4=EB=82=98=20dm?= =?UTF-8?q?=20=EC=97=90=EC=84=9C=20=EB=AF=B8=EB=A6=AC=EB=B3=B4=EA=B8=B0=20?= =?UTF-8?q?=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EC=95=88=EB=B3=B4=EC=9D=B4?= =?UTF-8?q?=EB=8D=98=20=ED=98=84=EC=83=81=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/auth/service/AuthService.kt | 17 ++++- .../auth/service/EmailVerificationService.kt | 7 +- .../auth/service/PasswordRecoveryService.kt | 7 +- .../user/service/UserLifecycleEmailService.kt | 60 ++++++++++++++++ .../domain/user/service/UserService.kt | 6 +- .../vlainter/global/config/SecurityConfig.kt | 2 + .../global/mail/EmailTemplateService.kt | 42 +++++++++++ .../email/content/auth/account-deletion.html | 12 ++++ .../content/auth/temporary-password.html | 16 ++--- .../email/content/auth/verification-code.html | 16 ++--- .../resources/email/content/auth/welcome.html | 13 ++++ src/main/resources/email/frame/default.html | 68 +++--------------- src/main/resources/email/logo/favicon.png | Bin 0 -> 11220 bytes src/main/resources/static/favicon.png | Bin 0 -> 11220 bytes src/main/resources/static/social-preview.png | Bin 0 -> 54306 bytes .../domain/auth/service/AuthServiceTests.kt | 53 +++++++++++++- .../service/EmailVerificationServiceTests.kt | 11 +-- .../service/PasswordRecoveryServiceTests.kt | 13 ++-- .../domain/user/service/UserServiceTests.kt | 7 +- 19 files changed, 260 insertions(+), 90 deletions(-) create mode 100644 src/main/kotlin/com/cw/vlainter/domain/user/service/UserLifecycleEmailService.kt create mode 100644 src/main/resources/email/content/auth/account-deletion.html create mode 100644 src/main/resources/email/content/auth/welcome.html create mode 100644 src/main/resources/email/logo/favicon.png create mode 100644 src/main/resources/static/favicon.png create mode 100644 src/main/resources/static/social-preview.png diff --git a/src/main/kotlin/com/cw/vlainter/domain/auth/service/AuthService.kt b/src/main/kotlin/com/cw/vlainter/domain/auth/service/AuthService.kt index 00cb3dc..3eafe98 100644 --- a/src/main/kotlin/com/cw/vlainter/domain/auth/service/AuthService.kt +++ b/src/main/kotlin/com/cw/vlainter/domain/auth/service/AuthService.kt @@ -6,6 +6,7 @@ import com.cw.vlainter.domain.user.entity.User import com.cw.vlainter.domain.user.entity.UserRole import com.cw.vlainter.domain.user.entity.UserStatus import com.cw.vlainter.domain.user.repository.UserRepository +import com.cw.vlainter.domain.user.service.UserLifecycleEmailService import com.cw.vlainter.global.security.JwtTokenProvider import com.cw.vlainter.global.security.LoginSessionStore import com.cw.vlainter.global.security.RefreshTokenValidationResult @@ -35,7 +36,8 @@ class AuthService( private val loginSessionStore: LoginSessionStore, private val authAccessAuditService: AuthAccessAuditService, private val redirectUriValidator: RedirectUriValidator, - private val emailVerificationService: EmailVerificationService + private val emailVerificationService: EmailVerificationService, + private val userLifecycleEmailService: UserLifecycleEmailService ) { private val logger = LoggerFactory.getLogger(AuthService::class.java) private val passwordComplexityRegex = Regex("^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)(?=.*[^A-Za-z\\d]).{8,100}$") @@ -80,6 +82,11 @@ class AuthService( role = UserRole.USER ) ) + userLifecycleEmailService.sendWelcomeEmail( + email = createdUser.email, + userName = createdUser.name, + signupChannel = "카카오" + ) logger.info("Auth social signup created new user userId={} email={}", createdUser.id, createdUser.email) createdUser } @@ -156,7 +163,13 @@ class AuthService( role = UserRole.USER ) return try { - userRepository.save(user) + val saved = userRepository.save(user) + userLifecycleEmailService.sendWelcomeEmail( + email = saved.email, + userName = saved.name, + signupChannel = "이메일" + ) + saved } catch (_: DataIntegrityViolationException) { throw ResponseStatusException(HttpStatus.CONFLICT, "이미 가입된 이메일입니다.") } diff --git a/src/main/kotlin/com/cw/vlainter/domain/auth/service/EmailVerificationService.kt b/src/main/kotlin/com/cw/vlainter/domain/auth/service/EmailVerificationService.kt index a073afb..3b01a7b 100644 --- a/src/main/kotlin/com/cw/vlainter/domain/auth/service/EmailVerificationService.kt +++ b/src/main/kotlin/com/cw/vlainter/domain/auth/service/EmailVerificationService.kt @@ -160,13 +160,18 @@ class EmailVerificationService( private fun sendEmail(email: String, code: String) { val html = emailTemplateService.buildVerificationCodeEmail(code, emailVerificationProperties.codeExpSeconds) val message = mailSender.createMimeMessage() - val helper = MimeMessageHelper(message, StandardCharsets.UTF_8.name()) + val helper = MimeMessageHelper(message, true, StandardCharsets.UTF_8.name()) if (senderEmail.isNotBlank()) { helper.setFrom(senderEmail) } helper.setTo(email) helper.setSubject("[VlaInter] 이메일 인증 코드") helper.setText(html, true) + helper.addInline( + emailTemplateService.logoContentId(), + emailTemplateService.logoResource(), + "image/png" + ) mailSender.send(message) } diff --git a/src/main/kotlin/com/cw/vlainter/domain/auth/service/PasswordRecoveryService.kt b/src/main/kotlin/com/cw/vlainter/domain/auth/service/PasswordRecoveryService.kt index a98b96f..96a0b05 100644 --- a/src/main/kotlin/com/cw/vlainter/domain/auth/service/PasswordRecoveryService.kt +++ b/src/main/kotlin/com/cw/vlainter/domain/auth/service/PasswordRecoveryService.kt @@ -89,13 +89,18 @@ class PasswordRecoveryService( private fun sendTemporaryPasswordEmail(email: String, temporaryPassword: String) { val html = emailTemplateService.buildTemporaryPasswordEmail(temporaryPassword) val message = mailSender.createMimeMessage() - val helper = MimeMessageHelper(message, StandardCharsets.UTF_8.name()) + val helper = MimeMessageHelper(message, true, StandardCharsets.UTF_8.name()) if (senderEmail.isNotBlank()) { helper.setFrom(senderEmail) } helper.setTo(email) helper.setSubject("[VlaInter] 임시 비밀번호") helper.setText(html, true) + helper.addInline( + emailTemplateService.logoContentId(), + emailTemplateService.logoResource(), + "image/png" + ) mailSender.send(message) } diff --git a/src/main/kotlin/com/cw/vlainter/domain/user/service/UserLifecycleEmailService.kt b/src/main/kotlin/com/cw/vlainter/domain/user/service/UserLifecycleEmailService.kt new file mode 100644 index 0000000..fa0586c --- /dev/null +++ b/src/main/kotlin/com/cw/vlainter/domain/user/service/UserLifecycleEmailService.kt @@ -0,0 +1,60 @@ +package com.cw.vlainter.domain.user.service + +import com.cw.vlainter.global.mail.EmailTemplateService +import org.slf4j.LoggerFactory +import org.springframework.beans.factory.annotation.Value +import org.springframework.mail.javamail.JavaMailSender +import org.springframework.mail.javamail.MimeMessageHelper +import org.springframework.stereotype.Service +import java.nio.charset.StandardCharsets + +@Service +class UserLifecycleEmailService( + private val mailSender: JavaMailSender, + private val emailTemplateService: EmailTemplateService, + @Value("\${spring.mail.username:}") + private val senderEmail: String +) { + private val logger = LoggerFactory.getLogger(javaClass) + + fun sendWelcomeEmail(email: String, userName: String, signupChannel: String) { + val html = emailTemplateService.buildWelcomeEmail(userName, signupChannel) + sendBestEffort( + recipient = email, + subject = "[VlaInter] 가입해주셔서 감사합니다", + html = html, + event = "welcome" + ) + } + + fun sendAccountDeletionEmail(email: String, userName: String) { + val html = emailTemplateService.buildAccountDeletionEmail(userName) + sendBestEffort( + recipient = email, + subject = "[VlaInter] 회원 탈퇴가 완료되었습니다", + html = html, + event = "account-deletion" + ) + } + + private fun sendBestEffort(recipient: String, subject: String, html: String, event: String) { + runCatching { + val message = mailSender.createMimeMessage() + val helper = MimeMessageHelper(message, true, StandardCharsets.UTF_8.name()) + if (senderEmail.isNotBlank()) { + helper.setFrom(senderEmail) + } + helper.setTo(recipient) + helper.setSubject(subject) + helper.setText(html, true) + helper.addInline( + emailTemplateService.logoContentId(), + emailTemplateService.logoResource(), + "image/png" + ) + mailSender.send(message) + }.onFailure { ex -> + logger.warn("lifecycle email send failed event={} recipient={} reason={}", event, recipient, ex.message) + } + } +} diff --git a/src/main/kotlin/com/cw/vlainter/domain/user/service/UserService.kt b/src/main/kotlin/com/cw/vlainter/domain/user/service/UserService.kt index 5dee6c7..0fe1807 100644 --- a/src/main/kotlin/com/cw/vlainter/domain/user/service/UserService.kt +++ b/src/main/kotlin/com/cw/vlainter/domain/user/service/UserService.kt @@ -37,7 +37,8 @@ class UserService( private val passwordEncoder: PasswordEncoder, private val loginSessionStore: LoginSessionStore, private val userGeminiApiKeyService: UserGeminiApiKeyService, - private val authAccessAuditService: AuthAccessAuditService + private val authAccessAuditService: AuthAccessAuditService, + private val userLifecycleEmailService: UserLifecycleEmailService ) { private val passwordComplexityRegex = Regex("^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)(?=.*[^A-Za-z\\d]).{8,100}$") @@ -97,9 +98,12 @@ class UserService( .orElseThrow { unauthorizedException() } ensureActiveUser(user.status) + val originalEmail = user.email + val originalName = user.name markUserSoftDeleted(user) userRepository.save(user) loginSessionStore.deleteAllByUserId(user.id) + userLifecycleEmailService.sendAccountDeletionEmail(originalEmail, originalName) } @Transactional(readOnly = true) diff --git a/src/main/kotlin/com/cw/vlainter/global/config/SecurityConfig.kt b/src/main/kotlin/com/cw/vlainter/global/config/SecurityConfig.kt index 64dd61d..1527121 100644 --- a/src/main/kotlin/com/cw/vlainter/global/config/SecurityConfig.kt +++ b/src/main/kotlin/com/cw/vlainter/global/config/SecurityConfig.kt @@ -124,6 +124,8 @@ class SecurityConfig( "/error/**", "/assets/**", "/favicon.ico", + "/favicon.png", + "/social-preview.png", "/vite.svg", "/icon/**" ) diff --git a/src/main/kotlin/com/cw/vlainter/global/mail/EmailTemplateService.kt b/src/main/kotlin/com/cw/vlainter/global/mail/EmailTemplateService.kt index 89be4e6..d387a00 100644 --- a/src/main/kotlin/com/cw/vlainter/global/mail/EmailTemplateService.kt +++ b/src/main/kotlin/com/cw/vlainter/global/mail/EmailTemplateService.kt @@ -15,6 +15,7 @@ class EmailTemplateService { val model = mapOf( "mail_title" to "이메일 인증 코드", "service_name" to "VlaInter", + "logo_src" to "cid:$LOGO_CONTENT_ID", "verification_code" to code, "expires_in_seconds" to expiresInSeconds.toString(), "contact_email" to CONTACT_EMAIL, @@ -28,6 +29,7 @@ class EmailTemplateService { val model = mapOf( "mail_title" to "임시 비밀번호", "service_name" to "VlaInter", + "logo_src" to "cid:$LOGO_CONTENT_ID", "temporary_password" to temporaryPassword, "contact_email" to CONTACT_EMAIL, "footer_year" to Year.now().value.toString(), @@ -36,6 +38,33 @@ class EmailTemplateService { return TemporaryPasswordTemplate().render(model) } + fun buildWelcomeEmail(userName: String, signupChannel: String): String { + val model = mapOf( + "mail_title" to "회원가입을 환영합니다", + "service_name" to "VlaInter", + "logo_src" to "cid:$LOGO_CONTENT_ID", + "user_name" to userName, + "signup_channel" to signupChannel, + "contact_email" to CONTACT_EMAIL, + "footer_year" to Year.now().value.toString(), + "footer_description" to "AI 면접 트레이닝 플랫폼" + ) + return WelcomeTemplate().render(model) + } + + fun buildAccountDeletionEmail(userName: String): String { + val model = mapOf( + "mail_title" to "회원 탈퇴가 완료되었습니다", + "service_name" to "VlaInter", + "logo_src" to "cid:$LOGO_CONTENT_ID", + "user_name" to userName, + "contact_email" to CONTACT_EMAIL, + "footer_year" to Year.now().value.toString(), + "footer_description" to "AI 면접 트레이닝 플랫폼" + ) + return AccountDeletionTemplate().render(model) + } + private abstract inner class BaseTemplate { fun render(rawModel: Map): String { val escapedModel = rawModel.mapValues { HtmlUtils.htmlEscape(it.value) } @@ -74,7 +103,20 @@ class EmailTemplateService { override fun contentPath(): String = "email/content/auth/temporary-password.html" } + private inner class WelcomeTemplate : BaseTemplate() { + override fun contentPath(): String = "email/content/auth/welcome.html" + } + + private inner class AccountDeletionTemplate : BaseTemplate() { + override fun contentPath(): String = "email/content/auth/account-deletion.html" + } + + fun logoResource(): ClassPathResource = ClassPathResource("email/logo/favicon.png") + + fun logoContentId(): String = LOGO_CONTENT_ID + companion object { private const val CONTACT_EMAIL = "info@vlainter.online" + private const val LOGO_CONTENT_ID = "vlainter-logo" } } diff --git a/src/main/resources/email/content/auth/account-deletion.html b/src/main/resources/email/content/auth/account-deletion.html new file mode 100644 index 0000000..ddea8e8 --- /dev/null +++ b/src/main/resources/email/content/auth/account-deletion.html @@ -0,0 +1,12 @@ +
+

+ 안녕하세요, {{user_name}}님.
+ {{service_name}} 회원 탈퇴가 정상적으로 처리되었습니다. +

+
+
+
    +
  • 이 메일은 회원 탈퇴 처리 내역을 안내하기 위한 확인 메일입니다.
  • +
  • 본인이 요청하지 않은 탈퇴라면 즉시 {{contact_email}} 로 문의해 주세요.
  • +
+
diff --git a/src/main/resources/email/content/auth/temporary-password.html b/src/main/resources/email/content/auth/temporary-password.html index 6ced2d5..794e594 100644 --- a/src/main/resources/email/content/auth/temporary-password.html +++ b/src/main/resources/email/content/auth/temporary-password.html @@ -1,14 +1,14 @@ -
-

- 안녕하세요! {{service_name}}를 이용해주셔서 감사합니다.
- 아래 임시 비밀번호
+

+

+ 안녕하세요! {{service_name}}를 이용해주셔서 감사합니다.
+ 아래 임시 비밀번호
로그인 후 반드시 비밀번호를 변경해 주세요.

-
{{temporary_password}}
-
-