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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -42,4 +42,4 @@ HEALTHCHECK --interval=30s --timeout=3s --start-period=60s --retries=3 \
CMD wget -q --spider http://localhost:8080/actuator/health || exit 1

# Run the application
ENTRYPOINT ["java", "-jar", "app.jar"]
ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"]
4 changes: 2 additions & 2 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,9 @@ services:
# REVIEWER mode: comma-separated API keys
GEMINI_REVIEWER_KEYS: ${GEMINI_REVIEWER_KEYS:-}
# Grading model fallback chain (PROD + REVIEWER)
GEMINI_GRADING_MODELS: ${GEMINI_GRADING_MODELS:-gemini-3-flash-preview,gemini-2.5-flash,gemini-2.5-flash-lite,gemma-3-12b-it}
GEMINI_GRADING_MODELS: ${GEMINI_GRADING_MODELS:-gemini-2.5-flash,gemini-2.5-flash-lite,gemma-3-12b-it}
# JVM options
JAVA_OPTS: ${JAVA_OPTS:-"-Xmx512m -Xms256m"}
JAVA_OPTS: ${JAVA_OPTS:--Xmx512m -Xms256m}
depends_on:
postgres:
condition: service_healthy
Expand Down
26 changes: 5 additions & 21 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -84,27 +84,7 @@
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-flyway-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc-test</artifactId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
Expand All @@ -129,6 +109,10 @@
<version>1.20.4</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<!-- HTTP Client for the Gemini WebSocket -->
<dependency>
<groupId>com.squareup.okhttp3</groupId>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,18 @@
@ConfigurationProperties(prefix = "gemini")
public class GeminiConfig {

/**
* Application mode: DEV, PROD, or REVIEWER.
* Note: Uses @Value because app.mode is outside the "gemini" prefix.
*/
@Value("${app.mode:DEV}")
private String appMode;

private String apiKey;

private String liveModel = "gemini-2.0-flash-exp";

private String gradingModel = "gemini-2.5-pro-preview-05-06";
private String gradingModel = "gemini-2.5-flash";

private String voiceName = "Aoede";

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import org.springframework.security.config.annotation.web.configurers.HeadersConfigurer;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.header.writers.ReferrerPolicyHeaderWriter;
import org.springframework.security.web.header.writers.XXssProtectionHeaderWriter;
Expand Down Expand Up @@ -38,6 +39,11 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
.csrf(csrf -> csrf
.ignoringRequestMatchers("/ws/**", "/api/**")
)
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)
.sessionFixation().migrateSession()
.maximumSessions(1)
)
.headers(headers -> headers
.frameOptions(HeadersConfigurer.FrameOptionsConfig::deny)
.xssProtection(xss -> xss
Expand All @@ -55,7 +61,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
.contentSecurityPolicy(csp -> csp
.policyDirectives(
"default-src 'self'; " +
"script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdn.tailwindcss.com https://cdn.jsdelivr.net; " +
"script-src 'self' https://cdn.tailwindcss.com https://cdn.jsdelivr.net; " +
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://cdnjs.cloudflare.com; " +
"font-src 'self' https://fonts.gstatic.com https://cdnjs.cloudflare.com; " +
"img-src 'self' data:; " +
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,14 @@ public void configureMessageBroker(MessageBrokerRegistry config) {
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
// WebSocket endpoint that browser connects to
// In production, restrict to your domain via ALLOWED_ORIGINS env var
String allowedOrigins = System.getenv("ALLOWED_ORIGINS");
String[] origins = (allowedOrigins != null && !allowedOrigins.isBlank())
? allowedOrigins.split(",")
: new String[]{"http://localhost:8080", "http://127.0.0.1:8080"};

registry.addEndpoint("/ws/interview")
.setAllowedOriginPatterns("*")
.setAllowedOriginPatterns(origins)
.withSockJS();
}//registerStompEndpoints

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,10 +79,9 @@ public String dashboard(
// Override totalSessions with actual total from pagination (respects filters)
stats.put("totalSessions", pageData.get("totalElements"));

// Build feedback lookup map (sessionId -> feedback)
Map<UUID, InterviewFeedback> feedbackMap = sessions.stream()
.map(s -> feedbackRepository.findBySessionId(s.getId()).orElse(null))
.filter(f -> f != null)
// Build feedback lookup map (sessionId -> feedback) — single batch query
List<UUID> sessionIds = sessions.stream().map(InterviewSession::getId).toList();
Map<UUID, InterviewFeedback> feedbackMap = feedbackRepository.findBySessionIdIn(sessionIds).stream()
.collect(Collectors.toMap(f -> f.getSession().getId(), f -> f));

// Calculate durations (in minutes)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -130,11 +130,8 @@ public ResponseEntity<Map<String, Object>> validateApiKey(


private String getClientIp(HttpServletRequest request) {
String xForwardedFor = request.getHeader("X-Forwarded-For");
if (xForwardedFor != null && !xForwardedFor.isBlank()) {
// Take the first IP in case of multiple proxies
return xForwardedFor.split(",")[0].trim();
}
// Only trust X-Forwarded-For when behind a known proxy.
// Use request.getRemoteAddr() as the reliable source.
return request.getRemoteAddr();
}// getClientIp

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ public ResponseEntity<Map<String, Object>> uploadCv(@RequestParam("file") Multip
log.error("CV processing failed", e);
return ResponseEntity.internalServerError().body(Map.of(
"success", false,
"error", "Failed to process CV: " + e.getMessage()
"error", "Failed to process CV. Please ensure the file is a valid PDF or DOCX."
));
}
}//uploadCv
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package net.k2ai.interviewSimulator.controller;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import net.k2ai.interviewSimulator.config.GeminiConfig;
import net.k2ai.interviewSimulator.entity.InterviewSession;
import net.k2ai.interviewSimulator.repository.InterviewSessionRepository;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.RequestParam;

import java.util.List;

@Slf4j
@RequiredArgsConstructor
@Controller
public class HistoryController {

private static final String LAYOUT = "layouts/main";

private final InterviewSessionRepository sessionRepository;
private final GeminiConfig geminiConfig;

@ModelAttribute("appMode")
public String appMode() {
return geminiConfig.getAppMode();
}// appMode


@GetMapping("/history")
public String showHistory(
@RequestParam(value = "token", required = false) String userToken,
Model model
) {
List<InterviewSession> sessions = List.of();

if (userToken != null && !userToken.isBlank() && userToken.length() <= 64) {
sessions = sessionRepository.findByUserTokenOrderByStartedAtDesc(userToken);
}

model.addAttribute("sessions", sessions);
model.addAttribute("content", "pages/history");
return LAYOUT;
}// showHistory

}// HistoryController
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
public class InterviewWebSocketController {

private static final Set<String> VALID_DIFFICULTIES = Set.of("Easy", "Standard", "Hard");
private static final Set<String> VALID_LANGUAGES = Set.of("en", "bg");
private static final Set<String> VALID_LANGUAGES = Set.of("en", "bg", "de", "es", "fr");
private static final Set<String> VALID_VOICES = Set.of("Algieba", "Kore", "Fenrir", "Despina");

private final GeminiIntegrationService geminiIntegrationService;
Expand All @@ -43,6 +43,9 @@ public void startInterview(@Payload Map<String, String> payload, SimpMessageHead
String interviewerNameEN = sanitizerService.sanitizeName(payload.get("interviewerNameEN"));
String interviewerNameBG = sanitizerService.sanitizeName(payload.get("interviewerNameBG"));
String userApiKey = payload.get("userApiKey");
String userToken = payload.get("userToken");
String topicFocus = payload.get("topicFocus");
String interviewLength = payload.get("interviewLength");

// Validate required fields
if (candidateName == null || candidateName.isBlank()) {
Expand Down Expand Up @@ -83,7 +86,8 @@ public void startInterview(@Payload Map<String, String> payload, SimpMessageHead

UUID interviewSessionId = geminiIntegrationService.startInterview(
sessionIdStr, candidateName, position, difficulty, language, cvText,
voiceId, interviewerNameEN, interviewerNameBG, userApiKey);
voiceId, interviewerNameEN, interviewerNameBG, userApiKey,
userToken, topicFocus, interviewLength);

log.info("Interview started - WebSocket: {}, Interview Session: {}, Language: {}, Voice: {}, CV provided: {}, User API key: {}",
sessionIdStr, interviewSessionId, language, voiceId, cvText != null && !cvText.isBlank(), userApiKey != null);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,17 @@
import net.k2ai.interviewSimulator.config.GeminiConfig;
import net.k2ai.interviewSimulator.entity.InterviewFeedback;
import net.k2ai.interviewSimulator.repository.InterviewFeedbackRepository;
import net.k2ai.interviewSimulator.repository.InterviewSessionRepository;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;

import java.util.Arrays;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;

import java.util.Collections;
import java.util.List;
import java.util.UUID;
Expand All @@ -31,6 +34,8 @@ public class ReportController {

private final InterviewFeedbackRepository feedbackRepository;
private final GeminiConfig geminiConfig;
private final ObjectMapper objectMapper;
private final InterviewSessionRepository sessionRepository;

@ModelAttribute("appMode")
public String appMode() {
Expand Down Expand Up @@ -65,10 +70,16 @@ public String showReport(
List<String> strengths = parseJsonArray(feedback.getStrengths());
List<String> improvements = parseJsonArray(feedback.getImprovements());

// Load session for transcript replay
var sessionOpt = sessionRepository.findById(uuid);
String transcript = sessionOpt.map(s -> s.getTranscript()).orElse("");

model.addAttribute("feedback", feedback);
model.addAttribute("sessionId", sessionId.substring(0, 8));
model.addAttribute("fullSessionId", sessionId);
model.addAttribute("strengths", strengths);
model.addAttribute("improvements", improvements);
model.addAttribute("transcript", transcript);
model.addAttribute("content", "pages/report-standalone");

// Clear the setup form from session since interview is complete
Expand All @@ -95,24 +106,10 @@ private List<String> parseJsonArray(String jsonArray) {
}

try {
// Simple parsing for JSON arrays like ["item1", "item2"]
String cleaned = jsonArray.trim();
if (cleaned.startsWith("[") && cleaned.endsWith("]")) {
cleaned = cleaned.substring(1, cleaned.length() - 1);
}

if (cleaned.isBlank()) {
return Collections.emptyList();
}

// Split by "," and clean up quotes
return Arrays.stream(cleaned.split("\",\\s*\""))
.map(s -> s.replaceAll("^\"|\"$", "").trim())
.filter(s -> !s.isBlank())
.toList();
return objectMapper.readValue(jsonArray, new TypeReference<List<String>>() {});
} catch (Exception e) {
log.warn("Failed to parse JSON array: {}", jsonArray);
return List.of(jsonArray); // Return as single item if parsing fails
return List.of(jsonArray);
}
}// parseJsonArray

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -143,8 +143,8 @@ public String processStep2(
// Sanitize extracted CV text
String sanitizedCvText = sanitizerService.sanitizeCvText(extractedText);
form.setCvText(sanitizedCvText);
form.setCvFileName(cvFile.getOriginalFilename());
log.info("CV processed: {} ({} chars)", cvFile.getOriginalFilename(), sanitizedCvText.length());
form.setCvFileName(cvProcessingService.sanitizeFilename(cvFile.getOriginalFilename()));
log.info("CV processed: {} ({} chars)", form.getCvFileName(), sanitizedCvText.length());
cvWasUploaded = true;
} catch (IllegalArgumentException e) {
bindingResult.rejectValue("cvFile", "validation.cv.invalid");
Expand Down Expand Up @@ -177,6 +177,14 @@ public String processStep2(
String[] validDifficulties = {"Easy", "Standard", "Hard"};
form.setDifficulty(sanitizerService.validateEnum(form.getDifficulty(), validDifficulties, "Easy"));

// Validate topic focus (optional)
String[] validTopics = {"general", "system_design", "behavioral", "algorithms", "culture_fit"};
form.setTopicFocus(sanitizerService.validateEnum(form.getTopicFocus(), validTopics, "general"));

// Validate interview length
String[] validLengths = {"quick", "standard", "marathon"};
form.setInterviewLength(sanitizerService.validateEnum(form.getInterviewLength(), validLengths, "standard"));

if (bindingResult.hasErrors()) {
model.addAttribute("content", "pages/setup/step2");
model.addAttribute("currentStep", 2);
Expand Down Expand Up @@ -227,7 +235,7 @@ public String processStep3(
}

// Validate and sanitize language
String[] validLanguages = {"en", "bg"};
String[] validLanguages = {"en", "bg", "de", "es", "fr"};
String sanitizedLanguage = sanitizerService.validateEnum(form.getLanguage(), validLanguages, null);
if (sanitizedLanguage == null) {
bindingResult.rejectValue("language", "validation.language.required");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ public class VoiceController {

private static final Set<String> VALID_VOICE_IDS = Set.of("Algieba", "Kore", "Fenrir", "Despina");

private static final Set<String> VALID_LANGUAGES = Set.of("EN", "BG");
private static final Set<String> VALID_LANGUAGES = Set.of("EN", "BG", "DE", "ES", "FR");


@GetMapping
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,11 @@ public class InterviewSetupDTO implements Serializable {
private String cvText;
private String cvFileName;

// Step 2 (continued): Topic Focus and Interview Length
private String topicFocus;

private String interviewLength = "standard";

// Step 3: Voice & Language
@NotBlank(message = "{validation.language.required}")
@ValidLanguage
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;

import java.time.LocalDateTime;
Expand All @@ -12,12 +13,14 @@
@Entity
@Table(name = "admin_users")
@Data
@EqualsAndHashCode(onlyExplicitlyIncluded = true)
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class AdminUser {

@Id
@EqualsAndHashCode.Include
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;

Expand Down
Loading