Skip to content

Commit 10ea091

Browse files
authored
Merge pull request #82 from thughari/dev
fallback AI
2 parents 1f42f95 + 7abe804 commit 10ea091

6 files changed

Lines changed: 165 additions & 61 deletions

File tree

backend/service.yaml

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,26 @@ spec:
5151
name: gemini-api-key
5252
key: latest
5353

54+
- name: GROK_API_KEY
55+
valueFrom:
56+
secretKeyRef:
57+
name: groq-api-key
58+
key: latest
59+
- name: GROQ_API_URL
60+
value: "https://api.groq.com/openai/v1/chat/completions"
61+
- name: GROQ_MODEL
62+
value: "llama-3.1-8b-instant"
63+
64+
- name: OPENROUTER_API_KEY
65+
valueFrom:
66+
secretKeyRef:
67+
name: openrouter-api-key
68+
key: latest
69+
- name: OPENROUTER_API_URL
70+
value: "https://openrouter.ai/api/v1/chat/completions"
71+
- name: OPENROUTER_MODEL
72+
value: "meta-llama/llama-3.1-8b-instruct"
73+
5474
- name: JWT_SECRET
5575
valueFrom:
5676
secretKeyRef:

backend/src/main/java/com/thughari/jobtrackerpro/service/GeminiExtractionService.java

Lines changed: 108 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,24 @@ public class GeminiExtractionService implements GeminiService {
3434
@Value("${gemini.api.url}")
3535
private String apiUrl;
3636

37+
@Value("${groq.api.key:}")
38+
private String groqApiKey;
39+
40+
@Value("${groq.api.url:}")
41+
private String groqApiUrl;
42+
43+
@Value("${groq.api.model:}")
44+
private String groqModel;
45+
46+
@Value("${openrouter.api.key:}")
47+
private String openRouterApiKey;
48+
49+
@Value("${openrouter.api.url:}")
50+
private String openRouterApiUrl;
51+
52+
@Value("${openrouter.api.model:}")
53+
private String openRouterModel;
54+
3755
public GeminiExtractionService() {
3856
this.restClient = RestClient.create();
3957
this.objectMapper = new ObjectMapper()
@@ -45,26 +63,10 @@ public JobDTO extractJobFromEmail(String from, String subject, String body) {
4563
String prompt = buildPrompt(from, subject, body);
4664

4765
try {
48-
Map<String, Object> requestBody = Map.of(
49-
"contents", List.of(
50-
Map.of(
51-
"role", "user",
52-
"parts", List.of(Map.of("text", prompt))
53-
)
54-
)
55-
);
56-
57-
String response = restClient.post()
58-
.uri(apiUrl + "?key=" + apiKey)
59-
.contentType(MediaType.APPLICATION_JSON)
60-
.body(requestBody)
61-
.retrieve()
62-
.body(String.class);
63-
64-
return parseGeminiResponse(response);
65-
66+
String contentText = executeWithFallback(prompt);
67+
return parseExtractedText(contentText);
6668
} catch (Exception e) {
67-
log.error("AI Extraction failed or timed out", e);
69+
log.error("AI Extraction failed or timed out across all providers", e);
6870
return null;
6971
}
7072
}
@@ -76,24 +78,91 @@ public List<JobDTO> extractJobsFromBatch(List<EmailBatchItem> items) {
7678
String prompt = buildBatchPrompt(items);
7779

7880
try {
79-
Map<String, Object> requestBody = Map.of(
80-
"contents", List.of(
81-
Map.of("role", "user", "parts", List.of(Map.of("text", prompt)))
82-
)
83-
);
81+
String contentText = executeWithFallback(prompt);
82+
return parseBulkExtractedText(contentText);
83+
} catch (Exception e) {
84+
log.error("Bulk AI Extraction failed across all providers", e);
85+
return List.of();
86+
}
87+
}
88+
89+
private String executeWithFallback(String prompt) {
90+
try {
91+
return executeGeminiCall(prompt);
92+
} catch (Exception e) {
93+
log.warn("Gemini AI failed, falling back to Groq AI: {}", e.getMessage());
94+
try {
95+
return executeGroqCall(prompt);
96+
} catch (Exception ex) {
97+
log.warn("Groq AI failed, falling back to OpenRouter AI: {}", ex.getMessage());
98+
return executeOpenRouterCall(prompt);
99+
}
100+
}
101+
}
84102

85-
String response = restClient.post()
86-
.uri(apiUrl + "?key=" + apiKey)
87-
.contentType(MediaType.APPLICATION_JSON)
88-
.body(requestBody)
89-
.retrieve()
90-
.body(String.class);
103+
private String executeGeminiCall(String prompt) {
104+
Map<String, Object> requestBody = Map.of(
105+
"contents", List.of(
106+
Map.of("role", "user", "parts", List.of(Map.of("text", prompt)))
107+
)
108+
);
91109

92-
return parseBulkGeminiResponse(response);
110+
String response = restClient.post()
111+
.uri(apiUrl + "?key=" + apiKey)
112+
.contentType(MediaType.APPLICATION_JSON)
113+
.body(requestBody)
114+
.retrieve()
115+
.body(String.class);
93116

117+
try {
118+
JsonNode root = objectMapper.readTree(response);
119+
JsonNode candidates = root.path("candidates");
120+
if (candidates.isMissingNode() || candidates.isEmpty()) {
121+
throw new RuntimeException("Empty Gemini response");
122+
}
123+
return candidates.get(0).path("content").path("parts").get(0).path("text").asText();
94124
} catch (Exception e) {
95-
log.error("Bulk AI Extraction failed", e);
96-
return List.of();
125+
throw new RuntimeException("Failed to parse Gemini response: " + e.getMessage(), e);
126+
}
127+
}
128+
129+
private String executeGroqCall(String prompt) {
130+
return executeOpenAIFormatCall(prompt, groqApiUrl, groqApiKey, groqModel);
131+
}
132+
133+
private String executeOpenRouterCall(String prompt) {
134+
return executeOpenAIFormatCall(prompt, openRouterApiUrl, openRouterApiKey, openRouterModel);
135+
}
136+
137+
private String executeOpenAIFormatCall(String prompt, String url, String key, String model) {
138+
if (key == null || key.isBlank() || url == null || url.isBlank()) {
139+
throw new RuntimeException("API key or URL is not configured for provider");
140+
}
141+
142+
Map<String, Object> requestBody = Map.of(
143+
"model", model,
144+
"messages", List.of(
145+
Map.of("role", "user", "content", prompt)
146+
)
147+
);
148+
149+
String response = restClient.post()
150+
.uri(url)
151+
.header("Authorization", "Bearer " + key)
152+
.contentType(MediaType.APPLICATION_JSON)
153+
.body(requestBody)
154+
.retrieve()
155+
.body(String.class);
156+
157+
try {
158+
JsonNode root = objectMapper.readTree(response);
159+
JsonNode choices = root.path("choices");
160+
if (choices.isMissingNode() || choices.isEmpty()) {
161+
throw new RuntimeException("Empty response from AI provider");
162+
}
163+
return choices.get(0).path("message").path("content").asText();
164+
} catch (Exception e) {
165+
throw new RuntimeException("Failed to parse AI response: " + e.getMessage(), e);
97166
}
98167
}
99168

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

453-
private List<JobDTO> parseBulkGeminiResponse(String rawResponse) {
522+
private List<JobDTO> parseBulkExtractedText(String contentText) {
454523
try {
455-
JsonNode root = objectMapper.readTree(rawResponse);
456-
JsonNode candidates = root.path("candidates");
524+
if (contentText == null) return List.of();
457525

458-
if (candidates.isMissingNode() || candidates.isEmpty()) return List.of();
459-
460-
String contentText = candidates.get(0)
461-
.path("content").path("parts").get(0)
462-
.path("text").asText();
463-
464526
contentText = contentText.replaceAll("```json", "").replaceAll("```", "").trim();
465527

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

489551
} catch (Exception e) {
490-
log.error("Failed to parse Bulk AI response: {}", rawResponse);
552+
log.error("Failed to parse Bulk AI response: {}", contentText);
491553
return List.of();
492554
}
493555
}
@@ -579,19 +641,10 @@ Return ONLY raw JSON (no markdown blocks, no explanations):
579641
""".formatted(from, subject, safeBody);
580642
}
581643

582-
private JobDTO parseGeminiResponse(String rawResponse) {
644+
private JobDTO parseExtractedText(String contentText) {
583645
try {
584-
JsonNode root = objectMapper.readTree(rawResponse);
585-
JsonNode candidates = root.path("candidates");
646+
if (contentText == null) return null;
586647

587-
if (candidates.isMissingNode() || candidates.isEmpty()) {
588-
return null;
589-
}
590-
591-
String contentText = candidates.get(0)
592-
.path("content").path("parts").get(0)
593-
.path("text").asText();
594-
595648
contentText = contentText.replaceAll("```json", "").replaceAll("```", "").trim();
596649

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

620673
} catch (Exception e) {
621-
log.error("Failed to parse AI response: {}", rawResponse);
674+
log.error("Failed to parse AI response: {}", contentText);
622675
return null;
623676
}
624677
}

backend/src/main/resources/application-dev.properties

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,16 @@ app.gemini.enabled=true
2323
gemini.api.key=${GEMINI_API_KEY}
2424
gemini.api.url=https://aiplatform.googleapis.com/v1/publishers/google/models/gemini-2.5-flash-lite:generateContent
2525

26+
# Groq AI
27+
groq.api.key=${GROK_API_KEY:}
28+
groq.api.url=${GROQ_API_URL:}
29+
groq.api.model=${GROQ_MODEL:}
30+
31+
# OpenRouter AI
32+
openrouter.api.key=${OPENROUTER_API_KEY:}
33+
openrouter.api.url=${OPENROUTER_API_URL:}
34+
openrouter.api.model=${OPENROUTER_MODEL:}
35+
2636

2737
# UI url
2838
app.ui.url=http://localhost:4200

backend/src/main/resources/application-local.properties

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,14 @@ app.gemini.enabled=false
2121
# gemini.api.key=${GEMINI_API_KEY}
2222
# gemini.api.url=https://aiplatform.googleapis.com/v1/publishers/google/models/gemini-2.5-flash-lite:generateContent
2323

24+
# groq.api.key=${GROK_API_KEY:}
25+
# groq.api.url=${GROQ_API_URL:}
26+
# groq.api.model=${GROQ_MODEL:}
27+
28+
# openrouter.api.key=${OPENROUTER_API_KEY:}
29+
# openrouter.api.url=${OPENROUTER_API_URL:}
30+
# openrouter.api.model=${OPENROUTER_MODEL:}
31+
2432
app.storage.type=local
2533

2634
# JWT (Only for dev)

backend/src/main/resources/application-prod.properties

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,16 @@ app.gemini.enabled=true
3838
gemini.api.key=${GEMINI_API_KEY}
3939
gemini.api.url=https://aiplatform.googleapis.com/v1/publishers/google/models/gemini-2.5-flash-lite:generateContent
4040

41+
# Groq AI
42+
groq.api.key=${GROK_API_KEY:}
43+
groq.api.url=${GROQ_API_URL:}
44+
groq.api.model=${GROQ_MODEL:}
45+
46+
# OpenRouter AI
47+
openrouter.api.key=${OPENROUTER_API_KEY:}
48+
openrouter.api.url=${OPENROUTER_API_URL:}
49+
openrouter.api.model=${OPENROUTER_MODEL:}
50+
4151
# UI url
4252
app.ui.url=https://jobtrackerpro.in
4353

frontend/src/app/components/application-list/application-list.component.html

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -98,18 +98,21 @@ <h2 class="text-2xl md:text-3xl font-black text-gray-900 dark:text-white trackin
9898
@for (job of jobs(); track job.id) {
9999
<tr class="hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-colors">
100100
<td class="px-6 py-4 whitespace-nowrap">
101-
<div class="flex items-center">
102-
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{ job.company }}</span>
101+
<div class="flex items-center max-w-[160px] lg:max-w-[240px]">
102+
<span class="text-sm font-semibold text-gray-900 dark:text-white truncate" [title]="job.company">{{ job.company }}</span>
103103
@if (job.url && job.url.startsWith('http')) {
104104
<a [href]="job.url" target="_blank"
105-
class="ml-2 text-gray-400 hover:text-indigo-500 dark:hover:text-indigo-400"><i
105+
class="ml-2 flex-shrink-0 text-gray-400 hover:text-indigo-500 dark:hover:text-indigo-400"><i
106106
class="ph ph-arrow-square-out"></i></a>
107107
}
108108
</div>
109109
</td>
110-
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-600 dark:text-gray-300">{{ job.role }}</td>
111-
<td class="hidden md:table-cell px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">{{
112-
job.location }}</td>
110+
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-600 dark:text-gray-300">
111+
<div class="max-w-[180px] lg:max-w-[260px] truncate" [title]="job.role">{{ job.role }}</div>
112+
</td>
113+
<td class="hidden md:table-cell px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
114+
<div class="max-w-[140px] lg:max-w-[200px] truncate" [title]="job.location">{{ job.location }}</div>
115+
</td>
113116
<td class="hidden md:table-cell px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
114117
<span class="text-sm text-gray-900 dark:text-white">{{ job.appliedDate | date:'MM/dd/yyyy' }}</span>
115118
</td>

0 commit comments

Comments
 (0)