From bfd7def88dd1a7dc995666c95b45095743bcf235 Mon Sep 17 00:00:00 2001 From: Homelab Pulse Date: Tue, 24 Dec 2024 15:22:49 -0600 Subject: [PATCH] Basic blog generation --- .../java/com/homelabpulse/Application.java | 2 + .../com/homelabpulse/config/OllamaConfig.java | 12 ++ .../controller/BlogController.java | 7 ++ .../java/com/homelabpulse/dto/BlogDTO.java | 1 + .../java/com/homelabpulse/entity/Blog.java | 3 + .../com/homelabpulse/service/BlogService.java | 1 + .../service/BlogWriterService.java | 112 ++++++++++++++++++ .../homelabpulse/service/OllamaService.java | 43 +++++++ .../resources/db/migration/V3__Blog_Title.sql | 3 + 9 files changed, 184 insertions(+) create mode 100644 src/main/java/com/homelabpulse/service/BlogWriterService.java create mode 100644 src/main/java/com/homelabpulse/service/OllamaService.java create mode 100644 src/main/resources/db/migration/V3__Blog_Title.sql diff --git a/src/main/java/com/homelabpulse/Application.java b/src/main/java/com/homelabpulse/Application.java index 6934b98..77d26e7 100644 --- a/src/main/java/com/homelabpulse/Application.java +++ b/src/main/java/com/homelabpulse/Application.java @@ -3,9 +3,11 @@ package com.homelabpulse; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +import org.springframework.scheduling.annotation.EnableAsync; @SpringBootApplication @EnableJpaAuditing +@EnableAsync public class Application { public static void main(String[] args) { SpringApplication.run(Application.class, args); diff --git a/src/main/java/com/homelabpulse/config/OllamaConfig.java b/src/main/java/com/homelabpulse/config/OllamaConfig.java index d2173e2..3285f97 100644 --- a/src/main/java/com/homelabpulse/config/OllamaConfig.java +++ b/src/main/java/com/homelabpulse/config/OllamaConfig.java @@ -6,6 +6,7 @@ import org.springframework.ai.ollama.api.OllamaApi; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; @Configuration public class OllamaConfig { @@ -20,4 +21,15 @@ public class OllamaConfig { .build(); } + @Bean(name = "ollamaExecutor") + public ThreadPoolTaskExecutor ollamaExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(10); // Set your core pool size + executor.setMaxPoolSize(25); // Set your maximum pool size + executor.setQueueCapacity(250); // Set your queue capacity + executor.setThreadNamePrefix("OllamaAsync-"); // Prefix for thread names + executor.initialize(); + return executor; + } + } diff --git a/src/main/java/com/homelabpulse/controller/BlogController.java b/src/main/java/com/homelabpulse/controller/BlogController.java index ef2f534..d7a9291 100644 --- a/src/main/java/com/homelabpulse/controller/BlogController.java +++ b/src/main/java/com/homelabpulse/controller/BlogController.java @@ -2,6 +2,7 @@ package com.homelabpulse.controller; import com.homelabpulse.dto.BlogDTO; import com.homelabpulse.service.BlogService; +import com.homelabpulse.service.BlogWriterService; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; @@ -14,12 +15,18 @@ import java.util.List; @RequestMapping("/blogs") public class BlogController { private final BlogService blogService; + private final BlogWriterService blogWriterService; @PostMapping public ResponseEntity save(@RequestBody BlogDTO blogDTO) { return new ResponseEntity<>(blogService.save(blogDTO), HttpStatus.CREATED); } + @PostMapping("/{id}/write") + public ResponseEntity write(@PathVariable("id") Long id) { + return ResponseEntity.ok(blogWriterService.writeProductBlog(id)); + } + @GetMapping public ResponseEntity> getAll() { List blogs = blogService.getAll(); diff --git a/src/main/java/com/homelabpulse/dto/BlogDTO.java b/src/main/java/com/homelabpulse/dto/BlogDTO.java index 2ff42cc..fedf511 100644 --- a/src/main/java/com/homelabpulse/dto/BlogDTO.java +++ b/src/main/java/com/homelabpulse/dto/BlogDTO.java @@ -12,5 +12,6 @@ import lombok.Setter; public class BlogDTO extends AuditableDTO { private Long id; private Long productId; + private String title; private String content; } diff --git a/src/main/java/com/homelabpulse/entity/Blog.java b/src/main/java/com/homelabpulse/entity/Blog.java index 4cb667f..13dc16a 100644 --- a/src/main/java/com/homelabpulse/entity/Blog.java +++ b/src/main/java/com/homelabpulse/entity/Blog.java @@ -17,6 +17,9 @@ public class Blog extends AuditableEntity { @Column(name = "product_id") private Long productId; + @Column(name = "title") + private String title; + @Column(name = "content") private String content; } diff --git a/src/main/java/com/homelabpulse/service/BlogService.java b/src/main/java/com/homelabpulse/service/BlogService.java index 6af0dd7..4abf48d 100644 --- a/src/main/java/com/homelabpulse/service/BlogService.java +++ b/src/main/java/com/homelabpulse/service/BlogService.java @@ -38,4 +38,5 @@ public class BlogService { blogOpt.ifPresent(blogRepository::delete); return blogDto; } + } diff --git a/src/main/java/com/homelabpulse/service/BlogWriterService.java b/src/main/java/com/homelabpulse/service/BlogWriterService.java new file mode 100644 index 0000000..4b6dd63 --- /dev/null +++ b/src/main/java/com/homelabpulse/service/BlogWriterService.java @@ -0,0 +1,112 @@ +package com.homelabpulse.service; + +import com.homelabpulse.dto.BlogDTO; +import com.homelabpulse.entity.Blog; +import com.homelabpulse.entity.Product; +import com.homelabpulse.mapper.BlogMapper; +import com.homelabpulse.repository.BlogRepository; +import com.homelabpulse.repository.ProductRepository; +import jakarta.persistence.EntityNotFoundException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.ai.chat.messages.SystemMessage; +import org.springframework.ai.chat.messages.UserMessage; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +@Slf4j +public class BlogWriterService { + private final OllamaService ollamaService; + private final BlogRepository blogRepository; + private final ProductRepository productRepository; + private final BlogMapper blogMapper; + + private static final String SYSTEM_MESSAGE = """ + You are a blog writer for Homelab Pulse (https://homelabpulse.com), specializing in homelab tech. + You review products in a subtle way, so your readers aren't turned off by blatant advertising. + When possible, provide an example use case of a project for which the product can be used. + The products you review are not made or supported by homelabpulse.com. + """; + private static final String PRODUCT_MESSAGE_SEGMENT = """ + Here is the product info: + + ``` Product URL + %1$s + ``` + + ``` Product Title + %2$s + ``` + + ``` Product Description + %3$s + ``` + """; + + public BlogDTO writeProductBlog(Long blogId) { + log.info("Writing blogId={}", blogId); + Blog blog = blogRepository.findById(blogId).orElseThrow(() -> new EntityNotFoundException("Blog id=" + blogId + " not found")); + Product product = productRepository.findById(blog.getProductId()).orElseThrow(() -> new EntityNotFoundException("Product id=" + blog.getProductId() + " not found")); + + boolean newTitle = false; + if (blog.getTitle() == null) { + newTitle = true; + String title = generateNewTitle(blog, product); + blog.setTitle(title); + } + + if (blog.getContent() == null || newTitle) { + String content = generateNewContent(blog, product); + blog.setContent(content); + } + + blogRepository.save(blog); + log.info("Finished writing blogId={}", blogId); + return blogMapper.toDTO(blog); + } + + private String generateNewContent(Blog blog, Product product) { + UserMessage userMessage = new UserMessage( + """ + There exists an empty blog titled "%1$s". + Write the blog post, in markdown format. + + %2$s + """.formatted(blog.getTitle(), getProductSegment(product)) + ); + String content = ollamaService.call( + new SystemMessage(SYSTEM_MESSAGE), + userMessage + ); + log.debug("Content for blog id={}:\n\n{}", blog.getId(), content); + return content; + } + + private String generateNewTitle(Blog blog, Product product) { + + UserMessage userMessage = new UserMessage( + """ + Write the title of a blog post about the following product. + Only write the title of the post; do not add anything else to the response. + Maximum characters is 200. + + %1$s + """.formatted(getProductSegment(product)) + ); + String title = ollamaService.call( + new SystemMessage(SYSTEM_MESSAGE), + userMessage + ); + if (title.startsWith("Title: ")) { + log.debug("Removing 'Title: '"); + title = title.replace("Title: ", "").strip(); + } + log.info("Title for blogId={}: {}", blog.getId(), title); + return title; + } + + private String getProductSegment(Product product) { + return PRODUCT_MESSAGE_SEGMENT.formatted(product.getUrl(), product.getTitle(), product.getDescription()); + } +} diff --git a/src/main/java/com/homelabpulse/service/OllamaService.java b/src/main/java/com/homelabpulse/service/OllamaService.java new file mode 100644 index 0000000..0a5f0f7 --- /dev/null +++ b/src/main/java/com/homelabpulse/service/OllamaService.java @@ -0,0 +1,43 @@ +package com.homelabpulse.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.ai.chat.messages.SystemMessage; +import org.springframework.ai.chat.messages.UserMessage; +import org.springframework.ai.chat.model.ChatModel; +import org.springframework.ai.chat.model.ChatResponse; +import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.ai.ollama.api.OllamaOptions; +import org.springframework.stereotype.Service; + +import java.util.Arrays; + +@Service +@RequiredArgsConstructor +@Slf4j +public class OllamaService { + private final ChatModel chatModel; + private static final String DEFAULT_MODEL = "hermes3"; + + public String call(SystemMessage systemMessage, UserMessage userMessage) { + return call(systemMessage, userMessage, DEFAULT_MODEL); + } + + public String call(SystemMessage systemMessage, UserMessage userMessage, String model) { + log.debug("UserMessage:\n{}", userMessage.getText()); + OllamaOptions ollamaOptions = OllamaOptions.builder() + .model(model) + .build(); + Prompt prompt = new Prompt(Arrays.asList(systemMessage, userMessage), ollamaOptions); + ChatResponse chatResponse = chatModel.call(prompt); + String responseStr = chatResponse.getResult().getOutput().getText(); + log.debug("Response: {}", responseStr); + responseStr = responseStr.strip(); + if (responseStr.charAt(0) == '"' && responseStr.charAt(responseStr.length() - 1) == '"') { + log.debug("Stripping quotes: {}", responseStr); + responseStr = responseStr.substring(1, responseStr.length() - 1).strip(); + } + log.debug("Trimmed response: {}", responseStr); + return responseStr; + } +} diff --git a/src/main/resources/db/migration/V3__Blog_Title.sql b/src/main/resources/db/migration/V3__Blog_Title.sql new file mode 100644 index 0000000..5300e8b --- /dev/null +++ b/src/main/resources/db/migration/V3__Blog_Title.sql @@ -0,0 +1,3 @@ +-- V3__Blog_Title.sql + +ALTER TABLE blog ADD COLUMN title VARCHAR(500);