diff --git a/build.gradle b/build.gradle index 8484c3d..f1df19a 100644 --- a/build.gradle +++ b/build.gradle @@ -29,6 +29,7 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-data-redis' + 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' @@ -44,7 +45,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/config/SecurityConfig.java b/src/main/java/com/example/skillboost/auth/config/SecurityConfig.java index 51365a1..e7678aa 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; @@ -35,9 +36,13 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { // 요청 권한 설정 .authorizeHttpRequests(auth -> auth + .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() .requestMatchers( "/", "/api/auth/**", + "/api/review/**", + "/api/coding/**", + "/api/interview/**", "/oauth2/**", "/login/oauth2/**", "/swagger-ui/**", @@ -45,8 +50,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { "/v3/api-docs/**", "/swagger-resources/**", "/webjars/**", - "/favicon.ico", - "/api/coding/**" + "/favicon.ico" ).permitAll() .anyRequest().authenticated() ) 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 65ecb17..39e0839 100644 --- a/src/main/java/com/example/skillboost/auth/handler/OAuth2SuccessHandler.java +++ b/src/main/java/com/example/skillboost/auth/handler/OAuth2SuccessHandler.java @@ -28,6 +28,7 @@ public class OAuth2SuccessHandler implements AuthenticationSuccessHandler { private final UserRepository userRepository; private final TokenService tokenService; private final ObjectMapper objectMapper = new ObjectMapper(); + @Override public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, diff --git a/src/main/java/com/example/skillboost/codereview/github/GithubFile.java b/src/main/java/com/example/skillboost/codeReview/GithubFile.java similarity index 69% rename from src/main/java/com/example/skillboost/codereview/github/GithubFile.java rename to src/main/java/com/example/skillboost/codeReview/GithubFile.java index fa2e5b7..37c8b4b 100644 --- a/src/main/java/com/example/skillboost/codereview/github/GithubFile.java +++ b/src/main/java/com/example/skillboost/codeReview/GithubFile.java @@ -1,5 +1,4 @@ -// src/main/java/com/example/skillboost/codereview/github/GithubFile.java -package com.example.skillboost.codereview.github; +package com.example.skillboost.codeReview; import lombok.Getter; import lombok.NoArgsConstructor; @@ -7,7 +6,6 @@ @Getter @NoArgsConstructor public class GithubFile { - private String path; private String content; diff --git a/src/main/java/com/example/skillboost/codeReview/controller/CodeReviewController.java b/src/main/java/com/example/skillboost/codeReview/controller/CodeReviewController.java new file mode 100644 index 0000000..3ad53df --- /dev/null +++ b/src/main/java/com/example/skillboost/codeReview/controller/CodeReviewController.java @@ -0,0 +1,93 @@ +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.*; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/api/review") +public class CodeReviewController { + + @Autowired + private GithubService githubService; + + @Autowired + private CodeReviewService codeReviewService; + + @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 = codeReviewService.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); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/example/skillboost/codeReview/controller/GithubController.java b/src/main/java/com/example/skillboost/codeReview/controller/GithubController.java new file mode 100644 index 0000000..bd7c039 --- /dev/null +++ b/src/main/java/com/example/skillboost/codeReview/controller/GithubController.java @@ -0,0 +1,25 @@ +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; + +@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/codeReview/service/CodeReviewService.java b/src/main/java/com/example/skillboost/codeReview/service/CodeReviewService.java new file mode 100644 index 0000000..f7fe6b9 --- /dev/null +++ b/src/main/java/com/example/skillboost/codeReview/service/CodeReviewService.java @@ -0,0 +1,175 @@ +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; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@Service +public class CodeReviewService { + + private final WebClient webClient; + + @Value("${gemini.key}") + private String geminiApiKey; + + @Value("${gemini.model:gemini-2.5-flash}") + private String geminiModel; + + public CodeReviewService(WebClient.Builder webClientBuilder) { + this.webClient = webClientBuilder + .baseUrl("https://generativelanguage.googleapis.com") + .build(); + } + + public String reviewWithContext(String targetCode, String comment, List repoContext) { + String prompt = buildPrompt(targetCode, comment, repoContext); + + System.out.println("생성된 프롬프트 길이: " + prompt.length() + "자"); + + if (geminiApiKey == null || geminiApiKey.isEmpty()) { + System.out.println("Gemini API 키가 없습니다. Mock 리뷰를 생성합니다."); + return generateMockReview(repoContext != null ? repoContext.size() : 0); + } + + return callGemini(prompt); + } + + private String buildPrompt(String targetCode, String comment, List repoContext) { + StringBuilder prompt = new StringBuilder(); + prompt.append("당신은 경험 많은 시니어 개발자입니다. 전체 구조를 이해하고, 코드 품질을 향상시키는 리뷰를 제공합니다.\n\n"); + + if (repoContext != null && !repoContext.isEmpty()) { + prompt.append("=== 프로젝트 전체 구조 ===\n\n"); + prompt.append("총 ").append(repoContext.size()).append("개의 파일로 구성된 프로젝트입니다.\n\n"); + 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"); + + 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).append("\n```\n\n"); + } + + prompt.append("=== 프로젝트 분석 ===\n"); + prompt.append(analyzeProjectStructure(repoContext)).append("\n\n"); + } + + prompt.append("=== 리뷰 대상 코드 ===\n\n```\n").append(targetCode).append("\n```\n\n"); + if (comment != null && !comment.isEmpty()) { + prompt.append("=== 개발자의 질문/고민 ===\n").append(comment).append("\n\n"); + } + + 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 -> { + String path = file.getPath(); + int dotIndex = path.lastIndexOf('.'); + return dotIndex > 0 ? path.substring(dotIndex) : "기타"; + }, + Collectors.counting() + )); + + 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")); + 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(); + } + + private String callGemini(String prompt) { + try { + Map requestBody = Map.of( + "contents", List.of( + Map.of("parts", List.of(Map.of("text", prompt))) + ) + ); + + Map response = webClient.post() + .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"); + 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); + } + } + + + 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- 기본 구조가 잘 갖춰져 있습니다\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("## 총평\n전체적으로 좋은 코드입니다.\n"); + return mock.toString(); + } +} diff --git a/src/main/java/com/example/skillboost/codeReview/service/GithubService.java b/src/main/java/com/example/skillboost/codeReview/service/GithubService.java new file mode 100644 index 0000000..7ff55df --- /dev/null +++ b/src/main/java/com/example/skillboost/codeReview/service/GithubService.java @@ -0,0 +1,97 @@ +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; +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); + + // 전체 트리 조회 + 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<>(); + + // 텍스트 파일만 필터링 + 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; + } +} 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); - } -} 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/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/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/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..b1837cd --- /dev/null +++ b/src/main/java/com/example/skillboost/interview/service/InterviewService.java @@ -0,0 +1,306 @@ +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 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 0e1dc01..8322c5d 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: ${JWT_SECRET_KEY} expiration-ms: 86400000 springdoc: @@ -42,4 +42,5 @@ springdoc: gemini: model: ${GEMINI_MODEL} api: - key: ${GEMINI_KEY} \ No newline at end of file + key: ${GEMINI_KEY} + diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index 2fa9d7b..7289430 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -36,4 +36,4 @@ jwt: gemini: model: ${GEMINI_MODEL} api: - key: ${GEMINI_KEY} + key: ${GEMINI_KEY} \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 82026c5..e476687 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 \ No newline at end of file + active: local