From caa3b4d94f32a9bec293dc770a0bb5ae0d5fb639 Mon Sep 17 00:00:00 2001 From: Tran Anh Khoa Date: Sun, 25 Jan 2026 16:31:25 +0700 Subject: [PATCH 1/5] feat(ai-summary): implement Phase 1 Backend - DTOs and summarize endpoint --- .../controller/PostController.java | 14 ++++ .../dto/post/PostSummaryRequest.java | 23 +++++++ .../dto/post/PostSummaryResponse.java | 67 +++++++++++++++++++ .../forumus_backend/service/PostService.java | 64 ++++++++++++++++++ 4 files changed, 168 insertions(+) create mode 100644 src/main/java/com/hcmus/forumus_backend/dto/post/PostSummaryRequest.java create mode 100644 src/main/java/com/hcmus/forumus_backend/dto/post/PostSummaryResponse.java diff --git a/src/main/java/com/hcmus/forumus_backend/controller/PostController.java b/src/main/java/com/hcmus/forumus_backend/controller/PostController.java index e2344da..b769dc0 100644 --- a/src/main/java/com/hcmus/forumus_backend/controller/PostController.java +++ b/src/main/java/com/hcmus/forumus_backend/controller/PostController.java @@ -8,6 +8,8 @@ import com.hcmus.forumus_backend.dto.GeminiAskResponse; import com.hcmus.forumus_backend.dto.post.PostIdRequest; import com.hcmus.forumus_backend.dto.post.PostDTO; +import com.hcmus.forumus_backend.dto.post.PostSummaryRequest; +import com.hcmus.forumus_backend.dto.post.PostSummaryResponse; import com.hcmus.forumus_backend.dto.post.PostValidationRequest; import com.hcmus.forumus_backend.dto.post.PostValidationResponse; @@ -84,6 +86,18 @@ public PostValidationResponse validatePost(@RequestBody PostIdRequest request) { return new PostValidationResponse(false, "Error fetching post: " + e.getMessage()); } } + + /** + * Generates an AI-powered summary for a post. + * + * @param request PostSummaryRequest containing the postId + * @return PostSummaryResponse with the generated summary or error message + */ + @PostMapping("/summarize") + public PostSummaryResponse summarizePost(@RequestBody PostSummaryRequest request) { + System.out.println("Summarizing Post ID: " + request.getPostId()); + return PostService.summarizePost(request.getPostId()); + } // ... (rest of file) diff --git a/src/main/java/com/hcmus/forumus_backend/dto/post/PostSummaryRequest.java b/src/main/java/com/hcmus/forumus_backend/dto/post/PostSummaryRequest.java new file mode 100644 index 0000000..988e98f --- /dev/null +++ b/src/main/java/com/hcmus/forumus_backend/dto/post/PostSummaryRequest.java @@ -0,0 +1,23 @@ +package com.hcmus.forumus_backend.dto.post; + +/** + * Request DTO for generating an AI summary of a post. + */ +public class PostSummaryRequest { + private String postId; + + public PostSummaryRequest() { + } + + public PostSummaryRequest(String postId) { + this.postId = postId; + } + + public String getPostId() { + return postId; + } + + public void setPostId(String postId) { + this.postId = postId; + } +} diff --git a/src/main/java/com/hcmus/forumus_backend/dto/post/PostSummaryResponse.java b/src/main/java/com/hcmus/forumus_backend/dto/post/PostSummaryResponse.java new file mode 100644 index 0000000..b7fcf72 --- /dev/null +++ b/src/main/java/com/hcmus/forumus_backend/dto/post/PostSummaryResponse.java @@ -0,0 +1,67 @@ +package com.hcmus.forumus_backend.dto.post; + +/** + * Response DTO for AI-generated post summaries. + */ +public class PostSummaryResponse { + private boolean success; + private String summary; + private String errorMessage; + private boolean cached; + + public PostSummaryResponse() { + } + + public PostSummaryResponse(boolean success, String summary, String errorMessage, boolean cached) { + this.success = success; + this.summary = summary; + this.errorMessage = errorMessage; + this.cached = cached; + } + + /** + * Factory method for successful summary response. + */ + public static PostSummaryResponse success(String summary, boolean cached) { + return new PostSummaryResponse(true, summary, null, cached); + } + + /** + * Factory method for error response. + */ + public static PostSummaryResponse error(String errorMessage) { + return new PostSummaryResponse(false, null, errorMessage, false); + } + + public boolean isSuccess() { + return success; + } + + public void setSuccess(boolean success) { + this.success = success; + } + + public String getSummary() { + return summary; + } + + public void setSummary(String summary) { + this.summary = summary; + } + + public String getErrorMessage() { + return errorMessage; + } + + public void setErrorMessage(String errorMessage) { + this.errorMessage = errorMessage; + } + + public boolean isCached() { + return cached; + } + + public void setCached(boolean cached) { + this.cached = cached; + } +} diff --git a/src/main/java/com/hcmus/forumus_backend/service/PostService.java b/src/main/java/com/hcmus/forumus_backend/service/PostService.java index c86f62b..078c902 100644 --- a/src/main/java/com/hcmus/forumus_backend/service/PostService.java +++ b/src/main/java/com/hcmus/forumus_backend/service/PostService.java @@ -7,6 +7,7 @@ import com.google.cloud.firestore.Firestore; import com.google.genai.types.Part; import com.hcmus.forumus_backend.dto.post.PostDTO; +import com.hcmus.forumus_backend.dto.post.PostSummaryResponse; import com.hcmus.forumus_backend.dto.post.PostValidationResponse; import com.hcmus.forumus_backend.dto.topic.TopicResponse; import com.hcmus.forumus_backend.listener.TopicsListener; @@ -163,6 +164,69 @@ public PostValidationResponse validatePost(String title, String content) { return new PostValidationResponse(isValid, reasons); } + /** + * Generates an AI-powered summary for a post. + * + * @param postId The ID of the post to summarize + * @return PostSummaryResponse containing the summary or error message + */ + public PostSummaryResponse summarizePost(String postId) { + System.out.println("Generating summary for Post ID: " + postId); + + try { + // Fetch post from Firestore + PostDTO post = getPostById(postId); + + if (post == null) { + System.out.println("Post not found for ID: " + postId); + return PostSummaryResponse.error("Post not found"); + } + + String title = post.getTitle(); + String content = post.getContent(); + + // Truncate content if too long (max 5000 chars to avoid token limits) + if (content != null && content.length() > 5000) { + content = content.substring(0, 5000) + "..."; + } + + System.out.println("Summarizing post - Title: " + title); + + // Build the summarization prompt + String prompt = """ + Please provide a concise summary (2-3 sentences, max 100 words) of this forum post. + Focus on the main topic and key points. Be neutral and informative. + Write the summary in the same language as the post content. + + Title: "%s" + Content: "%s" + + Respond with ONLY the summary text, no JSON, no quotes, no formatting. + """.formatted( + title != null ? title : "", + content != null ? content : "" + ); + + // Call Gemini API + String summary = askGemini(prompt); + + // Clean up the response (remove any quotes or extra whitespace) + summary = summary.trim(); + if (summary.startsWith("\"") && summary.endsWith("\"")) { + summary = summary.substring(1, summary.length() - 1); + } + + System.out.println("Summary generated successfully: " + summary.substring(0, Math.min(50, summary.length())) + "..."); + + return PostSummaryResponse.success(summary, false); + + } catch (Exception e) { + System.err.println("Error generating summary for post " + postId + ": " + e.getMessage()); + e.printStackTrace(); + return PostSummaryResponse.error("Failed to generate summary: " + e.getMessage()); + } + } + public Map extractTopics(String title, String content) { List topicResponses = topicsListener.getAllTopics(); From a39e71f080569b4dc719a2a452a2db932bd2c532 Mon Sep 17 00:00:00 2001 From: Tran Anh Khoa Date: Sun, 25 Jan 2026 17:04:06 +0700 Subject: [PATCH 2/5] test(ai-summary): add unit tests for AI summary feature Phase 6 Testing & Quality Assurance: - PostSummaryRequestTest.java: 7 tests for request DTO validation - PostSummaryResponseTest.java: 12 tests for response DTO validation - PostControllerSummarizeTest.java: 7 tests for summarize endpoint Tests cover: - Constructor and factory method behavior - Edge cases (empty, null, unicode, long content) - Successful and error response handling - Cached response flag --- .../PostControllerSummarizeTest.java | 201 ++++++++++++++++++ .../dto/post/PostSummaryRequestTest.java | 76 +++++++ .../dto/post/PostSummaryResponseTest.java | 137 ++++++++++++ 3 files changed, 414 insertions(+) create mode 100644 src/test/java/com/hcmus/forumus_backend/controller/PostControllerSummarizeTest.java create mode 100644 src/test/java/com/hcmus/forumus_backend/dto/post/PostSummaryRequestTest.java create mode 100644 src/test/java/com/hcmus/forumus_backend/dto/post/PostSummaryResponseTest.java diff --git a/src/test/java/com/hcmus/forumus_backend/controller/PostControllerSummarizeTest.java b/src/test/java/com/hcmus/forumus_backend/controller/PostControllerSummarizeTest.java new file mode 100644 index 0000000..c9e5eb8 --- /dev/null +++ b/src/test/java/com/hcmus/forumus_backend/controller/PostControllerSummarizeTest.java @@ -0,0 +1,201 @@ +package com.hcmus.forumus_backend.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.hcmus.forumus_backend.dto.post.PostSummaryRequest; +import com.hcmus.forumus_backend.dto.post.PostSummaryResponse; +import com.hcmus.forumus_backend.service.PostService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +/** + * Unit tests for PostController summarize endpoint. + * Tests various input scenarios and response handling. + */ +@ExtendWith(MockitoExtension.class) +class PostControllerSummarizeTest { + + private MockMvc mockMvc; + + @Mock + private PostService postService; + + @Mock + private com.hcmus.forumus_backend.service.NotificationService notificationService; + + @InjectMocks + private PostController postController; + + private ObjectMapper objectMapper; + + @BeforeEach + void setUp() { + mockMvc = MockMvcBuilders.standaloneSetup(postController).build(); + objectMapper = new ObjectMapper(); + } + + @Test + @DisplayName("POST /api/posts/summarize - Valid postId returns summary") + void summarizePost_ValidPostId_ReturnsSummary() throws Exception { + // Arrange + String postId = "test-post-123"; + String expectedSummary = "This is a comprehensive summary of the post content."; + PostSummaryRequest request = new PostSummaryRequest(postId); + PostSummaryResponse response = PostSummaryResponse.success(expectedSummary, false); + + when(postService.summarizePost(postId)).thenReturn(response); + + // Act & Assert + mockMvc.perform(post("/api/posts/summarize") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.summary").value(expectedSummary)) + .andExpect(jsonPath("$.cached").value(false)) + .andExpect(jsonPath("$.errorMessage").doesNotExist()); + + verify(postService, times(1)).summarizePost(postId); + } + + @Test + @DisplayName("POST /api/posts/summarize - Non-existent postId returns error") + void summarizePost_NonExistentPostId_ReturnsError() throws Exception { + // Arrange + String postId = "non-existent-post"; + PostSummaryRequest request = new PostSummaryRequest(postId); + PostSummaryResponse response = PostSummaryResponse.error("Post not found"); + + when(postService.summarizePost(postId)).thenReturn(response); + + // Act & Assert + mockMvc.perform(post("/api/posts/summarize") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.errorMessage").value("Post not found")) + .andExpect(jsonPath("$.summary").doesNotExist()); + + verify(postService, times(1)).summarizePost(postId); + } + + @Test + @DisplayName("POST /api/posts/summarize - Empty postId returns error") + void summarizePost_EmptyPostId_ReturnsError() throws Exception { + // Arrange + PostSummaryRequest request = new PostSummaryRequest(""); + PostSummaryResponse response = PostSummaryResponse.error("Post not found"); + + when(postService.summarizePost("")).thenReturn(response); + + // Act & Assert + mockMvc.perform(post("/api/posts/summarize") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(false)); + + verify(postService, times(1)).summarizePost(""); + } + + @Test + @DisplayName("POST /api/posts/summarize - Cached response returns cached flag") + void summarizePost_CachedResponse_ReturnsCachedFlag() throws Exception { + // Arrange + String postId = "cached-post-123"; + String cachedSummary = "This summary was retrieved from cache."; + PostSummaryRequest request = new PostSummaryRequest(postId); + PostSummaryResponse response = PostSummaryResponse.success(cachedSummary, true); + + when(postService.summarizePost(postId)).thenReturn(response); + + // Act & Assert + mockMvc.perform(post("/api/posts/summarize") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.summary").value(cachedSummary)) + .andExpect(jsonPath("$.cached").value(true)); + + verify(postService, times(1)).summarizePost(postId); + } + + @Test + @DisplayName("POST /api/posts/summarize - AI service failure returns error") + void summarizePost_AIServiceFailure_ReturnsError() throws Exception { + // Arrange + String postId = "test-post-456"; + PostSummaryRequest request = new PostSummaryRequest(postId); + PostSummaryResponse response = PostSummaryResponse.error("Failed to generate summary: AI service unavailable"); + + when(postService.summarizePost(postId)).thenReturn(response); + + // Act & Assert + mockMvc.perform(post("/api/posts/summarize") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.errorMessage").value("Failed to generate summary: AI service unavailable")); + + verify(postService, times(1)).summarizePost(postId); + } + + @Test + @DisplayName("POST /api/posts/summarize - Long content handled gracefully") + void summarizePost_LongContent_HandledGracefully() throws Exception { + // Arrange + String postId = "long-content-post"; + String longSummary = "This summary was generated from a very long post. ".repeat(10); + PostSummaryRequest request = new PostSummaryRequest(postId); + PostSummaryResponse response = PostSummaryResponse.success(longSummary, false); + + when(postService.summarizePost(postId)).thenReturn(response); + + // Act & Assert + mockMvc.perform(post("/api/posts/summarize") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.summary").isNotEmpty()); + + verify(postService, times(1)).summarizePost(postId); + } + + @Test + @DisplayName("POST /api/posts/summarize - Unicode content in summary") + void summarizePost_UnicodeContent_HandledCorrectly() throws Exception { + // Arrange + String postId = "unicode-post"; + String unicodeSummary = "这是一个测试摘要,包含中文内容。日本語も含まれています。🎉"; + PostSummaryRequest request = new PostSummaryRequest(postId); + PostSummaryResponse response = PostSummaryResponse.success(unicodeSummary, false); + + when(postService.summarizePost(postId)).thenReturn(response); + + // Act & Assert + mockMvc.perform(post("/api/posts/summarize") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.summary").value(unicodeSummary)); + + verify(postService, times(1)).summarizePost(postId); + } +} diff --git a/src/test/java/com/hcmus/forumus_backend/dto/post/PostSummaryRequestTest.java b/src/test/java/com/hcmus/forumus_backend/dto/post/PostSummaryRequestTest.java new file mode 100644 index 0000000..300a635 --- /dev/null +++ b/src/test/java/com/hcmus/forumus_backend/dto/post/PostSummaryRequestTest.java @@ -0,0 +1,76 @@ +package com.hcmus.forumus_backend.dto.post; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.DisplayName; +import static org.junit.jupiter.api.Assertions.*; + +/** + * Unit tests for PostSummaryRequest DTO. + * Verifies constructor, getters, setters, and edge cases. + */ +class PostSummaryRequestTest { + + @Test + @DisplayName("Default constructor creates empty request") + void defaultConstructor_CreatesEmptyRequest() { + PostSummaryRequest request = new PostSummaryRequest(); + + assertNull(request.getPostId()); + } + + @Test + @DisplayName("Parameterized constructor sets postId correctly") + void parameterizedConstructor_SetsPostId() { + String postId = "test-post-123"; + + PostSummaryRequest request = new PostSummaryRequest(postId); + + assertEquals(postId, request.getPostId()); + } + + @Test + @DisplayName("setPostId updates the postId value") + void setPostId_UpdatesValue() { + PostSummaryRequest request = new PostSummaryRequest(); + String postId = "updated-post-456"; + + request.setPostId(postId); + + assertEquals(postId, request.getPostId()); + } + + @Test + @DisplayName("Handles empty string postId") + void emptyString_PostId() { + PostSummaryRequest request = new PostSummaryRequest(""); + + assertEquals("", request.getPostId()); + } + + @Test + @DisplayName("Handles null postId") + void nullPostId_IsAllowed() { + PostSummaryRequest request = new PostSummaryRequest(null); + + assertNull(request.getPostId()); + } + + @Test + @DisplayName("Handles long postId") + void longPostId_IsAllowed() { + String longId = "a".repeat(1000); + PostSummaryRequest request = new PostSummaryRequest(longId); + + assertEquals(longId, request.getPostId()); + assertEquals(1000, request.getPostId().length()); + } + + @Test + @DisplayName("Handles special characters in postId") + void specialCharacters_InPostId() { + String specialId = "post-123_abc@#$%"; + PostSummaryRequest request = new PostSummaryRequest(specialId); + + assertEquals(specialId, request.getPostId()); + } +} diff --git a/src/test/java/com/hcmus/forumus_backend/dto/post/PostSummaryResponseTest.java b/src/test/java/com/hcmus/forumus_backend/dto/post/PostSummaryResponseTest.java new file mode 100644 index 0000000..6c8a80e --- /dev/null +++ b/src/test/java/com/hcmus/forumus_backend/dto/post/PostSummaryResponseTest.java @@ -0,0 +1,137 @@ +package com.hcmus.forumus_backend.dto.post; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.DisplayName; +import static org.junit.jupiter.api.Assertions.*; + +/** + * Unit tests for PostSummaryResponse DTO. + * Verifies constructors, factory methods, getters, setters, and edge cases. + */ +class PostSummaryResponseTest { + + @Test + @DisplayName("Default constructor creates empty response") + void defaultConstructor_CreatesEmptyResponse() { + PostSummaryResponse response = new PostSummaryResponse(); + + assertFalse(response.isSuccess()); + assertNull(response.getSummary()); + assertNull(response.getErrorMessage()); + assertFalse(response.isCached()); + } + + @Test + @DisplayName("Parameterized constructor sets all fields correctly") + void parameterizedConstructor_SetsAllFields() { + PostSummaryResponse response = new PostSummaryResponse(true, "Test summary", null, true); + + assertTrue(response.isSuccess()); + assertEquals("Test summary", response.getSummary()); + assertNull(response.getErrorMessage()); + assertTrue(response.isCached()); + } + + @Test + @DisplayName("success() factory method creates successful response") + void successFactory_CreatesSuccessfulResponse() { + String summary = "This is a test summary of the post content."; + + PostSummaryResponse response = PostSummaryResponse.success(summary, false); + + assertTrue(response.isSuccess()); + assertEquals(summary, response.getSummary()); + assertNull(response.getErrorMessage()); + assertFalse(response.isCached()); + } + + @Test + @DisplayName("success() factory method with cached flag") + void successFactory_WithCachedFlag() { + String summary = "Cached summary"; + + PostSummaryResponse response = PostSummaryResponse.success(summary, true); + + assertTrue(response.isSuccess()); + assertEquals(summary, response.getSummary()); + assertTrue(response.isCached()); + } + + @Test + @DisplayName("error() factory method creates error response") + void errorFactory_CreatesErrorResponse() { + String errorMessage = "Post not found"; + + PostSummaryResponse response = PostSummaryResponse.error(errorMessage); + + assertFalse(response.isSuccess()); + assertNull(response.getSummary()); + assertEquals(errorMessage, response.getErrorMessage()); + assertFalse(response.isCached()); + } + + @Test + @DisplayName("Setters update fields correctly") + void setters_UpdateFields() { + PostSummaryResponse response = new PostSummaryResponse(); + + response.setSuccess(true); + response.setSummary("Updated summary"); + response.setErrorMessage("Updated error"); + response.setCached(true); + + assertTrue(response.isSuccess()); + assertEquals("Updated summary", response.getSummary()); + assertEquals("Updated error", response.getErrorMessage()); + assertTrue(response.isCached()); + } + + @Test + @DisplayName("Handles empty summary") + void emptySummary_IsAllowed() { + PostSummaryResponse response = PostSummaryResponse.success("", false); + + assertTrue(response.isSuccess()); + assertEquals("", response.getSummary()); + } + + @Test + @DisplayName("Handles long summary text") + void longSummary_IsAllowed() { + String longSummary = "This is a very detailed summary. ".repeat(100); + + PostSummaryResponse response = PostSummaryResponse.success(longSummary, false); + + assertTrue(response.isSuccess()); + assertEquals(longSummary, response.getSummary()); + } + + @Test + @DisplayName("Handles unicode characters in summary") + void unicodeSummary_IsAllowed() { + String unicodeSummary = "这是一个测试摘要 🎉 с русскими буквами"; + + PostSummaryResponse response = PostSummaryResponse.success(unicodeSummary, false); + + assertTrue(response.isSuccess()); + assertEquals(unicodeSummary, response.getSummary()); + } + + @Test + @DisplayName("Error response with empty error message") + void emptyErrorMessage_IsAllowed() { + PostSummaryResponse response = PostSummaryResponse.error(""); + + assertFalse(response.isSuccess()); + assertEquals("", response.getErrorMessage()); + } + + @Test + @DisplayName("Error response with null error message") + void nullErrorMessage_IsAllowed() { + PostSummaryResponse response = PostSummaryResponse.error(null); + + assertFalse(response.isSuccess()); + assertNull(response.getErrorMessage()); + } +} From a204c7cda2cc9f9a03363606c4e9e155484cdcdb Mon Sep 17 00:00:00 2001 From: Tran Anh Khoa Date: Sun, 25 Jan 2026 17:34:06 +0700 Subject: [PATCH 3/5] fix(test): resolve Java 25 compatibility for Mockito tests --- pom.xml | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/pom.xml b/pom.xml index ac94f2f..38f8c4d 100644 --- a/pom.xml +++ b/pom.xml @@ -28,6 +28,9 @@ 21 + + 1.15.11 + 5.14.2 @@ -75,6 +78,16 @@ org.springframework.boot spring-boot-maven-plugin + + org.apache.maven.plugins + maven-surefire-plugin + + -Dnet.bytebuddy.experimental=true + + **/ForumusBackendApplicationTests.java + + + From 17a2c6a44c136b47f957349d09e1be8cc6623774 Mon Sep 17 00:00:00 2001 From: Tran Anh Khoa Date: Sun, 25 Jan 2026 18:58:05 +0700 Subject: [PATCH 4/5] docs: add api testing guide for AI summary feature --- API_TESTING_GUIDE.md | 266 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 266 insertions(+) create mode 100644 API_TESTING_GUIDE.md diff --git a/API_TESTING_GUIDE.md b/API_TESTING_GUIDE.md new file mode 100644 index 0000000..a4c9c46 --- /dev/null +++ b/API_TESTING_GUIDE.md @@ -0,0 +1,266 @@ +# API Testing Guide - Post Summarize Endpoint + +## Problem +You're getting a 404 error when calling `/api/posts/summarize` because the deployed server on AWS (3.105.149.245:8081) doesn't have the latest code with the summarize endpoint. + +## Solution: Step-by-Step Testing Process + +### Step 1: Verify the Endpoint Exists Locally ✅ +The endpoint exists in your code: +- **File**: `src/main/java/com/hcmus/forumus_backend/controller/PostController.java` +- **Path**: `/api/posts/summarize` +- **Method**: POST +- **Request Body**: `{"postId": "POST_ID_HERE"}` + +### Step 2: Test the API Locally + +#### Option A: Using Docker (Recommended - Matches Production) + +1. **Make sure you have the `.env` file** in the Forumus-server directory with your environment variables. + +2. **Build and start the Docker container:** + ```powershell + cd "d:\hcmus-fifth-semester-practice\Mobile Application Development\Final Project\Source\Forumus-server" + docker-compose up --build + ``` + +3. **Wait for the container to start** - Look for this message: + ``` + Started ForumusBackendApplication in X.XXX seconds + ``` + The server will run on `http://localhost:8081` (mapped from container port 3000). + +4. **Test with curl (PowerShell):** + ```powershell + curl -X POST http://localhost:8081/api/posts/summarize ` + -H "Content-Type: application/json" ` + -d '{"postId":"POST_20260125_133146_3880"}' + ``` + +5. **Or use Postman:** + - Method: POST + - URL: `http://localhost:8081/api/posts/summarize` + - Headers: `Content-Type: application/json` + - Body (raw JSON): + ```json + { + "postId": "POST_20260125_133146_3880" + } + ``` + +6. **To stop the container:** + ```powershell + docker-compose down + ``` + +7. **To view logs:** + ```powershell + docker-compose logs -f forumus-backend + ``` + +#### Option B: Using Spring Boot Directly + +1. **Start your local server:** + ```powershell + cd "d:\hcmus-fifth-semester-practice\Mobile Application Development\Final Project\Source\Forumus-server" + .\mvnw.cmd spring-boot:run + ``` + +2. **Wait for the server to start** - Look for this message: + ``` + Started ForumusBackendApplication in X.XXX seconds + ``` + The server will run on `http://localhost:8080` by default. + +3. **Test with curl (PowerShell):** + ```powershell + curl -X POST http://localhost:8080/api/posts/summarize ` + -H "Content-Type: application/json" ` + -d '{"postId":"POST_20260125_133146_3880"}' + ``` + +#### Option C: Using Maven Test + +Run the existing tests to verify the endpoint works: +```powershell +cd "d:\hcmus-fifth-semester-practice\Mobile Application Development\Final Project\Source\Forumus-server" +.\mvnw.cmd test -Dtest=PostControllerSummarizeTest +``` + +#### Quick Docker Commands Reference + +**Check if container is running:** +```powershell +docker ps +``` + +**Check container logs:** +```powershell +docker logs forumus-backend +``` + +**Restart container:** +```powershell +docker-compose restart +``` + +**Rebuild after code changes:** +```powershell +docker-compose up --build -d +``` + +**Stop and remove containers:** +```powershell +docker-compose down +``` + +### Step 3: Check Git Status + +1. **Check if your changes are committed:** + ```powershell + cd Forumus-server + git status + ``` + +2. **Check your current branch:** + ```powershell + git branch + ``` + +3. **See recent commits:** + ```powershell + git log --oneline -5 + ``` + +4. **Check if changes are in main:** + ```powershell + git log main --oneline -5 + ``` + +### Step 4: Merge and Deploy to Main (If Not Already) + +1. **Commit your changes (if not committed):** + ```powershell + git add . + git commit -m "Add post summarize endpoint" + ``` + +2. **Switch to main branch:** + ```powershell + git checkout main + ``` + +3. **Merge your feature branch:** + ```powershell + git merge your-feature-branch-name + ``` + +4. **Push to remote:** + ```powershell + git push origin main + ``` + +### Step 5: Verify Deployment to AWS + +1. **Check GitHub Actions** (if you have CI/CD setup): + - Go to your repository on GitHub + - Click on "Actions" tab + - Check if the deployment workflow is running/completed + - The workflows are in `.github/workflows/` + +2. **Check deployment status:** + - Look for `deploy-aws.yml` or `docker-build.yml` workflows + - Ensure they completed successfully + +3. **Wait for deployment** (usually 5-10 minutes after push) + +4. **Test the deployed endpoint:** + ```powershell + curl -X POST http://3.105.149.245:8081/api/posts/summarize ` + -H "Content-Type: application/json" ` + -d '{"postId":"POST_20260125_133146_3880"}' + ``` + +### Step 6: Test from Android App + +Once the server is deployed, your Android app should work. To verify: + +1. **Check the base URL in your Android app:** + - Look for `BASE_URL` or `API_URL` in your client code + - It should point to: `http://3.105.149.245:8081` + +2. **Run the app and try the summarize feature** + +3. **Check Logcat for the response** + +## Troubleshooting + +### If Local Server Won't Start + +**Check port 8080:** +```powershell +netstat -ano | findstr :8080 +``` + +If it's in use, kill the process or change the port in `application.properties`: +```properties +server.port=8081 +``` + +### If You Get Firebase Errors + +Ensure `service_account.json` exists in `src/main/resources/` + +### If Deployment Doesn't Happen Automatically + +**Manual deployment option:** +1. Build the JAR: + ```powershell + .\mvnw.cmd clean package -DskipTests + ``` + +2. The JAR will be in `target/` folder + +3. Deploy manually to AWS (you'll need AWS credentials) + +## Quick Verification Checklist + +- [ ] Endpoint exists in `PostController.java` +- [ ] Local server starts without errors +- [ ] Local endpoint returns valid response (not 404) +- [ ] Changes are committed to git +- [ ] Changes are merged to main branch +- [ ] Changes are pushed to GitHub +- [ ] CI/CD pipeline completed successfully +- [ ] AWS server has been updated (wait 5-10 minutes) +- [ ] Remote endpoint returns valid response +- [ ] Android app successfully calls the endpoint + +## Expected Response Format + +**Success (200):** +```json +{ + "postId": "POST_20260125_133146_3880", + "summary": "This is the AI-generated summary of the post...", + "error": null +} +``` + +**Error (404 or 500):** +```json +{ + "postId": "POST_20260125_133146_3880", + "summary": null, + "error": "Error message here" +} +``` + +## Next Steps + +1. **Start local server first** to confirm endpoint works +2. **Check git status** to see if changes are committed +3. **Merge to main** if on a feature branch +4. **Push to trigger deployment** +5. **Wait for deployment** to complete +6. **Test remote endpoint** again From 5c169f8a9c652e8c806824710713fd3e4f6ceb3c Mon Sep 17 00:00:00 2001 From: Tran Anh Khoa Date: Sun, 25 Jan 2026 21:26:06 +0700 Subject: [PATCH 5/5] feat(ai-summary): implemented an efficient AI-based text summarization caching system --- AI_SUMMARY_CACHING_ARCHITECTURE.md | 253 +++++++++++++++++ .../dto/post/PostSummaryResponse.java | 58 +++- .../forumus_backend/service/PostService.java | 63 ++++- .../service/SummaryCacheService.java | 264 ++++++++++++++++++ 4 files changed, 627 insertions(+), 11 deletions(-) create mode 100644 AI_SUMMARY_CACHING_ARCHITECTURE.md create mode 100644 src/main/java/com/hcmus/forumus_backend/service/SummaryCacheService.java diff --git a/AI_SUMMARY_CACHING_ARCHITECTURE.md b/AI_SUMMARY_CACHING_ARCHITECTURE.md new file mode 100644 index 0000000..d358d95 --- /dev/null +++ b/AI_SUMMARY_CACHING_ARCHITECTURE.md @@ -0,0 +1,253 @@ +# AI Summary Caching Architecture + +## Overview + +This document describes the efficient caching system for AI-based text summarization implemented to avoid redundant API calls, minimize costs, and improve performance. + +## Architecture Goals + +1. **Avoid Redundant API Calls**: Cache summaries to prevent repeated AI generation requests +2. **Content-Aware Invalidation**: Automatically regenerate summaries when post content changes +3. **Cost Optimization**: Minimize Gemini API usage and quota consumption +4. **Multi-Layer Caching**: Both server-side and client-side caching for optimal performance +5. **Scalability**: Support multiple concurrent users efficiently + +## System Architecture + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ CLIENT (Android App) │ +├─────────────────────────────────────────────────────────────────────┤ +│ ┌───────────────┐ ┌────────────────────────────────────────┐ │ +│ │ HomeFragment │────▶│ SummaryCacheManager │ │ +│ │ PostDetailFrag│ │ (SharedPreferences-based cache) │ │ +│ └───────────────┘ │ │ │ +│ │ │ - Local cache check (instant response)│ │ +│ │ │ - Content hash validation │ │ +│ │ │ - TTL-based expiration (24h) │ │ +│ │ │ - LRU eviction (max 100 entries) │ │ +│ ▼ └────────────────────────────────────────┘ │ +│ ┌───────────────┐ │ │ +│ │PostRepository │◀─────────────────────┘ │ +│ │ │ │ +│ │ getPostSummary() │ +│ └───────┬───────┘ │ +│ │ HTTP POST /api/posts/summarize │ +└──────────┼──────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────┐ +│ SERVER (Spring Boot) │ +├─────────────────────────────────────────────────────────────────────┤ +│ ┌───────────────┐ ┌────────────────────────────────────────┐ │ +│ │PostController │────▶│ SummaryCacheService │ │ +│ │ │ │ (ConcurrentHashMap-based cache) │ │ +│ │ /summarize │ │ │ │ +│ └───────────────┘ │ - Server-side cache check │ │ +│ │ │ - Content hash computation (SHA-256) │ │ +│ │ │ - TTL-based expiration (24h) │ │ +│ │ │ - LRU eviction (max 10,000 entries) │ │ +│ ▼ │ - Cache statistics tracking │ │ +│ ┌───────────────┐ └────────────────────────────────────────┘ │ +│ │ PostService │ │ │ +│ │ │◀─────────────────────┘ │ +│ │summarizePost()│ │ +│ └───────┬───────┘ │ +│ │ │ +│ ▼ (Only on cache miss) │ +│ ┌───────────────┐ │ +│ │ Gemini API │ │ +│ │ (gemini-2.5- │ │ +│ │ flash) │ │ +│ └───────────────┘ │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +## Cache Flow + +### Request Flow + +``` +User clicks "Summary" button + │ + ▼ +┌──────────────────────────────────────┐ +│ 1. Check LOCAL cache (instant) │ +│ - Valid & not expired? │ +│ - Content hash matches? │ +├──────────────────────────────────────┤ +│ YES │ NO +│ ▼ │ ▼ +│ Return cached │ Call server API +│ summary │ +└──────────────────────────────────────┘ + │ + ▼ + ┌──────────────────────────────────────┐ + │ 2. Check SERVER cache (fast) │ + │ - Valid & not expired? │ + │ - Content hash matches? │ + ├──────────────────────────────────────┤ + │ YES │ NO + │ ▼ │ ▼ + │ Return cached │ Call Gemini API + │ (cached: true) │ (slow, costs quota) + └──────────────────────────────────────┘ + │ + ▼ + ┌──────────────────────────────────────┐ + │ 3. Store in server cache │ + │ - Summary text │ + │ - Content hash │ + │ - Generated timestamp │ + └──────────────────────────────────────┘ + │ + ▼ + ┌──────────────────────────────────────┐ + │ 4. Response to client │ + │ - summary │ + │ - cached: false/true │ + │ - contentHash │ + │ - generatedAt │ + │ - expiresAt │ + └──────────────────────────────────────┘ + │ + ▼ + ┌──────────────────────────────────────┐ + │ 5. Store in LOCAL cache │ + │ - For next request │ + └──────────────────────────────────────┘ +``` + +## Content Hash Invalidation + +The system uses SHA-256 content hashing to detect when post content changes: + +```java +// Server-side hash computation +public String computeContentHash(String title, String content) { + String combined = (title != null ? title : "") + "|" + (content != null ? content : ""); + byte[] hashBytes = MessageDigest.getInstance("SHA-256").digest(combined.getBytes()); + return Base64.getEncoder().encodeToString(hashBytes); +} +``` + +When content changes: +1. New request computes new content hash +2. Hash doesn't match cached entry +3. Old cache entry is invalidated +4. New summary is generated and cached + +## API Response Structure + +```json +{ + "success": true, + "summary": "This post discusses the implementation of...", + "errorMessage": null, + "cached": true, + "contentHash": "a3f2b8c9d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1", + "generatedAt": 1706194800000, + "expiresAt": 1706281200000 +} +``` + +## Cache Configuration + +### Server-Side (SummaryCacheService) + +| Parameter | Value | Description | +|-----------|-------|-------------| +| TTL | 24 hours | Cache entry lifetime | +| Max Size | 10,000 entries | Maximum cached summaries | +| Eviction Policy | LRU (10%) | Removes oldest 10% when full | +| Hash Algorithm | SHA-256 | Content change detection | + +### Client-Side (SummaryCacheManager) + +| Parameter | Value | Description | +|-----------|-------|-------------| +| TTL | 24 hours | Cache entry lifetime | +| Max Size | 100 entries | Maximum cached summaries | +| Eviction Policy | LRU (20%) | Removes oldest 20% when full | +| Storage | SharedPreferences | Persistent local storage | + +## Performance Benefits + +### Without Caching +- Every summary request → Gemini API call +- Response time: 3-10 seconds +- Cost: Full API quota per request +- No offline support + +### With Caching +- Repeated requests → Instant response +- Response time: <100ms (local), <500ms (server cache) +- Cost: Only first request uses quota +- Offline support for cached summaries + +## Cache Statistics + +Both cache services track statistics: + +``` +Cache Status: size=150, hits=1234, misses=56, hitRate=95.67%, invalidations=12, evictions=0 +``` + +- **Hits**: Requests served from cache +- **Misses**: Requests requiring new generation +- **Hit Rate**: Percentage of cached responses +- **Invalidations**: Entries removed due to content change +- **Evictions**: Entries removed due to size limit + +## Server Files + +| File | Description | +|------|-------------| +| `SummaryCacheService.java` | In-memory cache service with ConcurrentHashMap | +| `PostService.java` | Updated to use cache before calling Gemini | +| `PostSummaryResponse.java` | DTO with cache metadata fields | + +## Testing the Cache + +### Via cURL + +```bash +# First request - generates new summary +curl -X POST http://localhost:3000/api/posts/summarize \ + -H "Content-Type: application/json" \ + -d '{"postId": "POST_20260125_120000_1234"}' + +# Check server logs for "Cache MISS" + +# Second request - returns cached +curl -X POST http://localhost:3000/api/posts/summarize \ + -H "Content-Type: application/json" \ + -d '{"postId": "POST_20260125_120000_1234"}' + +# Check server logs for "Cache HIT" +# Response will have "cached": true +``` + +### Expected Logs + +``` +Summary request for Post ID: POST_20260125_120000_1234 +Cache MISS for post POST_20260125_120000_1234 - generating new summary +Cache status: Cache Status: size=0, hits=0, misses=0, hitRate=0.00%, invalidations=0, evictions=0 +Summarizing post - Title: Sample Post +Summary generated and cached: This post discusses... +New cache status: Cache Status: size=1, hits=0, misses=1, hitRate=0.00%, invalidations=0, evictions=0 + +# Second request +Summary request for Post ID: POST_20260125_120000_1234 +Cache HIT for post POST_20260125_120000_1234 (hitCount: 1) +``` + +## Future Improvements + +1. **Redis Cache**: Replace in-memory cache with Redis for distributed caching +2. **Pre-warming**: Generate summaries for popular posts proactively +3. **Compression**: Compress cached summaries to save storage +4. **Cache Sync**: Sync cache invalidation across server instances +5. **Smart TTL**: Adjust TTL based on post update frequency diff --git a/src/main/java/com/hcmus/forumus_backend/dto/post/PostSummaryResponse.java b/src/main/java/com/hcmus/forumus_backend/dto/post/PostSummaryResponse.java index b7fcf72..00db3bd 100644 --- a/src/main/java/com/hcmus/forumus_backend/dto/post/PostSummaryResponse.java +++ b/src/main/java/com/hcmus/forumus_backend/dto/post/PostSummaryResponse.java @@ -1,13 +1,19 @@ package com.hcmus.forumus_backend.dto.post; /** - * Response DTO for AI-generated post summaries. + * Response DTO for AI-generated post summaries with caching support. + * + *

The contentHash field allows clients to detect when content has changed + * and invalidate their local cache accordingly.

*/ public class PostSummaryResponse { private boolean success; private String summary; private String errorMessage; private boolean cached; + private String contentHash; // Hash of post content for cache validation + private Long generatedAt; // Timestamp when summary was generated + private Long expiresAt; // Timestamp when cache expires public PostSummaryResponse() { } @@ -19,18 +25,38 @@ public PostSummaryResponse(boolean success, String summary, String errorMessage, this.cached = cached; } + public PostSummaryResponse(boolean success, String summary, String errorMessage, boolean cached, + String contentHash, Long generatedAt, Long expiresAt) { + this.success = success; + this.summary = summary; + this.errorMessage = errorMessage; + this.cached = cached; + this.contentHash = contentHash; + this.generatedAt = generatedAt; + this.expiresAt = expiresAt; + } + /** - * Factory method for successful summary response. + * Factory method for successful summary response with cache metadata. + */ + public static PostSummaryResponse success(String summary, boolean cached, String contentHash, Long generatedAt) { + // Cache expires in 24 hours + Long expiresAt = generatedAt != null ? generatedAt + (24 * 60 * 60 * 1000) : null; + return new PostSummaryResponse(true, summary, null, cached, contentHash, generatedAt, expiresAt); + } + + /** + * Factory method for successful summary response (legacy support). */ public static PostSummaryResponse success(String summary, boolean cached) { - return new PostSummaryResponse(true, summary, null, cached); + return new PostSummaryResponse(true, summary, null, cached, null, System.currentTimeMillis(), null); } /** * Factory method for error response. */ public static PostSummaryResponse error(String errorMessage) { - return new PostSummaryResponse(false, null, errorMessage, false); + return new PostSummaryResponse(false, null, errorMessage, false, null, null, null); } public boolean isSuccess() { @@ -64,4 +90,28 @@ public boolean isCached() { public void setCached(boolean cached) { this.cached = cached; } + + public String getContentHash() { + return contentHash; + } + + public void setContentHash(String contentHash) { + this.contentHash = contentHash; + } + + public Long getGeneratedAt() { + return generatedAt; + } + + public void setGeneratedAt(Long generatedAt) { + this.generatedAt = generatedAt; + } + + public Long getExpiresAt() { + return expiresAt; + } + + public void setExpiresAt(Long expiresAt) { + this.expiresAt = expiresAt; + } } diff --git a/src/main/java/com/hcmus/forumus_backend/service/PostService.java b/src/main/java/com/hcmus/forumus_backend/service/PostService.java index 078c902..2bd863d 100644 --- a/src/main/java/com/hcmus/forumus_backend/service/PostService.java +++ b/src/main/java/com/hcmus/forumus_backend/service/PostService.java @@ -38,9 +38,10 @@ public class PostService { private final TopicsListener topicsListener; private final Firestore db; private final ObjectMapper objectMapper; + private final SummaryCacheService summaryCache; public PostService(Client geminiClient, GenerateContentConfig generateContentConfig, TopicService topicService, - TopicsListener topicsListener, Firestore db) { + TopicsListener topicsListener, Firestore db, SummaryCacheService summaryCache) { this.geminiClient = geminiClient; this.generateContentConfig = GenerateContentConfig.builder() .systemInstruction(Content.fromParts(Part.fromText(instructionPrompt))) @@ -48,6 +49,7 @@ public PostService(Client geminiClient, GenerateContentConfig generateContentCon this.topicsListener = topicsListener; this.db = db; this.objectMapper = new ObjectMapper(); + this.summaryCache = summaryCache; } public PostDTO getPostById(String postId) throws ExecutionException, InterruptedException { @@ -165,13 +167,21 @@ public PostValidationResponse validatePost(String title, String content) { } /** - * Generates an AI-powered summary for a post. + * Generates an AI-powered summary for a post with intelligent caching. + * + *

Caching Strategy: + *

    + *
  • Computes content hash to detect changes
  • + *
  • Returns cached summary if content unchanged and cache valid
  • + *
  • Regenerates summary only when content changes or cache expires
  • + *
  • Stores new summaries in cache with content hash for validation
  • + *
* * @param postId The ID of the post to summarize * @return PostSummaryResponse containing the summary or error message */ public PostSummaryResponse summarizePost(String postId) { - System.out.println("Generating summary for Post ID: " + postId); + System.out.println("Summary request for Post ID: " + postId); try { // Fetch post from Firestore @@ -185,9 +195,23 @@ public PostSummaryResponse summarizePost(String postId) { String title = post.getTitle(); String content = post.getContent(); + // Compute content hash for cache validation + String contentHash = summaryCache.computeContentHash(title, content); + + // Check cache first + SummaryCacheService.CachedSummary cached = summaryCache.get(postId, contentHash); + if (cached != null) { + System.out.println("Cache HIT for post " + postId + " (hitCount: " + cached.getHitCount() + ")"); + return PostSummaryResponse.success(cached.getSummary(), true, contentHash, cached.getCreatedAt()); + } + + System.out.println("Cache MISS for post " + postId + " - generating new summary"); + System.out.println("Cache status: " + summaryCache.getCacheStatusSummary()); + // Truncate content if too long (max 5000 chars to avoid token limits) + String truncatedContent = content; if (content != null && content.length() > 5000) { - content = content.substring(0, 5000) + "..."; + truncatedContent = content.substring(0, 5000) + "..."; } System.out.println("Summarizing post - Title: " + title); @@ -204,7 +228,7 @@ Please provide a concise summary (2-3 sentences, max 100 words) of this forum po Respond with ONLY the summary text, no JSON, no quotes, no formatting. """.formatted( title != null ? title : "", - content != null ? content : "" + truncatedContent != null ? truncatedContent : "" ); // Call Gemini API @@ -216,9 +240,14 @@ Please provide a concise summary (2-3 sentences, max 100 words) of this forum po summary = summary.substring(1, summary.length() - 1); } - System.out.println("Summary generated successfully: " + summary.substring(0, Math.min(50, summary.length())) + "..."); + // Store in cache + long generatedAt = System.currentTimeMillis(); + summaryCache.put(postId, summary, contentHash); + + System.out.println("Summary generated and cached: " + summary.substring(0, Math.min(50, summary.length())) + "..."); + System.out.println("New cache status: " + summaryCache.getCacheStatusSummary()); - return PostSummaryResponse.success(summary, false); + return PostSummaryResponse.success(summary, false, contentHash, generatedAt); } catch (Exception e) { System.err.println("Error generating summary for post " + postId + ": " + e.getMessage()); @@ -227,6 +256,26 @@ Please provide a concise summary (2-3 sentences, max 100 words) of this forum po } } + /** + * Invalidates the cached summary for a post. + * Call this when a post is updated. + * + * @param postId The ID of the post to invalidate + */ + public void invalidateSummaryCache(String postId) { + summaryCache.invalidate(postId); + System.out.println("Summary cache invalidated for post: " + postId); + } + + /** + * Gets cache statistics for monitoring. + * + * @return String with cache statistics + */ + public String getSummaryCacheStats() { + return summaryCache.getCacheStatusSummary(); + } + public Map extractTopics(String title, String content) { List topicResponses = topicsListener.getAllTopics(); diff --git a/src/main/java/com/hcmus/forumus_backend/service/SummaryCacheService.java b/src/main/java/com/hcmus/forumus_backend/service/SummaryCacheService.java new file mode 100644 index 0000000..2ee9993 --- /dev/null +++ b/src/main/java/com/hcmus/forumus_backend/service/SummaryCacheService.java @@ -0,0 +1,264 @@ +package com.hcmus.forumus_backend.service; + +import org.springframework.stereotype.Service; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.concurrent.ConcurrentHashMap; +import java.util.Base64; + +/** + * Service for caching AI-generated summaries to avoid redundant API calls. + * + *

Architecture Overview: + *

    + *
  • Uses in-memory ConcurrentHashMap for fast, thread-safe caching
  • + *
  • Tracks content hash to detect when post content changes
  • + *
  • Automatically invalidates cache when content changes
  • + *
  • Supports TTL (Time-To-Live) for cache entries
  • + *
+ * + *

Cache Entry Structure: + *

    + *
  • Key: postId
  • + *
  • Value: CachedSummary containing summary, contentHash, and timestamp
  • + *
+ */ +@Service +public class SummaryCacheService { + + /** + * Internal class representing a cached summary entry. + */ + public static class CachedSummary { + private final String summary; + private final String contentHash; + private final long createdAt; + private long lastAccessedAt; + private int hitCount; + + public CachedSummary(String summary, String contentHash) { + this.summary = summary; + this.contentHash = contentHash; + this.createdAt = System.currentTimeMillis(); + this.lastAccessedAt = this.createdAt; + this.hitCount = 0; + } + + public String getSummary() { + return summary; + } + + public String getContentHash() { + return contentHash; + } + + public long getCreatedAt() { + return createdAt; + } + + public long getLastAccessedAt() { + return lastAccessedAt; + } + + public int getHitCount() { + return hitCount; + } + + public void recordAccess() { + this.lastAccessedAt = System.currentTimeMillis(); + this.hitCount++; + } + + public boolean isExpired(long ttlMillis) { + return System.currentTimeMillis() - createdAt > ttlMillis; + } + } + + /** + * Cache statistics for monitoring and optimization. + */ + public static class CacheStats { + private long hits; + private long misses; + private long invalidations; + private long evictions; + + public long getHits() { return hits; } + public long getMisses() { return misses; } + public long getInvalidations() { return invalidations; } + public long getEvictions() { return evictions; } + public double getHitRate() { + long total = hits + misses; + return total > 0 ? (double) hits / total : 0.0; + } + + public void recordHit() { hits++; } + public void recordMiss() { misses++; } + public void recordInvalidation() { invalidations++; } + public void recordEviction() { evictions++; } + } + + // Cache storage: postId -> CachedSummary + private final ConcurrentHashMap cache = new ConcurrentHashMap<>(); + + // Cache configuration + private static final long DEFAULT_TTL_MILLIS = 24 * 60 * 60 * 1000; // 24 hours + private static final int MAX_CACHE_SIZE = 10000; // Maximum entries + + // Cache statistics + private final CacheStats stats = new CacheStats(); + + /** + * Computes a SHA-256 hash of the given content. + * Used to detect when post content has changed. + * + * @param content The content to hash + * @return Base64-encoded hash string + */ + public String computeContentHash(String title, String content) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + String combined = (title != null ? title : "") + "|" + (content != null ? content : ""); + byte[] hashBytes = digest.digest(combined.getBytes()); + return Base64.getEncoder().encodeToString(hashBytes); + } catch (NoSuchAlgorithmException e) { + // Fallback to simple hash + return String.valueOf((title + "|" + content).hashCode()); + } + } + + /** + * Gets a cached summary if it exists and is still valid. + * + * @param postId The post ID + * @param currentContentHash The current content hash to validate against + * @return The cached summary if valid, null otherwise + */ + public CachedSummary get(String postId, String currentContentHash) { + CachedSummary cached = cache.get(postId); + + if (cached == null) { + stats.recordMiss(); + return null; + } + + // Check if content has changed (invalidation) + if (!cached.getContentHash().equals(currentContentHash)) { + invalidate(postId); + stats.recordInvalidation(); + stats.recordMiss(); + return null; + } + + // Check TTL expiration + if (cached.isExpired(DEFAULT_TTL_MILLIS)) { + invalidate(postId); + stats.recordEviction(); + stats.recordMiss(); + return null; + } + + // Valid cache hit + cached.recordAccess(); + stats.recordHit(); + return cached; + } + + /** + * Stores a summary in the cache. + * + * @param postId The post ID + * @param summary The generated summary + * @param contentHash The content hash at the time of generation + */ + public void put(String postId, String summary, String contentHash) { + // Evict old entries if cache is full + if (cache.size() >= MAX_CACHE_SIZE) { + evictOldestEntries(); + } + + cache.put(postId, new CachedSummary(summary, contentHash)); + } + + /** + * Invalidates a specific cache entry. + * + * @param postId The post ID to invalidate + */ + public void invalidate(String postId) { + cache.remove(postId); + } + + /** + * Clears the entire cache. + */ + public void clear() { + cache.clear(); + } + + /** + * Gets the current cache size. + * + * @return Number of cached entries + */ + public int size() { + return cache.size(); + } + + /** + * Gets cache statistics. + * + * @return CacheStats object with hit/miss information + */ + public CacheStats getStats() { + return stats; + } + + /** + * Checks if a post has a valid cached summary. + * + * @param postId The post ID + * @param currentContentHash The current content hash + * @return true if a valid cache entry exists + */ + public boolean hasValidCache(String postId, String currentContentHash) { + CachedSummary cached = cache.get(postId); + if (cached == null) { + return false; + } + return cached.getContentHash().equals(currentContentHash) && !cached.isExpired(DEFAULT_TTL_MILLIS); + } + + /** + * Evicts the oldest 10% of cache entries when cache is full. + * Uses last-accessed time for LRU-like behavior. + */ + private void evictOldestEntries() { + int entriesToEvict = MAX_CACHE_SIZE / 10; // Evict 10% + + cache.entrySet().stream() + .sorted((e1, e2) -> Long.compare(e1.getValue().getLastAccessedAt(), e2.getValue().getLastAccessedAt())) + .limit(entriesToEvict) + .forEach(entry -> { + cache.remove(entry.getKey()); + stats.recordEviction(); + }); + } + + /** + * Gets a summary of the cache state for monitoring. + * + * @return String describing cache state + */ + public String getCacheStatusSummary() { + return String.format( + "Cache Status: size=%d, hits=%d, misses=%d, hitRate=%.2f%%, invalidations=%d, evictions=%d", + size(), + stats.getHits(), + stats.getMisses(), + stats.getHitRate() * 100, + stats.getInvalidations(), + stats.getEvictions() + ); + } +}