diff --git a/backend/service.yaml b/backend/service.yaml index 55434c6..a8f1458 100644 --- a/backend/service.yaml +++ b/backend/service.yaml @@ -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: diff --git a/backend/src/main/java/com/thughari/jobtrackerpro/service/GeminiExtractionService.java b/backend/src/main/java/com/thughari/jobtrackerpro/service/GeminiExtractionService.java index 9776987..59facb8 100644 --- a/backend/src/main/java/com/thughari/jobtrackerpro/service/GeminiExtractionService.java +++ b/backend/src/main/java/com/thughari/jobtrackerpro/service/GeminiExtractionService.java @@ -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() @@ -45,26 +63,10 @@ public JobDTO extractJobFromEmail(String from, String subject, String body) { String prompt = buildPrompt(from, subject, body); try { - Map 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; } } @@ -76,24 +78,91 @@ public List extractJobsFromBatch(List items) { String prompt = buildBatchPrompt(items); try { - Map 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 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 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); } } @@ -450,17 +519,10 @@ private String buildBatchPrompt(List items) { ]""".formatted(emailListBuilder.toString()); } - private List parseBulkGeminiResponse(String rawResponse) { + private List 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")) { @@ -487,7 +549,7 @@ private List 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(); } } @@ -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")) { @@ -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; } } diff --git a/backend/src/main/resources/application-dev.properties b/backend/src/main/resources/application-dev.properties index 03b5be0..438dadf 100644 --- a/backend/src/main/resources/application-dev.properties +++ b/backend/src/main/resources/application-dev.properties @@ -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 diff --git a/backend/src/main/resources/application-local.properties b/backend/src/main/resources/application-local.properties index 34b2225..a375668 100644 --- a/backend/src/main/resources/application-local.properties +++ b/backend/src/main/resources/application-local.properties @@ -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) diff --git a/backend/src/main/resources/application-prod.properties b/backend/src/main/resources/application-prod.properties index 0fe8f1a..7dc3f47 100644 --- a/backend/src/main/resources/application-prod.properties +++ b/backend/src/main/resources/application-prod.properties @@ -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 diff --git a/frontend/src/app/components/application-list/application-list.component.html b/frontend/src/app/components/application-list/application-list.component.html index d3a64fd..6725c94 100644 --- a/frontend/src/app/components/application-list/application-list.component.html +++ b/frontend/src/app/components/application-list/application-list.component.html @@ -98,18 +98,21 @@

-
- {{ job.company }} +
+ {{ job.company }} @if (job.url && job.url.startsWith('http')) { }
- {{ job.role }} - {{ - job.location }} + +
{{ job.role }}
+ + +
{{ job.location }}
+ {{ job.appliedDate | date:'MM/dd/yyyy' }}