diff --git a/src/main/java/com/example/skillboost/interview/controller/InterviewController.java b/src/main/java/com/example/skillboost/interview/controller/InterviewController.java new file mode 100644 index 0000000..fc76afa --- /dev/null +++ b/src/main/java/com/example/skillboost/interview/controller/InterviewController.java @@ -0,0 +1,52 @@ +package com.example.skillboost.interview.controller; + +import com.example.skillboost.interview.dto.InterviewFeedbackRequest; +import com.example.skillboost.interview.dto.InterviewFeedbackResponse; +import com.example.skillboost.interview.dto.InterviewStartRequest; +import com.example.skillboost.interview.dto.InterviewStartResponse; +import com.example.skillboost.interview.service.InterviewFeedbackService; +import com.example.skillboost.interview.service.InterviewService; +import com.example.skillboost.interview.service.SpeechToTextService; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Profile; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import java.util.Map; + +@Profile({"local", "prod"}) +@RestController +@RequestMapping("/api/interview") +@RequiredArgsConstructor +public class InterviewController { + + private final InterviewService interviewService; + private final InterviewFeedbackService feedbackService; + private final SpeechToTextService speechToTextService; + + // 1) 면접 시작 + 질문 생성 + @PostMapping("/start") + public ResponseEntity start(@RequestBody InterviewStartRequest request) { + InterviewStartResponse response = interviewService.startInterview(request); + return ResponseEntity.ok(response); + } + + // 2) (텍스트 기반) 전체 답변 평가 + @PostMapping("/feedback") + public ResponseEntity feedback( + @RequestBody InterviewFeedbackRequest request + ) { + InterviewFeedbackResponse response = feedbackService.createFeedback(request); + return ResponseEntity.ok(response); + } + + // 3) 🔊 음성 → 텍스트(STT)만 담당 + @PostMapping("/stt") + public ResponseEntity> stt( + @RequestPart("audio") MultipartFile audioFile + ) { + String text = speechToTextService.transcribe(audioFile); + return ResponseEntity.ok(Map.of("text", text)); + } +} diff --git a/src/main/java/com/example/skillboost/interview/dto/InterviewAnswerDto.java b/src/main/java/com/example/skillboost/interview/dto/InterviewAnswerDto.java new file mode 100644 index 0000000..3476ed3 --- /dev/null +++ b/src/main/java/com/example/skillboost/interview/dto/InterviewAnswerDto.java @@ -0,0 +1,28 @@ +package com.example.skillboost.interview.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class InterviewAnswerDto { + + // 어떤 질문에 대한 답변인지 구분용 + private Long questionId; + + // 질문 타입 (기술 / 인성) + private QuestionType type; + + // 실제 질문 텍스트 + private String question; + + // STT로 변환된 지원자의 답변 텍스트 + private String answerText; + + // 답변에 사용된 시간(초) - 지금은 0으로 둬도 되고, 나중에 프론트에서 계산해서 넣어도 됨 + private int durationSec; +} diff --git a/src/main/java/com/example/skillboost/interview/dto/InterviewFeedbackRequest.java b/src/main/java/com/example/skillboost/interview/dto/InterviewFeedbackRequest.java new file mode 100644 index 0000000..f65b586 --- /dev/null +++ b/src/main/java/com/example/skillboost/interview/dto/InterviewFeedbackRequest.java @@ -0,0 +1,21 @@ +package com.example.skillboost.interview.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class InterviewFeedbackRequest { + + // 선택적이지만 있으면 리포팅/로깅에 도움 됨 + private String sessionId; + + // AI 평가용 전체 질문/답변 리스트 + private List answers; +} diff --git a/src/main/java/com/example/skillboost/interview/dto/InterviewFeedbackResponse.java b/src/main/java/com/example/skillboost/interview/dto/InterviewFeedbackResponse.java new file mode 100644 index 0000000..fee4d57 --- /dev/null +++ b/src/main/java/com/example/skillboost/interview/dto/InterviewFeedbackResponse.java @@ -0,0 +1,22 @@ +package com.example.skillboost.interview.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Getter +@NoArgsConstructor // ← 필요시를 대비한 기본 생성자 +@AllArgsConstructor +public class InterviewFeedbackResponse { + + // 전체 점수 (0 ~ 100) + private int overallScore; + + // 전체 답변에 대한 요약 한 문단 + private String summary; + + // 각 질문별 점수 + 피드백 리스트 + private List details; +} diff --git a/src/main/java/com/example/skillboost/interview/dto/InterviewQuestionDto.java b/src/main/java/com/example/skillboost/interview/dto/InterviewQuestionDto.java new file mode 100644 index 0000000..6eb3869 --- /dev/null +++ b/src/main/java/com/example/skillboost/interview/dto/InterviewQuestionDto.java @@ -0,0 +1,22 @@ +package com.example.skillboost.interview.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class InterviewQuestionDto { + + // 세션 내 질문 번호 (1 ~ 5) + private Long id; + + // TECH / BEHAV + private QuestionType type; + + // 질문 텍스트 + private String text; +} diff --git a/src/main/java/com/example/skillboost/interview/dto/InterviewStartRequest.java b/src/main/java/com/example/skillboost/interview/dto/InterviewStartRequest.java new file mode 100644 index 0000000..ae6be9e --- /dev/null +++ b/src/main/java/com/example/skillboost/interview/dto/InterviewStartRequest.java @@ -0,0 +1,14 @@ +package com.example.skillboost.interview.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor // JSON 역직렬화용 필수 +@AllArgsConstructor // 생성자 자동 생성 +public class InterviewStartRequest { + + // GitHub 레포 주소 + private String repoUrl; +} diff --git a/src/main/java/com/example/skillboost/interview/dto/InterviewStartResponse.java b/src/main/java/com/example/skillboost/interview/dto/InterviewStartResponse.java new file mode 100644 index 0000000..903bc72 --- /dev/null +++ b/src/main/java/com/example/skillboost/interview/dto/InterviewStartResponse.java @@ -0,0 +1,24 @@ +package com.example.skillboost.interview.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Getter +@NoArgsConstructor // JSON 역직렬화 대비용 +@AllArgsConstructor +@Builder // startInterview()에서 builder로 만들기 좋아짐 +public class InterviewStartResponse { + + // 세션 고유 ID (STT / 답변 제출 시 반드시 필요) + private String sessionId; + + // 질문당 제한 시간(초) - 기본 60초 + private int durationSec; + + // AI 생성 기술 질문 + 인성 질문 총 5개 + private List questions; +} diff --git a/src/main/java/com/example/skillboost/interview/dto/QuestionFeedbackDto.java b/src/main/java/com/example/skillboost/interview/dto/QuestionFeedbackDto.java new file mode 100644 index 0000000..95c8535 --- /dev/null +++ b/src/main/java/com/example/skillboost/interview/dto/QuestionFeedbackDto.java @@ -0,0 +1,17 @@ +// src/main/java/com/example/skillboost/interview/dto/QuestionFeedbackDto.java +package com.example.skillboost.interview.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class QuestionFeedbackDto { + + private Long questionId; + private String questionText; // ✅ 질문 내용 추가 + private int score; + private String feedback; +} diff --git a/src/main/java/com/example/skillboost/interview/dto/QuestionType.java b/src/main/java/com/example/skillboost/interview/dto/QuestionType.java new file mode 100644 index 0000000..282d8f8 --- /dev/null +++ b/src/main/java/com/example/skillboost/interview/dto/QuestionType.java @@ -0,0 +1,6 @@ +package com.example.skillboost.interview.dto; + +public enum QuestionType { + TECH, // 기술 질문 + BEHAV // 인성 질문 +} diff --git a/src/main/java/com/example/skillboost/interview/model/InterviewSession.java b/src/main/java/com/example/skillboost/interview/model/InterviewSession.java new file mode 100644 index 0000000..b28765d --- /dev/null +++ b/src/main/java/com/example/skillboost/interview/model/InterviewSession.java @@ -0,0 +1,32 @@ +package com.example.skillboost.interview.model; + +import com.example.skillboost.interview.dto.InterviewQuestionDto; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.io.Serializable; +import java.time.LocalDateTime; +import java.util.List; + +@Getter +@NoArgsConstructor // 세션 저장 시 역직렬화 대비 +@AllArgsConstructor +@Builder +public class InterviewSession implements Serializable { + + private String sessionId; // 세션 고유 ID + private String repoUrl; // 레포 주소 + private LocalDateTime createdAt; // 세션 생성 시간 + private List questions; // 질문 리스트 + + public static InterviewSession create(String sessionId, String repoUrl, List questions) { + return InterviewSession.builder() + .sessionId(sessionId) + .repoUrl(repoUrl) + .questions(questions) + .createdAt(LocalDateTime.now()) + .build(); + } +} diff --git a/src/main/java/com/example/skillboost/interview/service/GeminiClient.java b/src/main/java/com/example/skillboost/interview/service/GeminiClient.java new file mode 100644 index 0000000..61c0fb0 --- /dev/null +++ b/src/main/java/com/example/skillboost/interview/service/GeminiClient.java @@ -0,0 +1,113 @@ +package com.example.skillboost.interview.service; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Mono; + +import java.util.List; +import java.util.Map; + +@Slf4j +@Component +@RequiredArgsConstructor +public class GeminiClient { + + private final WebClient.Builder webClientBuilder; + + @Value("${gemini.api.key}") + private String apiKey; + + @Value("${gemini.model}") + private String model; + + private WebClient webClient() { + return webClientBuilder + .baseUrl("https://generativelanguage.googleapis.com/v1beta") + .build(); + } + + /** + * 단순 텍스트 프롬프트 요청 → 첫 번째 candidate의 text 반환 + */ + public String generateText(String prompt) { + + Map body = Map.of( + "contents", List.of( + Map.of("parts", List.of( + Map.of("text", prompt) + )) + ) + ); + + GeminiResponse response = null; + + try { + response = webClient() + .post() + .uri("/models/" + model + ":generateContent?key=" + apiKey) + .bodyValue(body) + .retrieve() + .bodyToMono(GeminiResponse.class) + .onErrorResume(ex -> { + log.error("Gemini API 호출 실패: {}", ex.getMessage()); + return Mono.empty(); + }) + .block(); + + } catch (Exception e) { + log.error("Gemini 요청 중 서버 오류", e); + return ""; // 완전 실패 시 빈 문자열 + } + + if (response == null || response.candidates == null || response.candidates.isEmpty()) { + log.warn("Gemini 응답이 비어 있음"); + return ""; + } + + // 첫 후보 꺼내기 + GeminiCandidate first = response.candidates.get(0); + + if (first.content == null || first.content.parts == null || first.content.parts.isEmpty()) { + log.warn("Gemini content.parts 없음"); + return ""; + } + + String text = first.content.parts.get(0).text; + return text != null ? text.trim() : ""; + } + + // ============================= + // 내부 응답 DTO + // ============================= + + @Getter + @JsonIgnoreProperties(ignoreUnknown = true) + private static class GeminiResponse { + private List candidates; + } + + @Getter + @JsonIgnoreProperties(ignoreUnknown = true) + private static class GeminiCandidate { + private GeminiContent content; + } + + @Getter + @JsonIgnoreProperties(ignoreUnknown = true) + private static class GeminiContent { + private List parts; + } + + @Getter + @JsonIgnoreProperties(ignoreUnknown = true) + private static class GeminiPart { + @JsonProperty("text") + private String text; + } +} diff --git a/src/main/java/com/example/skillboost/interview/service/InterviewFeedbackService.java b/src/main/java/com/example/skillboost/interview/service/InterviewFeedbackService.java new file mode 100644 index 0000000..3a7cef8 --- /dev/null +++ b/src/main/java/com/example/skillboost/interview/service/InterviewFeedbackService.java @@ -0,0 +1,152 @@ +package com.example.skillboost.interview.service; + +import com.example.skillboost.interview.dto.InterviewAnswerDto; +import com.example.skillboost.interview.dto.InterviewFeedbackRequest; +import com.example.skillboost.interview.dto.InterviewFeedbackResponse; +import com.example.skillboost.interview.dto.QuestionFeedbackDto; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@Slf4j +@Service +@RequiredArgsConstructor +public class InterviewFeedbackService { + + private final GeminiClient geminiClient; + private final ObjectMapper objectMapper; + + public InterviewFeedbackResponse createFeedback(InterviewFeedbackRequest request) { + + // 1. 질문/답변 리스트를 JSON 형태로 준비 + List> qaList = new ArrayList<>(); + // questionId -> questionText 매핑용 + Map idToQuestion = new HashMap<>(); + + for (InterviewAnswerDto answer : request.getAnswers()) { + qaList.add(Map.of( + "questionId", answer.getQuestionId(), + "question", answer.getQuestion(), + "answer", answer.getAnswerText() + )); + if (answer.getQuestionId() != null) { + idToQuestion.put(answer.getQuestionId(), answer.getQuestion()); + } + } + + String qaJson; + try { + qaJson = objectMapper.writeValueAsString(qaList); + } catch (Exception e) { + throw new RuntimeException("질문/답변 JSON 변환 실패", e); + } + + // 2. Gemini에 평가 요청 + String prompt = """ + 당신은 시니어 개발자/리더 면접관입니다. + 아래는 지원자가 기술/인성 면접에서 답변한 질문/답변 목록입니다. + 각 질문에 대해 0~20점 사이의 점수를 매기고, + 구체적인 피드백을 작성해 주세요. + 또한 전체적인 인상에 대한 한 문단 요약과 0~100점 사이의 총점을 만들어 주세요. + + 질문/답변 목록(JSON): + %s + + 출력 형식은 반드시 아래 JSON 형식만 사용하세요. + + { + "overallScore": 87, + "summary": "전체적인 인상 요약 문단", + "details": [ + { + "questionId": 1, + "score": 18, + "feedback": "이 답변이 왜 좋은지/부족한지에 대한 구체적 피드백" + }, + { + "questionId": 2, + "score": 14, + "feedback": "..." + } + ] + } + + - 다른 아무 텍스트도 추가하지 말고, JSON만 출력하세요. + - score는 반드시 0~20 범위의 정수로 주세요. + - 질문을 이해하지 못했거나 답변이 거의 없는 경우, 낮은 점수를 주고 그 이유를 feedback에 명확히 적어 주세요. + - 특히, ```json, ``` 같은 코드 블록 마크다운은 절대로 붙이지 마세요. + """.formatted(qaJson); + + String json = geminiClient.generateText(prompt); + if (json == null || json.isBlank()) { + return new InterviewFeedbackResponse( + 0, + "AI 분석 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.", + List.of() + ); + } + + try { + // 🔥 코드블록(```json ... ```) 등 앞뒤 잡소리 제거 + json = cleanupJson(json); + log.info("Gemini output after cleanup: {}", json); + + Map root = objectMapper.readValue(json, Map.class); + + int overallScore = ((Number) root.getOrDefault("overallScore", 0)).intValue(); + String summary = (String) root.getOrDefault("summary", "요약 정보를 생성하지 못했습니다."); + + @SuppressWarnings("unchecked") + List> detailsRaw = + (List>) root.getOrDefault("details", List.of()); + + List details = new ArrayList<>(); + for (Map d : detailsRaw) { + Long qid = d.get("questionId") != null + ? ((Number) d.get("questionId")).longValue() + : null; + int score = d.get("score") != null + ? ((Number) d.get("score")).intValue() + : 0; + String feedback = (String) d.getOrDefault("feedback", ""); + + // questionId로 원래 질문 텍스트 찾기 + String questionText = (qid != null) ? idToQuestion.getOrDefault(qid, "") : ""; + + details.add(new QuestionFeedbackDto(qid, questionText, score, feedback)); + } + + return new InterviewFeedbackResponse(overallScore, summary, details); + + } catch (Exception e) { + log.error("Interview feedback JSON 파싱 오류. raw={}", json, e); + return new InterviewFeedbackResponse( + 0, + "AI 분석 결과를 해석하는 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.", + List.of() + ); + } + } + + /** + * ```json ... ``` 처럼 감싸져 올 경우 대비용 헬퍼 + */ + private String cleanupJson(String raw) { + if (raw == null) return ""; + String trimmed = raw.trim(); + if (trimmed.startsWith("```")) { + int firstBrace = trimmed.indexOf('{'); + int lastBrace = trimmed.lastIndexOf('}'); + if (firstBrace != -1 && lastBrace != -1 && lastBrace > firstBrace) { + return trimmed.substring(firstBrace, lastBrace + 1); + } + } + return trimmed; + } +} diff --git a/src/main/java/com/example/skillboost/interview/service/InterviewService.java b/src/main/java/com/example/skillboost/interview/service/InterviewService.java new file mode 100644 index 0000000..da3dafb --- /dev/null +++ b/src/main/java/com/example/skillboost/interview/service/InterviewService.java @@ -0,0 +1,309 @@ +package com.example.skillboost.interview.service; + +import com.example.skillboost.codeReview.GithubFile; +import com.example.skillboost.codeReview.service.GithubService; +import com.example.skillboost.interview.dto.*; +import com.example.skillboost.interview.model.InterviewSession; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.stream.Collectors; +import java.util.stream.LongStream; + + +@Profile({"local", "prod"}) +@Service +@RequiredArgsConstructor +public class InterviewService { + + private static final int QUESTION_DURATION_SEC = 60; + + private final Map sessions = new ConcurrentHashMap<>(); + + private final GeminiClient geminiClient; + private final SpeechToTextService speechToTextService; + private final ObjectMapper objectMapper; + private final GithubService githubService; // 🔥 GitHub 읽기 서비스 + + // --------------------------------------------------------- + // 음성 답변 처리 + // --------------------------------------------------------- + public InterviewAnswerDto processAnswer(String sessionId, int questionIndex, MultipartFile audioFile) { + InterviewSession session = findSession(sessionId) + .orElseThrow(() -> new IllegalArgumentException("세션을 찾을 수 없습니다.")); + + List questions = session.getQuestions(); + if (questionIndex < 0 || questionIndex >= questions.size()) { + throw new IllegalArgumentException("잘못된 questionIndex 입니다."); + } + + InterviewQuestionDto questionDto = questions.get(questionIndex); + + String answerText = speechToTextService.transcribe(audioFile); + + return InterviewAnswerDto.builder() + .questionId(questionDto.getId()) + .type(questionDto.getType()) + .question(questionDto.getText()) + .answerText(answerText) + .durationSec(0) + .build(); + } + + // --------------------------------------------------------- + // 면접 시작 + // --------------------------------------------------------- + public InterviewStartResponse startInterview(InterviewStartRequest request) { + String repoUrl = request.getRepoUrl(); + + List techQuestions = generateTechQuestionsWithGemini(repoUrl); + List behavQuestions = pickRandomBehavQuestions(2); // 🔥 자동 생성된 인성 질문 + + List all = new ArrayList<>(); + all.addAll(techQuestions); + all.addAll(behavQuestions); + Collections.shuffle(all); + + List numbered = LongStream + .rangeClosed(1, all.size()) + .mapToObj(i -> new InterviewQuestionDto( + i, + all.get((int) i - 1).getType(), + all.get((int) i - 1).getText() + )).collect(Collectors.toList()); + + String sessionId = UUID.randomUUID().toString(); + InterviewSession session = InterviewSession.create(sessionId, repoUrl, numbered); + sessions.put(sessionId, session); + + return InterviewStartResponse.builder() + .sessionId(sessionId) + .durationSec(QUESTION_DURATION_SEC) + .questions(numbered) + .build(); + } + + // --------------------------------------------------------- + // 🔥 GitHub 레포 기반 기술 질문 생성 + // --------------------------------------------------------- + private List generateTechQuestionsWithGemini(String repoUrl) { + String repoName = extractRepoName(repoUrl); + + // 1) GitHub 파일 읽기 + List files; + try { + files = githubService.fetchRepoCode(repoUrl, "main"); + } catch (Exception e) { + e.printStackTrace(); + return fallbackTechQuestions(repoName); + } + + if (files == null || files.isEmpty()) { + return fallbackTechQuestions(repoName); + } + + // 2) 파일 내용을 하나의 큰 텍스트로 합침 + StringBuilder repoText = new StringBuilder(); + for (GithubFile f : files) { + repoText.append("### FILE: ").append(f.getPath()).append("\n"); + repoText.append(f.getContent()).append("\n\n"); + } + + // 3) Gemini 프롬프트 생성 + String prompt = """ + 당신은 시니어 백엔드 개발자 면접관입니다. + 아래는 지원자의 GitHub 레포지토리 전체 코드입니다. + 이 내용을 기반으로 기술 면접 질문 3개를 생성하세요. + + --- Repository Code Start --- + %s + --- Repository Code End --- + + 질문 규칙: + - 각 질문은 1문장 + - 80자 이내 + - 이 코드의 구조/설계/모듈/DTO/서비스/컨트롤러 기반 + - 추상적인 질문 금지 + - JSON 배열만 출력 + + 출력 형식: + [ + { "text": "질문1" }, + { "text": "질문2" }, + { "text": "질문3" } + ] + """.formatted(repoText.toString()); + + // 4) Gemini 호출 + String raw; + try { + raw = geminiClient.generateText(prompt); + } catch (Exception e) { + e.printStackTrace(); + return fallbackTechQuestions(repoName); + } + + if (raw == null || raw.isBlank()) { + return fallbackTechQuestions(repoName); + } + + // 5) JSON 배열 추출 + String cleaned = extractJsonArray(raw).trim(); + if (!cleaned.startsWith("[")) { + return fallbackTechQuestions(repoName); + } + + // 6) 파싱 + try { + List> list = objectMapper.readValue( + cleaned, + objectMapper.getTypeFactory().constructCollectionType(List.class, Map.class) + ); + + List result = new ArrayList<>(); + for (Map item : list) { + Object textObj = item.get("text"); + if (textObj == null) continue; + + String text = String.valueOf(textObj).trim(); + if (text.isEmpty()) continue; + + result.add(new InterviewQuestionDto(null, QuestionType.TECH, text)); + } + + return result.size() >= 3 ? result.subList(0, 3) : fallbackTechQuestions(repoName); + + } catch (Exception e) { + e.printStackTrace(); + return fallbackTechQuestions(repoName); + } + } + + // --------------------------------------------------------- + // 기술면접 fallback + // --------------------------------------------------------- + private List fallbackTechQuestions(String repoName) { + return List.of( + new InterviewQuestionDto(null, QuestionType.TECH, + repoName + " 프로젝트의 전체 아키텍처를 설명해주세요."), + new InterviewQuestionDto(null, QuestionType.TECH, + repoName + " 레포의 주요 모듈 설계 의도를 설명해주세요."), + new InterviewQuestionDto(null, QuestionType.TECH, + "외부 API 호출 시 예외/타임아웃 처리 방식을 설명해주세요.") + ); + } + + // --------------------------------------------------------- + // 🔥 Gemini 기반 인성 질문 자동 생성 + // --------------------------------------------------------- + private List pickRandomBehavQuestions(int count) { + + String prompt = """ + 당신은 인성 면접 전문 면접관입니다. + 아래 조건에 따라 인성 면접 질문을 JSON 배열 형태로 생성하세요. + + 조건: + - 심층적이지만 과도하게 추상적이지 않은 질문 + - 1문장, 60자 이내 + - 지원자의 성격·협업 능력·책임감·문제 해결 능력 중심 + - JSON 배열로만 출력 + + 출력 예시: + [ + { "text": "협업 과정에서 갈등을 해결했던 경험을 말해주세요." }, + { "text": "압박이 있을 때 자신의 감정을 어떻게 관리하나요?" } + ] + + 질문 개수: %d개 + """.formatted(count); + + String raw; + try { + raw = geminiClient.generateText(prompt); + } catch (Exception e) { + e.printStackTrace(); + return fallbackBehavQuestions(count); + } + + if (raw == null || raw.isBlank()) { + return fallbackBehavQuestions(count); + } + + String cleaned = extractJsonArray(raw).trim(); + if (!cleaned.startsWith("[")) { + return fallbackBehavQuestions(count); + } + + try { + List> list = objectMapper.readValue( + cleaned, + objectMapper.getTypeFactory().constructCollectionType(List.class, Map.class) + ); + + List result = new ArrayList<>(); + for (Map item : list) { + Object textObj = item.get("text"); + if (textObj == null) continue; + + String text = String.valueOf(textObj).trim(); + if (text.isEmpty()) continue; + + result.add(new InterviewQuestionDto(null, QuestionType.BEHAV, text)); + } + + if (result.size() < count) return fallbackBehavQuestions(count); + return result.subList(0, count); + + } catch (Exception e) { + e.printStackTrace(); + return fallbackBehavQuestions(count); + } + } + + // --------------------------------------------------------- + // 인성 fallback + // --------------------------------------------------------- + private List fallbackBehavQuestions(int count) { + List defaults = List.of( + "협업 과정에서 갈등을 해결했던 경험을 설명해주세요.", + "압박이 큰 상황에서 감정을 관리하는 방법을 말해주세요.", + "가장 최근에 성장했다고 느낀 경험을 말해주세요.", + "실수했을 때 어떻게 대응했는지 말해주세요.", + "목표 달성을 위해 본인이 했던 노력을 설명해주세요." + ); + + Collections.shuffle(defaults); + + return defaults.subList(0, Math.min(count, defaults.size())) + .stream() + .map(text -> new InterviewQuestionDto(null, QuestionType.BEHAV, text)) + .collect(Collectors.toList()); + } + + // --------------------------------------------------------- + // 기타 유틸 + // --------------------------------------------------------- + private String extractJsonArray(String raw) { + if (raw == null) return ""; + int start = raw.indexOf('['); + int end = raw.lastIndexOf(']'); + if (start == -1 || end == -1 || end <= start) return raw; + return raw.substring(start, end + 1); + } + + private String extractRepoName(String repoUrl) { + if (repoUrl == null || repoUrl.isBlank()) return "이 프로젝트"; + int slash = repoUrl.lastIndexOf('/'); + if (slash == -1 || slash == repoUrl.length() - 1) return repoUrl; + return repoUrl.substring(slash + 1); + } + + public Optional findSession(String sessionId) { + return Optional.ofNullable(sessions.get(sessionId)); + } +} diff --git a/src/main/java/com/example/skillboost/interview/service/SpeechToTextService.java b/src/main/java/com/example/skillboost/interview/service/SpeechToTextService.java new file mode 100644 index 0000000..5423792 --- /dev/null +++ b/src/main/java/com/example/skillboost/interview/service/SpeechToTextService.java @@ -0,0 +1,65 @@ +package com.example.skillboost.interview.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.PostConstruct; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; +import org.vosk.Model; +import org.vosk.Recognizer; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; + +@Profile({"local", "prod"}) +@Slf4j +@Service +public class SpeechToTextService { + + private Model model; + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Value("${stt.vosk-model-path}") + private String modelPath; + + @PostConstruct + public void init() { + try { + this.model = new Model(modelPath); + log.info("Vosk STT 모델 로드 완료: {}", modelPath); + } catch (IOException e) { + log.error("Vosk 모델 로드 실패", e); + throw new RuntimeException("Vosk 모델 로드 실패", e); + } + } + + public String transcribe(MultipartFile audioFile) { + if (model == null) throw new IllegalStateException("Vosk 모델 초기화 실패"); + + try { + byte[] data = audioFile.getBytes(); + + try (InputStream is = new ByteArrayInputStream(data); + Recognizer recognizer = new Recognizer(model, 16000)) { + + byte[] buffer = new byte[4096]; + int n; + + while ((n = is.read(buffer)) >= 0) { + recognizer.acceptWaveForm(buffer, n); + } + + String resultJson = recognizer.getFinalResult(); + JsonNode root = objectMapper.readTree(resultJson); + return root.path("text").asText("").trim(); + } + } catch (Exception e) { + log.error("STT 변환 실패", e); + throw new RuntimeException(e); + } + } +} diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index 0e5ccb5..a4cecd0 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -44,3 +44,5 @@ gemini: api: key: ${GEMINI_KEY} +stt: + vosk-model-path: ${STT_MODEL:vosk-model} \ No newline at end of file diff --git a/src/main/resources/application-test.yml b/src/main/resources/application-test.yml index 63d724e..737d2ba 100644 --- a/src/main/resources/application-test.yml +++ b/src/main/resources/application-test.yml @@ -35,3 +35,6 @@ gemini: model: test-model api: key: test-key + +stt: + vosk-model-path: test \ No newline at end of file