Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,19 +1,25 @@
package com.stackup.stackup.session.application;

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.stackup.stackup.common.config.properties.RabbitMqProperties;
import com.stackup.stackup.common.messaging.MessageContext;
import com.stackup.stackup.common.messaging.RabbitMessagePublisher;
import com.stackup.stackup.session.application.dto.GenerateFeedbackPayload;
import com.stackup.stackup.session.application.dto.GenerateFeedbackPayload.MessageItem;
import com.stackup.stackup.session.application.dto.GenerateFeedbackPayload.VoiceAnalysisSummary;
import com.stackup.stackup.session.application.event.SessionEndedEvent;
import com.stackup.stackup.session.domain.InterviewMessage;
import com.stackup.stackup.session.domain.InterviewMessageRepository;
import com.stackup.stackup.session.domain.InterviewSession;
import com.stackup.stackup.session.domain.InterviewSessionRepository;
import com.stackup.stackup.session.domain.SessionContext;
import com.stackup.stackup.session.domain.MessageVoiceAnalysis;
import com.stackup.stackup.session.domain.MessageVoiceAnalysisRepository;
import com.stackup.stackup.session.domain.SessionContextRepository;
import com.stackup.stackup.session.domain.SessionFeedbackRepository;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import lombok.RequiredArgsConstructor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
Expand All @@ -30,13 +36,15 @@
public class SessionFeedbackRequester {

private static final Logger log = LoggerFactory.getLogger(SessionFeedbackRequester.class);
private static final ObjectMapper JSON = new ObjectMapper();

private final RabbitMessagePublisher publisher;
private final RabbitMqProperties properties;
private final InterviewSessionRepository sessionRepository;
private final InterviewMessageRepository messageRepository;
private final SessionContextRepository contextRepository;
private final SessionFeedbackRepository feedbackRepository;
private final MessageVoiceAnalysisRepository voiceAnalysisRepository;

@Transactional(propagation = Propagation.REQUIRES_NEW)
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
Expand Down Expand Up @@ -66,7 +74,8 @@ public void onSessionEnded(SessionEndedEvent event) {
session.getTotalQuestionCount(),
event.reason(),
messages,
contextDocumentIds
contextDocumentIds,
summarizeVoiceAnalysis(event.sessionId())
);

publisher.publishToAi(
Expand All @@ -88,4 +97,56 @@ private MessageItem toItem(InterviewMessage m) {
parentId
);
}

private VoiceAnalysisSummary summarizeVoiceAnalysis(Long sessionId) {
List<MessageVoiceAnalysis> analyses = voiceAnalysisRepository.findByMessage_Session_Id(sessionId);
if (analyses.isEmpty()) {
return new VoiceAnalysisSummary(0, null, 0.0, Map.of());
}

double speakingRateTotal = 0.0;
int speakingRateCount = 0;
double silenceTotal = 0.0;
Map<String, Integer> fillerCounts = new TreeMap<>();

for (MessageVoiceAnalysis analysis : analyses) {
if (analysis.getSpeakingRateWpm() != null) {
speakingRateTotal += analysis.getSpeakingRateWpm();
speakingRateCount++;
}
if (analysis.getSilenceDurationSec() != null) {
silenceTotal += analysis.getSilenceDurationSec();
}
mergeFillerCounts(fillerCounts, analysis.getFillerWordCounts());
}

Double averageSpeakingRate = speakingRateCount == 0 ? null : speakingRateTotal / speakingRateCount;
return new VoiceAnalysisSummary(analyses.size(), averageSpeakingRate, silenceTotal, fillerCounts);
}

private void mergeFillerCounts(Map<String, Integer> totals, String fillerWordCountsJson) {
if (fillerWordCountsJson == null || fillerWordCountsJson.isBlank()) {
return;
}
try {
JsonNode root = JSON.readTree(fillerWordCountsJson);
if (!root.isObject()) {
return;
}
root.fields().forEachRemaining(entry -> {
String word = entry.getKey();
JsonNode value = entry.getValue();
if (word == null || word.isBlank() || !value.canConvertToInt()) {
return;
}
int count = value.asInt();
if (count <= 0) {
return;
}
totals.merge(word, count, Integer::sum);
});
} catch (Exception e) {
log.warn("filler_word_counts json parse failed. raw={}", fillerWordCountsJson, e);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import com.stackup.stackup.common.messaging.domain.ProcessedMessage;
import com.stackup.stackup.common.messaging.domain.ProcessedMessageRepository;
import com.stackup.stackup.common.sse.SseEventPublisher;
import com.stackup.stackup.common.messaging.RealtimeNotifyEvent;
import com.stackup.stackup.common.sse.SseEventType;
import com.stackup.stackup.session.application.dto.TtsCallbackEnvelope;
import com.stackup.stackup.session.application.dto.TtsCallbackPayload;
Expand All @@ -13,6 +13,7 @@
import lombok.RequiredArgsConstructor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
Expand All @@ -27,7 +28,7 @@ public class TtsCallbackService {

private final InterviewMessageRepository messageRepository;
private final ProcessedMessageRepository processedMessageRepository;
private final SseEventPublisher sseEventPublisher;
private final ApplicationEventPublisher events;

@Transactional
public void apply(TtsCallbackEnvelope envelope) {
Expand Down Expand Up @@ -94,9 +95,9 @@ private void publishNotice(InterviewMessage message, String errorCode) {
message.getTtsDurationSec(),
errorCode
);
sseEventPublisher.publishToSession(sessionId, SseEventType.SESSION_MESSAGE, notice);
sseEventPublisher.publishToUser(message.getSession().getUser().getId(),
SseEventType.SESSION_MESSAGE, notice);
events.publishEvent(RealtimeNotifyEvent.session(sessionId, SseEventType.SESSION_MESSAGE, notice));
events.publishEvent(RealtimeNotifyEvent.user(message.getSession().getUser().getId(),
SseEventType.SESSION_MESSAGE, notice));
}

private boolean isProcessed(String messageId) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,25 +1,35 @@
package com.stackup.stackup.session.application.dto;

import java.util.List;
import java.util.Map;

// generate.feedback envelope payload (Core AI).
// AI 가 세션 메시지/평가/분석문서 컨텍스트 기반으로 종합 피드백을 생성.
// generate.feedback envelope payload (Core -> AI).
// AI generates comprehensive feedback from session messages, evaluation context, documents, and voice summary.
public record GenerateFeedbackPayload(
Long sessionId,
String mode,
String jobCategory,
Integer totalQuestionCount,
String endReason, // USER_REQUEST | MAX_QUESTIONS_REACHED
String endReason,
List<MessageItem> messages,
List<Long> contextDocumentIds // pgvector RAG 검색용 — AI 가 search API 호출 시 사용
List<Long> contextDocumentIds,
VoiceAnalysisSummary voiceAnalysisSummary
) {

public record MessageItem(
Long id,
Integer sequenceNumber,
String role, // INTERVIEWER | INTERVIEWEE
String role,
String content,
Long parentMessageId
) {
}

public record VoiceAnalysisSummary(
int analyzedMessageCount,
Double averageSpeakingRateWpm,
Double totalSilenceDurationSec,
Map<String, Integer> fillerWordCounts
) {
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.stackup.stackup.session.domain;

import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;

Expand All @@ -8,4 +9,6 @@ public interface MessageVoiceAnalysisRepository extends JpaRepository<MessageVoi
Optional<MessageVoiceAnalysis> findByMessage_Id(Long messageId);

boolean existsByMessage_Id(Long messageId);

List<MessageVoiceAnalysis> findByMessage_Session_Id(Long sessionId);
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.stackup.stackup.session.presentation.dto;

import com.stackup.stackup.session.application.dto.FeedbackResult;
import io.swagger.v3.oas.annotations.media.Schema;
import java.time.Instant;
import java.util.List;

Expand All @@ -13,7 +14,9 @@ public record FeedbackResponse(
Double communicationScore,
String strengthsSummary,
String weaknessesSummary,
@Schema(description = "Improvement keywords returned by AI. The current contract is a string list.")
List<String> improvementKeywords,
@Schema(description = "Stored report path when AI generates a detailed learning guide/report.")
String reportFilePath,
Instant createdAt
) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
package com.stackup.stackup.document.application;

import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

import com.stackup.stackup.common.storage.ObjectStorageClient;
import com.stackup.stackup.document.application.dto.AnalyzedDocumentResult;
import com.stackup.stackup.document.domain.AnalysisStatus;
import com.stackup.stackup.document.domain.AnalyzedDocument;
import com.stackup.stackup.document.domain.AnalyzedDocumentRepository;
import com.stackup.stackup.document.domain.DocumentStatus;
import com.stackup.stackup.github.application.event.RepositoryDeletedEvent;
import com.stackup.stackup.github.domain.GithubRepository;
import com.stackup.stackup.resume.application.event.ResumeDeletedEvent;
import com.stackup.stackup.resume.domain.Resume;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;

@ExtendWith(MockitoExtension.class)
class AnalyzedDocumentCascadeListenerTest {

@Mock AnalyzedDocumentRepository documentRepository;
@Mock ObjectStorageClient storage;
@InjectMocks AnalyzedDocumentCascadeListener listener;

@Test
void resumeDeleted_softDeletesRelatedAnalyzedDocuments() {
AnalyzedDocument first = AnalyzedDocument.forResume(mock(Resume.class));
AnalyzedDocument second = AnalyzedDocument.forResume(mock(Resume.class));
when(documentRepository.findActiveByResumeIdAndOwner(10L, 1L)).thenReturn(List.of(first, second));

listener.on(new ResumeDeletedEvent(1L, 10L));

assertThat(first.isDeleted()).isTrue();
assertThat(second.isDeleted()).isTrue();
}

@Test
void repositoryDeleted_softDeletesRelatedAnalyzedDocuments() {
AnalyzedDocument document = AnalyzedDocument.forRepository(mock(GithubRepository.class));
when(documentRepository.findActiveByRepositoryIdAndOwner(20L, 1L)).thenReturn(List.of(document));

listener.on(new RepositoryDeletedEvent(1L, 20L));

assertThat(document.isDeleted()).isTrue();
}

@Test
void listForUser_usesActiveDocumentQuerySoDeletedDocumentsAreExcluded() {
AnalyzedDocumentQueryService queryService = new AnalyzedDocumentQueryService(documentRepository, storage);
AnalyzedDocument active = mockDocument();
when(documentRepository.findActiveByOwner(1L)).thenReturn(List.of(active));

List<AnalyzedDocumentResult> results = queryService.listForUser(1L, null, null);

assertThat(results).hasSize(1);
verify(documentRepository).findActiveByOwner(1L);
verify(documentRepository, never()).findByResume_User_IdOrRepository_User_Id(1L, 1L);
}

private AnalyzedDocument mockDocument() {
AnalyzedDocument document = mock(AnalyzedDocument.class);
when(document.getTechStack()).thenReturn("[\"Java\"]");
when(document.getEmbeddingChunkCount()).thenReturn(1);
when(document.getAnalysisStatus()).thenReturn(AnalysisStatus.ANALYZED);
when(document.getStatus()).thenReturn(DocumentStatus.ACTIVE);
return document;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.springframework.test.util.ReflectionTestUtils;

@ExtendWith(MockitoExtension.class)
Expand Down Expand Up @@ -60,4 +62,23 @@ void record_fallsBackToFailedOnUnknownStatus() {
verify(logRepository).save(cap.capture());
assertThat(cap.getValue().getStatus()).isEqualTo(AiRequestStatus.FAILED);
}

@ParameterizedTest
@ValueSource(strings = {
"generate.questions",
"generate.followup",
"generate.feedback",
"stt.transcribe",
"tts.synthesize"
})
void record_keepsAiWorkflowRequestType(String requestType) {
service.record(new AiRequestLogCommand(
null, null, requestType, "gemini-flash",
10, 20, 800, "SUCCESS", null
));

ArgumentCaptor<AiRequestLog> cap = ArgumentCaptor.forClass(AiRequestLog.class);
verify(logRepository).save(cap.capture());
assertThat(cap.getValue().getRequestType()).isEqualTo(requestType);
}
}
Loading