diff --git a/build.gradle b/build.gradle index 8484c3d..a95a036 100644 --- a/build.gradle +++ b/build.gradle @@ -28,7 +28,9 @@ 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' implementation 'org.springframework.boot:spring-boot-starter-data-redis' + implementation 'com.alphacephei:vosk:0.3.45' compileOnly 'org.projectlombok:lombok' developmentOnly 'org.springframework.boot:spring-boot-devtools' developmentOnly 'org.springframework.boot:spring-boot-docker-compose' @@ -38,8 +40,6 @@ dependencies { testImplementation 'com.h2database:h2' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' - implementation 'org.springframework.boot:spring-boot-starter-security' - implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' implementation 'io.jsonwebtoken:jjwt-api:0.11.5' runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5' runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5' diff --git a/src/main/java/com/example/skillboost/auth/JwtFilter.java b/src/main/java/com/example/skillboost/auth/JwtFilter.java deleted file mode 100644 index 0f81fbb..0000000 --- a/src/main/java/com/example/skillboost/auth/JwtFilter.java +++ /dev/null @@ -1,59 +0,0 @@ -package com.example.skillboost.auth; - -import jakarta.servlet.FilterChain; -import jakarta.servlet.ServletException; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.util.StringUtils; -import org.springframework.web.filter.OncePerRequestFilter; - -import java.io.IOException; - -@Slf4j -@RequiredArgsConstructor -public class JwtFilter extends OncePerRequestFilter { - - public static final String AUTHORIZATION_HEADER = "Authorization"; - public static final String BEARER_PREFIX = "Bearer "; - - private final JwtProvider jwtProvider; - - @Override - protected void doFilterInternal(HttpServletRequest request, - HttpServletResponse response, - FilterChain filterChain) throws IOException, ServletException { - - // Request Header에서 JWT 토큰 추출 - String jwt = resolveToken(request); - - // JWT 토큰 유효성 검증 - if (StringUtils.hasText(jwt) && jwtProvider.validateToken(jwt)) { - // 유효한 토큰이면 Authentication 객체를 생성하여 SecurityContext에 저장 - Authentication authentication = jwtProvider.getAuthentication(jwt); - SecurityContextHolder.getContext().setAuthentication(authentication); - log.debug("JWT 인증 성공: {}", authentication.getName()); - } else if (StringUtils.hasText(jwt)) { - log.warn("유효하지 않은 JWT 토큰"); - } - - // 다음 필터로 진행 - filterChain.doFilter(request, response); - } - - /** - * Request Header에서 JWT 토큰 추출 - */ - private String resolveToken(HttpServletRequest request) { - String bearerToken = request.getHeader(AUTHORIZATION_HEADER); - - if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(BEARER_PREFIX)) { - return bearerToken.substring(BEARER_PREFIX.length()).trim(); - } - - return null; - } -} \ No newline at end of file diff --git a/src/main/java/com/example/skillboost/auth/JwtProvider.java b/src/main/java/com/example/skillboost/auth/JwtProvider.java deleted file mode 100644 index 274ae7a..0000000 --- a/src/main/java/com/example/skillboost/auth/JwtProvider.java +++ /dev/null @@ -1,110 +0,0 @@ -package com.example.skillboost.auth; - -import io.jsonwebtoken.*; -import io.jsonwebtoken.security.Keys; -import jakarta.annotation.PostConstruct; -import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.authority.SimpleGrantedAuthority; -import org.springframework.security.core.userdetails.User; -import org.springframework.stereotype.Component; - -import java.security.Key; -import java.util.Base64; -import java.util.Collections; -import java.util.Date; - -@Slf4j -@Component -public class JwtProvider { - - private Key key; - - @Value("${jwt.secret-key}") - private String secretKeyBase64; - - @Value("${jwt.expiration-ms}") - private long expirationMs; - - @PostConstruct - protected void init() { - log.info("========== JWT 설정 값 확인 =========="); - log.info("입력된 Secret Key: [{}]", this.secretKeyBase64); - - if (this.secretKeyBase64 == null || this.secretKeyBase64.startsWith("${")) { - throw new RuntimeException("환경변수 [JWT_SECRET_KEY]가 설정되지 않았습니다! IntelliJ 설정을 확인해주세요."); - } - String safeKey = this.secretKeyBase64.replaceAll("\\s+", ""); - - try { - byte[] keyBytes = Base64.getDecoder().decode(safeKey); - this.key = Keys.hmacShaKeyFor(keyBytes); - log.info("JWT Provider 정상 초기화 완료"); - } catch (IllegalArgumentException e) { - log.error("Base64 디코딩 실패! 키 값을 확인해주세요. (현재 값: {})", safeKey); - throw e; - } - } - - /** - * JWT 토큰 생성 - */ - public String createToken(String email) { - Date now = new Date(); - Date expiry = new Date(now.getTime() + this.expirationMs); - - return Jwts.builder() - .setSubject(email) - .setIssuedAt(now) - .setExpiration(expiry) - .signWith(key, SignatureAlgorithm.HS256) - .compact(); - } - - /** - * JWT 토큰 유효성 검증 - */ - public boolean validateToken(String token) { - try { - Jwts.parserBuilder() - .setSigningKey(key) - .build() - .parseClaimsJws(token); - return true; - } catch (ExpiredJwtException e) { - log.error("JWT 토큰이 만료되었습니다: {}", e.getMessage()); - } catch (UnsupportedJwtException e) { - log.error("지원되지 않는 JWT 토큰입니다: {}", e.getMessage()); - } catch (MalformedJwtException e) { - log.error("잘못된 형식의 JWT 토큰입니다: {}", e.getMessage()); - } catch (SecurityException e) { - log.error("JWT 서명이 올바르지 않습니다: {}", e.getMessage()); - } catch (IllegalArgumentException e) { - log.error("JWT 토큰이 비어있습니다: {}", e.getMessage()); - } - return false; - } - - /** - * JWT 토큰에서 Authentication 객체 생성 - */ - public Authentication getAuthentication(String token) { - Claims claims = Jwts.parserBuilder() - .setSigningKey(key) - .build() - .parseClaimsJws(token) - .getBody(); - - String email = claims.getSubject(); - - User principal = new User( - email, - "", - Collections.singleton(new SimpleGrantedAuthority("ROLE_USER")) - ); - - return new UsernamePasswordAuthenticationToken(principal, token, principal.getAuthorities()); - } -} \ No newline at end of file diff --git a/src/main/java/com/example/skillboost/auth/config/SecurityConfig.java b/src/main/java/com/example/skillboost/auth/config/SecurityConfig.java deleted file mode 100644 index f5eff35..0000000 --- a/src/main/java/com/example/skillboost/auth/config/SecurityConfig.java +++ /dev/null @@ -1,60 +0,0 @@ -package com.example.skillboost.auth.config; - -import com.example.skillboost.auth.JwtFilter; -import com.example.skillboost.auth.JwtProvider; -import com.example.skillboost.auth.handler.OAuth2SuccessHandler; -import com.example.skillboost.auth.service.CustomOAuth2UserService; -import lombok.RequiredArgsConstructor; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -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; -import org.springframework.security.web.SecurityFilterChain; -import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; - -@RequiredArgsConstructor -@Configuration -@EnableWebSecurity -public class SecurityConfig { - - private final OAuth2SuccessHandler oAuth2SuccessHandler; - private final CustomOAuth2UserService customOAuth2UserService; - private final JwtProvider jwtProvider; - - @Bean - public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { - http - // 요청 권한 설정 - .authorizeHttpRequests(auth -> auth - .requestMatchers( - "/api/auth/**", - "/oauth2/**", - "/login/oauth2/**", - "/swagger-ui/**", - "/swagger-ui.html", - "/v3/api-docs/**", - "/swagger-resources/**", - "/webjars/**", - "/favicon.ico" - ).permitAll() - .anyRequest().authenticated() - ) - // CSRF 비활성화 - .csrf(AbstractHttpConfigurer::disable) - // JWT 필터 적용 - .addFilterBefore(new JwtFilter(jwtProvider), UsernamePasswordAuthenticationFilter.class) - // 기본 폼 로그인 및 HTTP Basic 인증 비활성화 - .formLogin(AbstractHttpConfigurer::disable) - .httpBasic(AbstractHttpConfigurer::disable) - // OAuth2 로그인 설정 - .oauth2Login(oauth2 -> oauth2 - .successHandler(oAuth2SuccessHandler) - .userInfoEndpoint(userInfo -> userInfo - .userService(customOAuth2UserService) - ) - ); - - return http.build(); - } -} \ No newline at end of file diff --git a/src/main/java/com/example/skillboost/auth/config/SwaggerConfig.java b/src/main/java/com/example/skillboost/auth/config/SwaggerConfig.java deleted file mode 100644 index 89870db..0000000 --- a/src/main/java/com/example/skillboost/auth/config/SwaggerConfig.java +++ /dev/null @@ -1,37 +0,0 @@ -package com.example.skillboost.auth.config; - -import io.swagger.v3.oas.models.Components; -import io.swagger.v3.oas.models.OpenAPI; -import io.swagger.v3.oas.models.info.Info; -import io.swagger.v3.oas.models.security.SecurityRequirement; -import io.swagger.v3.oas.models.security.SecurityScheme; -import io.swagger.v3.oas.models.servers.Server; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; - -import java.util.List; - -@Configuration -public class SwaggerConfig { - @Bean - public OpenAPI openAPI() { - Server localServer = new Server() - .url("http://localhost:8080") - .description("Local Server"); - - - return new OpenAPI() - .servers(List.of(localServer)) - .components(new Components() - .addSecuritySchemes("bearer-token", - new SecurityScheme() - .type(SecurityScheme.Type.HTTP) - .scheme("bearer") - .bearerFormat("JWT"))) - .addSecurityItem(new SecurityRequirement().addList("bearer-token")) - .info(new Info() - .title("My Application API") - .description("API Documentation") - .version("1.0.0")); - } -} \ No newline at end of file diff --git a/src/main/java/com/example/skillboost/auth/controller/AuthController.java b/src/main/java/com/example/skillboost/auth/controller/AuthController.java deleted file mode 100644 index 82dc33f..0000000 --- a/src/main/java/com/example/skillboost/auth/controller/AuthController.java +++ /dev/null @@ -1,23 +0,0 @@ -package com.example.skillboost.auth.controller; - -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.tags.Tag; -import java.util.Map; - -@Tag(name = "깃허브 인증 (Authentication)", description = "소셜 로그인 API") -@RestController -@RequestMapping("/api/auth") -public class AuthController { - - @Operation(summary = "GitHub 로그인 URL 반환", - description = "프론트엔드에서 이 주소로 GET 요청을 보내면, 사용자가 접속해야 할 GitHub 로그인 페이지 URL을 반환합니다.") - @GetMapping("/github-login-url") - public Map getGithubLoginUrl() { - String loginUrl = "/oauth2/authorization/github"; - - return Map.of("url", loginUrl); - } -} \ No newline at end of file diff --git a/src/main/java/com/example/skillboost/auth/handler/OAuth2SuccessHandler.java b/src/main/java/com/example/skillboost/auth/handler/OAuth2SuccessHandler.java deleted file mode 100644 index b484cc8..0000000 --- a/src/main/java/com/example/skillboost/auth/handler/OAuth2SuccessHandler.java +++ /dev/null @@ -1,75 +0,0 @@ -package com.example.skillboost.auth.handler; - -import com.example.skillboost.auth.JwtProvider; -import com.example.skillboost.domain.User; -import com.example.skillboost.repository.UserRepository; -import com.fasterxml.jackson.databind.ObjectMapper; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.security.core.Authentication; -import org.springframework.security.oauth2.core.user.OAuth2User; -import org.springframework.security.web.authentication.AuthenticationSuccessHandler; -import org.springframework.stereotype.Component; - -import java.io.IOException; -import java.util.HashMap; -import java.util.Map; - -@Slf4j -@Component -@RequiredArgsConstructor -public class OAuth2SuccessHandler implements AuthenticationSuccessHandler { - - private final JwtProvider jwtProvider; - private final UserRepository userRepository; - private final ObjectMapper objectMapper = new ObjectMapper(); - - @Override - public void onAuthenticationSuccess(HttpServletRequest request, - HttpServletResponse response, - Authentication authentication) throws IOException { - - log.info("OAuth2 인증 성공!"); - - OAuth2User oAuth2User = (OAuth2User) authentication.getPrincipal(); - String email = (String) oAuth2User.getAttributes().get("email"); - - // GitHub에서 이메일을 비공개로 설정한 경우 처리 - if (email == null || email.isEmpty()) { - String githubId = String.valueOf(oAuth2User.getAttributes().get("id")); - email = githubId + "@github.temp"; - log.warn("이메일 비공개 사용자 - 임시 이메일 사용: {}", email); - } - - // Lambda에서 사용하기 위한 final 변수 - final String finalEmail = email; - - // 사용자 조회 - User user = userRepository.findByEmail(finalEmail) - .orElseThrow(() -> { - log.error("사용자를 찾을 수 없습니다: {}", finalEmail); - return new RuntimeException("User not found: " + finalEmail); - }); - - // JWT 토큰 생성 - String token = jwtProvider.createToken(user.getEmail()); - log.info("JWT 토큰 생성 완료: {}", user.getEmail()); - - // JSON 응답 생성 - Map responseData = new HashMap<>(); - responseData.put("success", true); - responseData.put("token", token); - 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.sendRedirect("http://localhost:3000/oauth2/redirect?token=" + token); - } -} \ No newline at end of file diff --git a/src/main/java/com/example/skillboost/auth/service/CustomOAuth2UserService.java b/src/main/java/com/example/skillboost/auth/service/CustomOAuth2UserService.java deleted file mode 100644 index d4cbf0f..0000000 --- a/src/main/java/com/example/skillboost/auth/service/CustomOAuth2UserService.java +++ /dev/null @@ -1,75 +0,0 @@ -package com.example.skillboost.auth.service; - -import com.example.skillboost.domain.User; -import com.example.skillboost.repository.UserRepository; -import jakarta.transaction.Transactional; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.security.core.authority.SimpleGrantedAuthority; -import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService; -import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; -import org.springframework.security.oauth2.core.OAuth2AuthenticationException; -import org.springframework.security.oauth2.core.user.DefaultOAuth2User; -import org.springframework.security.oauth2.core.user.OAuth2User; -import org.springframework.stereotype.Service; - -import java.util.Collections; -import java.util.Map; - -@Slf4j -@Service -@RequiredArgsConstructor -public class CustomOAuth2UserService extends DefaultOAuth2UserService { - - private final UserRepository userRepository; - - @Override - @Transactional - public OAuth2User loadUser(OAuth2UserRequest request) throws OAuth2AuthenticationException { - // GitHub에서 사용자 정보 가져오기 - OAuth2User oAuth2User = super.loadUser(request); - Map attributes = oAuth2User.getAttributes(); - - log.info("GitHub OAuth2 사용자 정보: {}", attributes); - - // GitHub 사용자 정보 추출 - String email = (String) attributes.get("email"); - String githubId = String.valueOf(attributes.get("id")); - String username = (String) attributes.get("login"); - - // 이메일이 비공개인 경우 임시 이메일 생성 - if (email == null || email.isEmpty()) { - email = githubId + "@github.temp"; - log.warn("이메일 비공개 사용자 - 임시 이메일 생성: {}", email); - } - - // 사용자 저장 또는 업데이트 - final String finalEmail = email; - User user = userRepository.findByEmail(email) - .map(existing -> { - log.info("기존 사용자 업데이트: {}", finalEmail); - existing.setGithubId(githubId); - existing.setUsername(username); - return existing; - }) - .orElseGet(() -> { - log.info("새로운 사용자 생성: {}", finalEmail); - return User.builder() - .email(finalEmail) - .username(username) - .githubId(githubId) - .provider("github") - .build(); - }); - - userRepository.save(user); - log.info("사용자 정보 저장 완료: {} (GitHub ID: {})", user.getEmail(), user.getGithubId()); - - // OAuth2User 객체 반환 - return new DefaultOAuth2User( - Collections.singleton(new SimpleGrantedAuthority("ROLE_USER")), - attributes, - "id" - ); - } -} \ No newline at end of file diff --git a/src/main/java/com/example/skillboost/domain/User.java b/src/main/java/com/example/skillboost/domain/User.java deleted file mode 100644 index fd4aac4..0000000 --- a/src/main/java/com/example/skillboost/domain/User.java +++ /dev/null @@ -1,25 +0,0 @@ -package com.example.skillboost.domain; - -import jakarta.persistence.*; -import lombok.*; - -@Entity -@Getter -@Setter -@Builder -@NoArgsConstructor -@AllArgsConstructor -@Table(name = "users") -public class User { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @Column(nullable = false, unique = true) - private String email; - - private String username; - private String githubId; - private String provider; // github, local -} 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..65d2a97 --- /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/java/com/example/skillboost/repository/UserRepository.java b/src/main/java/com/example/skillboost/repository/UserRepository.java deleted file mode 100644 index 7bb196b..0000000 --- a/src/main/java/com/example/skillboost/repository/UserRepository.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.example.skillboost.repository; - -import com.example.skillboost.domain.User; -import org.springframework.data.jpa.repository.JpaRepository; -import java.util.Optional; - -public interface UserRepository extends JpaRepository { - Optional findByEmail(String email); -} \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 35e1022..df029dc 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -22,6 +22,7 @@ server: port: 8080 gemini: - api: - key: # model: gemini-2.5-flash + +stt: + vosk-model-path: D:/IdeaProjects/model/vosk-model-small-ko-0.22 \ No newline at end of file