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
28 changes: 28 additions & 0 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
name: Qodana
on:
workflow_dispatch:
pull_request:
push:
branches: # Specify your branches here
- main # The 'main' branch
- 'releases/*' # The release branches

jobs:
qodana:
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
checks: write
steps:
- uses: actions/checkout@v3
with:
ref: ${{ github.event.pull_request.head.sha }} # to check out the actual pull request commit, not the merge commit
fetch-depth: 0 # a full history is required for pull request analysis
- name: 'Qodana Scan'
uses: JetBrains/qodana-action@v2025.1
with:
pr-mode: false
env:
QODANA_TOKEN: ${{ secrets.QODANA_TOKEN }}
QODANA_ENDPOINT: 'https://qodana.cloud'
13 changes: 13 additions & 0 deletions .zed/tasks.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,5 +46,18 @@
"shell": "system",
// Represents the tags for inline runnable indicators, or spawning multiple tasks at once.
"tags": []
},
{
"label": "Test Tomatoes Integration",
"command": "bash gradlew test --tests '*TomatoesRestControllerIntegrationTest*'",
"cwd": "KetchApp-App-Api",
"env": {},
"use_new_terminal": false,
"allow_concurrent_runs": false,
"reveal": "always",
"reveal_target": "dock",
"hide": "never",
"shell": "system",
"tags": []
}
]
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import com.alessandra_alessandro.ketchapp.jwt.JwtAuthenticationFilter;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,6 @@
import com.alessandra_alessandro.ketchapp.models.dto.PlanBuilderRequestDto;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.util.HashMap;
import java.util.Map;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
Expand Down
151 changes: 103 additions & 48 deletions src/main/java/com/alessandra_alessandro/ketchapp/utils/GeminiApi.java
Original file line number Diff line number Diff line change
@@ -1,32 +1,30 @@
package com.alessandra_alessandro.ketchapp.utils;

import com.alessandra_alessandro.ketchapp.models.Schema;
import com.alessandra_alessandro.ketchapp.models.dto.PlanBuilderRequestDto;
import com.alessandra_alessandro.ketchapp.models.dto.PlanBuilderResponseDto;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.LocalDate;

import com.alessandra_alessandro.ketchapp.models.Schema;

import java.util.HashMap;
import java.util.Arrays;
import java.util.HashMap;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

@Component
public class GeminiApi {
public static final String BASE_URL = "https://generativelanguage.googleapis.com/v1beta/models/";

public static final String BASE_URL =
"https://generativelanguage.googleapis.com/v1beta/models/";
private final String apiKey;
public static final String MODEL = "gemini-2.5-flash";
private final String endpoint;
Expand All @@ -50,7 +48,9 @@ public GeminiApi(@Value("${GEMINI_API_KEY}") String apiKey) {
public PlanBuilderRequestDto ask(PlanBuilderResponseDto dto) {
assert dto != null : "Request DTO cannot be null";
if (apiKey == null || apiKey.isEmpty()) {
log.error("Error: GEMINI_API_KEY property not set in application.properties.");
log.error(
"Error: GEMINI_API_KEY property not set in application.properties."
);
return null;
}
log.debug("Received PlanBuilderResponseDto: {}", dto);
Expand All @@ -67,11 +67,24 @@ public PlanBuilderRequestDto ask(PlanBuilderResponseDto dto) {
String jsonPayload = buildGeminiPayload(dtoJson, question);
log.debug("Built Gemini API payload: {}", jsonPayload);
HttpRequest request = buildHttpRequest(jsonPayload);
log.debug("Sending HTTP request to Gemini API endpoint: {}", endpoint);
HttpResponse<String> response = HTTP_CLIENT.send(request, HttpResponse.BodyHandlers.ofString());
log.debug("Received response from Gemini API: status={}, body={}", response.statusCode(), response.body());
log.debug(
"Sending HTTP request to Gemini API endpoint: {}",
endpoint
);
HttpResponse<String> response = HTTP_CLIENT.send(
request,
HttpResponse.BodyHandlers.ofString()
);
log.debug(
"Received response from Gemini API: status={}, body={}",
response.statusCode(),
response.body()
);
if (response.statusCode() != 200) {
log.error("Error: Gemini API returned status code {}", response.statusCode());
log.error(
"Error: Gemini API returned status code {}",
response.statusCode()
);
return null;
}
return extractDtoFromGeminiResponse(response.body());
Expand Down Expand Up @@ -112,28 +125,37 @@ private static String getPauseFromDto(PlanBuilderResponseDto dto) {
* @param dto the PlanBuilderResponseDto containing subjects and other info
* @return a formatted question string for the Gemini API
*/
private static String buildQuestion(String session, String pause, PlanBuilderResponseDto dto) {
private static String buildQuestion(
String session,
String pause,
PlanBuilderResponseDto dto
) {
StringBuilder subjectsInfo = new StringBuilder();
if (dto.getSubjects() != null && !dto.getSubjects().isEmpty()) {
subjectsInfo.append("Subjects to study:\n");
for (var subject : dto.getSubjects()) {
subjectsInfo.append("- ")
.append(subject.getName())
.append("\n");
subjectsInfo
.append("- ")
.append(subject.getName())
.append("\n");
}
}
return String.format("""
Today's date is %s.
Look at the events you have in your calendar and, based on those, create a study plan for me that allows me to study without overlapping with my scheduled commitments.

Subjects to study: %s
Each study session lasts %s and the break is %s.
Leave a margin of 30 minutes before and after each calendar event.
Remember that Start_at, End_at, and Pause_end_at are in ISO 8601 format (YYYY-MM-DDTHH:MM:SSZ).""",
LocalDate.now(),
subjectsInfo,
session,
pause);
return String.format(
"""
Today's date is %s.
Look at the events you have in your calendar and, based on those, create a study plan for me that allows me to study without overlapping with my scheduled commitments.

Subjects to study: %s
Each study session lasts %s and the break is %s.
Leave a margin of 30 minutes before and after each calendar event.
Remember that Start_at, End_at, and Pause_end_at are in ISO 8601 format (YYYY-MM-DDTHH:MM:SSZ).
IMPORTANT Ensure that all study sessions (tomatoes) are scheduled exclusively for future times.
""",
LocalDate.now(),
subjectsInfo,
session,
pause
);
}

/**
Expand All @@ -146,7 +168,8 @@ private static String buildQuestion(String session, String pause, PlanBuilderRes
* @return the complete JSON payload as a string
* @throws JsonProcessingException if serialization fails
*/
private static String buildGeminiPayload(String dtoJson, String question) throws JsonProcessingException {
private static String buildGeminiPayload(String dtoJson, String question)
throws JsonProcessingException {
ObjectNode payload = OBJECT_MAPPER.createObjectNode();
ArrayNode contentsArray = OBJECT_MAPPER.createArrayNode();
ObjectNode contentNode = OBJECT_MAPPER.createObjectNode();
Expand All @@ -162,7 +185,10 @@ private static String buildGeminiPayload(String dtoJson, String question) throws
ObjectNode generationConfig = OBJECT_MAPPER.createObjectNode();
generationConfig.put("responseMimeType", "application/json");
generationConfig.put("temperature", 1);
generationConfig.set("responseSchema", OBJECT_MAPPER.valueToTree(buildResponseSchema()));
generationConfig.set(
"responseSchema",
OBJECT_MAPPER.valueToTree(buildResponseSchema())
);

payload.set("contents", contentsArray);
payload.set("generationConfig", generationConfig);
Expand All @@ -181,23 +207,41 @@ private static Schema.ObjectSchema buildResponseSchema() {
calendarItemProps.put("title", new Schema.PropertySchema("string"));
calendarItemProps.put("start_at", new Schema.PropertySchema("string"));
calendarItemProps.put("end_at", new Schema.PropertySchema("string"));
Schema.ObjectSchema calendarItemSchema = new Schema.ObjectSchema(calendarItemProps, Arrays.asList("title", "start_at", "end_at"));
Schema.ObjectSchema calendarItemSchema = new Schema.ObjectSchema(
calendarItemProps,
Arrays.asList("title", "start_at", "end_at")
);

HashMap<String, Object> tomatoItemProps = new HashMap<>();
tomatoItemProps.put("start_at", new Schema.PropertySchema("string"));
tomatoItemProps.put("end_at", new Schema.PropertySchema("string"));
tomatoItemProps.put("pause_end_at", new Schema.PropertySchema("string"));
Schema.ObjectSchema tomatoItemSchema = new Schema.ObjectSchema(tomatoItemProps, Arrays.asList("start_at", "end_at", "pause_end_at"));
tomatoItemProps.put(
"pause_end_at",
new Schema.PropertySchema("string")
);
Schema.ObjectSchema tomatoItemSchema = new Schema.ObjectSchema(
tomatoItemProps,
Arrays.asList("start_at", "end_at", "pause_end_at")
);

HashMap<String, Object> subjectItemProps = new HashMap<>();
subjectItemProps.put("name", new Schema.PropertySchema("string"));
subjectItemProps.put("tomatoes", new Schema.ArraySchema(tomatoItemSchema));
Schema.ObjectSchema subjectItemSchema = new Schema.ObjectSchema(subjectItemProps, Arrays.asList("name", "tomatoes"));
subjectItemProps.put(
"tomatoes",
new Schema.ArraySchema(tomatoItemSchema)
);
Schema.ObjectSchema subjectItemSchema = new Schema.ObjectSchema(
subjectItemProps,
Arrays.asList("name", "tomatoes")
);

HashMap<String, Object> mainProps = new HashMap<>();
mainProps.put("calendar", new Schema.ArraySchema(calendarItemSchema));
mainProps.put("subjects", new Schema.ArraySchema(subjectItemSchema));
return new Schema.ObjectSchema(mainProps, Arrays.asList("calendar", "subjects"));
return new Schema.ObjectSchema(
mainProps,
Arrays.asList("calendar", "subjects")
);
}

/**
Expand All @@ -208,10 +252,10 @@ private static Schema.ObjectSchema buildResponseSchema() {
*/
private HttpRequest buildHttpRequest(String jsonPayload) {
return HttpRequest.newBuilder()
.uri(URI.create(endpoint))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(jsonPayload))
.build();
.uri(URI.create(endpoint))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(jsonPayload))
.build();
}

/**
Expand All @@ -221,18 +265,29 @@ private HttpRequest buildHttpRequest(String jsonPayload) {
* @param responseBody the raw JSON response from the Gemini API
* @return the extracted PlanBuilderRequestDto, or null if extraction fails
*/
private static PlanBuilderRequestDto extractDtoFromGeminiResponse(String responseBody) {
private static PlanBuilderRequestDto extractDtoFromGeminiResponse(
String responseBody
) {
try {
JsonNode root = OBJECT_MAPPER.readTree(responseBody);
JsonNode candidates = root.path("candidates");
if (candidates.isArray() && !candidates.isEmpty()) {
JsonNode parts = candidates.get(0).path("content").path("parts");
JsonNode parts = candidates
.get(0)
.path("content")
.path("parts");
if (parts.isArray() && !parts.isEmpty()) {
String jsonText = parts.get(0).path("text").asText();
return OBJECT_MAPPER.readValue(jsonText, PlanBuilderRequestDto.class);
return OBJECT_MAPPER.readValue(
jsonText,
PlanBuilderRequestDto.class
);
}
}
log.error("Could not extract DTO from Gemini API response: {}", responseBody);
log.error(
"Could not extract DTO from Gemini API response: {}",
responseBody
);
} catch (Exception e) {
log.error("Error parsing Gemini API response: {}", e.getMessage());
}
Expand Down
2 changes: 1 addition & 1 deletion src/main/resources/application.properties
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ GEMINI_API_KEY=AIzaSyClXWWxzTCaB6mMZl1Uqcr_3Pe2xZgFHyE

# Kafka Configuration
app.kafka.topic.mail-service=MailService
spring.kafka.bootstrap-servers=localhost:29092
spring.kafka.bootstrap-servers=151.42.165.160:29092
spring.kafka.consumer.group-id=KafkaID
spring.kafka.consumer.auto-offset-reset=earliest
# important for initial consumer
Expand Down
Loading