Skip to content
Merged
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
20 changes: 20 additions & 0 deletions backend/service.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,26 @@ spec:
name: gemini-api-key
key: latest

- name: GROK_API_KEY
valueFrom:
secretKeyRef:
name: groq-api-key
key: latest
- name: GROQ_API_URL
value: "https://api.groq.com/openai/v1/chat/completions"
- name: GROQ_MODEL
value: "llama-3.1-8b-instant"

- name: OPENROUTER_API_KEY
valueFrom:
secretKeyRef:
name: openrouter-api-key
key: latest
- name: OPENROUTER_API_URL
value: "https://openrouter.ai/api/v1/chat/completions"
- name: OPENROUTER_MODEL
value: "meta-llama/llama-3.1-8b-instruct"

- name: JWT_SECRET
valueFrom:
secretKeyRef:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,24 @@ public class GeminiExtractionService implements GeminiService {
@Value("${gemini.api.url}")
private String apiUrl;

@Value("${groq.api.key:}")
private String groqApiKey;

@Value("${groq.api.url:}")
private String groqApiUrl;

@Value("${groq.api.model:}")
private String groqModel;

@Value("${openrouter.api.key:}")
private String openRouterApiKey;

@Value("${openrouter.api.url:}")
private String openRouterApiUrl;

@Value("${openrouter.api.model:}")
private String openRouterModel;

public GeminiExtractionService() {
this.restClient = RestClient.create();
this.objectMapper = new ObjectMapper()
Expand All @@ -45,26 +63,10 @@ public JobDTO extractJobFromEmail(String from, String subject, String body) {
String prompt = buildPrompt(from, subject, body);

try {
Map<String, Object> requestBody = Map.of(
"contents", List.of(
Map.of(
"role", "user",
"parts", List.of(Map.of("text", prompt))
)
)
);

String response = restClient.post()
.uri(apiUrl + "?key=" + apiKey)
.contentType(MediaType.APPLICATION_JSON)
.body(requestBody)
.retrieve()
.body(String.class);

return parseGeminiResponse(response);

String contentText = executeWithFallback(prompt);
return parseExtractedText(contentText);
} catch (Exception e) {
log.error("AI Extraction failed or timed out", e);
log.error("AI Extraction failed or timed out across all providers", e);
return null;
}
}
Expand All @@ -76,24 +78,91 @@ public List<JobDTO> extractJobsFromBatch(List<EmailBatchItem> items) {
String prompt = buildBatchPrompt(items);

try {
Map<String, Object> requestBody = Map.of(
"contents", List.of(
Map.of("role", "user", "parts", List.of(Map.of("text", prompt)))
)
);
String contentText = executeWithFallback(prompt);
return parseBulkExtractedText(contentText);
} catch (Exception e) {
log.error("Bulk AI Extraction failed across all providers", e);
return List.of();
}
}

private String executeWithFallback(String prompt) {
try {
return executeGeminiCall(prompt);
} catch (Exception e) {
log.warn("Gemini AI failed, falling back to Groq AI: {}", e.getMessage());
try {
return executeGroqCall(prompt);
} catch (Exception ex) {
log.warn("Groq AI failed, falling back to OpenRouter AI: {}", ex.getMessage());
return executeOpenRouterCall(prompt);
}
}
}

String response = restClient.post()
.uri(apiUrl + "?key=" + apiKey)
.contentType(MediaType.APPLICATION_JSON)
.body(requestBody)
.retrieve()
.body(String.class);
private String executeGeminiCall(String prompt) {
Map<String, Object> requestBody = Map.of(
"contents", List.of(
Map.of("role", "user", "parts", List.of(Map.of("text", prompt)))
)
);

return parseBulkGeminiResponse(response);
String response = restClient.post()
.uri(apiUrl + "?key=" + apiKey)
.contentType(MediaType.APPLICATION_JSON)
.body(requestBody)
.retrieve()
.body(String.class);

try {
JsonNode root = objectMapper.readTree(response);
JsonNode candidates = root.path("candidates");
if (candidates.isMissingNode() || candidates.isEmpty()) {
throw new RuntimeException("Empty Gemini response");
}
return candidates.get(0).path("content").path("parts").get(0).path("text").asText();
} catch (Exception e) {
log.error("Bulk AI Extraction failed", e);
return List.of();
throw new RuntimeException("Failed to parse Gemini response: " + e.getMessage(), e);
}
}

private String executeGroqCall(String prompt) {
return executeOpenAIFormatCall(prompt, groqApiUrl, groqApiKey, groqModel);
}

private String executeOpenRouterCall(String prompt) {
return executeOpenAIFormatCall(prompt, openRouterApiUrl, openRouterApiKey, openRouterModel);
}

private String executeOpenAIFormatCall(String prompt, String url, String key, String model) {
if (key == null || key.isBlank() || url == null || url.isBlank()) {
throw new RuntimeException("API key or URL is not configured for provider");
}

Map<String, Object> requestBody = Map.of(
"model", model,
"messages", List.of(
Map.of("role", "user", "content", prompt)
)
);

String response = restClient.post()
.uri(url)
.header("Authorization", "Bearer " + key)
.contentType(MediaType.APPLICATION_JSON)
.body(requestBody)
.retrieve()
.body(String.class);

try {
JsonNode root = objectMapper.readTree(response);
JsonNode choices = root.path("choices");
if (choices.isMissingNode() || choices.isEmpty()) {
throw new RuntimeException("Empty response from AI provider");
}
return choices.get(0).path("message").path("content").asText();
} catch (Exception e) {
throw new RuntimeException("Failed to parse AI response: " + e.getMessage(), e);
}
}

Expand Down Expand Up @@ -450,17 +519,10 @@ private String buildBatchPrompt(List<EmailBatchItem> items) {
]""".formatted(emailListBuilder.toString());
}

private List<JobDTO> parseBulkGeminiResponse(String rawResponse) {
private List<JobDTO> parseBulkExtractedText(String contentText) {
try {
JsonNode root = objectMapper.readTree(rawResponse);
JsonNode candidates = root.path("candidates");
if (contentText == null) return List.of();

if (candidates.isMissingNode() || candidates.isEmpty()) return List.of();

String contentText = candidates.get(0)
.path("content").path("parts").get(0)
.path("text").asText();

contentText = contentText.replaceAll("```json", "").replaceAll("```", "").trim();

if (contentText.equals("[]") || contentText.equalsIgnoreCase("null")) {
Expand All @@ -487,7 +549,7 @@ private List<JobDTO> parseBulkGeminiResponse(String rawResponse) {
return jobs;

} catch (Exception e) {
log.error("Failed to parse Bulk AI response: {}", rawResponse);
log.error("Failed to parse Bulk AI response: {}", contentText);
return List.of();
}
}
Expand Down Expand Up @@ -579,19 +641,10 @@ Return ONLY raw JSON (no markdown blocks, no explanations):
""".formatted(from, subject, safeBody);
}

private JobDTO parseGeminiResponse(String rawResponse) {
private JobDTO parseExtractedText(String contentText) {
try {
JsonNode root = objectMapper.readTree(rawResponse);
JsonNode candidates = root.path("candidates");
if (contentText == null) return null;

if (candidates.isMissingNode() || candidates.isEmpty()) {
return null;
}

String contentText = candidates.get(0)
.path("content").path("parts").get(0)
.path("text").asText();

contentText = contentText.replaceAll("```json", "").replaceAll("```", "").trim();

if (contentText.equalsIgnoreCase("null")) {
Expand All @@ -618,7 +671,7 @@ private JobDTO parseGeminiResponse(String rawResponse) {
return job;

} catch (Exception e) {
log.error("Failed to parse AI response: {}", rawResponse);
log.error("Failed to parse AI response: {}", contentText);
return null;
}
}
Expand Down
10 changes: 10 additions & 0 deletions backend/src/main/resources/application-dev.properties
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,16 @@ app.gemini.enabled=true
gemini.api.key=${GEMINI_API_KEY}
gemini.api.url=https://aiplatform.googleapis.com/v1/publishers/google/models/gemini-2.5-flash-lite:generateContent

# Groq AI
groq.api.key=${GROK_API_KEY:}
groq.api.url=${GROQ_API_URL:}
groq.api.model=${GROQ_MODEL:}

# OpenRouter AI
openrouter.api.key=${OPENROUTER_API_KEY:}
openrouter.api.url=${OPENROUTER_API_URL:}
openrouter.api.model=${OPENROUTER_MODEL:}


# UI url
app.ui.url=http://localhost:4200
Expand Down
8 changes: 8 additions & 0 deletions backend/src/main/resources/application-local.properties
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,14 @@ app.gemini.enabled=false
# gemini.api.key=${GEMINI_API_KEY}
# gemini.api.url=https://aiplatform.googleapis.com/v1/publishers/google/models/gemini-2.5-flash-lite:generateContent

# groq.api.key=${GROK_API_KEY:}
# groq.api.url=${GROQ_API_URL:}
# groq.api.model=${GROQ_MODEL:}

# openrouter.api.key=${OPENROUTER_API_KEY:}
# openrouter.api.url=${OPENROUTER_API_URL:}
# openrouter.api.model=${OPENROUTER_MODEL:}

app.storage.type=local

# JWT (Only for dev)
Expand Down
10 changes: 10 additions & 0 deletions backend/src/main/resources/application-prod.properties
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,16 @@ app.gemini.enabled=true
gemini.api.key=${GEMINI_API_KEY}
gemini.api.url=https://aiplatform.googleapis.com/v1/publishers/google/models/gemini-2.5-flash-lite:generateContent

# Groq AI
groq.api.key=${GROK_API_KEY:}
groq.api.url=${GROQ_API_URL:}
groq.api.model=${GROQ_MODEL:}

# OpenRouter AI
openrouter.api.key=${OPENROUTER_API_KEY:}
openrouter.api.url=${OPENROUTER_API_URL:}
openrouter.api.model=${OPENROUTER_MODEL:}

# UI url
app.ui.url=https://jobtrackerpro.in

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -98,18 +98,21 @@ <h2 class="text-2xl md:text-3xl font-black text-gray-900 dark:text-white trackin
@for (job of jobs(); track job.id) {
<tr class="hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-colors">
<td class="px-6 py-4 whitespace-nowrap">
<div class="flex items-center">
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{ job.company }}</span>
<div class="flex items-center max-w-[160px] lg:max-w-[240px]">
<span class="text-sm font-semibold text-gray-900 dark:text-white truncate" [title]="job.company">{{ job.company }}</span>
@if (job.url && job.url.startsWith('http')) {
<a [href]="job.url" target="_blank"
class="ml-2 text-gray-400 hover:text-indigo-500 dark:hover:text-indigo-400"><i
class="ml-2 flex-shrink-0 text-gray-400 hover:text-indigo-500 dark:hover:text-indigo-400"><i
class="ph ph-arrow-square-out"></i></a>
}
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-600 dark:text-gray-300">{{ job.role }}</td>
<td class="hidden md:table-cell px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">{{
job.location }}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-600 dark:text-gray-300">
<div class="max-w-[180px] lg:max-w-[260px] truncate" [title]="job.role">{{ job.role }}</div>
</td>
<td class="hidden md:table-cell px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
<div class="max-w-[140px] lg:max-w-[200px] truncate" [title]="job.location">{{ job.location }}</div>
</td>
<td class="hidden md:table-cell px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
<span class="text-sm text-gray-900 dark:text-white">{{ job.appliedDate | date:'MM/dd/yyyy' }}</span>
</td>
Expand Down
Loading