From 4303190ec814083ded74597ea6055db31013132e Mon Sep 17 00:00:00 2001 From: Junseung-Ock Date: Thu, 13 Nov 2025 20:16:18 +0900 Subject: [PATCH 01/15] gitReader --- build.gradle | 1 + .../githubReader/GithubController.java | 23 +++++ .../skillboost/githubReader/GithubFile.java | 16 ++++ .../githubReader/GithubService.java | 96 +++++++++++++++++++ 4 files changed, 136 insertions(+) create mode 100644 src/main/java/com/example/skillboost/githubReader/GithubController.java create mode 100644 src/main/java/com/example/skillboost/githubReader/GithubFile.java create mode 100644 src/main/java/com/example/skillboost/githubReader/GithubService.java diff --git a/build.gradle b/build.gradle index 74c055c..86f6f46 100644 --- a/build.gradle +++ b/build.gradle @@ -28,6 +28,7 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-webflux' compileOnly 'org.projectlombok:lombok' developmentOnly 'org.springframework.boot:spring-boot-devtools' developmentOnly 'org.springframework.boot:spring-boot-docker-compose' diff --git a/src/main/java/com/example/skillboost/githubReader/GithubController.java b/src/main/java/com/example/skillboost/githubReader/GithubController.java new file mode 100644 index 0000000..8cfae5c --- /dev/null +++ b/src/main/java/com/example/skillboost/githubReader/GithubController.java @@ -0,0 +1,23 @@ +package com.example.skillboost.githubReader; + +import org.springframework.web.bind.annotation.*; +import java.util.List; + +@RestController +@RequestMapping("/api/github") +public class GithubController { + + private final GithubService githubService; + + public GithubController(GithubService githubService) { + this.githubService = githubService; + } + + @GetMapping("/repo") + public List getRepoContents( + @RequestParam String repoUrl, + @RequestParam(defaultValue = "main") String branch + ) { + return githubService.fetchRepoCode(repoUrl, branch); + } +} diff --git a/src/main/java/com/example/skillboost/githubReader/GithubFile.java b/src/main/java/com/example/skillboost/githubReader/GithubFile.java new file mode 100644 index 0000000..a909b9c --- /dev/null +++ b/src/main/java/com/example/skillboost/githubReader/GithubFile.java @@ -0,0 +1,16 @@ +package com.example.skillboost.githubReader; + +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class GithubFile { + private String path; + private String content; + + public GithubFile(String path, String content) { + this.path = path; + this.content = content; + } +} diff --git a/src/main/java/com/example/skillboost/githubReader/GithubService.java b/src/main/java/com/example/skillboost/githubReader/GithubService.java new file mode 100644 index 0000000..921f4e1 --- /dev/null +++ b/src/main/java/com/example/skillboost/githubReader/GithubService.java @@ -0,0 +1,96 @@ +package com.example.skillboost.githubReader; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Mono; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +@Service +public class GithubService { + + private final WebClient webClient; + + @Value("${github.token:}") + private String githubToken; + + public GithubService(WebClient.Builder builder) { + this.webClient = builder.baseUrl("https://api.github.com").build(); + } + + public List fetchRepoCode(String repoUrl, String branch) { + String[] parts = repoUrl.replace("https://github.com/", "").split("/"); + if (parts.length < 2) throw new IllegalArgumentException("잘못된 GitHub URL 형식입니다."); + + String owner = parts[0]; + String repo = parts[1]; + String treeUrl = String.format("/repos/%s/%s/git/trees/%s?recursive=1", owner, repo, branch); + + // 🔹 1. 전체 트리 조회 + Map response = webClient.get() + .uri(treeUrl) + .headers(h -> { + if (!githubToken.isEmpty()) + h.setBearerAuth(githubToken); + }) + .retrieve() + .bodyToMono(Map.class) + .block(); + + List> tree = (List>) response.get("tree"); + List files = new ArrayList<>(); + + // 🔹 2. 텍스트 파일만 필터링 + for (Map file : tree) { + if ("blob".equals(file.get("type"))) { + String path = (String) file.get("path"); + + if (!isTextFile(path)) continue; // 🧩 이진 파일은 스킵 + + String rawUrl = String.format( + "https://raw.githubusercontent.com/%s/%s/%s/%s", + owner, repo, branch, path + ); + + String content = fetchFileContent(rawUrl); + files.add(new GithubFile(path, content)); + } + } + + return files; + } + + // 🔹 개별 파일 내용 불러오기 + private String fetchFileContent(String rawUrl) { + try { + return webClient.get() + .uri(rawUrl) + .headers(h -> { + if (!githubToken.isEmpty()) + h.setBearerAuth(githubToken); + }) + .retrieve() + .bodyToMono(String.class) + .onErrorResume(e -> Mono.just("")) // 오류 발생 시 빈 문자열 반환 + .block(); + } catch (Exception e) { + return ""; + } + } + + // 🔹 텍스트 파일 확장자 필터 + private static final List TEXT_EXTENSIONS = List.of( + ".java", ".kt", ".xml", ".json", ".yml", ".yaml", + ".md", ".gradle", ".gitignore", ".txt", ".properties", ".csv" + ); + + private boolean isTextFile(String path) { + for (String ext : TEXT_EXTENSIONS) { + if (path.toLowerCase().endsWith(ext)) return true; + } + return false; + } +} From 9aa4894b0452f9742d90eeec5724c9ceb01e1572 Mon Sep 17 00:00:00 2001 From: Junseung-Ock Date: Fri, 28 Nov 2025 14:22:38 +0900 Subject: [PATCH 02/15] =?UTF-8?q?feat:=20=EC=BD=94=EB=93=9C=20=EB=A6=AC?= =?UTF-8?q?=EB=B7=B0=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../githubReader/AIReviewService.java | 245 ++++++++++++++++++ .../githubReader/CodeReviewController.java | 91 +++++++ .../githubReader/GithubService.java | 10 +- 3 files changed, 341 insertions(+), 5 deletions(-) create mode 100644 src/main/java/com/example/skillboost/githubReader/AIReviewService.java create mode 100644 src/main/java/com/example/skillboost/githubReader/CodeReviewController.java diff --git a/src/main/java/com/example/skillboost/githubReader/AIReviewService.java b/src/main/java/com/example/skillboost/githubReader/AIReviewService.java new file mode 100644 index 0000000..0830274 --- /dev/null +++ b/src/main/java/com/example/skillboost/githubReader/AIReviewService.java @@ -0,0 +1,245 @@ +package com.example.skillboost.githubReader; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.web.reactive.function.client.WebClient; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@Service +public class AIReviewService { + + private final WebClient webClient; + + @Value("${gemini.api.key:}") + private String geminiApiKey; + + public AIReviewService(WebClient.Builder webClientBuilder) { + this.webClient = webClientBuilder.baseUrl("https://generativelanguage.googleapis.com").build(); + } + + /** + * GitHub repo 컨텍스트를 기반으로 코드 리뷰 생성 + */ + public String reviewWithContext(String targetCode, String comment, List repoContext) { + String prompt = buildPrompt(targetCode, comment, repoContext); + + System.out.println("📝 생성된 프롬프트 길이: " + prompt.length() + "자"); + + // Gemini API 호출 (API 키가 없으면 Mock 리뷰 생성) + if (geminiApiKey == null || geminiApiKey.isEmpty()) { + System.out.println("⚠️ Gemini API 키가 없습니다. Mock 리뷰를 생성합니다."); + return generateMockReview(repoContext != null ? repoContext.size() : 0); + } + + return callGemini(prompt); + } + + /** + * AI 프롬프트 생성 + */ + private String buildPrompt(String targetCode, String comment, List repoContext) { + StringBuilder prompt = new StringBuilder(); + + // 1. 역할 설정 + prompt.append("당신은 경험 많은 시니어 개발자입니다. "); + prompt.append("프로젝트의 전체 구조를 이해하고, 코드 품질을 향상시키는 리뷰를 제공합니다.\n\n"); + + // 2. 프로젝트 컨텍스트 + if (repoContext != null && !repoContext.isEmpty()) { + prompt.append("=== 📦 프로젝트 전체 구조 ===\n\n"); + prompt.append("총 ").append(repoContext.size()).append("개의 파일로 구성된 프로젝트입니다.\n\n"); + + // 파일 목록 (최대 50개) + prompt.append("📁 파일 목록:\n"); + int fileListCount = 0; + for (GithubFile file : repoContext) { + if (fileListCount++ >= 50) break; + prompt.append(" - ").append(file.getPath()).append("\n"); + } + if (repoContext.size() > 50) { + prompt.append(" ... 외 ").append(repoContext.size() - 50).append("개 파일\n"); + } + prompt.append("\n"); + + // 주요 파일 내용 (최대 5개, 각 1500자 제한) + prompt.append("=== 📄 주요 파일 내용 (샘플) ===\n\n"); + int contentCount = 0; + for (GithubFile file : repoContext) { + if (contentCount++ >= 5) break; + + prompt.append("#### ").append(file.getPath()).append("\n"); + prompt.append("```\n"); + + String content = file.getContent(); + if (content.length() > 1500) { + content = content.substring(0, 1500) + "\n... (생략)"; + } + prompt.append(content); + prompt.append("\n```\n\n"); + } + + // 프로젝트 특징 분석 + prompt.append("=== 🔍 프로젝트 분석 ===\n"); + prompt.append(analyzeProjectStructure(repoContext)); + prompt.append("\n\n"); + } + + // 3. 리뷰 대상 코드 + prompt.append("=== 🎯 리뷰 대상 코드 ===\n\n"); + prompt.append("```\n").append(targetCode).append("\n```\n\n"); + + // 4. 사용자 코멘트 + if (comment != null && !comment.isEmpty()) { + prompt.append("=== 💬 개발자의 질문/고민 ===\n"); + prompt.append(comment).append("\n\n"); + } + + // 5. 리뷰 가이드라인 + prompt.append("=== ✅ 리뷰 요청사항 ===\n\n"); + prompt.append("위 프로젝트의 전체 구조와 코드 스타일을 고려하여, 다음 관점에서 상세한 피드백을 제공해주세요:\n\n"); + prompt.append("1. **아키텍처 일관성**: 프로젝트의 기존 패턴과 일치하는가?\n"); + prompt.append("2. **네이밍 컨벤션**: 프로젝트의 네이밍 규칙을 따르는가?\n"); + prompt.append("3. **코드 품질**: 가독성, 유지보수성, 효율성은 어떤가?\n"); + prompt.append("4. **잠재적 문제**: 버그, 보안 이슈, 성능 문제가 있는가?\n"); + prompt.append("5. **개선 제안**: 구체적인 코드 예시와 함께 개선 방안 제시\n\n"); + prompt.append("리뷰는 친절하고 건설적인 톤으로, 구체적인 예시를 포함해 작성해주세요."); + + return prompt.toString(); + } + + /** + * 프로젝트 구조 분석 + */ + private String analyzeProjectStructure(List files) { + StringBuilder analysis = new StringBuilder(); + + // 언어/파일 타입 분석 + Map extensions = files.stream() + .collect(Collectors.groupingBy( + file -> { + String path = file.getPath(); + int dotIndex = path.lastIndexOf('.'); + return dotIndex > 0 ? path.substring(dotIndex) : "기타"; + }, + Collectors.counting() + )); + + analysis.append("- 주요 언어/파일 타입: "); + analysis.append(extensions.entrySet().stream() + .sorted((a, b) -> Long.compare(b.getValue(), a.getValue())) + .limit(5) + .map(e -> e.getKey() + " (" + e.getValue() + "개)") + .collect(Collectors.joining(", "))); + analysis.append("\n"); + + // 아키텍처 패턴 분석 + boolean hasController = files.stream().anyMatch(f -> f.getPath().toLowerCase().contains("controller")); + boolean hasService = files.stream().anyMatch(f -> f.getPath().toLowerCase().contains("service")); + boolean hasRepository = files.stream().anyMatch(f -> f.getPath().toLowerCase().contains("repository")); + boolean hasComponent = files.stream().anyMatch(f -> f.getPath().toLowerCase().contains("component")); + + if (hasController && hasService && hasRepository) { + analysis.append("- 아키텍처: Layered Architecture (Controller-Service-Repository 패턴)\n"); + } else if (hasComponent) { + analysis.append("- 아키텍처: Component 기반 구조\n"); + } + + return analysis.toString(); + } + + /** + * Google Gemini API 호출 + */ + private String callGemini(String prompt) { + try { + // Gemini API 요청 형식 + Map requestBody = Map.of( + "contents", List.of( + Map.of( + "parts", List.of( + Map.of("text", prompt) + ) + ) + ) + ); + + // Gemini API 호출 + String apiUrl = String.format("/v1beta/models/gemini-pro:generateContent?key=%s", geminiApiKey); + + Map response = webClient.post() + .uri(apiUrl) + .header("Content-Type", "application/json") + .bodyValue(requestBody) + .retrieve() + .bodyToMono(Map.class) + .block(); + + // 응답 파싱 + List> candidates = (List>) response.get("candidates"); + if (candidates != null && !candidates.isEmpty()) { + Map content = (Map) candidates.get(0).get("content"); + List> parts = (List>) content.get("parts"); + if (parts != null && !parts.isEmpty()) { + return (String) parts.get(0).get("text"); + } + } + + throw new RuntimeException("Gemini API 응답 형식이 올바르지 않습니다."); + + } catch (Exception e) { + System.err.println("❌ Gemini API 호출 실패: " + e.getMessage()); + e.printStackTrace(); + return generateMockReview(0); + } + } + + /** + * Mock 리뷰 생성 (API 키가 없을 때) + */ + private String generateMockReview(int fileCount) { + StringBuilder mock = new StringBuilder(); + + mock.append("# 🔍 AI 코드 리뷰 결과\n\n"); + + if (fileCount > 0) { + mock.append("✅ **").append(fileCount).append("개의 프로젝트 파일**을 분석하여 전체 구조를 파악했습니다.\n\n"); + } + + mock.append("## ✅ 긍정적인 부분\n\n"); + mock.append("- 코드가 깔끔하고 읽기 쉽습니다\n"); + mock.append("- 기본적인 구조는 잘 갖춰져 있습니다\n"); + + if (fileCount > 0) { + mock.append("- 프로젝트의 전체적인 아키텍처 패턴을 잘 따르고 있습니다\n"); + } + + mock.append("\n## ⚠️ 개선이 필요한 부분\n\n"); + mock.append("1. **에러 처리**: 예외 상황에 대한 처리가 부족합니다\n"); + mock.append("2. **변수명**: 더 명확한 이름을 사용하면 좋겠습니다\n"); + mock.append("3. **주석**: 복잡한 로직에 설명 주석을 추가해주세요\n"); + + mock.append("\n## 💡 구체적인 개선 제안\n\n"); + mock.append("```java\n"); + mock.append("// 개선 전\n"); + mock.append("int x = getData();\n\n"); + mock.append("// 개선 후\n"); + mock.append("int userCount = getUserCount();\n"); + mock.append("```\n\n"); + + if (fileCount > 0) { + mock.append("## 🏗️ 프로젝트 구조 관점\n\n"); + mock.append("전체 프로젝트를 분석한 결과, 이 코드는 기존 패턴과 잘 맞습니다. "); + mock.append("다만 네이밍 컨벤션을 더 일관되게 유지하면 좋을 것 같습니다.\n\n"); + } + + mock.append("## 📝 총평\n\n"); + mock.append("전체적으로 좋은 코드입니다. 위 제안사항들을 반영하면 더욱 완성도 높은 코드가 될 것입니다!\n\n"); + mock.append("---\n"); + mock.append("*※ 이 리뷰는 Mock 데이터입니다. 실제 AI 리뷰를 받으려면 Gemini API 키를 `application.yml`에 설정해주세요.*\n"); + + return mock.toString(); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/skillboost/githubReader/CodeReviewController.java b/src/main/java/com/example/skillboost/githubReader/CodeReviewController.java new file mode 100644 index 0000000..cba6e93 --- /dev/null +++ b/src/main/java/com/example/skillboost/githubReader/CodeReviewController.java @@ -0,0 +1,91 @@ +package com.example.skillboost.githubReader; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/api/review") +@CrossOrigin(origins = "http://localhost:3000") // React 개발 서버 주소 +public class CodeReviewController { + + @Autowired + private GithubService githubService; + + @Autowired + private AIReviewService aiReviewService; + + @PostMapping + public ResponseEntity reviewCode( + @RequestParam("code") String code, + @RequestParam(value = "comment", required = false) String comment, + @RequestParam(value = "repo_url", required = false) String repoUrl, + @RequestParam(value = "branch", defaultValue = "main") String branch + ) { + try { + System.out.println("=".repeat(60)); + System.out.println("📥 코드 리뷰 요청 받음"); + System.out.println(" - 코드 길이: " + code.length() + "자"); + System.out.println(" - 코멘트: " + (comment != null ? comment : "(없음)")); + System.out.println(" - Repo URL: " + (repoUrl != null ? repoUrl : "(없음)")); + + // 1. GitHub repo 코드 가져오기 (repo_url이 있을 때만) + List repoContext = null; + if (repoUrl != null && !repoUrl.isEmpty()) { + System.out.println("\n🔍 GitHub Repository 분석 시작..."); + long startTime = System.currentTimeMillis(); + + repoContext = githubService.fetchRepoCode(repoUrl, branch); + + long elapsed = System.currentTimeMillis() - startTime; + System.out.println("✅ " + repoContext.size() + "개 파일 로드 완료 (" + elapsed + "ms)"); + + // 파일 목록 출력 (처음 10개만) + System.out.println("\n📁 로드된 파일 샘플:"); + int count = 0; + for (GithubFile file : repoContext) { + if (count++ >= 10) break; + System.out.println(" - " + file.getPath() + " (" + file.getContent().length() + "자)"); + } + if (repoContext.size() > 10) { + System.out.println(" ... 외 " + (repoContext.size() - 10) + "개 파일"); + } + } + + // 2. AI 리뷰 생성 + System.out.println("\n🤖 AI 리뷰 생성 중..."); + String reviewResult = aiReviewService.reviewWithContext(code, comment, repoContext); + + // 3. 응답 생성 + Map response = new HashMap<>(); + response.put("review", reviewResult); + response.put("context_files_count", repoContext != null ? repoContext.size() : 0); + response.put("repo_url", repoUrl != null ? repoUrl : ""); + response.put("success", true); + + System.out.println("✅ 리뷰 완료! (리뷰 길이: " + reviewResult.length() + "자)"); + System.out.println("=".repeat(60) + "\n"); + + return ResponseEntity.ok(response); + + } catch (IllegalArgumentException e) { + System.err.println("❌ 잘못된 요청: " + e.getMessage()); + Map errorResponse = new HashMap<>(); + errorResponse.put("success", false); + errorResponse.put("error", e.getMessage()); + return ResponseEntity.badRequest().body(errorResponse); + + } catch (Exception e) { + System.err.println("❌ 서버 오류: " + e.getMessage()); + e.printStackTrace(); + Map errorResponse = new HashMap<>(); + errorResponse.put("success", false); + errorResponse.put("error", "서버 오류: " + e.getMessage()); + return ResponseEntity.internalServerError().body(errorResponse); + } + } +} diff --git a/src/main/java/com/example/skillboost/githubReader/GithubService.java b/src/main/java/com/example/skillboost/githubReader/GithubService.java index 921f4e1..555f552 100644 --- a/src/main/java/com/example/skillboost/githubReader/GithubService.java +++ b/src/main/java/com/example/skillboost/githubReader/GithubService.java @@ -29,7 +29,7 @@ public List fetchRepoCode(String repoUrl, String branch) { String repo = parts[1]; String treeUrl = String.format("/repos/%s/%s/git/trees/%s?recursive=1", owner, repo, branch); - // 🔹 1. 전체 트리 조회 + // 전체 트리 조회 Map response = webClient.get() .uri(treeUrl) .headers(h -> { @@ -43,12 +43,12 @@ public List fetchRepoCode(String repoUrl, String branch) { List> tree = (List>) response.get("tree"); List files = new ArrayList<>(); - // 🔹 2. 텍스트 파일만 필터링 + // 텍스트 파일만 필터링 for (Map file : tree) { if ("blob".equals(file.get("type"))) { String path = (String) file.get("path"); - if (!isTextFile(path)) continue; // 🧩 이진 파일은 스킵 + if (!isTextFile(path)) continue; String rawUrl = String.format( "https://raw.githubusercontent.com/%s/%s/%s/%s", @@ -63,7 +63,7 @@ public List fetchRepoCode(String repoUrl, String branch) { return files; } - // 🔹 개별 파일 내용 불러오기 + // 개별 파일 내용 불러오기 private String fetchFileContent(String rawUrl) { try { return webClient.get() @@ -81,7 +81,7 @@ private String fetchFileContent(String rawUrl) { } } - // 🔹 텍스트 파일 확장자 필터 + // 텍스트 파일 확장자 필터 private static final List TEXT_EXTENSIONS = List.of( ".java", ".kt", ".xml", ".json", ".yml", ".yaml", ".md", ".gradle", ".gitignore", ".txt", ".properties", ".csv" From 1ac628eef0a04a295b37cc10cbf091cc19d741c1 Mon Sep 17 00:00:00 2001 From: Junseung-Ock Date: Fri, 28 Nov 2025 17:01:24 +0900 Subject: [PATCH 03/15] =?UTF-8?q?refactor:=20api=20key=20=EC=97=B0?= =?UTF-8?q?=EA=B2=B0=20=EC=98=A4=EB=A5=98=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../githubReader/AIReviewService.java | 161 +++++------------- .../githubReader/CodeReviewController.java | 16 +- 2 files changed, 53 insertions(+), 124 deletions(-) diff --git a/src/main/java/com/example/skillboost/githubReader/AIReviewService.java b/src/main/java/com/example/skillboost/githubReader/AIReviewService.java index 0830274..ec1ed7a 100644 --- a/src/main/java/com/example/skillboost/githubReader/AIReviewService.java +++ b/src/main/java/com/example/skillboost/githubReader/AIReviewService.java @@ -13,47 +13,39 @@ public class AIReviewService { private final WebClient webClient; - @Value("${gemini.api.key:}") + @Value("${gemini.api.key}") private String geminiApiKey; + @Value("${gemini.model:gemini-2.5-flash}") + private String geminiModel; + public AIReviewService(WebClient.Builder webClientBuilder) { - this.webClient = webClientBuilder.baseUrl("https://generativelanguage.googleapis.com").build(); + this.webClient = webClientBuilder + .baseUrl("https://generativelanguage.googleapis.com") + .build(); } - /** - * GitHub repo 컨텍스트를 기반으로 코드 리뷰 생성 - */ public String reviewWithContext(String targetCode, String comment, List repoContext) { String prompt = buildPrompt(targetCode, comment, repoContext); - System.out.println("📝 생성된 프롬프트 길이: " + prompt.length() + "자"); + System.out.println("생성된 프롬프트 길이: " + prompt.length() + "자"); - // Gemini API 호출 (API 키가 없으면 Mock 리뷰 생성) if (geminiApiKey == null || geminiApiKey.isEmpty()) { - System.out.println("⚠️ Gemini API 키가 없습니다. Mock 리뷰를 생성합니다."); + System.out.println("Gemini API 키가 없습니다. Mock 리뷰를 생성합니다."); return generateMockReview(repoContext != null ? repoContext.size() : 0); } return callGemini(prompt); } - /** - * AI 프롬프트 생성 - */ private String buildPrompt(String targetCode, String comment, List repoContext) { StringBuilder prompt = new StringBuilder(); + prompt.append("당신은 경험 많은 시니어 개발자입니다. 전체 구조를 이해하고, 코드 품질을 향상시키는 리뷰를 제공합니다.\n\n"); - // 1. 역할 설정 - prompt.append("당신은 경험 많은 시니어 개발자입니다. "); - prompt.append("프로젝트의 전체 구조를 이해하고, 코드 품질을 향상시키는 리뷰를 제공합니다.\n\n"); - - // 2. 프로젝트 컨텍스트 if (repoContext != null && !repoContext.isEmpty()) { - prompt.append("=== 📦 프로젝트 전체 구조 ===\n\n"); + prompt.append("=== 프로젝트 전체 구조 ===\n\n"); prompt.append("총 ").append(repoContext.size()).append("개의 파일로 구성된 프로젝트입니다.\n\n"); - - // 파일 목록 (최대 50개) - prompt.append("📁 파일 목록:\n"); + prompt.append("파일 목록:\n"); int fileListCount = 0; for (GithubFile file : repoContext) { if (fileListCount++ >= 50) break; @@ -64,59 +56,36 @@ private String buildPrompt(String targetCode, String comment, List r } prompt.append("\n"); - // 주요 파일 내용 (최대 5개, 각 1500자 제한) - prompt.append("=== 📄 주요 파일 내용 (샘플) ===\n\n"); + prompt.append("=== 주요 파일 내용 (샘플) ===\n\n"); int contentCount = 0; for (GithubFile file : repoContext) { if (contentCount++ >= 5) break; - prompt.append("#### ").append(file.getPath()).append("\n"); prompt.append("```\n"); - String content = file.getContent(); - if (content.length() > 1500) { - content = content.substring(0, 1500) + "\n... (생략)"; - } - prompt.append(content); - prompt.append("\n```\n\n"); + if (content.length() > 1500) content = content.substring(0, 1500) + "\n... (생략)"; + prompt.append(content).append("\n```\n\n"); } - // 프로젝트 특징 분석 - prompt.append("=== 🔍 프로젝트 분석 ===\n"); - prompt.append(analyzeProjectStructure(repoContext)); - prompt.append("\n\n"); + prompt.append("=== 프로젝트 분석 ===\n"); + prompt.append(analyzeProjectStructure(repoContext)).append("\n\n"); } - // 3. 리뷰 대상 코드 - prompt.append("=== 🎯 리뷰 대상 코드 ===\n\n"); - prompt.append("```\n").append(targetCode).append("\n```\n\n"); - - // 4. 사용자 코멘트 + prompt.append("=== 리뷰 대상 코드 ===\n\n```\n").append(targetCode).append("\n```\n\n"); if (comment != null && !comment.isEmpty()) { - prompt.append("=== 💬 개발자의 질문/고민 ===\n"); - prompt.append(comment).append("\n\n"); + prompt.append("=== 개발자의 질문/고민 ===\n").append(comment).append("\n\n"); } - // 5. 리뷰 가이드라인 - prompt.append("=== ✅ 리뷰 요청사항 ===\n\n"); - prompt.append("위 프로젝트의 전체 구조와 코드 스타일을 고려하여, 다음 관점에서 상세한 피드백을 제공해주세요:\n\n"); - prompt.append("1. **아키텍처 일관성**: 프로젝트의 기존 패턴과 일치하는가?\n"); - prompt.append("2. **네이밍 컨벤션**: 프로젝트의 네이밍 규칙을 따르는가?\n"); - prompt.append("3. **코드 품질**: 가독성, 유지보수성, 효율성은 어떤가?\n"); - prompt.append("4. **잠재적 문제**: 버그, 보안 이슈, 성능 문제가 있는가?\n"); - prompt.append("5. **개선 제안**: 구체적인 코드 예시와 함께 개선 방안 제시\n\n"); - prompt.append("리뷰는 친절하고 건설적인 톤으로, 구체적인 예시를 포함해 작성해주세요."); + prompt.append("=== 리뷰 요청사항 ===\n\n"); + prompt.append("전체 구조와 코드 스타일을 고려하여 다음 관점에서 상세한 피드백을 제공해주세요:\n"); + prompt.append("1. 아키텍처 일관성\n2. 네이밍 컨벤션\n3. 코드 품질\n4. 잠재적 문제\n5. 개선 제안\n\n"); + prompt.append("리뷰는 친절하고 구체적인 예시를 포함해 작성해주세요."); return prompt.toString(); } - /** - * 프로젝트 구조 분석 - */ private String analyzeProjectStructure(List files) { StringBuilder analysis = new StringBuilder(); - - // 언어/파일 타입 분석 Map extensions = files.stream() .collect(Collectors.groupingBy( file -> { @@ -127,15 +96,14 @@ private String analyzeProjectStructure(List files) { Collectors.counting() )); - analysis.append("- 주요 언어/파일 타입: "); - analysis.append(extensions.entrySet().stream() - .sorted((a, b) -> Long.compare(b.getValue(), a.getValue())) - .limit(5) - .map(e -> e.getKey() + " (" + e.getValue() + "개)") - .collect(Collectors.joining(", "))); - analysis.append("\n"); + analysis.append("- 주요 언어/파일 타입: ") + .append(extensions.entrySet().stream() + .sorted((a, b) -> Long.compare(b.getValue(), a.getValue())) + .limit(5) + .map(e -> e.getKey() + " (" + e.getValue() + "개)") + .collect(Collectors.joining(", "))) + .append("\n"); - // 아키텍처 패턴 분석 boolean hasController = files.stream().anyMatch(f -> f.getPath().toLowerCase().contains("controller")); boolean hasService = files.stream().anyMatch(f -> f.getPath().toLowerCase().contains("service")); boolean hasRepository = files.stream().anyMatch(f -> f.getPath().toLowerCase().contains("repository")); @@ -150,34 +118,25 @@ private String analyzeProjectStructure(List files) { return analysis.toString(); } - /** - * Google Gemini API 호출 - */ private String callGemini(String prompt) { try { - // Gemini API 요청 형식 Map requestBody = Map.of( "contents", List.of( - Map.of( - "parts", List.of( - Map.of("text", prompt) - ) - ) + Map.of("parts", List.of(Map.of("text", prompt))) ) ); - // Gemini API 호출 - String apiUrl = String.format("/v1beta/models/gemini-pro:generateContent?key=%s", geminiApiKey); - Map response = webClient.post() - .uri(apiUrl) + .uri(uriBuilder -> uriBuilder + .path("/v1/models/{model}:generateContent") + .queryParam("key", geminiApiKey) + .build(geminiModel)) .header("Content-Type", "application/json") .bodyValue(requestBody) .retrieve() .bodyToMono(Map.class) .block(); - // 응답 파싱 List> candidates = (List>) response.get("candidates"); if (candidates != null && !candidates.isEmpty()) { Map content = (Map) candidates.get(0).get("content"); @@ -190,56 +149,26 @@ private String callGemini(String prompt) { throw new RuntimeException("Gemini API 응답 형식이 올바르지 않습니다."); } catch (Exception e) { - System.err.println("❌ Gemini API 호출 실패: " + e.getMessage()); + System.err.println("Gemini API 호출 실패: " + e.getMessage()); e.printStackTrace(); return generateMockReview(0); } } - /** - * Mock 리뷰 생성 (API 키가 없을 때) - */ + private String generateMockReview(int fileCount) { StringBuilder mock = new StringBuilder(); - - mock.append("# 🔍 AI 코드 리뷰 결과\n\n"); - - if (fileCount > 0) { - mock.append("✅ **").append(fileCount).append("개의 프로젝트 파일**을 분석하여 전체 구조를 파악했습니다.\n\n"); - } - - mock.append("## ✅ 긍정적인 부분\n\n"); - mock.append("- 코드가 깔끔하고 읽기 쉽습니다\n"); - mock.append("- 기본적인 구조는 잘 갖춰져 있습니다\n"); - + mock.append("# AI 코드 리뷰 결과\n\n"); if (fileCount > 0) { - mock.append("- 프로젝트의 전체적인 아키텍처 패턴을 잘 따르고 있습니다\n"); + mock.append("**").append(fileCount).append("개의 프로젝트 파일**을 분석했습니다.\n\n"); } - - mock.append("\n## ⚠️ 개선이 필요한 부분\n\n"); - mock.append("1. **에러 처리**: 예외 상황에 대한 처리가 부족합니다\n"); - mock.append("2. **변수명**: 더 명확한 이름을 사용하면 좋겠습니다\n"); - mock.append("3. **주석**: 복잡한 로직에 설명 주석을 추가해주세요\n"); - - mock.append("\n## 💡 구체적인 개선 제안\n\n"); - mock.append("```java\n"); - mock.append("// 개선 전\n"); - mock.append("int x = getData();\n\n"); - mock.append("// 개선 후\n"); - mock.append("int userCount = getUserCount();\n"); - mock.append("```\n\n"); - + mock.append("## 긍정적인 부분\n- 코드가 깔끔하고 읽기 쉽습니다\n- 기본 구조가 잘 갖춰져 있습니다\n"); + mock.append("## 개선이 필요한 부분\n1. 에러 처리 보강\n2. 변수명 명확화\n3. 주석 보강\n"); + mock.append("## 개선 제안 예시\n```java\nint userCount = getUserCount();\n```\n"); if (fileCount > 0) { - mock.append("## 🏗️ 프로젝트 구조 관점\n\n"); - mock.append("전체 프로젝트를 분석한 결과, 이 코드는 기존 패턴과 잘 맞습니다. "); - mock.append("다만 네이밍 컨벤션을 더 일관되게 유지하면 좋을 것 같습니다.\n\n"); + mock.append("## 프로젝트 구조 관점\n전체 프로젝트 패턴과 비교해 네이밍 컨벤션 일관성 유지 필요\n"); } - - mock.append("## 📝 총평\n\n"); - mock.append("전체적으로 좋은 코드입니다. 위 제안사항들을 반영하면 더욱 완성도 높은 코드가 될 것입니다!\n\n"); - mock.append("---\n"); - mock.append("*※ 이 리뷰는 Mock 데이터입니다. 실제 AI 리뷰를 받으려면 Gemini API 키를 `application.yml`에 설정해주세요.*\n"); - + mock.append("## 총평\n전체적으로 좋은 코드입니다.\n"); return mock.toString(); } -} \ No newline at end of file +} diff --git a/src/main/java/com/example/skillboost/githubReader/CodeReviewController.java b/src/main/java/com/example/skillboost/githubReader/CodeReviewController.java index cba6e93..ea1771b 100644 --- a/src/main/java/com/example/skillboost/githubReader/CodeReviewController.java +++ b/src/main/java/com/example/skillboost/githubReader/CodeReviewController.java @@ -28,7 +28,7 @@ public ResponseEntity reviewCode( ) { try { System.out.println("=".repeat(60)); - System.out.println("📥 코드 리뷰 요청 받음"); + System.out.println(" 코드 리뷰 요청 받음"); System.out.println(" - 코드 길이: " + code.length() + "자"); System.out.println(" - 코멘트: " + (comment != null ? comment : "(없음)")); System.out.println(" - Repo URL: " + (repoUrl != null ? repoUrl : "(없음)")); @@ -36,16 +36,16 @@ public ResponseEntity reviewCode( // 1. GitHub repo 코드 가져오기 (repo_url이 있을 때만) List repoContext = null; if (repoUrl != null && !repoUrl.isEmpty()) { - System.out.println("\n🔍 GitHub Repository 분석 시작..."); + System.out.println("\n GitHub Repository 분석 시작..."); long startTime = System.currentTimeMillis(); repoContext = githubService.fetchRepoCode(repoUrl, branch); long elapsed = System.currentTimeMillis() - startTime; - System.out.println("✅ " + repoContext.size() + "개 파일 로드 완료 (" + elapsed + "ms)"); + System.out.println(" " + repoContext.size() + "개 파일 로드 완료 (" + elapsed + "ms)"); // 파일 목록 출력 (처음 10개만) - System.out.println("\n📁 로드된 파일 샘플:"); + System.out.println("\n 로드된 파일 샘플:"); int count = 0; for (GithubFile file : repoContext) { if (count++ >= 10) break; @@ -57,7 +57,7 @@ public ResponseEntity reviewCode( } // 2. AI 리뷰 생성 - System.out.println("\n🤖 AI 리뷰 생성 중..."); + System.out.println("\n AI 리뷰 생성 중..."); String reviewResult = aiReviewService.reviewWithContext(code, comment, repoContext); // 3. 응답 생성 @@ -67,20 +67,20 @@ public ResponseEntity reviewCode( response.put("repo_url", repoUrl != null ? repoUrl : ""); response.put("success", true); - System.out.println("✅ 리뷰 완료! (리뷰 길이: " + reviewResult.length() + "자)"); + System.out.println(" 리뷰 완료! (리뷰 길이: " + reviewResult.length() + "자)"); System.out.println("=".repeat(60) + "\n"); return ResponseEntity.ok(response); } catch (IllegalArgumentException e) { - System.err.println("❌ 잘못된 요청: " + e.getMessage()); + System.err.println(" 잘못된 요청: " + e.getMessage()); Map errorResponse = new HashMap<>(); errorResponse.put("success", false); errorResponse.put("error", e.getMessage()); return ResponseEntity.badRequest().body(errorResponse); } catch (Exception e) { - System.err.println("❌ 서버 오류: " + e.getMessage()); + System.err.println(" 서버 오류: " + e.getMessage()); e.printStackTrace(); Map errorResponse = new HashMap<>(); errorResponse.put("success", false); From bb5a799357a876c8d2c142f981c2d11ed77de27f Mon Sep 17 00:00:00 2001 From: Junseung-Ock Date: Sun, 30 Nov 2025 20:50:13 +0900 Subject: [PATCH 04/15] =?UTF-8?q?develop=20=ED=95=A9=EB=B3=91=20=ED=9B=84?= =?UTF-8?q?=20=EC=98=A4=EB=A5=98=20=ED=95=B4=EA=B2=B0=20=EB=B0=8F=20?= =?UTF-8?q?=EA=B5=AC=EC=A1=B0=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/config/SecurityConfig.java | 22 +- .../GithubFile.java | 2 +- .../controller}/CodeReviewController.java | 9 +- .../controller}/GithubController.java | 4 +- .../service}/GithubService.java | 3 +- .../controller/CodeReviewController.java | 26 -- .../codereview/dto/CodeReviewRequest.java | 59 ----- .../codereview/dto/CodeReviewResponse.java | 23 -- .../codereview/github/GithubController.java | 4 - .../codereview/github/GithubFile.java | 18 -- .../codereview/github/GithubService.java | 114 -------- .../llm/GeminiCodeReviewClient.java | 245 ------------------ .../codereview/service/CodeReviewService.java | 9 - .../service/CodeReviewServiceImpl.java | 44 ---- src/main/resources/application-local.yml | 2 +- src/main/resources/application.yml | 26 +- 16 files changed, 36 insertions(+), 574 deletions(-) rename src/main/java/com/example/skillboost/{githubReader => codeReview}/GithubFile.java (86%) rename src/main/java/com/example/skillboost/{githubReader => codeReview/controller}/CodeReviewController.java (90%) rename src/main/java/com/example/skillboost/{githubReader => codeReview/controller}/GithubController.java (76%) rename src/main/java/com/example/skillboost/{githubReader => codeReview/service}/GithubService.java (96%) delete mode 100644 src/main/java/com/example/skillboost/codereview/controller/CodeReviewController.java delete mode 100644 src/main/java/com/example/skillboost/codereview/dto/CodeReviewRequest.java delete mode 100644 src/main/java/com/example/skillboost/codereview/dto/CodeReviewResponse.java delete mode 100644 src/main/java/com/example/skillboost/codereview/github/GithubController.java delete mode 100644 src/main/java/com/example/skillboost/codereview/github/GithubFile.java delete mode 100644 src/main/java/com/example/skillboost/codereview/github/GithubService.java delete mode 100644 src/main/java/com/example/skillboost/codereview/llm/GeminiCodeReviewClient.java delete mode 100644 src/main/java/com/example/skillboost/codereview/service/CodeReviewService.java delete mode 100644 src/main/java/com/example/skillboost/codereview/service/CodeReviewServiceImpl.java diff --git a/src/main/java/com/example/skillboost/auth/config/SecurityConfig.java b/src/main/java/com/example/skillboost/auth/config/SecurityConfig.java index f5eff35..f077265 100644 --- a/src/main/java/com/example/skillboost/auth/config/SecurityConfig.java +++ b/src/main/java/com/example/skillboost/auth/config/SecurityConfig.java @@ -12,6 +12,11 @@ import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +import java.util.Arrays; @RequiredArgsConstructor @Configuration @@ -25,10 +30,12 @@ public class SecurityConfig { @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http - // 요청 권한 설정 + .cors(cors -> cors.configurationSource(corsConfigurationSource())) .authorizeHttpRequests(auth -> auth .requestMatchers( + "/", "/api/auth/**", + "/api/review/**", "/oauth2/**", "/login/oauth2/**", "/swagger-ui/**", @@ -57,4 +64,17 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { return http.build(); } + + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration configuration = new CorsConfiguration(); + configuration.setAllowedOrigins(Arrays.asList("http://localhost:3000")); + configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS")); + configuration.setAllowedHeaders(Arrays.asList("*")); + configuration.setAllowCredentials(true); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", configuration); + return source; + } } \ No newline at end of file diff --git a/src/main/java/com/example/skillboost/githubReader/GithubFile.java b/src/main/java/com/example/skillboost/codeReview/GithubFile.java similarity index 86% rename from src/main/java/com/example/skillboost/githubReader/GithubFile.java rename to src/main/java/com/example/skillboost/codeReview/GithubFile.java index a909b9c..37c8b4b 100644 --- a/src/main/java/com/example/skillboost/githubReader/GithubFile.java +++ b/src/main/java/com/example/skillboost/codeReview/GithubFile.java @@ -1,4 +1,4 @@ -package com.example.skillboost.githubReader; +package com.example.skillboost.codeReview; import lombok.Getter; import lombok.NoArgsConstructor; diff --git a/src/main/java/com/example/skillboost/githubReader/CodeReviewController.java b/src/main/java/com/example/skillboost/codeReview/controller/CodeReviewController.java similarity index 90% rename from src/main/java/com/example/skillboost/githubReader/CodeReviewController.java rename to src/main/java/com/example/skillboost/codeReview/controller/CodeReviewController.java index ea1771b..846553c 100644 --- a/src/main/java/com/example/skillboost/githubReader/CodeReviewController.java +++ b/src/main/java/com/example/skillboost/codeReview/controller/CodeReviewController.java @@ -1,5 +1,8 @@ -package com.example.skillboost.githubReader; +package com.example.skillboost.codeReview.controller; +import com.example.skillboost.codeReview.service.CodeReviewService; +import com.example.skillboost.codeReview.GithubFile; +import com.example.skillboost.codeReview.service.GithubService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; @@ -17,7 +20,7 @@ public class CodeReviewController { private GithubService githubService; @Autowired - private AIReviewService aiReviewService; + private CodeReviewService codeReviewService; @PostMapping public ResponseEntity reviewCode( @@ -58,7 +61,7 @@ public ResponseEntity reviewCode( // 2. AI 리뷰 생성 System.out.println("\n AI 리뷰 생성 중..."); - String reviewResult = aiReviewService.reviewWithContext(code, comment, repoContext); + String reviewResult = codeReviewService.reviewWithContext(code, comment, repoContext); // 3. 응답 생성 Map response = new HashMap<>(); diff --git a/src/main/java/com/example/skillboost/githubReader/GithubController.java b/src/main/java/com/example/skillboost/codeReview/controller/GithubController.java similarity index 76% rename from src/main/java/com/example/skillboost/githubReader/GithubController.java rename to src/main/java/com/example/skillboost/codeReview/controller/GithubController.java index 8cfae5c..bd7c039 100644 --- a/src/main/java/com/example/skillboost/githubReader/GithubController.java +++ b/src/main/java/com/example/skillboost/codeReview/controller/GithubController.java @@ -1,5 +1,7 @@ -package com.example.skillboost.githubReader; +package com.example.skillboost.codeReview.controller; +import com.example.skillboost.codeReview.GithubFile; +import com.example.skillboost.codeReview.service.GithubService; import org.springframework.web.bind.annotation.*; import java.util.List; diff --git a/src/main/java/com/example/skillboost/githubReader/GithubService.java b/src/main/java/com/example/skillboost/codeReview/service/GithubService.java similarity index 96% rename from src/main/java/com/example/skillboost/githubReader/GithubService.java rename to src/main/java/com/example/skillboost/codeReview/service/GithubService.java index 555f552..7ff55df 100644 --- a/src/main/java/com/example/skillboost/githubReader/GithubService.java +++ b/src/main/java/com/example/skillboost/codeReview/service/GithubService.java @@ -1,5 +1,6 @@ -package com.example.skillboost.githubReader; +package com.example.skillboost.codeReview.service; +import com.example.skillboost.codeReview.GithubFile; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.springframework.web.reactive.function.client.WebClient; diff --git a/src/main/java/com/example/skillboost/codereview/controller/CodeReviewController.java b/src/main/java/com/example/skillboost/codereview/controller/CodeReviewController.java deleted file mode 100644 index 0c01fe6..0000000 --- a/src/main/java/com/example/skillboost/codereview/controller/CodeReviewController.java +++ /dev/null @@ -1,26 +0,0 @@ -// src/main/java/com/example/skillboost/codereview/controller/CodeReviewController.java -package com.example.skillboost.codereview.controller; - -import com.example.skillboost.codereview.dto.CodeReviewRequest; -import com.example.skillboost.codereview.dto.CodeReviewResponse; -import com.example.skillboost.codereview.service.CodeReviewService; -import lombok.RequiredArgsConstructor; -import org.springframework.http.MediaType; -import org.springframework.web.bind.annotation.*; - -@CrossOrigin(origins = "http://localhost:3000") -@RestController -@RequestMapping("/api/review") -@RequiredArgsConstructor -public class CodeReviewController { - - private final CodeReviewService codeReviewService; - - @PostMapping( - consumes = MediaType.APPLICATION_JSON_VALUE, - produces = MediaType.APPLICATION_JSON_VALUE - ) - public CodeReviewResponse review(@RequestBody CodeReviewRequest request) { - return codeReviewService.review(request); - } -} diff --git a/src/main/java/com/example/skillboost/codereview/dto/CodeReviewRequest.java b/src/main/java/com/example/skillboost/codereview/dto/CodeReviewRequest.java deleted file mode 100644 index e413c1d..0000000 --- a/src/main/java/com/example/skillboost/codereview/dto/CodeReviewRequest.java +++ /dev/null @@ -1,59 +0,0 @@ -// src/main/java/com/example/skillboost/codereview/dto/CodeReviewRequest.java -package com.example.skillboost.codereview.dto; - -public class CodeReviewRequest { - - private String code; - private String comment; - - // 🔹 레포지터리 기반 리뷰용 필드 - private String repoUrl; // 예: https://github.com/Junseung-Ock/java-calculator-7 - private String branch; // 기본값: main - - public CodeReviewRequest() { - } - - public CodeReviewRequest(String code, String comment) { - this.code = code; - this.comment = comment; - } - - public CodeReviewRequest(String code, String comment, String repoUrl, String branch) { - this.code = code; - this.comment = comment; - this.repoUrl = repoUrl; - this.branch = branch; - } - - public String getCode() { - return code; - } - - public void setCode(String code) { - this.code = code; - } - - public String getComment() { - return comment; - } - - public void setComment(String comment) { - this.comment = comment; - } - - public String getRepoUrl() { - return repoUrl; - } - - public void setRepoUrl(String repoUrl) { - this.repoUrl = repoUrl; - } - - public String getBranch() { - return branch; - } - - public void setBranch(String branch) { - this.branch = branch; - } -} diff --git a/src/main/java/com/example/skillboost/codereview/dto/CodeReviewResponse.java b/src/main/java/com/example/skillboost/codereview/dto/CodeReviewResponse.java deleted file mode 100644 index 6fb48fd..0000000 --- a/src/main/java/com/example/skillboost/codereview/dto/CodeReviewResponse.java +++ /dev/null @@ -1,23 +0,0 @@ -package com.example.skillboost.codereview.dto; - -import java.util.ArrayList; -import java.util.List; - -public class CodeReviewResponse { - - private String review; - private List questions = new ArrayList<>(); - - public CodeReviewResponse() {} - - public CodeReviewResponse(String review, List questions) { - this.review = review; - this.questions = questions; - } - - public String getReview() { return review; } - public void setReview(String review) { this.review = review; } - - public List getQuestions() { return questions; } - public void setQuestions(List questions) { this.questions = questions; } -} diff --git a/src/main/java/com/example/skillboost/codereview/github/GithubController.java b/src/main/java/com/example/skillboost/codereview/github/GithubController.java deleted file mode 100644 index 0ccb95e..0000000 --- a/src/main/java/com/example/skillboost/codereview/github/GithubController.java +++ /dev/null @@ -1,4 +0,0 @@ -package com.example.skillboost.codereview.github; - -public class GithubController { -} diff --git a/src/main/java/com/example/skillboost/codereview/github/GithubFile.java b/src/main/java/com/example/skillboost/codereview/github/GithubFile.java deleted file mode 100644 index fa2e5b7..0000000 --- a/src/main/java/com/example/skillboost/codereview/github/GithubFile.java +++ /dev/null @@ -1,18 +0,0 @@ -// src/main/java/com/example/skillboost/codereview/github/GithubFile.java -package com.example.skillboost.codereview.github; - -import lombok.Getter; -import lombok.NoArgsConstructor; - -@Getter -@NoArgsConstructor -public class GithubFile { - - private String path; - private String content; - - public GithubFile(String path, String content) { - this.path = path; - this.content = content; - } -} diff --git a/src/main/java/com/example/skillboost/codereview/github/GithubService.java b/src/main/java/com/example/skillboost/codereview/github/GithubService.java deleted file mode 100644 index 0b81c55..0000000 --- a/src/main/java/com/example/skillboost/codereview/github/GithubService.java +++ /dev/null @@ -1,114 +0,0 @@ -// src/main/java/com/example/skillboost/codereview/github/GithubService.java -package com.example.skillboost.codereview.github; - -import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.http.*; -import org.springframework.stereotype.Service; -import org.springframework.web.client.RestTemplate; - -import java.util.*; - -@Slf4j -@Service -public class GithubService { - - private final RestTemplate restTemplate = new RestTemplate(); - - @Value("${github.token:}") - private String githubToken; - - private static final List TEXT_EXTENSIONS = List.of( - ".java", ".kt", ".xml", ".json", ".yml", ".yaml", - ".md", ".gradle", ".gitignore", ".txt", ".properties", ".csv" - ); - - public List fetchRepoCode(String repoUrl, String branch) { - if (repoUrl == null || !repoUrl.contains("github.com/")) { - throw new IllegalArgumentException("잘못된 GitHub URL 형식입니다."); - } - - try { - String[] parts = repoUrl.replace("https://github.com/", "") - .replace("http://github.com/", "") - .split("/"); - if (parts.length < 2) throw new IllegalArgumentException("잘못된 GitHub URL 형식입니다."); - - String owner = parts[0]; - String repo = parts[1]; - - String treeUrl = String.format( - "https://api.github.com/repos/%s/%s/git/trees/%s?recursive=1", - owner, repo, branch - ); - - log.info("[GithubService] tree 호출: {}", treeUrl); - - HttpHeaders headers = new HttpHeaders(); - if (githubToken != null && !githubToken.isEmpty()) { - headers.setBearerAuth(githubToken); - } - HttpEntity entity = new HttpEntity<>(headers); - - ResponseEntity resp = restTemplate.exchange( - treeUrl, HttpMethod.GET, entity, Map.class - ); - - Map body = resp.getBody(); - if (body == null || !body.containsKey("tree")) { - return Collections.emptyList(); - } - - List> tree = (List>) body.get("tree"); - List files = new ArrayList<>(); - - for (Map file : tree) { - if (!"blob".equals(file.get("type"))) continue; - - String path = (String) file.get("path"); - if (!isTextFile(path)) continue; - - String rawUrl = String.format( - "https://raw.githubusercontent.com/%s/%s/%s/%s", - owner, repo, branch, path - ); - - String content = fetchFileContent(rawUrl); - files.add(new GithubFile(path, content)); - } - - log.info("[GithubService] {} 개 파일 로드 완료", files.size()); - return files; - - } catch (Exception e) { - log.error("[GithubService] 레포지터리 로드 실패: {}", e.getMessage()); - return Collections.emptyList(); - } - } - - private String fetchFileContent(String rawUrl) { - try { - HttpHeaders headers = new HttpHeaders(); - if (githubToken != null && !githubToken.isEmpty()) { - headers.setBearerAuth(githubToken); - } - HttpEntity entity = new HttpEntity<>(headers); - - ResponseEntity resp = restTemplate.exchange( - rawUrl, HttpMethod.GET, entity, String.class - ); - return resp.getBody() != null ? resp.getBody() : ""; - } catch (Exception e) { - log.warn("[GithubService] 파일 읽기 실패: {} ({})", rawUrl, e.getMessage()); - return ""; - } - } - - private boolean isTextFile(String path) { - String lower = path.toLowerCase(); - for (String ext : TEXT_EXTENSIONS) { - if (lower.endsWith(ext)) return true; - } - return false; - } -} diff --git a/src/main/java/com/example/skillboost/codereview/llm/GeminiCodeReviewClient.java b/src/main/java/com/example/skillboost/codereview/llm/GeminiCodeReviewClient.java deleted file mode 100644 index a3e8145..0000000 --- a/src/main/java/com/example/skillboost/codereview/llm/GeminiCodeReviewClient.java +++ /dev/null @@ -1,245 +0,0 @@ -// src/main/java/com/example/skillboost/codereview/llm/GeminiCodeReviewClient.java -package com.example.skillboost.codereview.llm; - -import com.example.skillboost.codereview.dto.CodeReviewResponse; -import com.example.skillboost.codereview.github.GithubFile; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.http.*; -import org.springframework.stereotype.Component; -import org.springframework.web.client.RestTemplate; - -import java.util.*; - -@Component -public class GeminiCodeReviewClient { - - private final RestTemplate restTemplate; - private final ObjectMapper objectMapper; - private final String apiKey; - private final String model; - - public GeminiCodeReviewClient( - @Value("${gemini.api.key}") String apiKey, - @Value("${gemini.model}") String model - ) { - this.apiKey = apiKey; - this.model = model; - this.restTemplate = new RestTemplate(); - this.objectMapper = new ObjectMapper(); - } - - // 🔹 코드만 사용하는 기존 모드 (호환용) - public CodeReviewResponse requestReview(String code, String comment) { - return requestReview(code, comment, null); - } - - // 🔹 레포지터리 컨텍스트까지 함께 넘기는 확장 버전 - public CodeReviewResponse requestReview(String code, String comment, List repoContext) { - try { - String url = "https://generativelanguage.googleapis.com/v1beta/models/" - + model + ":generateContent?key=" + apiKey; - - String prompt = buildPrompt(code, comment, repoContext); - - Map textPart = new HashMap<>(); - textPart.put("text", prompt); - - Map content = new HashMap<>(); - content.put("parts", Collections.singletonList(textPart)); - - Map requestBody = new HashMap<>(); - requestBody.put("contents", Collections.singletonList(content)); - - HttpHeaders headers = new HttpHeaders(); - headers.setContentType(MediaType.APPLICATION_JSON); - - HttpEntity> entity = new HttpEntity<>(requestBody, headers); - - ResponseEntity response = restTemplate.postForEntity(url, entity, String.class); - String body = response.getBody(); - - if (!response.getStatusCode().is2xxSuccessful() || body == null) { - CodeReviewResponse fallback = new CodeReviewResponse(); - fallback.setReview("AI 코드 리뷰 요청에 실패했습니다. 상태코드: " + response.getStatusCode()); - fallback.setQuestions(Collections.emptyList()); - return fallback; - } - - return parseGeminiResponse(body); - - } catch (Exception e) { - CodeReviewResponse fallback = new CodeReviewResponse(); - fallback.setReview("AI 코드 리뷰 중 오류가 발생했습니다: " + e.getMessage()); - fallback.setQuestions(Collections.emptyList()); - return fallback; - } - } - - /** - * 코드 + (선택) GitHub 레포지터리 컨텍스트(README, 파일구조, 일부 코드)를 포함한 프롬프트 - */ - private String buildPrompt(String code, String comment, List repoContext) { - String userRequirement = (comment != null && !comment.trim().isEmpty()) - ? comment.trim() - : "특별한 추가 요구사항은 없습니다. 핵심만 간결하게 리뷰해줘."; - - StringBuilder sb = new StringBuilder(); - - // 1) 레포지터리 전체 맥락 - if (repoContext != null && !repoContext.isEmpty()) { - sb.append("이 코드는 GitHub 레포지터리 전체 맥락 안에 있는 일부 코드입니다.\n") - .append("레포지터리의 README와 파일 구조, 주요 코드 파일을 참고해서 '요구사항을 만족하는지'와 '아키텍처 적절성'까지 함께 리뷰해 주세요.\n\n"); - - // README 찾기 - GithubFile readme = repoContext.stream() - .filter(f -> f.getPath().equalsIgnoreCase("README.md") - || f.getPath().toLowerCase().endsWith("/readme.md")) - .findFirst() - .orElse(null); - - if (readme != null && readme.getContent() != null) { - String readmeContent = readme.getContent(); - if (readmeContent.length() > 2000) { - readmeContent = readmeContent.substring(0, 2000) + "\n... (생략)"; - } - - sb.append("=== README (요구사항 기준) ===\n"); - sb.append(readmeContent).append("\n\n"); - } - - // 파일 목록 (최대 40개) - sb.append("=== 프로젝트 파일 구조 (일부) ===\n"); - repoContext.stream() - .limit(40) - .forEach(f -> sb.append("- ").append(f.getPath()).append("\n")); - if (repoContext.size() > 40) { - sb.append("... 외 ").append(repoContext.size() - 40).append("개 파일 더 있음\n"); - } - sb.append("\n"); - - // 주요 코드 샘플 (java 위주 최대 5개) - sb.append("=== 주요 코드 샘플 (일부) ===\n"); - repoContext.stream() - .filter(f -> f.getPath().endsWith(".java")) - .limit(5) - .forEach(f -> { - sb.append("#### ").append(f.getPath()).append("\n"); - String c = f.getContent(); - if (c != null && c.length() > 1200) { - c = c.substring(0, 1200) + "\n... (생략)"; - } - sb.append(c == null ? "" : c).append("\n\n"); - }); - - sb.append("위 정보를 참고하여, 아래 사용자가 제공한 코드가 이 레포지터리/README 요구사항과 잘 맞는지 검토해 주세요.\n\n"); - } - - // 2) 여기부터는 JSON 형식 / 출력 규칙 안내 (기존 로직 유지) - sb.append(""" - 너는 숙련된 시니어 백엔드 개발자이자 코드 리뷰어야. - 아래 코드를 분석해서 반드시 **JSON 형식 하나만** 출력해. - - ⚠️ 모든 출력은 반드시 한국어로 작성해. - 마크다운 금지(**, ```, # 등) - JSON 외 텍스트 출력 금지. - - 🔒 출력 형식 규칙 - - review 항목은: - - 모든 줄을 '□ ' 로 시작 - - 한 줄은 핵심 한 문장 - - 항목 사이에는 빈 줄(\\n\\n) 있어야 함 - - - questions 항목은: - - 배열 형태 - - 각 질문은 한국어 한 문장 - - 번호(1. 2.)는 넣지 말 것 - - JSON 예시: - - { - "review": "□ 핵심 피드백입니다.\\n\\n□ 또 다른 핵심 피드백입니다.", - "questions": [ - "이 코드에서 개선할 수 있는 부분은 무엇인가요?", - "예외 처리를 추가한다면 어떤 케이스를 고려하겠습니까?" - ] - } - - 사용자가 요청한 요구사항: - """).append("\n") - .append(userRequirement).append("\n\n") - .append("리뷰할 코드:\n") - .append(code); - - return sb.toString(); - } - - private CodeReviewResponse parseGeminiResponse(String body) throws Exception { - JsonNode root = objectMapper.readTree(body); - - JsonNode candidates = root.path("candidates"); - if (!candidates.isArray() || candidates.isEmpty()) { - CodeReviewResponse resp = new CodeReviewResponse(); - resp.setReview("AI 응답이 비어 있습니다."); - resp.setQuestions(Collections.emptyList()); - return resp; - } - - JsonNode textNode = candidates.get(0) - .path("content") - .path("parts") - .get(0) - .path("text"); - - String rawText = textNode.asText(""); - if (rawText.isEmpty()) { - CodeReviewResponse resp = new CodeReviewResponse(); - resp.setReview("AI 응답 텍스트를 찾지 못했습니다."); - resp.setQuestions(Collections.emptyList()); - return resp; - } - - String cleaned = stripCodeFence(rawText); - - try { - JsonNode json = objectMapper.readTree(cleaned); - - String review = json.path("review").asText(""); - if (review.isEmpty()) review = cleaned; - - List questions = new ArrayList<>(); - JsonNode qNode = json.path("questions"); - if (qNode.isArray()) { - for (JsonNode q : qNode) questions.add(q.asText()); - } - - CodeReviewResponse resp = new CodeReviewResponse(); - resp.setReview(review); - resp.setQuestions(questions); - return resp; - - } catch (Exception e) { - CodeReviewResponse resp = new CodeReviewResponse(); - resp.setReview(cleaned); - resp.setQuestions(Collections.emptyList()); - return resp; - } - } - - private String stripCodeFence(String text) { - if (text == null) return ""; - String trimmed = text.trim(); - - if (!trimmed.startsWith("```")) return trimmed; - - int firstNewline = trimmed.indexOf('\n'); - int lastFence = trimmed.lastIndexOf("```"); - - if (firstNewline != -1 && lastFence != -1 && lastFence > firstNewline) { - return trimmed.substring(firstNewline + 1, lastFence).trim(); - } - - return trimmed; - } -} diff --git a/src/main/java/com/example/skillboost/codereview/service/CodeReviewService.java b/src/main/java/com/example/skillboost/codereview/service/CodeReviewService.java deleted file mode 100644 index c8eb152..0000000 --- a/src/main/java/com/example/skillboost/codereview/service/CodeReviewService.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.example.skillboost.codereview.service; - -import com.example.skillboost.codereview.dto.CodeReviewRequest; -import com.example.skillboost.codereview.dto.CodeReviewResponse; - -public interface CodeReviewService { - - CodeReviewResponse review(CodeReviewRequest request); -} diff --git a/src/main/java/com/example/skillboost/codereview/service/CodeReviewServiceImpl.java b/src/main/java/com/example/skillboost/codereview/service/CodeReviewServiceImpl.java deleted file mode 100644 index 1edc828..0000000 --- a/src/main/java/com/example/skillboost/codereview/service/CodeReviewServiceImpl.java +++ /dev/null @@ -1,44 +0,0 @@ -// src/main/java/com/example/skillboost/codereview/service/CodeReviewServiceImpl.java -package com.example.skillboost.codereview.service; - -import com.example.skillboost.codereview.dto.CodeReviewRequest; -import com.example.skillboost.codereview.dto.CodeReviewResponse; -import com.example.skillboost.codereview.github.GithubFile; -import com.example.skillboost.codereview.github.GithubService; -import com.example.skillboost.codereview.llm.GeminiCodeReviewClient; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; -import org.springframework.util.StringUtils; - -import java.util.Collections; -import java.util.List; - -@Service -@RequiredArgsConstructor -public class CodeReviewServiceImpl implements CodeReviewService { - - private final GeminiCodeReviewClient geminiCodeReviewClient; - private final GithubService githubService; - - @Override - public CodeReviewResponse review(CodeReviewRequest request) { - if (request == null || !StringUtils.hasText(request.getCode())) { - throw new IllegalArgumentException("코드가 비어 있습니다."); - } - - String code = request.getCode(); - String comment = request.getComment(); - String repoUrl = request.getRepoUrl(); - String branch = StringUtils.hasText(request.getBranch()) ? request.getBranch() : "main"; - - List repoContext = Collections.emptyList(); - - // 🔹 repoUrl 이 있으면 GitHub 레포 전체 읽어오기 - if (StringUtils.hasText(repoUrl)) { - repoContext = githubService.fetchRepoCode(repoUrl, branch); - } - - // 🔹 코드 + (있다면) 레포 컨텍스트 기반으로 Gemini에 리뷰 요청 - return geminiCodeReviewClient.requestReview(code, comment, repoContext); - } -} diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index ac03546..b1970b1 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -29,7 +29,7 @@ spring: user-name-attribute: id jwt: - secret-key: TXlTdXBlclNlY3JldEtleUZvclNraWxsQm9vc3RQcm9qZWN0MjAyNUNoYWxsZW5nZSE= + secret-key: expiration-ms: 86400000 springdoc: diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 35e1022..e476687 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,27 +1,5 @@ spring: - config: - import: optional:classpath:application-secret.yml application: name: skill-boost - - datasource: - url: jdbc:mysql://localhost:3306/mydatabase?serverTimezone=Asia/Seoul&characterEncoding=UTF-8 - username: myuser - password: secret - driver-class-name: com.mysql.cj.jdbc.Driver - - jpa: - hibernate: - ddl-auto: update - properties: - hibernate: - format_sql: true - show-sql: true - -server: - port: 8080 - -gemini: - api: - key: # - model: gemini-2.5-flash + profiles: + active: local From e99252098743802f66c9595ec0eae1459b835179 Mon Sep 17 00:00:00 2001 From: Junseung-Ock Date: Sun, 30 Nov 2025 20:52:25 +0900 Subject: [PATCH 05/15] =?UTF-8?q?CodeReviewService=20=EC=9C=84=EC=B9=98=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/CodeReviewService.java} | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) rename src/main/java/com/example/skillboost/{githubReader/AIReviewService.java => codeReview/service/CodeReviewService.java} (97%) diff --git a/src/main/java/com/example/skillboost/githubReader/AIReviewService.java b/src/main/java/com/example/skillboost/codeReview/service/CodeReviewService.java similarity index 97% rename from src/main/java/com/example/skillboost/githubReader/AIReviewService.java rename to src/main/java/com/example/skillboost/codeReview/service/CodeReviewService.java index ec1ed7a..ce159ab 100644 --- a/src/main/java/com/example/skillboost/githubReader/AIReviewService.java +++ b/src/main/java/com/example/skillboost/codeReview/service/CodeReviewService.java @@ -1,5 +1,6 @@ -package com.example.skillboost.githubReader; +package com.example.skillboost.codeReview.service; +import com.example.skillboost.codeReview.GithubFile; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.springframework.web.reactive.function.client.WebClient; @@ -9,7 +10,7 @@ import java.util.stream.Collectors; @Service -public class AIReviewService { +public class CodeReviewService { private final WebClient webClient; @@ -19,7 +20,7 @@ public class AIReviewService { @Value("${gemini.model:gemini-2.5-flash}") private String geminiModel; - public AIReviewService(WebClient.Builder webClientBuilder) { + public CodeReviewService(WebClient.Builder webClientBuilder) { this.webClient = webClientBuilder .baseUrl("https://generativelanguage.googleapis.com") .build(); From 1ca771003882b786e541cc444e9dd03aeea38e41 Mon Sep 17 00:00:00 2001 From: Junseung-Ock Date: Mon, 1 Dec 2025 00:02:39 +0900 Subject: [PATCH 06/15] =?UTF-8?q?yml=20=ED=8C=8C=EC=9D=BC=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/application-local.yml | 2 +- src/main/resources/application-prod.yml | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index b1970b1..e99892f 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -29,7 +29,7 @@ spring: user-name-attribute: id jwt: - secret-key: + secret-key: ${JWT_SECRET_KEY} expiration-ms: 86400000 springdoc: diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index f7044b2..f5d0ee8 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -32,3 +32,9 @@ spring: jwt: secret-key: ${JWT_SECRET_KEY} expiration-ms: 86400000 + +gemini: + api: + key: ${GEMINI_API_KEY} + model: gemini-2.5-flash + From 8bcf357ab314dd0d60e8e0e970bd5ff7b3c4a0e8 Mon Sep 17 00:00:00 2001 From: ChoiWonkeun Date: Mon, 1 Dec 2025 00:59:07 +0900 Subject: [PATCH 07/15] Backend: implement GitHub OAuth2 login integration --- build.gradle | 4 +- .../auth/handler/OAuth2SuccessHandler.java | 35 ++- .../controller/InterviewController.java | 50 +++ .../interview/dto/InterviewAnswerDto.java | 28 ++ .../dto/InterviewFeedbackRequest.java | 21 ++ .../dto/InterviewFeedbackResponse.java | 22 ++ .../interview/dto/InterviewQuestionDto.java | 22 ++ .../interview/dto/InterviewStartRequest.java | 14 + .../interview/dto/InterviewStartResponse.java | 24 ++ .../interview/dto/QuestionFeedbackDto.java | 17 ++ .../interview/dto/QuestionType.java | 6 + .../interview/model/InterviewSession.java | 32 ++ .../interview/service/GeminiClient.java | 113 +++++++ .../service/InterviewFeedbackService.java | 152 +++++++++ .../interview/service/InterviewService.java | 288 ++++++++++++++++++ .../service/SpeechToTextService.java | 63 ++++ src/main/resources/application-local.yml | 12 +- src/main/resources/application-prod.yml | 5 + src/main/resources/application.yml | 26 +- 19 files changed, 898 insertions(+), 36 deletions(-) create mode 100644 src/main/java/com/example/skillboost/interview/controller/InterviewController.java create mode 100644 src/main/java/com/example/skillboost/interview/dto/InterviewAnswerDto.java create mode 100644 src/main/java/com/example/skillboost/interview/dto/InterviewFeedbackRequest.java create mode 100644 src/main/java/com/example/skillboost/interview/dto/InterviewFeedbackResponse.java create mode 100644 src/main/java/com/example/skillboost/interview/dto/InterviewQuestionDto.java create mode 100644 src/main/java/com/example/skillboost/interview/dto/InterviewStartRequest.java create mode 100644 src/main/java/com/example/skillboost/interview/dto/InterviewStartResponse.java create mode 100644 src/main/java/com/example/skillboost/interview/dto/QuestionFeedbackDto.java create mode 100644 src/main/java/com/example/skillboost/interview/dto/QuestionType.java create mode 100644 src/main/java/com/example/skillboost/interview/model/InterviewSession.java create mode 100644 src/main/java/com/example/skillboost/interview/service/GeminiClient.java create mode 100644 src/main/java/com/example/skillboost/interview/service/InterviewFeedbackService.java create mode 100644 src/main/java/com/example/skillboost/interview/service/InterviewService.java create mode 100644 src/main/java/com/example/skillboost/interview/service/SpeechToTextService.java diff --git a/build.gradle b/build.gradle index 8484c3d..cca30a5 100644 --- a/build.gradle +++ b/build.gradle @@ -44,7 +44,9 @@ dependencies { runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5' runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5' implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.3.0' - + implementation group: 'com.alphacephei', name: 'vosk', version: '0.3.45' + implementation 'org.springframework.boot:spring-boot-starter-webflux' + implementation 'com.alphacephei:vosk:0.3.38' } tasks.named('test') { diff --git a/src/main/java/com/example/skillboost/auth/handler/OAuth2SuccessHandler.java b/src/main/java/com/example/skillboost/auth/handler/OAuth2SuccessHandler.java index 0aeac80..c67278d 100644 --- a/src/main/java/com/example/skillboost/auth/handler/OAuth2SuccessHandler.java +++ b/src/main/java/com/example/skillboost/auth/handler/OAuth2SuccessHandler.java @@ -15,6 +15,8 @@ import org.springframework.stereotype.Component; import java.io.IOException; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; import java.util.HashMap; import java.util.Map; @@ -59,18 +61,33 @@ public void onAuthenticationSuccess(HttpServletRequest request, log.info("JWT 토큰 생성 및 Redis 저장 완료: {}", user.getEmail()); + String frontendBaseUrl = "http://localhost:3000"; // 배포 시에는 Vercel 주소로 변경 + + // 프론트로 리다이렉트 (Vite dev 서버 기준) + String redirectUrl = frontendBaseUrl + + "/oauth/github/callback" + + "?accessToken=" + URLEncoder.encode(accessToken, StandardCharsets.UTF_8) + + "&refreshToken=" + URLEncoder.encode(refreshToken, StandardCharsets.UTF_8) + + "&email=" + URLEncoder.encode(user.getEmail(), StandardCharsets.UTF_8) + + "&username=" + URLEncoder.encode( + user.getUsername() != null ? user.getUsername() : "", + StandardCharsets.UTF_8 + ); + + log.info("프론트로 리다이렉트: {}", redirectUrl); + response.sendRedirect(redirectUrl); // JSON 응답 생성 - Map responseData = new HashMap<>(); - responseData.put("success", true); - responseData.put("accessToken", accessToken); - responseData.put("refreshToken", refreshToken); // 프론트엔드에서 저장해야 함 - responseData.put("email", user.getEmail()); - responseData.put("username", user.getUsername()); + // Map responseData = new HashMap<>(); + // responseData.put("success", true); + // responseData.put("accessToken", accessToken); + // responseData.put("refreshToken", refreshToken); // 프론트엔드에서 저장해야 함 + // responseData.put("email", user.getEmail()); + // responseData.put("username", user.getUsername()); // 클라이언트에 JWT 응답 - response.setContentType("application/json;charset=UTF-8"); - response.setStatus(HttpServletResponse.SC_OK); - response.getWriter().write(objectMapper.writeValueAsString(responseData)); + // response.setContentType("application/json;charset=UTF-8"); + // response.setStatus(HttpServletResponse.SC_OK); + // response.getWriter().write(objectMapper.writeValueAsString(responseData)); // 실제 서비스 배포 시, 사용자를 다시 웹사이트 메인 화면으로 돌려보내기 위해 사용 // response.sendRedirect("http://localhost:3000/oauth2/redirect?accessToken=" + accessToken + "&refreshToken=" + refreshToken); 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..dd84f67 --- /dev/null +++ b/src/main/java/com/example/skillboost/interview/controller/InterviewController.java @@ -0,0 +1,50 @@ +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.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import java.util.Map; + +@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..02e1b5f --- /dev/null +++ b/src/main/java/com/example/skillboost/interview/service/InterviewService.java @@ -0,0 +1,288 @@ +package com.example.skillboost.interview.service; + +import com.example.skillboost.interview.dto.InterviewAnswerDto; +import com.example.skillboost.interview.dto.InterviewQuestionDto; +import com.example.skillboost.interview.dto.InterviewStartRequest; +import com.example.skillboost.interview.dto.InterviewStartResponse; +import com.example.skillboost.interview.dto.QuestionType; +import com.example.skillboost.interview.model.InterviewSession; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +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; + +@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 static final List BEHAV_QUESTIONS = List.of( + "가장 최근에 도전적인 일을 경험한 적이 있다면 설명해 주세요.", + "팀 프로젝트에서 갈등을 겪은 적이 있다면, 어떻게 해결했나요?", + "본인의 성격 중 강점과 약점을 각각 설명해 주세요.", + "압박감이 큰 상황에서는 어떻게 스트레스를 관리하나요?", + "어려운 문제를 만났을 때 해결하기 위해 어떤 접근 방식을 사용하나요?", + "주변 사람들에게 어떤 사람으로 기억되고 싶나요?", + "새로운 기술을 배울 때 본인만의 학습 방법이 있나요?", + "실수했던 경험이 있다면 어떻게 대응했나요?", + "목표를 설정한 뒤 성취하기 위해 어떤 계획을 세우나요?", + "여러 작업을 동시에 처리해야 할 때 우선순위는 어떻게 정하나요?", + "리더 역할을 맡아본 적이 있다면 어떤 방식으로 팀을 이끌었나요?", + "본인이 맡았던 일 중 가장 책임감 있게 완수한 경험을 말해 주세요.", + "지속적으로 성장하기 위해 하고 있는 노력은 무엇인가요?", + "비판적인 피드백을 받았을 때 어떻게 반응하나요?", + "혼자 일할 때와 팀으로 일할 때 각각 어떤 스타일인가요?", + "가장 뿌듯했던 성취 경험을 말해 주세요.", + "예상치 못한 문제가 발생했을 때 대응했던 경험을 이야기해 주세요.", + "협업 과정에서 소통을 원활하게 하기 위해 어떤 노력을 하나요?", + "새로운 환경이나 변화에 적응했던 경험을 말해 주세요.", + "성과를 내지 못한 경험이 있다면 무엇을 배우셨나요?", + "갈등 상황에서 감정을 다스리는 본인만의 방법이 있나요?", + "주도적으로 문제를 해결했던 경험을 설명해 주세요.", + "가장 최근에 배운 기술이나 지식은 무엇이며, 어떻게 활용했나요?", + "조직이나 팀에 긍정적인 영향을 준 경험이 있다면 설명해 주세요.", + "본인의 가치관 중 일을 할 때 가장 중요하게 생각하는 것은 무엇인가요?", + "스스로 부족하다고 느끼는 점은 무엇이고, 어떻게 개선하고 있나요?", + "업무나 학업에서 동기부여가 필요할 때 어떻게 동기를 찾나요?", + "복잡한 문제를 단순화해서 해결했던 경험이 있나요?", + "시간 압박 속에서 빠르게 결정을 내려야 했던 상황을 말해 주세요.", + "새로운 역할을 맡았을 때 빠르게 적응하기 위해 무엇을 했나요?", + "목표 달성이 어려워졌을 때 포기하지 않고 노력했던 경험을 말해 주세요.", + "본인이 경험한 가장 큰 실패는 무엇이고 무엇을 배우셨나요?", + "팀원과 의견 차이가 있을 때 어떻게 설득하거나 조율하나요?", + "집중력이 떨어질 때 다시 집중력을 끌어올리는 방법이 있나요?", + "주변 사람과 신뢰를 쌓기 위해 어떤 노력을 하나요?", + "업무 효율을 높이기 위해 본인이 자주 사용하는 방식이나 도구가 있나요?", + "예상보다 일이 오래 걸릴 때 본인의 태도는 어떠한가요?", + "가장 인상 깊었던 협업 경험을 이야기해 주세요.", + "기대치보다 낮은 평가를 받았을 때 어떻게 대처했나요?", + "타인의 입장에서 생각해야 했던 경험을 말해 주세요.", + "누군가에게 도움을 요청해야 했던 상황이 있다면 설명해 주세요.", + "팀 분위기가 좋지 않을 때 본인이 기여할 수 있는 부분은 무엇인가요?", + "맡았던 일을 끝까지 책임지기 위해 어떤 노력을 하나요?", + "어떤 상황에서 본인의 리더십이 발휘된다고 생각하나요?", + "가장 마지막으로 읽었던 책이나 들었던 강의는 무엇인가요?", + "어려운 결정을 내려야 했던 경험을 설명해 주세요.", + "모르는 것을 인정하고 배우는 태도에 대해 어떻게 생각하나요?", + "본인의 단점을 보완하기 위해 꾸준히 실천하고 있는 습관이 있나요?", + "스스로에게 가장 자랑스러운 순간은 언제였나요?", + "상사에게 부당한 지시를 받았을 때 어떻게 대처하나요?" + ); + + // --------------------------------------------------- + // 0. 음성 답변 → STT → Answer DTO 생성 + // --------------------------------------------------- + public InterviewAnswerDto processAnswer( + String sessionId, + int questionIndex, // 프론트에서 0-based 인덱스로 보낸다고 가정 + MultipartFile audioFile + ) { + // 1) 세션 찾기 + 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); + + // 2) 🔊 STT: 음성을 텍스트로 변환 + String answerText = speechToTextService.transcribe(audioFile); + + // 3) 프론트에 돌려줄 Answer DTO 생성 + // - 프론트는 이걸 answers 배열에 모았다가 /feedback 에서 한 번에 보냄 + return InterviewAnswerDto.builder() + .questionId(questionDto.getId()) + .type(questionDto.getType()) + .question(questionDto.getText()) + .answerText(answerText) + .durationSec(0) // TODO: 나중에 원하면 프론트에서 실제 답변 시간 보내서 채워도 됨 + .build(); + } + + // --------------------------------------------------- + // 1. 면접 시작 + 질문 생성 + // --------------------------------------------------- + public InterviewStartResponse startInterview(InterviewStartRequest request) { + String repoUrl = request.getRepoUrl(); + + // 1. 기술 질문 3개: Gemini 기반 + List techQuestions = generateTechQuestionsWithGemini(repoUrl); + + // 2. 인성 질문 2개: 기존 50개 중 랜덤 + List behavQuestions = pickRandomBehavQuestions(2); + + // 3. 합치고 섞기 + List all = new ArrayList<>(); + all.addAll(techQuestions); + all.addAll(behavQuestions); + Collections.shuffle(all); + + // 4. id를 1~N 으로 재부여 + 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()); + + // 5. 세션 생성 & 저장 + 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(); + } + + /** + * Gemini를 사용하여 repoUrl 기반 기술 질문 3개 생성 + * - JSON 배열로만 응답하도록 강제 + */ + private List generateTechQuestionsWithGemini(String repoUrl) { + String repoName = extractRepoName(repoUrl); + + String prompt = """ + 당신은 시니어 백엔드 개발자 면접관입니다. + 아래 GitHub 레포지토리를 기반으로 이 프로젝트를 개발한 지원자에게 물어볼 + 기술 면접 질문 3개를 만들어 주세요. + + 레포지토리 URL: %s + 레포지토리 이름: %s + 이 프로젝트는 코딩테스트, 코드 리뷰, AI 면접 등 개발자 역량 강화를 위한 웹 서비스라고 가정합니다. + + ❗질문 스타일 제한 + - 각 질문은 **1문장**으로만 작성하세요. + - 길이는 최대 **80자 이내**로 해 주세요. + - 불필요한 배경 설명, 예시는 넣지 마세요. + - "핵심이 무엇인가요?" 같은 추상적인 질문은 피하고, + "어떤 클래스/레이어에서 무엇을 어떻게 처리했는지"처럼 + **구현·설계를 구체적으로 묻는 질문**으로만 작성하세요. + + 질문 주제 예시 + - 아키텍처 구성 방식 + - 모듈 간 의존성, 레이어드 구조 + - 예외 처리, 타임아웃 처리 방식 + - 성능/확장성 고려 + - 테스트 전략, 트랜잭션 처리 등 + + 출력 형식 (반드시 이 JSON 배열만 출력) + [ + { "text": "질문 내용1" }, + { "text": "질문 내용2" }, + { "text": "질문 내용3" } + ] + """.formatted(repoUrl, repoName); + + String raw; + try { + raw = geminiClient.generateText(prompt); + } catch (Exception e) { + e.printStackTrace(); + return fallbackTechQuestions(repoName); + } + + if (raw == null || raw.isBlank()) { + return fallbackTechQuestions(repoName); + } + + String cleaned = extractJsonArray(raw).trim(); + + if (!cleaned.startsWith("[")) { + return fallbackTechQuestions(repoName); + } + + 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)); + } + + if (result.isEmpty()) { + return fallbackTechQuestions(repoName); + } + + return result.size() > 3 ? result.subList(0, 3) : result; + + } catch (Exception e) { + e.printStackTrace(); + return fallbackTechQuestions(repoName); + } + } + + private String extractJsonArray(String raw) { + if (raw == null) return ""; + String trimmed = raw.trim(); + + int start = trimmed.indexOf('['); + int end = trimmed.lastIndexOf(']'); + if (start == -1 || end == -1 || end <= start) { + return trimmed; + } + return trimmed.substring(start, end + 1); + } + + private List fallbackTechQuestions(String repoName) { + String q1 = String.format("이 레포지토리(%s)의 전체 아키텍처를 간단히 설명해 주세요.", repoName); + String q2 = String.format("%s 프로젝트에서 주요 모듈(코딩테스트/코드리뷰/AI면접)의 역할과 연결 구조를 설명해 주세요.", repoName); + String q3 = String.format("%s에서 외부 API(Gemini, 채점 서버 등)를 호출할 때 예외/타임아웃을 어떻게 처리했는지 설명해 주세요.", repoName); + + return List.of( + new InterviewQuestionDto(null, QuestionType.TECH, q1), + new InterviewQuestionDto(null, QuestionType.TECH, q2), + new InterviewQuestionDto(null, QuestionType.TECH, q3) + ); + } + + 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); + } + + private List pickRandomBehavQuestions(int count) { + List pool = new ArrayList<>(BEHAV_QUESTIONS); + Collections.shuffle(pool); + return pool.subList(0, Math.min(count, pool.size())) + .stream() + .map(text -> new InterviewQuestionDto(null, QuestionType.BEHAV, text)) + .collect(Collectors.toList()); + } + + 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..d189964 --- /dev/null +++ b/src/main/java/com/example/skillboost/interview/service/SpeechToTextService.java @@ -0,0 +1,63 @@ +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.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; + +@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 ac03546..70c5baa 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -16,7 +16,7 @@ spring: registration: github: client-id: Ov23liXAPa0etQe0EisI - client-secret: a5dc74aff160176ad62591fbe3d2c0a839eb1ef6 + client-secret: ${GITHUB_CLIENT_SECRET} scope: - read:user - user:email @@ -37,4 +37,12 @@ springdoc: enabled: true swagger-ui: enabled: true - path: /swagger-ui.html \ No newline at end of file + path: /swagger-ui.html + +gemini: + model: ${GEMINI_MODEL} + api: + key: ${GEMINI_API_KEY} + +stt: + vosk-model-path: ${STT_MODEL:vosk-model} diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index 86d2308..8b7efec 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -32,3 +32,8 @@ spring: jwt: secret-key: ${JWT_SECRET_KEY} expiration-ms: 86400000 + +gemini: + model: ${GEMINI_MODEL} + api: + key: ${GEMINI_KEY} \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 35e1022..82026c5 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,27 +1,5 @@ spring: - config: - import: optional:classpath:application-secret.yml application: name: skill-boost - - datasource: - url: jdbc:mysql://localhost:3306/mydatabase?serverTimezone=Asia/Seoul&characterEncoding=UTF-8 - username: myuser - password: secret - driver-class-name: com.mysql.cj.jdbc.Driver - - jpa: - hibernate: - ddl-auto: update - properties: - hibernate: - format_sql: true - show-sql: true - -server: - port: 8080 - -gemini: - api: - key: # - model: gemini-2.5-flash + profiles: + active: local \ No newline at end of file From f493f5adebd6c2dd55b588aa94764fb4b1f17e6c Mon Sep 17 00:00:00 2001 From: Junseung-Ock Date: Tue, 2 Dec 2025 13:19:49 +0900 Subject: [PATCH 08/15] =?UTF-8?q?yml=20=ED=8C=8C=EC=9D=BC=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=20=EB=B0=8F=20SecurityConfig=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/example/skillboost/auth/config/SecurityConfig.java | 3 +++ .../skillboost/codeReview/controller/CodeReviewController.java | 1 - .../skillboost/codingtest/controller/SubmissionController.java | 1 - src/main/resources/application-prod.yml | 2 +- 4 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/example/skillboost/auth/config/SecurityConfig.java b/src/main/java/com/example/skillboost/auth/config/SecurityConfig.java index f077265..505df7d 100644 --- a/src/main/java/com/example/skillboost/auth/config/SecurityConfig.java +++ b/src/main/java/com/example/skillboost/auth/config/SecurityConfig.java @@ -7,6 +7,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; @@ -32,10 +33,12 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .cors(cors -> cors.configurationSource(corsConfigurationSource())) .authorizeHttpRequests(auth -> auth + .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() .requestMatchers( "/", "/api/auth/**", "/api/review/**", + "/api/coding/**", "/oauth2/**", "/login/oauth2/**", "/swagger-ui/**", diff --git a/src/main/java/com/example/skillboost/codeReview/controller/CodeReviewController.java b/src/main/java/com/example/skillboost/codeReview/controller/CodeReviewController.java index 846553c..85ebb56 100644 --- a/src/main/java/com/example/skillboost/codeReview/controller/CodeReviewController.java +++ b/src/main/java/com/example/skillboost/codeReview/controller/CodeReviewController.java @@ -13,7 +13,6 @@ @RestController @RequestMapping("/api/review") -@CrossOrigin(origins = "http://localhost:3000") // React 개발 서버 주소 public class CodeReviewController { @Autowired diff --git a/src/main/java/com/example/skillboost/codingtest/controller/SubmissionController.java b/src/main/java/com/example/skillboost/codingtest/controller/SubmissionController.java index d16b355..7d94538 100644 --- a/src/main/java/com/example/skillboost/codingtest/controller/SubmissionController.java +++ b/src/main/java/com/example/skillboost/codingtest/controller/SubmissionController.java @@ -12,7 +12,6 @@ @RestController @RequestMapping("/api/coding") @RequiredArgsConstructor -@CrossOrigin(origins = "*") public class SubmissionController { private final GradingService gradingService; diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index f5d0ee8..600da51 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -36,5 +36,5 @@ jwt: gemini: api: key: ${GEMINI_API_KEY} - model: gemini-2.5-flash + model: ${GEMINI_MODEL} From 30d5fd915e1b81de22b1664269ed5af3e6d41f8f Mon Sep 17 00:00:00 2001 From: Junseung-Ock Date: Tue, 2 Dec 2025 21:00:22 +0900 Subject: [PATCH 09/15] =?UTF-8?q?=EC=9D=B8=ED=84=B0=EB=B7=B0=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20github=20repo=20=EC=9D=BD=EC=96=B4=EC=98=A4?= =?UTF-8?q?=EA=B8=B0=20=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/config/SecurityConfig.java | 1 + .../codeReview/service/CodeReviewService.java | 2 +- .../interview/service/InterviewService.java | 322 +++++++++--------- src/main/resources/application-local.yml | 2 +- 4 files changed, 173 insertions(+), 154 deletions(-) diff --git a/src/main/java/com/example/skillboost/auth/config/SecurityConfig.java b/src/main/java/com/example/skillboost/auth/config/SecurityConfig.java index ec4c477..02c352a 100644 --- a/src/main/java/com/example/skillboost/auth/config/SecurityConfig.java +++ b/src/main/java/com/example/skillboost/auth/config/SecurityConfig.java @@ -39,6 +39,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { "/api/auth/**", "/api/review/**", "/api/coding/**", + "/api/interview/**", "/oauth2/**", "/login/oauth2/**", "/swagger-ui/**", diff --git a/src/main/java/com/example/skillboost/codeReview/service/CodeReviewService.java b/src/main/java/com/example/skillboost/codeReview/service/CodeReviewService.java index ce159ab..f7fe6b9 100644 --- a/src/main/java/com/example/skillboost/codeReview/service/CodeReviewService.java +++ b/src/main/java/com/example/skillboost/codeReview/service/CodeReviewService.java @@ -14,7 +14,7 @@ public class CodeReviewService { private final WebClient webClient; - @Value("${gemini.api.key}") + @Value("${gemini.key}") private String geminiApiKey; @Value("${gemini.model:gemini-2.5-flash}") diff --git a/src/main/java/com/example/skillboost/interview/service/InterviewService.java b/src/main/java/com/example/skillboost/interview/service/InterviewService.java index 02e1b5f..b1837cd 100644 --- a/src/main/java/com/example/skillboost/interview/service/InterviewService.java +++ b/src/main/java/com/example/skillboost/interview/service/InterviewService.java @@ -1,10 +1,8 @@ package com.example.skillboost.interview.service; -import com.example.skillboost.interview.dto.InterviewAnswerDto; -import com.example.skillboost.interview.dto.InterviewQuestionDto; -import com.example.skillboost.interview.dto.InterviewStartRequest; -import com.example.skillboost.interview.dto.InterviewStartResponse; -import com.example.skillboost.interview.dto.QuestionType; +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; @@ -22,76 +20,17 @@ 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 읽기 서비스 - // 인성 질문 풀 - private static final List BEHAV_QUESTIONS = List.of( - "가장 최근에 도전적인 일을 경험한 적이 있다면 설명해 주세요.", - "팀 프로젝트에서 갈등을 겪은 적이 있다면, 어떻게 해결했나요?", - "본인의 성격 중 강점과 약점을 각각 설명해 주세요.", - "압박감이 큰 상황에서는 어떻게 스트레스를 관리하나요?", - "어려운 문제를 만났을 때 해결하기 위해 어떤 접근 방식을 사용하나요?", - "주변 사람들에게 어떤 사람으로 기억되고 싶나요?", - "새로운 기술을 배울 때 본인만의 학습 방법이 있나요?", - "실수했던 경험이 있다면 어떻게 대응했나요?", - "목표를 설정한 뒤 성취하기 위해 어떤 계획을 세우나요?", - "여러 작업을 동시에 처리해야 할 때 우선순위는 어떻게 정하나요?", - "리더 역할을 맡아본 적이 있다면 어떤 방식으로 팀을 이끌었나요?", - "본인이 맡았던 일 중 가장 책임감 있게 완수한 경험을 말해 주세요.", - "지속적으로 성장하기 위해 하고 있는 노력은 무엇인가요?", - "비판적인 피드백을 받았을 때 어떻게 반응하나요?", - "혼자 일할 때와 팀으로 일할 때 각각 어떤 스타일인가요?", - "가장 뿌듯했던 성취 경험을 말해 주세요.", - "예상치 못한 문제가 발생했을 때 대응했던 경험을 이야기해 주세요.", - "협업 과정에서 소통을 원활하게 하기 위해 어떤 노력을 하나요?", - "새로운 환경이나 변화에 적응했던 경험을 말해 주세요.", - "성과를 내지 못한 경험이 있다면 무엇을 배우셨나요?", - "갈등 상황에서 감정을 다스리는 본인만의 방법이 있나요?", - "주도적으로 문제를 해결했던 경험을 설명해 주세요.", - "가장 최근에 배운 기술이나 지식은 무엇이며, 어떻게 활용했나요?", - "조직이나 팀에 긍정적인 영향을 준 경험이 있다면 설명해 주세요.", - "본인의 가치관 중 일을 할 때 가장 중요하게 생각하는 것은 무엇인가요?", - "스스로 부족하다고 느끼는 점은 무엇이고, 어떻게 개선하고 있나요?", - "업무나 학업에서 동기부여가 필요할 때 어떻게 동기를 찾나요?", - "복잡한 문제를 단순화해서 해결했던 경험이 있나요?", - "시간 압박 속에서 빠르게 결정을 내려야 했던 상황을 말해 주세요.", - "새로운 역할을 맡았을 때 빠르게 적응하기 위해 무엇을 했나요?", - "목표 달성이 어려워졌을 때 포기하지 않고 노력했던 경험을 말해 주세요.", - "본인이 경험한 가장 큰 실패는 무엇이고 무엇을 배우셨나요?", - "팀원과 의견 차이가 있을 때 어떻게 설득하거나 조율하나요?", - "집중력이 떨어질 때 다시 집중력을 끌어올리는 방법이 있나요?", - "주변 사람과 신뢰를 쌓기 위해 어떤 노력을 하나요?", - "업무 효율을 높이기 위해 본인이 자주 사용하는 방식이나 도구가 있나요?", - "예상보다 일이 오래 걸릴 때 본인의 태도는 어떠한가요?", - "가장 인상 깊었던 협업 경험을 이야기해 주세요.", - "기대치보다 낮은 평가를 받았을 때 어떻게 대처했나요?", - "타인의 입장에서 생각해야 했던 경험을 말해 주세요.", - "누군가에게 도움을 요청해야 했던 상황이 있다면 설명해 주세요.", - "팀 분위기가 좋지 않을 때 본인이 기여할 수 있는 부분은 무엇인가요?", - "맡았던 일을 끝까지 책임지기 위해 어떤 노력을 하나요?", - "어떤 상황에서 본인의 리더십이 발휘된다고 생각하나요?", - "가장 마지막으로 읽었던 책이나 들었던 강의는 무엇인가요?", - "어려운 결정을 내려야 했던 경험을 설명해 주세요.", - "모르는 것을 인정하고 배우는 태도에 대해 어떻게 생각하나요?", - "본인의 단점을 보완하기 위해 꾸준히 실천하고 있는 습관이 있나요?", - "스스로에게 가장 자랑스러운 순간은 언제였나요?", - "상사에게 부당한 지시를 받았을 때 어떻게 대처하나요?" - ); - - // --------------------------------------------------- - // 0. 음성 답변 → STT → Answer DTO 생성 - // --------------------------------------------------- - public InterviewAnswerDto processAnswer( - String sessionId, - int questionIndex, // 프론트에서 0-based 인덱스로 보낸다고 가정 - MultipartFile audioFile - ) { - // 1) 세션 찾기 + // --------------------------------------------------------- + // 음성 답변 처리 + // --------------------------------------------------------- + public InterviewAnswerDto processAnswer(String sessionId, int questionIndex, MultipartFile audioFile) { InterviewSession session = findSession(sessionId) .orElseThrow(() -> new IllegalArgumentException("세션을 찾을 수 없습니다.")); @@ -102,49 +41,39 @@ public InterviewAnswerDto processAnswer( InterviewQuestionDto questionDto = questions.get(questionIndex); - // 2) 🔊 STT: 음성을 텍스트로 변환 String answerText = speechToTextService.transcribe(audioFile); - // 3) 프론트에 돌려줄 Answer DTO 생성 - // - 프론트는 이걸 answers 배열에 모았다가 /feedback 에서 한 번에 보냄 return InterviewAnswerDto.builder() .questionId(questionDto.getId()) .type(questionDto.getType()) .question(questionDto.getText()) .answerText(answerText) - .durationSec(0) // TODO: 나중에 원하면 프론트에서 실제 답변 시간 보내서 채워도 됨 + .durationSec(0) .build(); } - // --------------------------------------------------- - // 1. 면접 시작 + 질문 생성 - // --------------------------------------------------- + // --------------------------------------------------------- + // 면접 시작 + // --------------------------------------------------------- public InterviewStartResponse startInterview(InterviewStartRequest request) { String repoUrl = request.getRepoUrl(); - // 1. 기술 질문 3개: Gemini 기반 List techQuestions = generateTechQuestionsWithGemini(repoUrl); + List behavQuestions = pickRandomBehavQuestions(2); // 🔥 자동 생성된 인성 질문 - // 2. 인성 질문 2개: 기존 50개 중 랜덤 - List behavQuestions = pickRandomBehavQuestions(2); - - // 3. 합치고 섞기 List all = new ArrayList<>(); all.addAll(techQuestions); all.addAll(behavQuestions); Collections.shuffle(all); - // 4. id를 1~N 으로 재부여 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()); + )).collect(Collectors.toList()); - // 5. 세션 생성 & 저장 String sessionId = UUID.randomUUID().toString(); InterviewSession session = InterviewSession.create(sessionId, repoUrl, numbered); sessions.put(sessionId, session); @@ -156,45 +85,58 @@ public InterviewStartResponse startInterview(InterviewStartRequest request) { .build(); } - /** - * Gemini를 사용하여 repoUrl 기반 기술 질문 3개 생성 - * - JSON 배열로만 응답하도록 강제 - */ + // --------------------------------------------------------- + // 🔥 GitHub 레포 기반 기술 질문 생성 + // --------------------------------------------------------- private List generateTechQuestionsWithGemini(String repoUrl) { String repoName = extractRepoName(repoUrl); - String prompt = """ - 당신은 시니어 백엔드 개발자 면접관입니다. - 아래 GitHub 레포지토리를 기반으로 이 프로젝트를 개발한 지원자에게 물어볼 - 기술 면접 질문 3개를 만들어 주세요. - - 레포지토리 URL: %s - 레포지토리 이름: %s - 이 프로젝트는 코딩테스트, 코드 리뷰, AI 면접 등 개발자 역량 강화를 위한 웹 서비스라고 가정합니다. - - ❗질문 스타일 제한 - - 각 질문은 **1문장**으로만 작성하세요. - - 길이는 최대 **80자 이내**로 해 주세요. - - 불필요한 배경 설명, 예시는 넣지 마세요. - - "핵심이 무엇인가요?" 같은 추상적인 질문은 피하고, - "어떤 클래스/레이어에서 무엇을 어떻게 처리했는지"처럼 - **구현·설계를 구체적으로 묻는 질문**으로만 작성하세요. - - 질문 주제 예시 - - 아키텍처 구성 방식 - - 모듈 간 의존성, 레이어드 구조 - - 예외 처리, 타임아웃 처리 방식 - - 성능/확장성 고려 - - 테스트 전략, 트랜잭션 처리 등 - - 출력 형식 (반드시 이 JSON 배열만 출력) - [ - { "text": "질문 내용1" }, - { "text": "질문 내용2" }, - { "text": "질문 내용3" } - ] - """.formatted(repoUrl, repoName); + // 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); @@ -207,12 +149,13 @@ private List generateTechQuestionsWithGemini(String repoUr return fallbackTechQuestions(repoName); } + // 5) JSON 배열 추출 String cleaned = extractJsonArray(raw).trim(); - if (!cleaned.startsWith("[")) { return fallbackTechQuestions(repoName); } + // 6) 파싱 try { List> list = objectMapper.readValue( cleaned, @@ -230,11 +173,7 @@ private List generateTechQuestionsWithGemini(String repoUr result.add(new InterviewQuestionDto(null, QuestionType.TECH, text)); } - if (result.isEmpty()) { - return fallbackTechQuestions(repoName); - } - - return result.size() > 3 ? result.subList(0, 3) : result; + return result.size() >= 3 ? result.subList(0, 3) : fallbackTechQuestions(repoName); } catch (Exception e) { e.printStackTrace(); @@ -242,28 +181,116 @@ private List generateTechQuestionsWithGemini(String repoUr } } - private String extractJsonArray(String raw) { - if (raw == null) return ""; - String trimmed = raw.trim(); + // --------------------------------------------------------- + // 기술면접 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": "압박이 있을 때 자신의 감정을 어떻게 관리하나요?" } + ] - int start = trimmed.indexOf('['); - int end = trimmed.lastIndexOf(']'); - if (start == -1 || end == -1 || end <= start) { - return trimmed; + 질문 개수: %d개 + """.formatted(count); + + String raw; + try { + raw = geminiClient.generateText(prompt); + } catch (Exception e) { + e.printStackTrace(); + return fallbackBehavQuestions(count); } - return trimmed.substring(start, end + 1); - } - private List fallbackTechQuestions(String repoName) { - String q1 = String.format("이 레포지토리(%s)의 전체 아키텍처를 간단히 설명해 주세요.", repoName); - String q2 = String.format("%s 프로젝트에서 주요 모듈(코딩테스트/코드리뷰/AI면접)의 역할과 연결 구조를 설명해 주세요.", repoName); - String q3 = String.format("%s에서 외부 API(Gemini, 채점 서버 등)를 호출할 때 예외/타임아웃을 어떻게 처리했는지 설명해 주세요.", repoName); + if (raw == null || raw.isBlank()) { + return fallbackBehavQuestions(count); + } - return List.of( - new InterviewQuestionDto(null, QuestionType.TECH, q1), - new InterviewQuestionDto(null, QuestionType.TECH, q2), - new InterviewQuestionDto(null, QuestionType.TECH, q3) + 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) { @@ -273,15 +300,6 @@ private String extractRepoName(String repoUrl) { return repoUrl.substring(slash + 1); } - private List pickRandomBehavQuestions(int count) { - List pool = new ArrayList<>(BEHAV_QUESTIONS); - Collections.shuffle(pool); - return pool.subList(0, Math.min(count, pool.size())) - .stream() - .map(text -> new InterviewQuestionDto(null, QuestionType.BEHAV, text)) - .collect(Collectors.toList()); - } - public Optional findSession(String sessionId) { return Optional.ofNullable(sessions.get(sessionId)); } diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index f6bd2ac..fe4ec93 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -42,7 +42,7 @@ springdoc: gemini: model: ${GEMINI_MODEL} api: - key: ${GEMINI_API_KEY} + key: ${GEMINI_KEY} stt: vosk-model-path: ${STT_MODEL:vosk-model} From b93510f647f44e10f66d9e1ebf0332e873ef7049 Mon Sep 17 00:00:00 2001 From: Junseung-Ock Date: Wed, 3 Dec 2025 18:29:51 +0900 Subject: [PATCH 10/15] =?UTF-8?q?CodeReviewController=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/CodeReviewController.java | 2 +- .../controller/CodeReviewController.java | 24 ------------------- 2 files changed, 1 insertion(+), 25 deletions(-) delete mode 100644 src/main/java/com/example/skillboost/codereview/controller/CodeReviewController.java diff --git a/src/main/java/com/example/skillboost/codeReview/controller/CodeReviewController.java b/src/main/java/com/example/skillboost/codeReview/controller/CodeReviewController.java index 85ebb56..3ad53df 100644 --- a/src/main/java/com/example/skillboost/codeReview/controller/CodeReviewController.java +++ b/src/main/java/com/example/skillboost/codeReview/controller/CodeReviewController.java @@ -90,4 +90,4 @@ public ResponseEntity reviewCode( return ResponseEntity.internalServerError().body(errorResponse); } } -} +} \ No newline at end of file diff --git a/src/main/java/com/example/skillboost/codereview/controller/CodeReviewController.java b/src/main/java/com/example/skillboost/codereview/controller/CodeReviewController.java deleted file mode 100644 index 622af4f..0000000 --- a/src/main/java/com/example/skillboost/codereview/controller/CodeReviewController.java +++ /dev/null @@ -1,24 +0,0 @@ -package com.example.skillboost.codereview.controller; - -import com.example.skillboost.codereview.dto.CodeReviewRequest; -import com.example.skillboost.codereview.dto.CodeReviewResponse; -import com.example.skillboost.codereview.service.CodeReviewService; -import lombok.RequiredArgsConstructor; -import org.springframework.http.MediaType; -import org.springframework.web.bind.annotation.*; - -@RestController -@RequestMapping("/api/review") -@RequiredArgsConstructor -public class CodeReviewController { - - private final CodeReviewService codeReviewService; - - @PostMapping( - consumes = MediaType.APPLICATION_JSON_VALUE, - produces = MediaType.APPLICATION_JSON_VALUE - ) - public CodeReviewResponse review(@RequestBody CodeReviewRequest request) { - return codeReviewService.review(request); - } -} From f27b112fa2652b093af458783d00f0cbb37e5b39 Mon Sep 17 00:00:00 2001 From: Junseung-Ock Date: Wed, 3 Dec 2025 19:51:02 +0900 Subject: [PATCH 11/15] =?UTF-8?q?local.yml=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/application-local.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index fe4ec93..8322c5d 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -44,5 +44,3 @@ gemini: api: key: ${GEMINI_KEY} -stt: - vosk-model-path: ${STT_MODEL:vosk-model} From 13b77168499fbbfb7b81fae135b364fa5b14894f Mon Sep 17 00:00:00 2001 From: Junseung-Ock Date: Wed, 3 Dec 2025 20:17:06 +0900 Subject: [PATCH 12/15] =?UTF-8?q?local.yml=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/application-local.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index 8322c5d..0e5ccb5 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -29,7 +29,7 @@ spring: user-name-attribute: id jwt: - secret-key: ${JWT_SECRET_KEY} + secret-key: TXlTdXBlclNlY3JldEtleUZvclNraWxsQm9vc3RQcm9qZWN0MjAyNUNoYWxsZW5nZSE= expiration-ms: 86400000 springdoc: From 7db9cf44119ea1e38b415fdd15260c2851fb8fde Mon Sep 17 00:00:00 2001 From: Junseung-Ock Date: Wed, 3 Dec 2025 20:23:43 +0900 Subject: [PATCH 13/15] =?UTF-8?q?yml=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/application.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index e476687..5e050b5 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -2,4 +2,4 @@ spring: application: name: skill-boost profiles: - active: local + active: test From 3a89776043b8aab0f94a1a3376a867235433d813 Mon Sep 17 00:00:00 2001 From: Junseung-Ock Date: Wed, 3 Dec 2025 20:39:59 +0900 Subject: [PATCH 14/15] =?UTF-8?q?yml=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../codeReview/service/CodeReviewService.java | 2 +- .../controller/InterviewController.java | 50 --- .../interview/dto/InterviewAnswerDto.java | 28 -- .../dto/InterviewFeedbackRequest.java | 21 -- .../dto/InterviewFeedbackResponse.java | 22 -- .../interview/dto/InterviewQuestionDto.java | 22 -- .../interview/dto/InterviewStartRequest.java | 14 - .../interview/dto/InterviewStartResponse.java | 24 -- .../interview/dto/QuestionFeedbackDto.java | 17 - .../interview/dto/QuestionType.java | 6 - .../interview/model/InterviewSession.java | 32 -- .../interview/service/GeminiClient.java | 113 ------- .../service/InterviewFeedbackService.java | 152 --------- .../interview/service/InterviewService.java | 306 ------------------ .../service/SpeechToTextService.java | 63 ---- src/main/resources/application-local.yml | 4 +- src/main/resources/application.yml | 2 +- 17 files changed, 4 insertions(+), 874 deletions(-) delete mode 100644 src/main/java/com/example/skillboost/interview/controller/InterviewController.java delete mode 100644 src/main/java/com/example/skillboost/interview/dto/InterviewAnswerDto.java delete mode 100644 src/main/java/com/example/skillboost/interview/dto/InterviewFeedbackRequest.java delete mode 100644 src/main/java/com/example/skillboost/interview/dto/InterviewFeedbackResponse.java delete mode 100644 src/main/java/com/example/skillboost/interview/dto/InterviewQuestionDto.java delete mode 100644 src/main/java/com/example/skillboost/interview/dto/InterviewStartRequest.java delete mode 100644 src/main/java/com/example/skillboost/interview/dto/InterviewStartResponse.java delete mode 100644 src/main/java/com/example/skillboost/interview/dto/QuestionFeedbackDto.java delete mode 100644 src/main/java/com/example/skillboost/interview/dto/QuestionType.java delete mode 100644 src/main/java/com/example/skillboost/interview/model/InterviewSession.java delete mode 100644 src/main/java/com/example/skillboost/interview/service/GeminiClient.java delete mode 100644 src/main/java/com/example/skillboost/interview/service/InterviewFeedbackService.java delete mode 100644 src/main/java/com/example/skillboost/interview/service/InterviewService.java delete mode 100644 src/main/java/com/example/skillboost/interview/service/SpeechToTextService.java diff --git a/src/main/java/com/example/skillboost/codeReview/service/CodeReviewService.java b/src/main/java/com/example/skillboost/codeReview/service/CodeReviewService.java index f7fe6b9..ce159ab 100644 --- a/src/main/java/com/example/skillboost/codeReview/service/CodeReviewService.java +++ b/src/main/java/com/example/skillboost/codeReview/service/CodeReviewService.java @@ -14,7 +14,7 @@ public class CodeReviewService { private final WebClient webClient; - @Value("${gemini.key}") + @Value("${gemini.api.key}") private String geminiApiKey; @Value("${gemini.model:gemini-2.5-flash}") diff --git a/src/main/java/com/example/skillboost/interview/controller/InterviewController.java b/src/main/java/com/example/skillboost/interview/controller/InterviewController.java deleted file mode 100644 index dd84f67..0000000 --- a/src/main/java/com/example/skillboost/interview/controller/InterviewController.java +++ /dev/null @@ -1,50 +0,0 @@ -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.http.ResponseEntity; -import org.springframework.web.bind.annotation.*; -import org.springframework.web.multipart.MultipartFile; - -import java.util.Map; - -@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 deleted file mode 100644 index 3476ed3..0000000 --- a/src/main/java/com/example/skillboost/interview/dto/InterviewAnswerDto.java +++ /dev/null @@ -1,28 +0,0 @@ -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 deleted file mode 100644 index f65b586..0000000 --- a/src/main/java/com/example/skillboost/interview/dto/InterviewFeedbackRequest.java +++ /dev/null @@ -1,21 +0,0 @@ -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 deleted file mode 100644 index fee4d57..0000000 --- a/src/main/java/com/example/skillboost/interview/dto/InterviewFeedbackResponse.java +++ /dev/null @@ -1,22 +0,0 @@ -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 deleted file mode 100644 index 6eb3869..0000000 --- a/src/main/java/com/example/skillboost/interview/dto/InterviewQuestionDto.java +++ /dev/null @@ -1,22 +0,0 @@ -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 deleted file mode 100644 index ae6be9e..0000000 --- a/src/main/java/com/example/skillboost/interview/dto/InterviewStartRequest.java +++ /dev/null @@ -1,14 +0,0 @@ -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 deleted file mode 100644 index 903bc72..0000000 --- a/src/main/java/com/example/skillboost/interview/dto/InterviewStartResponse.java +++ /dev/null @@ -1,24 +0,0 @@ -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 deleted file mode 100644 index 95c8535..0000000 --- a/src/main/java/com/example/skillboost/interview/dto/QuestionFeedbackDto.java +++ /dev/null @@ -1,17 +0,0 @@ -// 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 deleted file mode 100644 index 282d8f8..0000000 --- a/src/main/java/com/example/skillboost/interview/dto/QuestionType.java +++ /dev/null @@ -1,6 +0,0 @@ -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 deleted file mode 100644 index b28765d..0000000 --- a/src/main/java/com/example/skillboost/interview/model/InterviewSession.java +++ /dev/null @@ -1,32 +0,0 @@ -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 deleted file mode 100644 index 61c0fb0..0000000 --- a/src/main/java/com/example/skillboost/interview/service/GeminiClient.java +++ /dev/null @@ -1,113 +0,0 @@ -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 deleted file mode 100644 index 3a7cef8..0000000 --- a/src/main/java/com/example/skillboost/interview/service/InterviewFeedbackService.java +++ /dev/null @@ -1,152 +0,0 @@ -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 deleted file mode 100644 index b1837cd..0000000 --- a/src/main/java/com/example/skillboost/interview/service/InterviewService.java +++ /dev/null @@ -1,306 +0,0 @@ -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.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; - -@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 deleted file mode 100644 index d189964..0000000 --- a/src/main/java/com/example/skillboost/interview/service/SpeechToTextService.java +++ /dev/null @@ -1,63 +0,0 @@ -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.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; - -@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..b0c3622 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -40,7 +40,7 @@ springdoc: path: /swagger-ui.html gemini: - model: ${GEMINI_MODEL} + model: test-model api: - key: ${GEMINI_KEY} + key: test-key diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 5e050b5..82026c5 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -2,4 +2,4 @@ spring: application: name: skill-boost profiles: - active: test + active: local \ No newline at end of file From 50dc7c5a20dd0cfa8f3a49c10b86e209528d29a5 Mon Sep 17 00:00:00 2001 From: Junseung-Ock Date: Wed, 3 Dec 2025 22:55:40 +0900 Subject: [PATCH 15/15] =?UTF-8?q?local.yml=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/application-local.yml | 4 ++-- .../com/example/skillboost/SkillBoostApplicationTests.java | 2 ++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index b0c3622..0e5ccb5 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -40,7 +40,7 @@ springdoc: path: /swagger-ui.html gemini: - model: test-model + model: ${GEMINI_MODEL} api: - key: test-key + key: ${GEMINI_KEY} diff --git a/src/test/java/com/example/skillboost/SkillBoostApplicationTests.java b/src/test/java/com/example/skillboost/SkillBoostApplicationTests.java index babf589..8039cb1 100644 --- a/src/test/java/com/example/skillboost/SkillBoostApplicationTests.java +++ b/src/test/java/com/example/skillboost/SkillBoostApplicationTests.java @@ -2,7 +2,9 @@ import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +@ActiveProfiles("test") @SpringBootTest class SkillBoostApplicationTests {