From 5eca69a2dc0145e57082d104cf5110f265a65c66 Mon Sep 17 00:00:00 2001 From: MyoJun <168921419+hyoj-dev@users.noreply.github.com> Date: Tue, 21 Jul 2026 00:27:27 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=ED=9A=8C=EA=B3=A0=20=EC=A1=B0=ED=9A=8C?= =?UTF-8?q?=20=EB=B0=8F=20AI=20=EC=B2=98=EB=A6=AC=20=EC=84=B1=EB=8A=A5=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 --- .../dashboards/didit-ai-performance.json | 94 +++++++++++++ .../com/didit/adapter/config/AsyncConfig.kt | 39 +++++- .../adapter/integration/ai/OpenAiClient.kt | 57 ++++---- .../adapter/integration/ai/OpenAiMetrics.kt | 74 ++++++++++ .../webapi/retrospect/RetrospectApi.kt | 2 +- .../dto/RetrospectiveListItemResponse.kt | 9 ++ .../retrospect/DeepQuestionEventListener.kt | 31 +++++ .../retrospect/DeepQuestionGenerationEvent.kt | 8 ++ .../retrospect/DeepQuestionProcessor.kt | 79 +++++++++++ .../retrospect/RetrospectQueryService.kt | 4 + .../retrospect/RetrospectService.kt | 86 +----------- .../retrospect/RetrospectiveAiMetrics.kt | 59 ++++++++ .../RetrospectiveCompletionCoordinator.kt | 129 ++++++++++++++++++ .../dto/RetrospectiveListItemResult.kt | 11 ++ .../exception/RetrospectErrorCode.kt | 2 + .../exception/RetrospectException.kt | 14 ++ .../provided/RetrospectiveFinder.kt | 3 + .../required/RetrospectiveRepository.kt | 26 ++++ .../didit/domain/retrospect/Retrospective.kt | 15 ++ .../retrospect/SummaryGenerationStatus.kt | 7 + ...mize_retrospective_generation_and_list.sql | 9 ++ .../webapi/retrospect/RetrospectApiTest.kt | 13 +- .../retrospect/DeepQuestionProcessorTest.kt | 82 +++++++++++ .../retrospect/RetrospectServiceTest.kt | 47 +------ .../RetrospectiveCompletionCoordinatorTest.kt | 117 ++++++++++++++++ .../required/RetrospectiveRepositoryTest.kt | 16 +++ 26 files changed, 882 insertions(+), 151 deletions(-) create mode 100644 deploy/prod/grafana/dashboards/didit-ai-performance.json create mode 100644 src/main/kotlin/com/didit/adapter/integration/ai/OpenAiMetrics.kt create mode 100644 src/main/kotlin/com/didit/application/retrospect/DeepQuestionEventListener.kt create mode 100644 src/main/kotlin/com/didit/application/retrospect/DeepQuestionGenerationEvent.kt create mode 100644 src/main/kotlin/com/didit/application/retrospect/DeepQuestionProcessor.kt create mode 100644 src/main/kotlin/com/didit/application/retrospect/RetrospectiveAiMetrics.kt create mode 100644 src/main/kotlin/com/didit/application/retrospect/RetrospectiveCompletionCoordinator.kt create mode 100644 src/main/kotlin/com/didit/application/retrospect/dto/RetrospectiveListItemResult.kt create mode 100644 src/main/kotlin/com/didit/domain/retrospect/SummaryGenerationStatus.kt create mode 100644 src/main/resources/db/migration/V45__optimize_retrospective_generation_and_list.sql create mode 100644 src/test/kotlin/com/didit/application/retrospect/DeepQuestionProcessorTest.kt create mode 100644 src/test/kotlin/com/didit/application/retrospect/RetrospectiveCompletionCoordinatorTest.kt diff --git a/deploy/prod/grafana/dashboards/didit-ai-performance.json b/deploy/prod/grafana/dashboards/didit-ai-performance.json new file mode 100644 index 00000000..bd7b9cf3 --- /dev/null +++ b/deploy/prod/grafana/dashboards/didit-ai-performance.json @@ -0,0 +1,94 @@ +{ + "annotations": { "list": [] }, + "editable": true, + "graphTooltip": 1, + "panels": [ + { + "type": "timeseries", + "title": "AI Workflow p95 by Stage", + "id": 1, + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 0 }, + "targets": [ + { + "expr": "histogram_quantile(0.95, sum(rate(didit_ai_workflow_duration_seconds_bucket{job=\"didit-api\"}[5m])) by (le, operation, stage))", + "legendFormat": "{{operation}} / {{stage}}", + "refId": "A" + } + ], + "fieldConfig": { "defaults": { "unit": "s" }, "overrides": [] } + }, + { + "type": "timeseries", + "title": "OpenAI Average and Maximum Duration", + "id": 2, + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 0 }, + "targets": [ + { + "expr": "sum(rate(didit_openai_request_duration_seconds_sum{job=\"didit-api\",status=\"success\"}[5m])) by (operation) / sum(rate(didit_openai_request_duration_seconds_count{job=\"didit-api\",status=\"success\"}[5m])) by (operation)", + "legendFormat": "{{operation}} avg", + "refId": "A" + }, + { + "expr": "max(didit_openai_request_duration_seconds_max{job=\"didit-api\"}) by (operation)", + "legendFormat": "{{operation}} max", + "refId": "B" + } + ], + "fieldConfig": { "defaults": { "unit": "s" }, "overrides": [] } + }, + { + "type": "timeseries", + "title": "AI Executor", + "id": 3, + "gridPos": { "h": 8, "w": 8, "x": 0, "y": 8 }, + "targets": [ + { "expr": "didit_ai_executor_active{job=\"didit-api\"}", "legendFormat": "active", "refId": "A" }, + { "expr": "didit_ai_executor_queue_size{job=\"didit-api\"}", "legendFormat": "queue", "refId": "B" }, + { "expr": "increase(didit_ai_executor_rejected_total{job=\"didit-api\"}[5m])", "legendFormat": "rejected", "refId": "C" } + ] + }, + { + "type": "timeseries", + "title": "Tomcat Threads", + "id": 4, + "gridPos": { "h": 8, "w": 8, "x": 8, "y": 8 }, + "targets": [ + { "expr": "tomcat_threads_busy_threads{job=\"didit-api\"}", "legendFormat": "busy", "refId": "A" }, + { "expr": "tomcat_threads_current_threads{job=\"didit-api\"}", "legendFormat": "current", "refId": "B" }, + { "expr": "tomcat_threads_config_max_threads{job=\"didit-api\"}", "legendFormat": "max", "refId": "C" } + ] + }, + { + "type": "timeseries", + "title": "Hikari Connections", + "id": 5, + "gridPos": { "h": 8, "w": 8, "x": 16, "y": 8 }, + "targets": [ + { "expr": "hikaricp_connections_active{job=\"didit-api\"}", "legendFormat": "active", "refId": "A" }, + { "expr": "hikaricp_connections_pending{job=\"didit-api\"}", "legendFormat": "pending", "refId": "B" } + ] + }, + { + "type": "timeseries", + "title": "OpenAI Tokens", + "id": 6, + "gridPos": { "h": 8, "w": 24, "x": 0, "y": 16 }, + "targets": [ + { + "expr": "sum(increase(didit_openai_tokens_total{job=\"didit-api\"}[1h])) by (operation, type)", + "legendFormat": "{{operation}} / {{type}}", + "refId": "A" + } + ] + } + ], + "refresh": "30s", + "schemaVersion": 39, + "tags": ["didit", "ai", "performance"], + "templating": { "list": [] }, + "time": { "from": "now-1h", "to": "now" }, + "timezone": "browser", + "title": "DIDIT AI Performance", + "uid": "didit-ai-performance", + "version": 1 +} diff --git a/src/main/kotlin/com/didit/adapter/config/AsyncConfig.kt b/src/main/kotlin/com/didit/adapter/config/AsyncConfig.kt index d207e74b..262f7975 100644 --- a/src/main/kotlin/com/didit/adapter/config/AsyncConfig.kt +++ b/src/main/kotlin/com/didit/adapter/config/AsyncConfig.kt @@ -1,8 +1,45 @@ package com.didit.adapter.config +import io.micrometer.core.instrument.Gauge +import io.micrometer.core.instrument.MeterRegistry +import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration import org.springframework.scheduling.annotation.EnableAsync +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor +import java.util.concurrent.ThreadPoolExecutor @EnableAsync @Configuration -class AsyncConfig +class AsyncConfig { + @Bean("taskExecutor") + fun taskExecutor(): ThreadPoolTaskExecutor = + ThreadPoolTaskExecutor().apply { + corePoolSize = 2 + maxPoolSize = 4 + queueCapacity = 100 + setThreadNamePrefix("didit-async-") + } + + @Bean("aiTaskExecutor") + fun aiTaskExecutor(meterRegistry: MeterRegistry): ThreadPoolTaskExecutor = + ThreadPoolTaskExecutor().apply { + corePoolSize = 4 + maxPoolSize = 8 + queueCapacity = 50 + setThreadNamePrefix("didit-ai-") + setRejectedExecutionHandler(ThreadPoolExecutor.AbortPolicy()) + + Gauge.builder("didit.ai.executor.active", this) { it.activeCount.toDouble() }.register(meterRegistry) + Gauge + .builder("didit.ai.executor.queue.size", this) { + it.threadPoolExecutor.queue.size + .toDouble() + }.register(meterRegistry) + Gauge + .builder( + "didit.ai.executor.completed", + this, + ) { it.threadPoolExecutor.completedTaskCount.toDouble() } + .register(meterRegistry) + } +} diff --git a/src/main/kotlin/com/didit/adapter/integration/ai/OpenAiClient.kt b/src/main/kotlin/com/didit/adapter/integration/ai/OpenAiClient.kt index d1f7bc01..43dfad04 100644 --- a/src/main/kotlin/com/didit/adapter/integration/ai/OpenAiClient.kt +++ b/src/main/kotlin/com/didit/adapter/integration/ai/OpenAiClient.kt @@ -20,6 +20,7 @@ class OpenAiClient( private val restClient: RestClient, private val objectMapper: ObjectMapper, private val feedbackPrompts: FeedbackPrompts, + private val metrics: OpenAiMetrics, @param:Value("\${openai.api-key}") private val apiKey: String, @param:Value("\${openai.chat.model}") private val model: String, ) : AIClient { @@ -37,7 +38,7 @@ class OpenAiClient( logger.debug("심화 질문 프롬프트 - job: $job, prompt:\n$prompt") - val result = callWithResult(prompt, "deep_question", deepQuestionSchema()) + val result = callWithResult(prompt, "deep_question", "deep_question", deepQuestionSchema()) return parseDeepQuestion(result) } @@ -51,43 +52,49 @@ class OpenAiClient( logger.debug("요약 프롬프트 - job: $job, prompt:\n$prompt") - val result = callWithResult(prompt, "retrospective_summary", summarySchema()) + val result = callWithResult(prompt, "summary", "retrospective_summary", summarySchema()) return parseSummary(result) } private fun callWithResult( prompt: String, + operation: String, schemaName: String, schema: Map, ): OpenAiResponse { + metrics.recordPromptCharacters(operation, prompt.length) val rawResponse = - restClient - .post() - .uri(URL) - .header("Authorization", "Bearer $apiKey") - .contentType(MediaType.APPLICATION_JSON) - .body( - OpenAiRequest( - model = model, - instructions = SYSTEM_PROMPT, - input = prompt, - maxOutputTokens = 3000, - text = - OpenAiTextFormat( - format = - OpenAiJsonSchemaFormat( - name = schemaName, - schema = schema, - ), - ), - ), - ).retrieve() - .body() ?: throw RuntimeException("OpenAI 응답을 받지 못했습니다.") + metrics.record(operation) { + restClient + .post() + .uri(URL) + .header("Authorization", "Bearer $apiKey") + .contentType(MediaType.APPLICATION_JSON) + .body( + OpenAiRequest( + model = model, + instructions = SYSTEM_PROMPT, + input = prompt, + maxOutputTokens = 3000, + text = + OpenAiTextFormat( + format = + OpenAiJsonSchemaFormat( + name = schemaName, + schema = schema, + ), + ), + ), + ).retrieve() + .body() ?: throw RuntimeException("OpenAI 응답을 받지 못했습니다.") + } logger.debug("OpenAI 전체 응답: $rawResponse") - return objectMapper.readValue(rawResponse) + return objectMapper.readValue(rawResponse).also { + metrics.recordTokens(operation, it.usage?.inputTokens ?: 0, it.usage?.outputTokens ?: 0) + } } private fun parseDeepQuestion(response: OpenAiResponse): GeneratedDeepQuestion = diff --git a/src/main/kotlin/com/didit/adapter/integration/ai/OpenAiMetrics.kt b/src/main/kotlin/com/didit/adapter/integration/ai/OpenAiMetrics.kt new file mode 100644 index 00000000..1b3581be --- /dev/null +++ b/src/main/kotlin/com/didit/adapter/integration/ai/OpenAiMetrics.kt @@ -0,0 +1,74 @@ +package com.didit.adapter.integration.ai + +import io.micrometer.core.instrument.DistributionSummary +import io.micrometer.core.instrument.MeterRegistry +import io.micrometer.core.instrument.Timer +import org.springframework.stereotype.Component +import java.time.Duration + +@Component +class OpenAiMetrics( + private val meterRegistry: MeterRegistry, +) { + fun record( + operation: String, + block: () -> T, + ): T { + val sample = Timer.start(meterRegistry) + try { + return block().also { stop(sample, operation, "success") } + } catch (exception: Exception) { + stop(sample, operation, classify(exception)) + throw exception + } + } + + fun recordPromptCharacters( + operation: String, + count: Int, + ) { + DistributionSummary + .builder("didit.openai.prompt.characters") + .tag("operation", operation) + .register(meterRegistry) + .record(count.toDouble()) + } + + fun recordTokens( + operation: String, + inputTokens: Int, + outputTokens: Int, + ) { + meterRegistry.counter("didit.openai.tokens", "operation", operation, "type", "input").increment(inputTokens.toDouble()) + meterRegistry.counter("didit.openai.tokens", "operation", operation, "type", "output").increment(outputTokens.toDouble()) + } + + private fun stop( + sample: Timer.Sample, + operation: String, + status: String, + ) { + sample.stop( + Timer + .builder("didit.openai.request.duration") + .tags("operation", operation, "status", status) + .publishPercentileHistogram() + .serviceLevelObjectives( + Duration.ofMillis(500), + Duration.ofSeconds(1), + Duration.ofSeconds(2), + Duration.ofSeconds(3), + Duration.ofSeconds(4), + Duration.ofSeconds(5), + Duration.ofMillis(7500), + Duration.ofSeconds(10), + Duration.ofSeconds(15), + ).register(meterRegistry), + ) + } + + private fun classify(exception: Exception): String { + val text = generateSequence(exception) { it.cause }.joinToString(" ") { it.javaClass.simpleName.lowercase() } + return if ("timeout" in text) "timeout" else "error" + } +} diff --git a/src/main/kotlin/com/didit/adapter/webapi/retrospect/RetrospectApi.kt b/src/main/kotlin/com/didit/adapter/webapi/retrospect/RetrospectApi.kt index 0fcb0c01..043736cb 100644 --- a/src/main/kotlin/com/didit/adapter/webapi/retrospect/RetrospectApi.kt +++ b/src/main/kotlin/com/didit/adapter/webapi/retrospect/RetrospectApi.kt @@ -205,7 +205,7 @@ class RetrospectApi( fun findAll( @CurrentUserId userId: UUID, ): SuccessResponse> { - val retrospectives = retrospectiveFinder.findAllByUserId(userId) + val retrospectives = retrospectiveFinder.findListItemsByUserId(userId) return SuccessResponse.of(retrospectives.map { RetrospectiveListItemResponse.from(it) }) } diff --git a/src/main/kotlin/com/didit/adapter/webapi/retrospect/dto/RetrospectiveListItemResponse.kt b/src/main/kotlin/com/didit/adapter/webapi/retrospect/dto/RetrospectiveListItemResponse.kt index 8d715654..99adb7ea 100644 --- a/src/main/kotlin/com/didit/adapter/webapi/retrospect/dto/RetrospectiveListItemResponse.kt +++ b/src/main/kotlin/com/didit/adapter/webapi/retrospect/dto/RetrospectiveListItemResponse.kt @@ -1,5 +1,6 @@ package com.didit.adapter.webapi.retrospect.dto +import com.didit.application.retrospect.dto.RetrospectiveListItemResult import com.didit.domain.retrospect.Retrospective import java.time.LocalDateTime import java.util.UUID @@ -18,5 +19,13 @@ data class RetrospectiveListItemResponse( summary = retrospective.summary?.summary, completedAt = retrospective.completedAt, ) + + fun from(result: RetrospectiveListItemResult): RetrospectiveListItemResponse = + RetrospectiveListItemResponse( + id = result.id, + title = result.title, + summary = result.summary, + completedAt = result.completedAt, + ) } } diff --git a/src/main/kotlin/com/didit/application/retrospect/DeepQuestionEventListener.kt b/src/main/kotlin/com/didit/application/retrospect/DeepQuestionEventListener.kt new file mode 100644 index 00000000..21ef47cc --- /dev/null +++ b/src/main/kotlin/com/didit/application/retrospect/DeepQuestionEventListener.kt @@ -0,0 +1,31 @@ +package com.didit.application.retrospect + +import org.slf4j.LoggerFactory +import org.springframework.beans.factory.annotation.Qualifier +import org.springframework.core.task.TaskExecutor +import org.springframework.core.task.TaskRejectedException +import org.springframework.stereotype.Component +import org.springframework.transaction.event.TransactionPhase +import org.springframework.transaction.event.TransactionalEventListener + +@Component +class DeepQuestionEventListener( + private val processor: DeepQuestionProcessor, + private val metrics: RetrospectiveAiMetrics, + @Qualifier("aiTaskExecutor") private val aiTaskExecutor: TaskExecutor, +) { + companion object { + private val logger = LoggerFactory.getLogger(DeepQuestionEventListener::class.java) + } + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + fun on(event: DeepQuestionGenerationEvent) { + try { + aiTaskExecutor.execute { processor.process(event) } + } catch (exception: TaskRejectedException) { + metrics.incrementRejected() + logger.warn("AI executor 포화로 기본 심화 질문을 저장합니다. retrospectiveId: ${event.retrospectiveId}", exception) + processor.saveFallback(event) + } + } +} diff --git a/src/main/kotlin/com/didit/application/retrospect/DeepQuestionGenerationEvent.kt b/src/main/kotlin/com/didit/application/retrospect/DeepQuestionGenerationEvent.kt new file mode 100644 index 00000000..e0505c57 --- /dev/null +++ b/src/main/kotlin/com/didit/application/retrospect/DeepQuestionGenerationEvent.kt @@ -0,0 +1,8 @@ +package com.didit.application.retrospect + +import java.util.UUID + +data class DeepQuestionGenerationEvent( + val retrospectiveId: UUID, + val userId: UUID, +) diff --git a/src/main/kotlin/com/didit/application/retrospect/DeepQuestionProcessor.kt b/src/main/kotlin/com/didit/application/retrospect/DeepQuestionProcessor.kt new file mode 100644 index 00000000..a9f73787 --- /dev/null +++ b/src/main/kotlin/com/didit/application/retrospect/DeepQuestionProcessor.kt @@ -0,0 +1,79 @@ +package com.didit.application.retrospect + +import com.didit.application.auth.provided.UserFinder +import com.didit.application.retrospect.exception.RetrospectiveNotFoundException +import com.didit.application.retrospect.required.AIClient +import com.didit.application.retrospect.required.RetrospectiveRepository +import com.didit.domain.retrospect.ChatMessage +import com.didit.domain.retrospect.QuestionType +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Component +import org.springframework.transaction.support.TransactionTemplate + +@Component +class DeepQuestionProcessor( + private val retrospectiveRepository: RetrospectiveRepository, + private val userFinder: UserFinder, + private val aiClient: AIClient, + private val transactionTemplate: TransactionTemplate, + private val metrics: RetrospectiveAiMetrics, +) { + companion object { + private val logger = LoggerFactory.getLogger(DeepQuestionProcessor::class.java) + const val DEFAULT_FALLBACK_QUESTION = "오늘 회고를 통해 어떤 성찰을 얻으셨나요?" + } + + fun process(event: DeepQuestionGenerationEvent) { + val snapshot = + transactionTemplate.execute { + val retrospective = + retrospectiveRepository.findByIdAndUserId(event.retrospectiveId, event.userId) + ?: throw RetrospectiveNotFoundException(event.retrospectiveId) + if (!retrospective.canAddDeepQuestion()) return@execute null + DeepQuestionSnapshot( + job = userFinder.getJobByUserId(event.userId), + answers = retrospective.getAnswersUpToQ3(), + ) + } ?: return + + val generated = + runCatching { + metrics.recordExternalCallTransactionState("deep_question") + metrics.recordStage("deep_question", "openai") { + aiClient.generateDeepQuestion(snapshot.job, snapshot.answers) + } + }.onFailure { + logger.error("심화 질문 생성 실패 - retrospectiveId: ${event.retrospectiveId}", it) + }.getOrNull() + + saveQuestion(event, generated?.content ?: DEFAULT_FALLBACK_QUESTION, generated?.inputTokens ?: 0, generated?.outputTokens ?: 0) + } + + fun saveFallback(event: DeepQuestionGenerationEvent) { + saveQuestion(event, DEFAULT_FALLBACK_QUESTION, 0, 0) + } + + private fun saveQuestion( + event: DeepQuestionGenerationEvent, + content: String, + inputTokens: Int, + outputTokens: Int, + ) { + transactionTemplate.executeWithoutResult { + val retrospective = + retrospectiveRepository.findByIdAndUserIdForUpdate(event.retrospectiveId, event.userId) + ?: throw RetrospectiveNotFoundException(event.retrospectiveId) + if (!retrospective.canAddDeepQuestion()) return@executeWithoutResult + retrospective.addMessage( + ChatMessage.question(retrospective, content, QuestionType.Q4_DEEP), + ) + retrospective.addTokens(inputTokens, outputTokens) + retrospectiveRepository.save(retrospective) + } + } +} + +private data class DeepQuestionSnapshot( + val job: com.didit.domain.shared.Job?, + val answers: List, +) diff --git a/src/main/kotlin/com/didit/application/retrospect/RetrospectQueryService.kt b/src/main/kotlin/com/didit/application/retrospect/RetrospectQueryService.kt index 1835b81b..5797161a 100644 --- a/src/main/kotlin/com/didit/application/retrospect/RetrospectQueryService.kt +++ b/src/main/kotlin/com/didit/application/retrospect/RetrospectQueryService.kt @@ -7,6 +7,7 @@ import com.didit.application.organization.required.RetrospectTagRepository import com.didit.application.organization.required.TagRepository import com.didit.application.retrospect.dto.DeepQuestionResponse import com.didit.application.retrospect.dto.RetrospectiveDetailResult +import com.didit.application.retrospect.dto.RetrospectiveListItemResult import com.didit.application.retrospect.exception.RetrospectiveNotFoundException import com.didit.application.retrospect.provided.RetrospectiveFinder import com.didit.application.retrospect.provided.SearchHistoryRegister @@ -42,6 +43,9 @@ class RetrospectQueryService( override fun findAllByUserId(userId: UUID): List = retrospectiveRepository.findAllCompletedByUserId(userId) + override fun findListItemsByUserId(userId: UUID): List = + retrospectiveRepository.findListItemsByUserId(userId) + override fun findRecentByUserId( userId: UUID, limit: Int, diff --git a/src/main/kotlin/com/didit/application/retrospect/RetrospectService.kt b/src/main/kotlin/com/didit/application/retrospect/RetrospectService.kt index 2c71ca9e..69a1da61 100644 --- a/src/main/kotlin/com/didit/application/retrospect/RetrospectService.kt +++ b/src/main/kotlin/com/didit/application/retrospect/RetrospectService.kt @@ -18,7 +18,6 @@ import com.didit.application.retrospect.exception.SpeechUnsupportedFileException import com.didit.application.retrospect.exception.SummaryNotGeneratedException import com.didit.application.retrospect.provided.RetrospectiveFinder import com.didit.application.retrospect.provided.RetrospectiveRegister -import com.didit.application.retrospect.required.AIClient import com.didit.application.retrospect.required.RetrospectivePolicy import com.didit.application.retrospect.required.RetrospectiveRepository import com.didit.application.retrospect.required.SpeechClient @@ -27,13 +26,12 @@ import com.didit.domain.retrospect.InputType import com.didit.domain.retrospect.QuestionType import com.didit.domain.retrospect.Retrospective import com.didit.domain.retrospect.RetrospectiveCompletedEvent -import com.didit.domain.retrospect.RetrospectiveSummary import com.didit.domain.retrospect.Sender import com.didit.domain.shared.ServiceTime import org.slf4j.LoggerFactory import org.springframework.context.ApplicationEventPublisher -import org.springframework.scheduling.annotation.Async import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Propagation import org.springframework.transaction.annotation.Transactional import java.util.UUID @@ -43,7 +41,7 @@ class RetrospectService( private val retrospectiveRepository: RetrospectiveRepository, private val retrospectiveFinder: RetrospectiveFinder, private val speechClient: SpeechClient, - private val aiClient: AIClient, + private val completionCoordinator: RetrospectiveCompletionCoordinator, private val userFinder: UserFinder, private val eventPublisher: ApplicationEventPublisher, private val auditLogger: AuditLogger, @@ -54,7 +52,6 @@ class RetrospectService( private val logger = LoggerFactory.getLogger(RetrospectService::class.java) private const val DAILY_LIMIT = 3 private const val DEEP_QUESTION_GENERATING_MESSAGE = "심화 질문을 생성 중입니다." - private const val DEFAULT_FALLBACK_QUESTION = "오늘 회고를 통해 어떤 성찰을 얻으셨나요?" private val QUESTION_CONTENTS = mapOf( @@ -135,41 +132,6 @@ class RetrospectService( return transcribe(audioBytes, filename) } - @Async - @Transactional - fun generateDeepQuestionAsync( - retrospectiveId: UUID, - userId: UUID, - ) { - val retrospective = retrospectiveFinder.findById(retrospectiveId, userId) - if (!retrospective.canAddDeepQuestion()) return - - val job = userFinder.getJobByUserId(retrospective.userId) - val answers = retrospective.getAnswersUpToQ3() - - val deepQuestion = - try { - aiClient.generateDeepQuestion(job, answers) - } catch (e: Exception) { - logger.error("심화 질문 생성 실패 - userId: $userId, retrospectiveId: $retrospectiveId", e) - null - } - - retrospective.addMessage( - ChatMessage.question( - retrospective = retrospective, - content = deepQuestion?.content ?: DEFAULT_FALLBACK_QUESTION, - questionType = QuestionType.Q4_DEEP, - ), - ) - - if (deepQuestion != null) { - retrospective.addTokens(deepQuestion.inputTokens, deepQuestion.outputTokens) - } - - retrospectiveRepository.save(retrospective) - } - @Transactional override fun skipDeepQuestion( retrospectiveId: UUID, @@ -189,49 +151,11 @@ class RetrospectService( logger.info("심화 질문 스킵 - userId: $userId, retrospectiveId: $retrospectiveId") } - @Transactional + @Transactional(propagation = Propagation.NOT_SUPPORTED) override fun complete( retrospectiveId: UUID, userId: UUID, - ): AISummaryResponse { - val retrospective = retrospectiveFinder.findById(retrospectiveId, userId) - validateRetrospectiveInProgress(retrospective, retrospectiveId) - - val job = userFinder.getJobByUserId(userId) - - val deepQuestion = - retrospective.chatMessages - .find { it.questionType == QuestionType.Q4_DEEP && it.sender == Sender.AI } - ?.content - - val summary = - try { - aiClient.generateSummaryWithTitle(job, retrospective.getAllAnswers(), deepQuestion) - } catch (e: Exception) { - logger.error("회고 요약 생성 실패 - userId: $userId, retrospectiveId: $retrospectiveId", e) - throw e - } - - retrospective.saveSummary( - RetrospectiveSummary( - summary = summary.summary, - blockedPoint = summary.blockedPoint, - solutionProcess = summary.solutionProcess, - lessonLearned = summary.lessonLearned, - insightTitle = summary.insight.title, - insightDescription = summary.insight.description, - nextActionTitle = summary.nextAction.title, - nextActionDescription = summary.nextAction.description, - ), - ) - retrospective.addTokens(summary.inputTokens, summary.outputTokens) - - retrospectiveRepository.save(retrospective) - - logger.info("회고 완료 - userId: $userId, retrospectiveId: $retrospectiveId") - - return summary - } + ): AISummaryResponse = completionCoordinator.complete(retrospectiveId, userId) @Transactional override fun save( @@ -439,7 +363,7 @@ class RetrospectService( } private fun handleQ3Answer(retrospective: Retrospective): SubmitAnswerResponse { - generateDeepQuestionAsync(retrospective.id, retrospective.userId) + eventPublisher.publishEvent(DeepQuestionGenerationEvent(retrospective.id, retrospective.userId)) return SubmitAnswerResponse( nextQuestionType = QuestionType.Q4_DEEP, nextQuestionContent = DEEP_QUESTION_GENERATING_MESSAGE, diff --git a/src/main/kotlin/com/didit/application/retrospect/RetrospectiveAiMetrics.kt b/src/main/kotlin/com/didit/application/retrospect/RetrospectiveAiMetrics.kt new file mode 100644 index 00000000..534e68e1 --- /dev/null +++ b/src/main/kotlin/com/didit/application/retrospect/RetrospectiveAiMetrics.kt @@ -0,0 +1,59 @@ +package com.didit.application.retrospect + +import io.micrometer.core.instrument.Counter +import io.micrometer.core.instrument.MeterRegistry +import io.micrometer.core.instrument.Timer +import org.springframework.stereotype.Component +import org.springframework.transaction.support.TransactionSynchronizationManager +import java.time.Duration + +@Component +class RetrospectiveAiMetrics( + private val meterRegistry: MeterRegistry, +) { + private val transactionViolation = Counter.builder("didit.ai.transaction.active").register(meterRegistry) + + fun recordStage( + operation: String, + stage: String, + block: () -> T, + ): T = workflowTimer(operation, stage).recordCallable(block)!! + + fun recordExternalCallTransactionState(operation: String) { + if (TransactionSynchronizationManager.isActualTransactionActive()) { + transactionViolation.increment() + } + meterRegistry + .counter( + "didit.ai.external.call", + "operation", + operation, + "transaction_active", + TransactionSynchronizationManager.isActualTransactionActive().toString(), + ).increment() + } + + fun incrementRejected() { + meterRegistry.counter("didit.ai.executor.rejected").increment() + } + + private fun workflowTimer( + operation: String, + stage: String, + ): Timer = + Timer + .builder("didit.ai.workflow.duration") + .tags("operation", operation, "stage", stage) + .publishPercentileHistogram() + .serviceLevelObjectives( + Duration.ofMillis(500), + Duration.ofSeconds(1), + Duration.ofSeconds(2), + Duration.ofSeconds(3), + Duration.ofSeconds(4), + Duration.ofSeconds(5), + Duration.ofMillis(7500), + Duration.ofSeconds(10), + Duration.ofSeconds(15), + ).register(meterRegistry) +} diff --git a/src/main/kotlin/com/didit/application/retrospect/RetrospectiveCompletionCoordinator.kt b/src/main/kotlin/com/didit/application/retrospect/RetrospectiveCompletionCoordinator.kt new file mode 100644 index 00000000..ffa19151 --- /dev/null +++ b/src/main/kotlin/com/didit/application/retrospect/RetrospectiveCompletionCoordinator.kt @@ -0,0 +1,129 @@ +package com.didit.application.retrospect + +import com.didit.application.auth.provided.UserFinder +import com.didit.application.retrospect.dto.AISummaryResponse +import com.didit.application.retrospect.exception.RetrospectiveAlreadyCompletedException +import com.didit.application.retrospect.exception.RetrospectiveNotFoundException +import com.didit.application.retrospect.exception.RetrospectiveNotInProgressException +import com.didit.application.retrospect.exception.SummaryAlreadyGeneratedException +import com.didit.application.retrospect.exception.SummaryGenerationInProgressException +import com.didit.application.retrospect.required.AIClient +import com.didit.application.retrospect.required.RetrospectiveRepository +import com.didit.domain.retrospect.QuestionType +import com.didit.domain.retrospect.RetrospectiveSummary +import com.didit.domain.retrospect.Sender +import com.didit.domain.retrospect.SummaryGenerationStatus +import com.didit.domain.shared.Job +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Component +import org.springframework.transaction.support.TransactionTemplate +import java.util.UUID + +@Component +class RetrospectiveCompletionCoordinator( + private val retrospectiveRepository: RetrospectiveRepository, + private val userFinder: UserFinder, + private val aiClient: AIClient, + private val transactionTemplate: TransactionTemplate, + private val metrics: RetrospectiveAiMetrics, +) { + companion object { + private val logger = LoggerFactory.getLogger(RetrospectiveCompletionCoordinator::class.java) + } + + fun complete( + retrospectiveId: UUID, + userId: UUID, + ): AISummaryResponse = + metrics.recordStage("summary", "total") { + val snapshot = metrics.recordStage("summary", "snapshot") { prepare(retrospectiveId, userId) } + try { + metrics.recordExternalCallTransactionState("summary") + val summary = + metrics.recordStage("summary", "openai") { + aiClient.generateSummaryWithTitle(snapshot.job, snapshot.answers, snapshot.deepQuestion) + } + metrics.recordStage("summary", "save") { save(retrospectiveId, userId, summary) } + summary + } catch (exception: Exception) { + reset(retrospectiveId, userId) + logger.error("회고 요약 생성 실패 - userId: $userId, retrospectiveId: $retrospectiveId", exception) + throw exception + } + } + + private fun prepare( + retrospectiveId: UUID, + userId: UUID, + ): CompletionSnapshot = + transactionTemplate.execute { + val retrospective = + retrospectiveRepository.findByIdAndUserIdForUpdate(retrospectiveId, userId) + ?: throw RetrospectiveNotFoundException(retrospectiveId) + if (retrospective.isCompleted()) throw RetrospectiveAlreadyCompletedException(retrospectiveId) + if (retrospective.isDeleted()) throw RetrospectiveNotInProgressException(retrospectiveId) + when (retrospective.summaryGenerationStatus) { + SummaryGenerationStatus.GENERATING -> throw SummaryGenerationInProgressException(retrospectiveId) + SummaryGenerationStatus.GENERATED -> throw SummaryAlreadyGeneratedException(retrospectiveId) + SummaryGenerationStatus.NOT_STARTED -> retrospective.startSummaryGeneration() + } + retrospectiveRepository.save(retrospective) + CompletionSnapshot( + job = userFinder.getJobByUserId(userId), + answers = retrospective.getAllAnswers(), + deepQuestion = + retrospective.chatMessages + .find { it.questionType == QuestionType.Q4_DEEP && it.sender == Sender.AI } + ?.content, + ) + }!! + + private fun save( + retrospectiveId: UUID, + userId: UUID, + summary: AISummaryResponse, + ) { + transactionTemplate.executeWithoutResult { + val retrospective = + retrospectiveRepository.findByIdAndUserIdForUpdate(retrospectiveId, userId) + ?: throw RetrospectiveNotFoundException(retrospectiveId) + if (retrospective.summaryGenerationStatus != SummaryGenerationStatus.GENERATING) { + throw SummaryGenerationInProgressException(retrospectiveId) + } + retrospective.saveSummary( + RetrospectiveSummary( + summary = summary.summary, + blockedPoint = summary.blockedPoint, + solutionProcess = summary.solutionProcess, + lessonLearned = summary.lessonLearned, + insightTitle = summary.insight.title, + insightDescription = summary.insight.description, + nextActionTitle = summary.nextAction.title, + nextActionDescription = summary.nextAction.description, + ), + ) + retrospective.addTokens(summary.inputTokens, summary.outputTokens) + retrospectiveRepository.save(retrospective) + } + } + + private fun reset( + retrospectiveId: UUID, + userId: UUID, + ) { + runCatching { + transactionTemplate.executeWithoutResult { + retrospectiveRepository.findByIdAndUserIdForUpdate(retrospectiveId, userId)?.let { + it.resetSummaryGeneration() + retrospectiveRepository.save(it) + } + } + }.onFailure { logger.error("AI 요약 생성 상태 복구 실패 - retrospectiveId: $retrospectiveId", it) } + } +} + +private data class CompletionSnapshot( + val job: Job?, + val answers: List, + val deepQuestion: String?, +) diff --git a/src/main/kotlin/com/didit/application/retrospect/dto/RetrospectiveListItemResult.kt b/src/main/kotlin/com/didit/application/retrospect/dto/RetrospectiveListItemResult.kt new file mode 100644 index 00000000..2a8e4d83 --- /dev/null +++ b/src/main/kotlin/com/didit/application/retrospect/dto/RetrospectiveListItemResult.kt @@ -0,0 +1,11 @@ +package com.didit.application.retrospect.dto + +import java.time.LocalDateTime +import java.util.UUID + +data class RetrospectiveListItemResult( + val id: UUID, + val title: String?, + val summary: String?, + val completedAt: LocalDateTime?, +) diff --git a/src/main/kotlin/com/didit/application/retrospect/exception/RetrospectErrorCode.kt b/src/main/kotlin/com/didit/application/retrospect/exception/RetrospectErrorCode.kt index 612a972d..6b08ec79 100644 --- a/src/main/kotlin/com/didit/application/retrospect/exception/RetrospectErrorCode.kt +++ b/src/main/kotlin/com/didit/application/retrospect/exception/RetrospectErrorCode.kt @@ -12,6 +12,8 @@ enum class RetrospectErrorCode( RETROSPECTIVE_NOT_IN_PROGRESS(HttpStatus.BAD_REQUEST, "진행 중인 회고가 아닙니다."), DAILY_LIMIT_EXCEEDED(HttpStatus.BAD_REQUEST, "오늘 회고 횟수를 모두 사용했습니다."), SUMMARY_NOT_GENERATED(HttpStatus.BAD_REQUEST, "AI 요약이 아직 생성되지 않았습니다."), + SUMMARY_GENERATION_IN_PROGRESS(HttpStatus.CONFLICT, "AI 요약을 생성 중입니다."), + SUMMARY_ALREADY_GENERATED(HttpStatus.CONFLICT, "AI 요약이 이미 생성되었습니다."), SPEECH_EMPTY_FILE(HttpStatus.BAD_REQUEST, "음성 파일이 비어 있습니다."), SPEECH_UNSUPPORTED_FILE(HttpStatus.BAD_REQUEST, "지원하지 않는 음성 파일 형식입니다."), diff --git a/src/main/kotlin/com/didit/application/retrospect/exception/RetrospectException.kt b/src/main/kotlin/com/didit/application/retrospect/exception/RetrospectException.kt index 8e94da6e..a34cfea7 100644 --- a/src/main/kotlin/com/didit/application/retrospect/exception/RetrospectException.kt +++ b/src/main/kotlin/com/didit/application/retrospect/exception/RetrospectException.kt @@ -17,6 +17,20 @@ class SummaryNotGeneratedException( "retrospectiveId: $retrospectiveId", ) +class SummaryGenerationInProgressException( + retrospectiveId: UUID, +) : BusinessException( + RetrospectErrorCode.SUMMARY_GENERATION_IN_PROGRESS, + "retrospectiveId: $retrospectiveId", + ) + +class SummaryAlreadyGeneratedException( + retrospectiveId: UUID, +) : BusinessException( + RetrospectErrorCode.SUMMARY_ALREADY_GENERATED, + "retrospectiveId: $retrospectiveId", + ) + class RetrospectiveAlreadyCompletedException( retrospectiveId: UUID, ) : BusinessException( diff --git a/src/main/kotlin/com/didit/application/retrospect/provided/RetrospectiveFinder.kt b/src/main/kotlin/com/didit/application/retrospect/provided/RetrospectiveFinder.kt index aed9af44..1485fb75 100644 --- a/src/main/kotlin/com/didit/application/retrospect/provided/RetrospectiveFinder.kt +++ b/src/main/kotlin/com/didit/application/retrospect/provided/RetrospectiveFinder.kt @@ -2,6 +2,7 @@ package com.didit.application.retrospect.provided import com.didit.application.retrospect.dto.DeepQuestionResponse import com.didit.application.retrospect.dto.RetrospectiveDetailResult +import com.didit.application.retrospect.dto.RetrospectiveListItemResult import com.didit.domain.retrospect.Retrospective import java.time.LocalDate import java.util.UUID @@ -14,6 +15,8 @@ interface RetrospectiveFinder { fun findAllByUserId(userId: UUID): List + fun findListItemsByUserId(userId: UUID): List + fun findRecentByUserId( userId: UUID, limit: Int, diff --git a/src/main/kotlin/com/didit/application/retrospect/required/RetrospectiveRepository.kt b/src/main/kotlin/com/didit/application/retrospect/required/RetrospectiveRepository.kt index 23ace670..52e0bb68 100644 --- a/src/main/kotlin/com/didit/application/retrospect/required/RetrospectiveRepository.kt +++ b/src/main/kotlin/com/didit/application/retrospect/required/RetrospectiveRepository.kt @@ -1,9 +1,12 @@ package com.didit.application.retrospect.required +import com.didit.application.retrospect.dto.RetrospectiveListItemResult import com.didit.domain.retrospect.InputType import com.didit.domain.retrospect.RetroStatus import com.didit.domain.retrospect.Retrospective +import jakarta.persistence.LockModeType import org.springframework.data.domain.Pageable +import org.springframework.data.jpa.repository.Lock import org.springframework.data.jpa.repository.Query import org.springframework.data.repository.Repository import org.springframework.data.repository.query.Param @@ -18,6 +21,13 @@ interface RetrospectiveRepository : Repository { userId: UUID, ): Retrospective? + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT r FROM Retrospective r WHERE r.id = :id AND r.userId = :userId") + fun findByIdAndUserIdForUpdate( + @Param("id") id: UUID, + @Param("userId") userId: UUID, + ): Retrospective? + fun findByIdAndDeletedAtIsNull(retrospectiveId: UUID): Retrospective? @Query( @@ -54,6 +64,22 @@ interface RetrospectiveRepository : Repository { @Param("userId") userId: UUID, ): List + @Query( + """ + SELECT new com.didit.application.retrospect.dto.RetrospectiveListItemResult( + r.id, r.title, r.summary.summary, r.completedAt + ) + FROM Retrospective r + WHERE r.userId = :userId + AND r.status = 'COMPLETED' + AND r.deletedAt IS NULL + ORDER BY r.createdAt DESC + """, + ) + fun findListItemsByUserId( + @Param("userId") userId: UUID, + ): List + @Query( """ SELECT r FROM Retrospective r diff --git a/src/main/kotlin/com/didit/domain/retrospect/Retrospective.kt b/src/main/kotlin/com/didit/domain/retrospect/Retrospective.kt index 67540c93..91ed6ee3 100644 --- a/src/main/kotlin/com/didit/domain/retrospect/Retrospective.kt +++ b/src/main/kotlin/com/didit/domain/retrospect/Retrospective.kt @@ -41,6 +41,9 @@ class Retrospective( val chatMessages: MutableList = mutableListOf(), @Column var completedAt: LocalDateTime? = null, + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + var summaryGenerationStatus: SummaryGenerationStatus = SummaryGenerationStatus.NOT_STARTED, ) : BaseEntity() { fun isCompleted(): Boolean = status == RetroStatus.COMPLETED @@ -84,6 +87,18 @@ class Retrospective( fun saveSummary(summary: RetrospectiveSummary) { this.summary = summary + this.summaryGenerationStatus = SummaryGenerationStatus.GENERATED + } + + fun startSummaryGeneration() { + check(summaryGenerationStatus == SummaryGenerationStatus.NOT_STARTED) { "이미 AI 요약을 생성 중이거나 생성했습니다." } + summaryGenerationStatus = SummaryGenerationStatus.GENERATING + } + + fun resetSummaryGeneration() { + if (summaryGenerationStatus == SummaryGenerationStatus.GENERATING) { + summaryGenerationStatus = SummaryGenerationStatus.NOT_STARTED + } } fun addTokens( diff --git a/src/main/kotlin/com/didit/domain/retrospect/SummaryGenerationStatus.kt b/src/main/kotlin/com/didit/domain/retrospect/SummaryGenerationStatus.kt new file mode 100644 index 00000000..a0fa06ef --- /dev/null +++ b/src/main/kotlin/com/didit/domain/retrospect/SummaryGenerationStatus.kt @@ -0,0 +1,7 @@ +package com.didit.domain.retrospect + +enum class SummaryGenerationStatus { + NOT_STARTED, + GENERATING, + GENERATED, +} diff --git a/src/main/resources/db/migration/V45__optimize_retrospective_generation_and_list.sql b/src/main/resources/db/migration/V45__optimize_retrospective_generation_and_list.sql new file mode 100644 index 00000000..fcaabfd9 --- /dev/null +++ b/src/main/resources/db/migration/V45__optimize_retrospective_generation_and_list.sql @@ -0,0 +1,9 @@ +ALTER TABLE retrospectives + ADD COLUMN summary_generation_status VARCHAR(20) NOT NULL DEFAULT 'NOT_STARTED'; + +UPDATE retrospectives +SET summary_generation_status = 'GENERATED' +WHERE summary IS NOT NULL; + +CREATE INDEX idx_retrospectives_user_status_deleted_created + ON retrospectives (user_id, status, deleted_at, created_at DESC); diff --git a/src/test/kotlin/com/didit/adapter/webapi/retrospect/RetrospectApiTest.kt b/src/test/kotlin/com/didit/adapter/webapi/retrospect/RetrospectApiTest.kt index dbfc0011..c256a516 100644 --- a/src/test/kotlin/com/didit/adapter/webapi/retrospect/RetrospectApiTest.kt +++ b/src/test/kotlin/com/didit/adapter/webapi/retrospect/RetrospectApiTest.kt @@ -443,8 +443,17 @@ class RetrospectApiTest : AuthenticatedRestDocsSupport() { @Test fun `회고 목록 조회`() { - val retros = listOf(completedRetrospective()) - whenever(retrospectiveFinder.findAllByUserId(userId)).thenReturn(retros) + val retro = completedRetrospective() + val retros = + listOf( + com.didit.application.retrospect.dto.RetrospectiveListItemResult( + id = retro.id, + title = retro.title, + summary = retro.summary?.summary, + completedAt = retro.completedAt, + ), + ) + whenever(retrospectiveFinder.findListItemsByUserId(userId)).thenReturn(retros) mockMvc .perform(get("/api/v1/retrospectives")) diff --git a/src/test/kotlin/com/didit/application/retrospect/DeepQuestionProcessorTest.kt b/src/test/kotlin/com/didit/application/retrospect/DeepQuestionProcessorTest.kt new file mode 100644 index 00000000..624ddbff --- /dev/null +++ b/src/test/kotlin/com/didit/application/retrospect/DeepQuestionProcessorTest.kt @@ -0,0 +1,82 @@ +package com.didit.application.retrospect + +import com.didit.application.auth.provided.UserFinder +import com.didit.application.retrospect.required.AIClient +import com.didit.application.retrospect.required.GeneratedDeepQuestion +import com.didit.application.retrospect.required.RetrospectiveRepository +import com.didit.domain.retrospect.ChatMessage +import com.didit.domain.retrospect.InputType +import com.didit.domain.retrospect.QuestionType +import com.didit.domain.retrospect.Retrospective +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.springframework.transaction.PlatformTransactionManager +import org.springframework.transaction.support.SimpleTransactionStatus +import org.springframework.transaction.support.TransactionTemplate +import java.util.UUID + +class DeepQuestionProcessorTest { + private val repository = mock() + private val userFinder = mock() + private val aiClient = mock() + private val metrics = mock() + private val transactionManager = mock() + private lateinit var processor: DeepQuestionProcessor + + private val userId = UUID.randomUUID() + private val retrospectiveId = UUID.randomUUID() + + @BeforeEach + fun setUp() { + whenever(transactionManager.getTransaction(anyOrNull())).thenReturn(SimpleTransactionStatus()) + whenever(metrics.recordStage(any(), any(), any())).thenAnswer { + @Suppress("UNCHECKED_CAST") + (it.arguments[2] as () -> Any).invoke() + } + processor = DeepQuestionProcessor(repository, userFinder, aiClient, TransactionTemplate(transactionManager), metrics) + } + + @Test + fun `process - 심화 질문과 토큰을 한 번만 저장한다`() { + val retrospective = retrospectiveReadyForDeepQuestion() + whenever(repository.findByIdAndUserId(retrospectiveId, userId)).thenReturn(retrospective) + whenever(repository.findByIdAndUserIdForUpdate(retrospectiveId, userId)).thenReturn(retrospective) + whenever(aiClient.generateDeepQuestion(anyOrNull(), any())).thenReturn(GeneratedDeepQuestion("생성 질문", 10, 3)) + + processor.process(DeepQuestionGenerationEvent(retrospectiveId, userId)) + processor.process(DeepQuestionGenerationEvent(retrospectiveId, userId)) + + assertThat(retrospective.hasDeepQuestion()).isTrue() + assertThat(retrospective.inputTokens).isEqualTo(10) + assertThat(retrospective.outputTokens).isEqualTo(3) + verify(repository).save(retrospective) + } + + @Test + fun `process - AI 실패 시 기본 질문을 저장한다`() { + val retrospective = retrospectiveReadyForDeepQuestion() + whenever(repository.findByIdAndUserId(retrospectiveId, userId)).thenReturn(retrospective) + whenever(repository.findByIdAndUserIdForUpdate(retrospectiveId, userId)).thenReturn(retrospective) + whenever(aiClient.generateDeepQuestion(anyOrNull(), any())).thenThrow(IllegalStateException("failed")) + + processor.process(DeepQuestionGenerationEvent(retrospectiveId, userId)) + + assertThat(retrospective.hasDeepQuestion()).isTrue() + assertThat(retrospective.chatMessages.last().content).isEqualTo(DeepQuestionProcessor.DEFAULT_FALLBACK_QUESTION) + } + + private fun retrospectiveReadyForDeepQuestion() = + Retrospective(id = retrospectiveId, userId = userId).apply { + startProgress() + listOf(QuestionType.Q1, QuestionType.Q2, QuestionType.Q3).forEach { + addMessage(ChatMessage.question(this, it.name, it)) + addMessage(ChatMessage.userAnswer(this, "${it.name} 답변", it, InputType.TEXT)) + } + } +} diff --git a/src/test/kotlin/com/didit/application/retrospect/RetrospectServiceTest.kt b/src/test/kotlin/com/didit/application/retrospect/RetrospectServiceTest.kt index b04cf1f7..b27ae7dd 100644 --- a/src/test/kotlin/com/didit/application/retrospect/RetrospectServiceTest.kt +++ b/src/test/kotlin/com/didit/application/retrospect/RetrospectServiceTest.kt @@ -15,8 +15,6 @@ import com.didit.application.retrospect.exception.SpeechEmptyResultException import com.didit.application.retrospect.exception.SpeechUnsupportedFileException import com.didit.application.retrospect.exception.SummaryNotGeneratedException import com.didit.application.retrospect.provided.RetrospectiveFinder -import com.didit.application.retrospect.required.AIClient -import com.didit.application.retrospect.required.GeneratedDeepQuestion import com.didit.application.retrospect.required.RetrospectivePolicy import com.didit.application.retrospect.required.RetrospectiveRepository import com.didit.application.retrospect.required.SpeechClient @@ -27,7 +25,6 @@ import com.didit.domain.retrospect.QuestionType import com.didit.domain.retrospect.Retrospective import com.didit.domain.retrospect.RetrospectiveCompletedEvent import com.didit.domain.retrospect.RetrospectiveSummary -import com.didit.domain.shared.Job import com.didit.support.RetrospectiveFixture import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.BeforeEach @@ -37,7 +34,6 @@ import org.junit.jupiter.api.extension.ExtendWith import org.mockito.Mock import org.mockito.junit.jupiter.MockitoExtension import org.mockito.kotlin.any -import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.doReturn import org.mockito.kotlin.mock import org.mockito.kotlin.never @@ -58,7 +54,7 @@ class RetrospectServiceTest { lateinit var speechClient: SpeechClient @Mock - lateinit var aiClient: AIClient + lateinit var completionCoordinator: RetrospectiveCompletionCoordinator @Mock lateinit var userFinder: UserFinder @@ -87,7 +83,7 @@ class RetrospectServiceTest { retrospectiveRepository = retrospectiveRepository, retrospectiveFinder = retrospectiveFinder, speechClient = speechClient, - aiClient = aiClient, + completionCoordinator = completionCoordinator, userFinder = userFinder, eventPublisher = eventPublisher, auditLogger = auditLogger, @@ -223,16 +219,13 @@ class RetrospectServiceTest { } whenever(retrospectiveFinder.findById(any(), any())).thenReturn(retro) - whenever(userFinder.getJobByUserId(any())).thenReturn(Job.DEVELOPER) - whenever(aiClient.generateDeepQuestion(any(), any())).thenReturn( - GeneratedDeepQuestion(content = "심화 질문입니다.", inputTokens = 50, outputTokens = 20), - ) whenever(retrospectiveRepository.save(any())).thenAnswer { it.arguments[0] } val result = retrospectService.submitAnswer(retrospectiveId, userId, "Q3 답변") assertThat(result.nextQuestionType).isEqualTo(QuestionType.Q4_DEEP) assertThat(result.isReadyToComplete).isFalse() + verify(eventPublisher).publishEvent(DeepQuestionGenerationEvent(retro.id, userId)) } @Test @@ -389,26 +382,6 @@ class RetrospectServiceTest { } } - @Test - fun `generateDeepQuestionAsync - 이미 심화 질문이 있으면 생성하지 않는다`() { - val retro = - inProgressRetrospective().apply { - addMessage(ChatMessage.userAnswer(this, "Q1 답변", QuestionType.Q1, InputType.TEXT)) - addMessage(ChatMessage.question(this, "Q2", QuestionType.Q2)) - addMessage(ChatMessage.userAnswer(this, "Q2 답변", QuestionType.Q2, InputType.TEXT)) - addMessage(ChatMessage.question(this, "Q3", QuestionType.Q3)) - addMessage(ChatMessage.userAnswer(this, "Q3 답변", QuestionType.Q3, InputType.TEXT)) - addMessage(ChatMessage.question(this, "심화 질문입니다.", QuestionType.Q4_DEEP)) - } - - whenever(retrospectiveFinder.findById(retrospectiveId, userId)).thenReturn(retro) - - retrospectService.generateDeepQuestionAsync(retrospectiveId, userId) - - verify(aiClient, never()).generateDeepQuestion(any(), any()) - verify(retrospectiveRepository, never()).save(any()) - } - @Test fun `skipDeepQuestion - 심화 질문을 스킵한다`() { val retro = inProgressRetrospective() @@ -432,26 +405,18 @@ class RetrospectServiceTest { @Test fun `complete - AI 요약을 생성하고 토큰을 포함해 저장한다`() { - val retro = inProgressRetrospective() val summary = aiSummaryResponse() - whenever(retrospectiveFinder.findById(retrospectiveId, userId)).thenReturn(retro) - whenever(userFinder.getJobByUserId(userId)).thenReturn(Job.DEVELOPER) - whenever(aiClient.generateSummaryWithTitle(any(), any(), anyOrNull())).thenReturn(summary) - whenever(retrospectiveRepository.save(any())).thenAnswer { it.arguments[0] } + whenever(completionCoordinator.complete(retrospectiveId, userId)).thenReturn(summary) val result = retrospectService.complete(retrospectiveId, userId) assertThat(result.title).isEqualTo(summary.title) - assertThat(retro.summary).isNotNull() - assertThat(retro.inputTokens).isEqualTo(100) - assertThat(retro.outputTokens).isEqualTo(50) - verify(retrospectiveRepository).save(retro) + verify(completionCoordinator).complete(retrospectiveId, userId) } @Test fun `complete - 이미 완료된 회고면 예외가 발생한다`() { - val retro = RetrospectiveFixture.createCompleted(userId) - whenever(retrospectiveFinder.findById(retrospectiveId, userId)).thenReturn(retro) + whenever(completionCoordinator.complete(retrospectiveId, userId)).thenThrow(RetrospectiveAlreadyCompletedException(retrospectiveId)) assertThrows { retrospectService.complete(retrospectiveId, userId) diff --git a/src/test/kotlin/com/didit/application/retrospect/RetrospectiveCompletionCoordinatorTest.kt b/src/test/kotlin/com/didit/application/retrospect/RetrospectiveCompletionCoordinatorTest.kt new file mode 100644 index 00000000..97354e39 --- /dev/null +++ b/src/test/kotlin/com/didit/application/retrospect/RetrospectiveCompletionCoordinatorTest.kt @@ -0,0 +1,117 @@ +package com.didit.application.retrospect + +import com.didit.application.auth.provided.UserFinder +import com.didit.application.retrospect.dto.AISummaryResponse +import com.didit.application.retrospect.dto.InsightResponse +import com.didit.application.retrospect.dto.NextActionResponse +import com.didit.application.retrospect.exception.SummaryGenerationInProgressException +import com.didit.application.retrospect.required.AIClient +import com.didit.application.retrospect.required.RetrospectiveRepository +import com.didit.domain.retrospect.ChatMessage +import com.didit.domain.retrospect.InputType +import com.didit.domain.retrospect.QuestionType +import com.didit.domain.retrospect.Retrospective +import com.didit.domain.retrospect.SummaryGenerationStatus +import com.didit.domain.shared.Job +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.mock +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.springframework.transaction.PlatformTransactionManager +import org.springframework.transaction.support.SimpleTransactionStatus +import org.springframework.transaction.support.TransactionSynchronizationManager +import org.springframework.transaction.support.TransactionTemplate +import java.util.UUID + +class RetrospectiveCompletionCoordinatorTest { + private val repository = mock() + private val userFinder = mock() + private val aiClient = mock() + private val metrics = mock() + private val transactionManager = mock() + private lateinit var coordinator: RetrospectiveCompletionCoordinator + + private val userId = UUID.randomUUID() + private val retrospectiveId = UUID.randomUUID() + + @BeforeEach + fun setUp() { + whenever(transactionManager.getTransaction(anyOrNull())).thenReturn(SimpleTransactionStatus()) + whenever(metrics.recordStage(any(), any(), any())).thenAnswer { + @Suppress("UNCHECKED_CAST") + (it.arguments[2] as () -> Any).invoke() + } + coordinator = + RetrospectiveCompletionCoordinator( + repository, + userFinder, + aiClient, + TransactionTemplate(transactionManager), + metrics, + ) + } + + @Test + fun `complete - OpenAI 호출은 트랜잭션 밖에서 실행되고 요약을 저장한다`() { + val retrospective = inProgressRetrospective() + val summary = summary() + whenever(repository.findByIdAndUserIdForUpdate(retrospectiveId, userId)).thenReturn(retrospective) + whenever(userFinder.getJobByUserId(userId)).thenReturn(Job.DEVELOPER) + whenever(aiClient.generateSummaryWithTitle(anyOrNull(), any(), anyOrNull())).thenAnswer { + assertThat(TransactionSynchronizationManager.isActualTransactionActive()).isFalse() + summary + } + + val result = coordinator.complete(retrospectiveId, userId) + + assertThat(result).isEqualTo(summary) + assertThat(retrospective.summaryGenerationStatus).isEqualTo(SummaryGenerationStatus.GENERATED) + assertThat(retrospective.summary?.summary).isEqualTo(summary.summary) + verify(repository, times(2)).save(retrospective) + } + + @Test + fun `complete - OpenAI 실패 시 생성 상태를 복구한다`() { + val retrospective = inProgressRetrospective() + whenever(repository.findByIdAndUserIdForUpdate(retrospectiveId, userId)).thenReturn(retrospective) + whenever(aiClient.generateSummaryWithTitle(anyOrNull(), any(), anyOrNull())).thenThrow(IllegalStateException("failed")) + + assertThrows { coordinator.complete(retrospectiveId, userId) } + + assertThat(retrospective.summaryGenerationStatus).isEqualTo(SummaryGenerationStatus.NOT_STARTED) + } + + @Test + fun `complete - 이미 생성 중이면 중복 OpenAI 호출을 차단한다`() { + val retrospective = inProgressRetrospective().apply { startSummaryGeneration() } + whenever(repository.findByIdAndUserIdForUpdate(retrospectiveId, userId)).thenReturn(retrospective) + + assertThrows { coordinator.complete(retrospectiveId, userId) } + } + + private fun inProgressRetrospective() = + Retrospective(id = retrospectiveId, userId = userId).apply { + startProgress() + addMessage(ChatMessage.question(this, "Q1", QuestionType.Q1)) + addMessage(ChatMessage.userAnswer(this, "답변", QuestionType.Q1, InputType.TEXT)) + } + + private fun summary() = + AISummaryResponse( + title = "제목", + summary = "요약", + blockedPoint = listOf("막힘"), + solutionProcess = listOf("해결"), + lessonLearned = listOf("배움"), + insight = InsightResponse("인사이트", "설명"), + nextAction = NextActionResponse("행동", "설명"), + inputTokens = 10, + outputTokens = 5, + ) +} diff --git a/src/test/kotlin/com/didit/application/retrospect/required/RetrospectiveRepositoryTest.kt b/src/test/kotlin/com/didit/application/retrospect/required/RetrospectiveRepositoryTest.kt index a42c23e8..4e5eb25d 100644 --- a/src/test/kotlin/com/didit/application/retrospect/required/RetrospectiveRepositoryTest.kt +++ b/src/test/kotlin/com/didit/application/retrospect/required/RetrospectiveRepositoryTest.kt @@ -89,6 +89,22 @@ class RetrospectiveRepositoryTest : RepositoryTestSupport() { assertThat(found[0].title).isEqualTo("완료된 회고") } + @Test + fun `findListItemsByUserId - 응답 필드만 조회하고 완료된 회고만 반환한다`() { + retrospectiveRepository.save(Retrospective.create(userId)) + retrospectiveRepository.save(completedRetrospective(userId, "삭제된 회고").apply { softDelete() }) + val completed = retrospectiveRepository.save(completedRetrospective(userId, "완료된 회고")) + retrospectiveRepository.save(completedRetrospective(UUID.randomUUID(), "다른 사용자 회고")) + + val found = retrospectiveRepository.findListItemsByUserId(userId) + + assertThat(found).hasSize(1) + assertThat(found.single().id).isEqualTo(completed.id) + assertThat(found.single().title).isEqualTo("완료된 회고") + assertThat(found.single().summary).isEqualTo("...") + assertThat(found.single().completedAt).isNotNull() + } + @Test fun `findRecentCompletedByUserId - limit만큼만 반환한다`() { repeat(5) { retrospectiveRepository.save(completedRetrospective(userId, "완료된 회고 $it")) }