From db136326870563a0f7c059a0653a9e8ccd0e3cfd Mon Sep 17 00:00:00 2001 From: Tyler Perkins Date: Mon, 18 Aug 2025 21:02:22 -0400 Subject: [PATCH] Save progress --- .idea/dataSources.xml | 11 +- .idea/data_source_mapping.xml | 6 + .idea/sqldialects.xml | 2 +- agent/.env.example | 5 + agent/.gitignore | 3 + agent/pom.xml | 65 +++--- .../java/com/clortox/agent/agent/IAgent.java | 4 - .../agent/controllers/AgentController.java | 8 +- .../agent/agent/dto/ChatCompletionChunk.java | 19 ++ .../agent/dto/ChatCompletionRequest.java | 30 +++ .../agent/dto/ChatCompletionResponse.java | 26 +++ .../com/clortox/agent/agent/dto/Choice.java | 18 ++ .../clortox/agent/agent/dto/ChunkChoice.java | 18 ++ .../com/clortox/agent/agent/dto/Delta.java | 14 ++ .../clortox/agent/agent/dto/FinishReason.java | 17 ++ .../com/clortox/agent/agent/dto/Message.java | 17 ++ .../com/clortox/agent/agent/dto/Role.java | 17 ++ .../com/clortox/agent/agent/dto/Usage.java | 21 ++ .../memory/PersistentChatMemoryStore.java | 23 +- .../agent/memory/persistence/Message.java | 9 + .../memory/persistence/MessageRepository.java | 6 + .../clortox/agent/agent/service/Agent.java | 19 -- .../clortox/agent/agent/state/SmartAgent.java | 69 ++++++ .../config/GenericControllerAdvice.java | 31 +++ .../agent/common/dto/ErrorResponseDTO.java | 17 ++ .../clortox/agent/tools/ISmartAgentTool.java | 7 + .../clortox/agent/tools/dto/TenorResult.java | 19 ++ .../agent/tools/tenor/TenorSearch.java | 202 ++++++++++++++++++ agent/src/main/resources/application.yml | 14 +- .../db/migration/V0__placeholder.sql | 5 - .../resources/db/migration/V1.0.0.1__init.sql | 19 ++ .../clortox/agent/AgentApplicationTests.java | 4 + .../agent/FlywayTestConfiguration.java | 19 ++ .../com/clortox/agent/ModularityTest.java | 12 -- agent/src/test/resources/application-test.yml | 28 +++ data/agent.db | Bin 12288 -> 0 bytes 36 files changed, 724 insertions(+), 80 deletions(-) create mode 100644 .idea/data_source_mapping.xml create mode 100644 agent/.env.example delete mode 100644 agent/src/main/java/com/clortox/agent/agent/IAgent.java create mode 100644 agent/src/main/java/com/clortox/agent/agent/dto/ChatCompletionChunk.java create mode 100644 agent/src/main/java/com/clortox/agent/agent/dto/ChatCompletionRequest.java create mode 100644 agent/src/main/java/com/clortox/agent/agent/dto/ChatCompletionResponse.java create mode 100644 agent/src/main/java/com/clortox/agent/agent/dto/Choice.java create mode 100644 agent/src/main/java/com/clortox/agent/agent/dto/ChunkChoice.java create mode 100644 agent/src/main/java/com/clortox/agent/agent/dto/Delta.java create mode 100644 agent/src/main/java/com/clortox/agent/agent/dto/FinishReason.java create mode 100644 agent/src/main/java/com/clortox/agent/agent/dto/Message.java create mode 100644 agent/src/main/java/com/clortox/agent/agent/dto/Role.java create mode 100644 agent/src/main/java/com/clortox/agent/agent/dto/Usage.java create mode 100644 agent/src/main/java/com/clortox/agent/agent/memory/persistence/MessageRepository.java delete mode 100644 agent/src/main/java/com/clortox/agent/agent/service/Agent.java create mode 100644 agent/src/main/java/com/clortox/agent/agent/state/SmartAgent.java create mode 100644 agent/src/main/java/com/clortox/agent/common/config/GenericControllerAdvice.java create mode 100644 agent/src/main/java/com/clortox/agent/common/dto/ErrorResponseDTO.java create mode 100644 agent/src/main/java/com/clortox/agent/tools/ISmartAgentTool.java create mode 100644 agent/src/main/java/com/clortox/agent/tools/dto/TenorResult.java create mode 100644 agent/src/main/java/com/clortox/agent/tools/tenor/TenorSearch.java delete mode 100644 agent/src/main/resources/db/migration/V0__placeholder.sql create mode 100644 agent/src/main/resources/db/migration/V1.0.0.1__init.sql create mode 100644 agent/src/test/java/com/clortox/agent/FlywayTestConfiguration.java delete mode 100644 agent/src/test/java/com/clortox/agent/ModularityTest.java create mode 100644 agent/src/test/resources/application-test.yml delete mode 100644 data/agent.db diff --git a/.idea/dataSources.xml b/.idea/dataSources.xml index 494566a..24aecec 100644 --- a/.idea/dataSources.xml +++ b/.idea/dataSources.xml @@ -1,15 +1,6 @@ - - sqlite.xerial - true - true - $PROJECT_DIR$/agent/src/main/resources/application.yml - org.sqlite.JDBC - jdbc:sqlite:./data/agent.db - $ProjectFileDir$ - sqlite.xerial true @@ -27,7 +18,7 @@ jdbc:sqlite:./data/agent.db $ProjectFileDir$ - + sqlite.xerial true true diff --git a/.idea/data_source_mapping.xml b/.idea/data_source_mapping.xml new file mode 100644 index 0000000..f33bc1e --- /dev/null +++ b/.idea/data_source_mapping.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/sqldialects.xml b/.idea/sqldialects.xml index 39b8559..2976812 100644 --- a/.idea/sqldialects.xml +++ b/.idea/sqldialects.xml @@ -1,7 +1,7 @@ - + \ No newline at end of file diff --git a/agent/.env.example b/agent/.env.example new file mode 100644 index 0000000..de2882b --- /dev/null +++ b/agent/.env.example @@ -0,0 +1,5 @@ +OLLAMA_BASEURL=https://10.0.3.2:8080 +OLLAMA_MODEL=mistral-small3.2 +OLLAMA_TEMP=0.2 + +TOOLS_TENOR_API_KEY=mytenorkey diff --git a/agent/.gitignore b/agent/.gitignore index 667aaef..72f38b7 100644 --- a/agent/.gitignore +++ b/agent/.gitignore @@ -31,3 +31,6 @@ build/ ### VS Code ### .vscode/ + +.env +data/ diff --git a/agent/pom.xml b/agent/pom.xml index 485cccb..079ec96 100644 --- a/agent/pom.xml +++ b/agent/pom.xml @@ -28,7 +28,6 @@ 21 - 1.4.1 @@ -61,14 +60,20 @@ springdoc-openapi-starter-webmvc-ui 2.6.0 - - org.springframework.modulith - spring-modulith-starter-core - - - org.springframework.modulith - spring-modulith-starter-jpa - + + + org.xerial + sqlite-jdbc + 3.46.0.0 + + + + + org.flywaydb + flyway-core + 10.16.0 + + org.projectlombok @@ -80,11 +85,6 @@ spring-boot-starter-test test - - org.springframework.modulith - spring-modulith-starter-test - test - org.apache.httpcomponents.client5 httpclient5 @@ -118,25 +118,38 @@ langchain4j 1.3.0 - - - - + + org.bsc.langgraph4j + langgraph4j-langchain4j + + + org.bsc.langgraph4j + langgraph4j-agent-executor + true + + + org.bsc.langgraph4j + langgraph4j-core + dev.langchain4j langchain4j-ollama 1.3.0 - + + org.springframework.boot + spring-boot-starter-actuator + + - - org.springframework.modulith - spring-modulith-bom - ${spring-modulith.version} - pom - import - + + org.bsc.langgraph4j + langgraph4j-bom + 1.6.0 + pom + import + diff --git a/agent/src/main/java/com/clortox/agent/agent/IAgent.java b/agent/src/main/java/com/clortox/agent/agent/IAgent.java deleted file mode 100644 index 6f560ea..0000000 --- a/agent/src/main/java/com/clortox/agent/agent/IAgent.java +++ /dev/null @@ -1,4 +0,0 @@ -package com.clortox.agent.agent; - -public interface IAgent { -} diff --git a/agent/src/main/java/com/clortox/agent/agent/controllers/AgentController.java b/agent/src/main/java/com/clortox/agent/agent/controllers/AgentController.java index dc40643..cdf5910 100644 --- a/agent/src/main/java/com/clortox/agent/agent/controllers/AgentController.java +++ b/agent/src/main/java/com/clortox/agent/agent/controllers/AgentController.java @@ -1,5 +1,6 @@ package com.clortox.agent.agent.controllers; +import com.clortox.agent.agent.state.SmartAgent; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; @@ -9,9 +10,14 @@ import org.springframework.web.bind.annotation.RestController; @RestController("Agent") public class AgentController { + @Autowired + private SmartAgent agent; + @GetMapping(path = "/assistant") public ResponseEntity get(String message) { - return ResponseEntity.status(HttpStatus.OK).body("lol"); + return ResponseEntity.status(HttpStatus.OK).body(agent.invoke(message)); + +// return null; } } diff --git a/agent/src/main/java/com/clortox/agent/agent/dto/ChatCompletionChunk.java b/agent/src/main/java/com/clortox/agent/agent/dto/ChatCompletionChunk.java new file mode 100644 index 0000000..961a179 --- /dev/null +++ b/agent/src/main/java/com/clortox/agent/agent/dto/ChatCompletionChunk.java @@ -0,0 +1,19 @@ +package com.clortox.agent.agent.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.*; + +import java.util.List; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@JsonInclude(JsonInclude.Include.NON_NULL) +public class ChatCompletionChunk { + private String id; + private String object; // "chat.completion.chunk" + private long created; + private String model; + private List choices; +} diff --git a/agent/src/main/java/com/clortox/agent/agent/dto/ChatCompletionRequest.java b/agent/src/main/java/com/clortox/agent/agent/dto/ChatCompletionRequest.java new file mode 100644 index 0000000..f64a829 --- /dev/null +++ b/agent/src/main/java/com/clortox/agent/agent/dto/ChatCompletionRequest.java @@ -0,0 +1,30 @@ +package com.clortox.agent.agent.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonValue; +import lombok.*; + +import java.util.List; + + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@JsonInclude(JsonInclude.Include.NON_NULL) +public class ChatCompletionRequest { + + private String model; + private List messages; + + private Double temperature; + + @JsonProperty("max_tokens") + private Integer maxTokens; + + private Boolean stream; + + + +} diff --git a/agent/src/main/java/com/clortox/agent/agent/dto/ChatCompletionResponse.java b/agent/src/main/java/com/clortox/agent/agent/dto/ChatCompletionResponse.java new file mode 100644 index 0000000..a696247 --- /dev/null +++ b/agent/src/main/java/com/clortox/agent/agent/dto/ChatCompletionResponse.java @@ -0,0 +1,26 @@ +package com.clortox.agent.agent.dto; + +package com.example.openai.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonValue; +import lombok.*; + +import java.util.List; + +// ========= NON-STREAMING RESPONSE DTO ========= + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@JsonInclude(JsonInclude.Include.NON_NULL) +public class ChatCompletionResponse { + private String id; + private String object; // "chat.completion" + private long created; + private String model; + private List choices; + private Usage usage; +} diff --git a/agent/src/main/java/com/clortox/agent/agent/dto/Choice.java b/agent/src/main/java/com/clortox/agent/agent/dto/Choice.java new file mode 100644 index 0000000..c760e42 --- /dev/null +++ b/agent/src/main/java/com/clortox/agent/agent/dto/Choice.java @@ -0,0 +1,18 @@ +package com.clortox.agent.agent.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.*; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@JsonInclude(JsonInclude.Include.NON_NULL) +public class Choice { + private int index; + private Message message; + + @JsonProperty("finish_reason") + private FinishReason finishReason; +} diff --git a/agent/src/main/java/com/clortox/agent/agent/dto/ChunkChoice.java b/agent/src/main/java/com/clortox/agent/agent/dto/ChunkChoice.java new file mode 100644 index 0000000..846c734 --- /dev/null +++ b/agent/src/main/java/com/clortox/agent/agent/dto/ChunkChoice.java @@ -0,0 +1,18 @@ +package com.clortox.agent.agent.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.*; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@JsonInclude(JsonInclude.Include.NON_NULL) +public class ChunkChoice { + private int index; + private Delta delta; + + @JsonProperty("finish_reason") + private FinishReason finishReason; +} diff --git a/agent/src/main/java/com/clortox/agent/agent/dto/Delta.java b/agent/src/main/java/com/clortox/agent/agent/dto/Delta.java new file mode 100644 index 0000000..49c45e0 --- /dev/null +++ b/agent/src/main/java/com/clortox/agent/agent/dto/Delta.java @@ -0,0 +1,14 @@ +package com.clortox.agent.agent.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.*; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@JsonInclude(JsonInclude.Include.NON_NULL) +public class Delta { + private Role role; // only set in first chunk + private String content; +} diff --git a/agent/src/main/java/com/clortox/agent/agent/dto/FinishReason.java b/agent/src/main/java/com/clortox/agent/agent/dto/FinishReason.java new file mode 100644 index 0000000..f7b7ba6 --- /dev/null +++ b/agent/src/main/java/com/clortox/agent/agent/dto/FinishReason.java @@ -0,0 +1,17 @@ +package com.clortox.agent.agent.dto; + +import com.fasterxml.jackson.annotation.JsonValue; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum FinishReason { + STOP("stop"), + LENGTH("length"), + TOOL_CALLS("tool_calls"), + CONTENT_FILTER("content_filter"); + + @JsonValue + private final String value; +} diff --git a/agent/src/main/java/com/clortox/agent/agent/dto/Message.java b/agent/src/main/java/com/clortox/agent/agent/dto/Message.java new file mode 100644 index 0000000..95b0911 --- /dev/null +++ b/agent/src/main/java/com/clortox/agent/agent/dto/Message.java @@ -0,0 +1,17 @@ +package com.clortox.agent.agent.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@JsonInclude(JsonInclude.Include.NON_NULL) +public class Message { + private Role role; + private String content; +} diff --git a/agent/src/main/java/com/clortox/agent/agent/dto/Role.java b/agent/src/main/java/com/clortox/agent/agent/dto/Role.java new file mode 100644 index 0000000..2f78889 --- /dev/null +++ b/agent/src/main/java/com/clortox/agent/agent/dto/Role.java @@ -0,0 +1,17 @@ +package com.clortox.agent.agent.dto; + +import com.fasterxml.jackson.annotation.JsonValue; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum Role { + SYSTEM("system"), + USER("user"), + ASSISTANT("assistant"), + TOOL("tool"); + + @JsonValue + private final String value; +} diff --git a/agent/src/main/java/com/clortox/agent/agent/dto/Usage.java b/agent/src/main/java/com/clortox/agent/agent/dto/Usage.java new file mode 100644 index 0000000..a0842cc --- /dev/null +++ b/agent/src/main/java/com/clortox/agent/agent/dto/Usage.java @@ -0,0 +1,21 @@ +package com.clortox.agent.agent.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.*; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@JsonInclude(JsonInclude.Include.NON_NULL) +public class Usage { + @JsonProperty("prompt_tokens") + private int promptTokens; + + @JsonProperty("completion_tokens") + private int completionTokens; + + @JsonProperty("total_tokens") + private int totalTokens; +} diff --git a/agent/src/main/java/com/clortox/agent/agent/memory/PersistentChatMemoryStore.java b/agent/src/main/java/com/clortox/agent/agent/memory/PersistentChatMemoryStore.java index 43eb967..5984a25 100644 --- a/agent/src/main/java/com/clortox/agent/agent/memory/PersistentChatMemoryStore.java +++ b/agent/src/main/java/com/clortox/agent/agent/memory/PersistentChatMemoryStore.java @@ -1,15 +1,36 @@ package com.clortox.agent.agent.memory; +import com.clortox.agent.agent.memory.persistence.Message; +import com.clortox.agent.agent.memory.persistence.MessageRepository; import dev.langchain4j.data.message.ChatMessage; import dev.langchain4j.store.memory.chat.ChatMemoryStore; +import org.springframework.data.domain.Example; +import org.springframework.stereotype.Component; +import java.util.Collection; import java.util.List; +@Component public class PersistentChatMemoryStore implements ChatMemoryStore { + private MessageRepository repository; + + public PersistentChatMemoryStore(MessageRepository repository) { + this.repository = repository; + } + @Override public List getMessages(Object memoryId) { - return List.of(); + Message exampleMessage = new Message(); + exampleMessage.setConversationId(memoryId.toString()); + + Collection messages = repository.findAll(Example.of(exampleMessage)); + + for (Message message : messages) { + + } + + return null; } @Override diff --git a/agent/src/main/java/com/clortox/agent/agent/memory/persistence/Message.java b/agent/src/main/java/com/clortox/agent/agent/memory/persistence/Message.java index 489bf2d..16edf54 100644 --- a/agent/src/main/java/com/clortox/agent/agent/memory/persistence/Message.java +++ b/agent/src/main/java/com/clortox/agent/agent/memory/persistence/Message.java @@ -20,9 +20,18 @@ public class Message { private Long id; @Column(name = "conversationId", nullable = false, updatable = false) + @Getter + @Setter private String conversationId; + @Column(name = "messsageType", nullable = false, updatable = false) + @Getter + @Setter + private String messageType; + @Column(name = "content", nullable = false, updatable = false) + @Getter + @Setter private String content; diff --git a/agent/src/main/java/com/clortox/agent/agent/memory/persistence/MessageRepository.java b/agent/src/main/java/com/clortox/agent/agent/memory/persistence/MessageRepository.java new file mode 100644 index 0000000..8811956 --- /dev/null +++ b/agent/src/main/java/com/clortox/agent/agent/memory/persistence/MessageRepository.java @@ -0,0 +1,6 @@ +package com.clortox.agent.agent.memory.persistence; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface MessageRepository extends JpaRepository { +} diff --git a/agent/src/main/java/com/clortox/agent/agent/service/Agent.java b/agent/src/main/java/com/clortox/agent/agent/service/Agent.java deleted file mode 100644 index 6a1ab0d..0000000 --- a/agent/src/main/java/com/clortox/agent/agent/service/Agent.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.clortox.agent.agent.service; - -import dev.langchain4j.model.chat.ChatModel; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.stereotype.Service; - -@Service -public class Agent { - - private final ChatModel model; - - @Autowired - public Agent( - ChatModel model - ) { - - this.model = model; - } -} diff --git a/agent/src/main/java/com/clortox/agent/agent/state/SmartAgent.java b/agent/src/main/java/com/clortox/agent/agent/state/SmartAgent.java new file mode 100644 index 0000000..5a1eb7e --- /dev/null +++ b/agent/src/main/java/com/clortox/agent/agent/state/SmartAgent.java @@ -0,0 +1,69 @@ +package com.clortox.agent.agent.state; + +import com.clortox.agent.tools.ISmartAgentTool; +import dev.langchain4j.data.message.SystemMessage; +import dev.langchain4j.data.message.UserMessage; +import dev.langchain4j.model.chat.ChatModel; +import lombok.extern.slf4j.Slf4j; +import org.bsc.langgraph4j.CompiledGraph; +import org.bsc.langgraph4j.GraphStateException; +import org.bsc.langgraph4j.agentexecutor.AgentExecutor; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.Map; +import java.util.Optional; + +@Slf4j +@Service +public class SmartAgent { + + private static final String SYSTEM_PROMPT = """ + # Instructions + + You have access to tools. When you think you should use one + use it and send back the result of the tool call, if applicable. + + # Personality + + You are a very smart cute anime girl. You talk like an anime girl. + """; + + private final CompiledGraph agent; + + @Autowired + public SmartAgent(ChatModel model, List tools) throws GraphStateException { + + log.info("Building SmartAgent"); + var agentBuilder = AgentExecutor.builder() + .systemMessage(SystemMessage.from(SYSTEM_PROMPT)) + .chatModel(model); + + for(ISmartAgentTool tool : tools) { + log.info("Adding tool {}", tool.getClass().getSimpleName()); + agentBuilder.toolsFromObject(tool); + } + + agent = agentBuilder.build().compile(); + log.info("SmartAgent built"); + } + + /** + * Invoke the agent with a seed message + * @param message + * @return + */ + public String invoke(String message) { + + Optional finalState = agent.invoke(Map.of( + "messages", UserMessage.from(message) + )); + + AgentExecutor.State state = finalState.orElseThrow(); + return state.finalResponse().orElse("Agent failed"); + } + + + +} diff --git a/agent/src/main/java/com/clortox/agent/common/config/GenericControllerAdvice.java b/agent/src/main/java/com/clortox/agent/common/config/GenericControllerAdvice.java new file mode 100644 index 0000000..024a6cd --- /dev/null +++ b/agent/src/main/java/com/clortox/agent/common/config/GenericControllerAdvice.java @@ -0,0 +1,31 @@ +package com.clortox.agent.common.config; + +import com.clortox.agent.common.dto.ErrorResponseDTO; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.http.HttpRequest; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.bind.annotation.ExceptionHandler; + +import java.net.http.HttpResponse; + +@ControllerAdvice +public class GenericControllerAdvice { + + + @ExceptionHandler(Throwable.class) + public ResponseEntity generalError(Throwable t, HttpServletRequest request, HttpServletResponse response) { + HttpStatus status = HttpStatus.INTERNAL_SERVER_ERROR; + + return ResponseEntity.status(status) + .body(ErrorResponseDTO.builder() + .path(request.getRequestURI()) + .status(status.value()) + .message(t.getMessage()) + .error(status.getReasonPhrase()) + .build() + ); + } +} diff --git a/agent/src/main/java/com/clortox/agent/common/dto/ErrorResponseDTO.java b/agent/src/main/java/com/clortox/agent/common/dto/ErrorResponseDTO.java new file mode 100644 index 0000000..41b232f --- /dev/null +++ b/agent/src/main/java/com/clortox/agent/common/dto/ErrorResponseDTO.java @@ -0,0 +1,17 @@ +package com.clortox.agent.common.dto; + +import lombok.Builder; +import lombok.Data; + +import java.time.Instant; + +@Data +@Builder +public class ErrorResponseDTO { + + Instant timestamp = Instant.now(); + Integer status; + String error; + String message; + String path; +} diff --git a/agent/src/main/java/com/clortox/agent/tools/ISmartAgentTool.java b/agent/src/main/java/com/clortox/agent/tools/ISmartAgentTool.java new file mode 100644 index 0000000..a5216e2 --- /dev/null +++ b/agent/src/main/java/com/clortox/agent/tools/ISmartAgentTool.java @@ -0,0 +1,7 @@ +package com.clortox.agent.tools; + +/** + * Interface to categorize all tools for use with the smart agent + */ +public interface ISmartAgentTool { +} diff --git a/agent/src/main/java/com/clortox/agent/tools/dto/TenorResult.java b/agent/src/main/java/com/clortox/agent/tools/dto/TenorResult.java new file mode 100644 index 0000000..770fa10 --- /dev/null +++ b/agent/src/main/java/com/clortox/agent/tools/dto/TenorResult.java @@ -0,0 +1,19 @@ +package com.clortox.agent.tools.dto; + +import lombok.Builder; +import lombok.Data; + +import java.util.Collection; + +@Builder +@Data +public class TenorResult { + + public String url; + + public Collection tags; + + public String description; + + public String title; +} diff --git a/agent/src/main/java/com/clortox/agent/tools/tenor/TenorSearch.java b/agent/src/main/java/com/clortox/agent/tools/tenor/TenorSearch.java new file mode 100644 index 0000000..ca382d2 --- /dev/null +++ b/agent/src/main/java/com/clortox/agent/tools/tenor/TenorSearch.java @@ -0,0 +1,202 @@ +package com.clortox.agent.tools.tenor; + +import com.clortox.agent.tools.ISmartAgentTool; +import com.clortox.agent.tools.dto.TenorResult; +import dev.langchain4j.agent.tool.P; +import dev.langchain4j.agent.tool.Tool; +import lombok.Data; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; +import org.springframework.web.client.RestClientException; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.util.UriComponentsBuilder; + +import java.net.URI; +import java.net.URISyntaxException; +import java.util.*; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +@Service +@Slf4j +public class TenorSearch implements ISmartAgentTool { + + @Value("${agent.tools.tenor.api_key}") + private String tenorApiKey; + + private static final String TENOR_SEARCH = "https://tenor.googleapis.com/v2/search"; + private static final String TENOR_RELATED = "https://tenor.googleapis.com/v2/related"; + + // constant params required by your ask: + private static final String MEDIA_FILTER = "minimal"; + private static final String CONTENTFILTER = "off"; + + private final RestTemplate http = new RestTemplate(); + + /** + * Search tenor for a gif + * @param query + * @param results + * @return + */ + @Tool("Search Tenor for GIFs with a text query") + public Collection search( + @P("Search query") String query, + @P(value = "Number of results to return", required = false) Integer results) { + if(results == null) results = 5; + + int limit = normalizeLimit(results); + if (!StringUtils.hasText(tenorApiKey) || !StringUtils.hasText(query)) return List.of(); + + String url = UriComponentsBuilder.fromHttpUrl(TENOR_SEARCH) + .queryParam("key", tenorApiKey) + .queryParam("q", query) + .queryParam("limit", limit) + .queryParam("media_filter", MEDIA_FILTER) + .queryParam("contentfilter", CONTENTFILTER) + .build(true) + .toUriString(); + + return executeAndMap(url); + } + + /** + * Serach tenor for gifs similar or related to a given tenor + * @param urlOrId + * @param results + * @return + */ + @Tool("Find GIFs related to a given Tenor GIF URL or ID") + public Collection related( + @P("A Tenor GIF URL or Tenor GIF ID") String urlOrId, + @P(value = "Number of results to return", required = false) Integer results) { + + if(results == null) results = 5; + + int limit = normalizeLimit(results); + if (!StringUtils.hasText(tenorApiKey) || !StringUtils.hasText(urlOrId)) return List.of(); + + String id = extractTenorId(urlOrId); + UriComponentsBuilder ucb; + if (id != null) { + ucb = UriComponentsBuilder.fromHttpUrl(TENOR_RELATED) + .queryParam("key", tenorApiKey) + .queryParam("id", id) + .queryParam("limit", limit) + .queryParam("media_filter", MEDIA_FILTER) + .queryParam("contentfilter", CONTENTFILTER); + } else { + // If we can't parse an ID, fall back to a normal search using the string (best-effort) + ucb = UriComponentsBuilder.fromHttpUrl(TENOR_SEARCH) + .queryParam("key", tenorApiKey) + .queryParam("q", urlOrId) + .queryParam("limit", limit) + .queryParam("media_filter", MEDIA_FILTER) + .queryParam("contentfilter", CONTENTFILTER); + } + + return executeAndMap(ucb.build(true).toUriString()); + } + + // ------------------------ + // Helpers + // ------------------------ + + private int normalizeLimit(Integer results) { + return (results == null || results <= 0) ? 10 : Math.min(results, 50); + } + + private Collection executeAndMap(String url) { + try { + TenorApiResponse resp = http.getForObject(url, TenorApiResponse.class); + if (resp == null || resp.results == null) return List.of(); + + return resp.results.stream() + .map(TenorSearch::mapToDto) + .filter(Objects::nonNull) + .collect(Collectors.toList()); + } catch (RestClientException e) { + log.error("Tenor API request failed: {}", e.getMessage(), e); + return List.of(); + } + } + + private static TenorResult mapToDto(TenorApiResult r) { + if (r == null) return null; + + String shareUrl = firstNonBlank(r.itemurl, r.url); + String title = firstNonBlank(r.title, r.content_description); + String desc = firstNonBlank(r.content_description, r.title); + Collection tags = (r.tags == null) ? List.of() : r.tags; + + return TenorResult.builder() + .url(shareUrl) + .title(title) + .description(desc) + .tags(tags) + .build(); + } + + private static String firstNonBlank(String... vals) { + for (String v : vals) if (v != null && !v.isBlank()) return v; + return null; + } + + private static String extractTenorId(String input) { + // Direct ID + if (input.matches("\\d+")) return input; + + // Try URL forms + try { + URI uri = new URI(input); + String host = Optional.ofNullable(uri.getHost()).orElse(""); + if (!host.contains("tenor.com")) return null; + + // itemid query param + String query = uri.getQuery(); + if (query != null) { + for (String part : query.split("&")) { + int idx = part.indexOf('='); + if (idx > 0) { + String key = part.substring(0, idx); + String val = part.substring(idx + 1); + if ("itemid".equalsIgnoreCase(key) && val.matches("\\d+")) return val; + } + } + } + + // trailing -digits in last path segment + String path = Optional.ofNullable(uri.getPath()).orElse(""); + String lastSeg = path.substring(path.lastIndexOf('/') + 1); + Matcher m = TRAILING_ID.matcher(lastSeg); + if (m.find()) return m.group(1); + + return null; + } catch (URISyntaxException ignored) { + return null; + } + } + + private static final Pattern TRAILING_ID = Pattern.compile(".*-(\\d+)$"); + + /* ===== Minimal Tenor API models ===== */ + + @Data + static class TenorApiResponse { + List results; + } + + @Data + static class TenorApiResult { + String id; + String itemurl; // share page + String url; // sometimes present + String title; + String content_description; + List tags; + // media_formats omitted; you asked for media_filter=minimal + DTO doesn't need direct media links + } +} diff --git a/agent/src/main/resources/application.yml b/agent/src/main/resources/application.yml index 3198954..8a33bf4 100644 --- a/agent/src/main/resources/application.yml +++ b/agent/src/main/resources/application.yml @@ -2,6 +2,12 @@ spring: datasource: url: jdbc:sqlite:${SQLITE_DB_LOCATION:./data/agent.db} driver-class-name: org.sqlite.JDBC + flyway: + enabled: true + locations: classpath:db/migration + create-schemas: false + baseline-on-migrate: true + baseline-version: 1.0.0.0 jpa: hibernate: ddl-auto: none @@ -13,6 +19,7 @@ spring: logging: level: + com.clortox.agent: debug org.hibernate.SQL: warn org.hibernate.type.descriptor.sql.BasicBinder: warn @@ -22,10 +29,15 @@ springdoc: agent: + prompt: + smartAgent: "${SMART_AGENT_PROMPT_PATH:./data/smartAgent.md}" ollama: baseUrl: "${OLLAMA_BASEURL:http://10.0.3.2:8080}" - model: "${OLLAMA_MODEL:llama3.2}" + model: "${OLLAMA_MODEL:mistral-small3.2}" temp: "${OLLAMA_TEMP:0.2}" + tools: + tenor: + api_key: "${TOOLS_TENOR_API_KEY:}" #langchain4j: # ollama: diff --git a/agent/src/main/resources/db/migration/V0__placeholder.sql b/agent/src/main/resources/db/migration/V0__placeholder.sql deleted file mode 100644 index e7bd515..0000000 --- a/agent/src/main/resources/db/migration/V0__placeholder.sql +++ /dev/null @@ -1,5 +0,0 @@ -CREATE TABLE message ( - ID INTEGER PRIMARY KEY AUTOINCREMENT, - conversationId VARCHAR(32) NOT NULL, - content VARCHAR(4096) NOT NULL -) \ No newline at end of file diff --git a/agent/src/main/resources/db/migration/V1.0.0.1__init.sql b/agent/src/main/resources/db/migration/V1.0.0.1__init.sql new file mode 100644 index 0000000..202ab59 --- /dev/null +++ b/agent/src/main/resources/db/migration/V1.0.0.1__init.sql @@ -0,0 +1,19 @@ +CREATE TABLE IF NOT EXISTS message ( + ID INTEGER PRIMARY KEY AUTOINCREMENT, + conversationId VARCHAR(32) NOT NULL, + messageType VARCHAR(64) NOT NULL CHECK ( messageType IN ( 'SYSTEM', 'AI', 'USER', 'TOOL_RESULT', 'CUSTOM' ) ), + content VARCHAR(4096) NOT NULL +); + +CREATE TABLE IF NOT EXISTS EVENT_PUBLICATION ( + ID TEXT NOT NULL, + LISTENER_ID TEXT NOT NULL, + EVENT_TYPE TEXT NOT NULL, + SERIALIZED_EVENT TEXT NOT NULL, + PUBLICATION_DATE DATETIME NOT NULL, + COMPLETION_DATE DATETIME DEFAULT NULL, + PRIMARY KEY (ID) +); + +CREATE INDEX IF NOT EXISTS EVENT_PUBLICATION_BY_COMPLETION_DATE_IDX + ON EVENT_PUBLICATION (COMPLETION_DATE); diff --git a/agent/src/test/java/com/clortox/agent/AgentApplicationTests.java b/agent/src/test/java/com/clortox/agent/AgentApplicationTests.java index 10987eb..40b041a 100644 --- a/agent/src/test/java/com/clortox/agent/AgentApplicationTests.java +++ b/agent/src/test/java/com/clortox/agent/AgentApplicationTests.java @@ -2,7 +2,11 @@ package com.clortox.agent; import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.ActiveProfiles; +@ActiveProfiles("test") +@Import(FlywayTestConfiguration.class) @SpringBootTest class AgentApplicationTests { diff --git a/agent/src/test/java/com/clortox/agent/FlywayTestConfiguration.java b/agent/src/test/java/com/clortox/agent/FlywayTestConfiguration.java new file mode 100644 index 0000000..493f4d2 --- /dev/null +++ b/agent/src/test/java/com/clortox/agent/FlywayTestConfiguration.java @@ -0,0 +1,19 @@ +package com.clortox.agent; + +import org.springframework.boot.autoconfigure.flyway.FlywayMigrationStrategy; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; + +@TestConfiguration +public class FlywayTestConfiguration { + + @Bean + FlywayMigrationStrategy cleanMigrate() { + return flyway -> { + flyway.clean(); + flyway.migrate(); + }; + } + + +} diff --git a/agent/src/test/java/com/clortox/agent/ModularityTest.java b/agent/src/test/java/com/clortox/agent/ModularityTest.java deleted file mode 100644 index 9743b12..0000000 --- a/agent/src/test/java/com/clortox/agent/ModularityTest.java +++ /dev/null @@ -1,12 +0,0 @@ -package com.clortox.agent; - -import org.junit.jupiter.api.Test; -import org.springframework.modulith.core.ApplicationModules; - -public class ModularityTest { - - @Test - public void verify() { - ApplicationModules.of(AgentApplication.class).verify(); - } -} diff --git a/agent/src/test/resources/application-test.yml b/agent/src/test/resources/application-test.yml new file mode 100644 index 0000000..f6cee4c --- /dev/null +++ b/agent/src/test/resources/application-test.yml @@ -0,0 +1,28 @@ +spring: + datasource: + url: jdbc:sqlite:./data/agent-test.db + driver-class-name: org.sqlite.JDBC + hikari: + maximum-pool-size: 1 # SQLite + tests = keep it simple + flyway: + enabled: true + locations: classpath:db/migration + clean-disabled: false # allow clean() in tests if you want + baseline-on-migrate: true + baseline-version: 1.0.0.0 + +spring.jpa: + hibernate.ddl-auto: none + database-platform: org.hibernate.dialect.SQLiteDialect + +agent: + prompt: + smartAgent: "${SMART_AGENT_PROMPT_PATH:./data/smartAgent.md}" + ollama: + baseUrl: "${OLLAMA_BASEURL:http://10.0.3.2:8080}" + model: "${OLLAMA_MODEL:mistral-small3.2}" + temp: "${OLLAMA_TEMP:0.2}" + tools: + tenor: + api_key: "test-key" + diff --git a/data/agent.db b/data/agent.db deleted file mode 100644 index b7c0e37d56d4bbb151d0e7932eedfd1febfd48f9..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 12288 zcmeI#Jx{|h5C&koAV8J+v2{JRR6)%RSzYm6i0Inpjx6VqL~LyCGtX@p2xNb2VIixC+Xd;6 zBF@%ZmWgeVNtz2YeG$gH91i>I4O-<^Cej4uG5_z_sAB6exim^>wUALzowzNPQL!)u zy~=_-74@r1`hMf6_cY*4C6Rp3-*nl*2V!Q^DqiN>qzo=