Modernize Feedback service

This commit is contained in:
2025-11-13 13:05:46 +01:00
parent b43ec0856b
commit 98483338b4
9 changed files with 172 additions and 184 deletions

View File

@@ -37,4 +37,11 @@ dependencies {
runtimeOnly 'org.springframework.boot:spring-boot-devtools'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
compileOnly("org.projectlombok:lombok:1.18.42")
annotationProcessor("org.projectlombok:lombok:1.18.42")
testCompileOnly("org.projectlombok:lombok:1.18.42")
testAnnotationProcessor("org.projectlombok:lombok:1.18.42")
}

View File

@@ -0,0 +1,51 @@
package com.retailstore.feedback;
import java.util.concurrent.*;
import java.util.concurrent.locks.*;
/**
* Thread pool executor that allows for pausing and resuming tasks
*/
public class PausableThreadPoolExecutor extends ThreadPoolExecutor {
private boolean isPaused;
private final ReentrantLock pauseLock = new ReentrantLock();
private final Condition unpaused = pauseLock.newCondition();
public PausableThreadPoolExecutor(int corePoolSize, int maximumPoolSize, long keepAliveTime, TimeUnit unit) {
super(corePoolSize, maximumPoolSize, keepAliveTime, unit, new LinkedBlockingDeque<>());
}
@Override
protected void beforeExecute(Thread t, Runnable r) {
super.beforeExecute(t, r);
pauseLock.lock();
try {
while (isPaused) {
unpaused.await();
}
} catch (InterruptedException ie) {
t.interrupt();
} finally {
pauseLock.unlock();
}
}
public void pause() {
pauseLock.lock();
try {
isPaused = true;
} finally {
pauseLock.unlock();
}
}
public void resume() {
pauseLock.lock();
try {
isPaused = false;
unpaused.signalAll();
} finally {
pauseLock.unlock();
}
}
}

View File

@@ -1,5 +1,7 @@
package com.retailstore.feedback;
import com.retailstore.feedback.model.FeedbackEntry;
import com.retailstore.feedback.model.FeedbackEntry.FeedbackEntryBuilder;
import edu.stanford.nlp.pipeline.CoreDocument;
import edu.stanford.nlp.pipeline.CoreSentence;
import edu.stanford.nlp.pipeline.StanfordCoreNLP;
@@ -117,9 +119,8 @@ public class SentimentAnalyzer {
if (feedbackEntry != null) {
// Analyze sentiment
log.info("Analyzing sentiment for entry #{}", feedbackEntry.getId());
String sentiment = analyzeSentiment(feedbackEntry.comment);
feedbackEntry.setSentiment(sentiment);
String sentiment = analyzeSentiment(feedbackEntry.getComment());
feedbackEntry = feedbackEntry.withSentiment(sentiment);
feedbackEntries.add(feedbackEntry);
} else {
log.error("Failed to parse entry at position {}", i);
@@ -136,7 +137,7 @@ public class SentimentAnalyzer {
* @return FeedbackEntry object
*/
private FeedbackEntry parseEntry(String entryText) {
FeedbackEntry entry = new FeedbackEntry();
FeedbackEntryBuilder entryBuilder = FeedbackEntry.builder();
try {
// Split into lines for easier parsing
@@ -150,7 +151,7 @@ public class SentimentAnalyzer {
if (line.startsWith("Feedback #")) {
Matcher numberMatcher = FEEDBACK_NUMBER_PATTERN.matcher(line);
if (numberMatcher.find()) {
entry.setId(Integer.parseInt(numberMatcher.group(1)));
entryBuilder.id(Integer.parseInt(numberMatcher.group(1)));
}
}
@@ -158,7 +159,7 @@ public class SentimentAnalyzer {
else if (line.startsWith("Customer:")) {
Matcher customerMatcher = CUSTOMER_PATTERN.matcher(line);
if (customerMatcher.find()) {
entry.setCustomer(customerMatcher.group(1).trim());
entryBuilder.customer(customerMatcher.group(1).trim());
}
}
@@ -166,7 +167,7 @@ public class SentimentAnalyzer {
else if (line.startsWith("Department:")) {
Matcher departmentMatcher = DEPARTMENT_PATTERN.matcher(line);
if (departmentMatcher.find()) {
entry.setDepartment(departmentMatcher.group(1).trim());
entryBuilder.department(departmentMatcher.group(1).trim());
}
}
@@ -174,7 +175,7 @@ public class SentimentAnalyzer {
else if (line.startsWith("Date:")) {
Matcher dateMatcher = DATE_PATTERN.matcher(line);
if (dateMatcher.find()) {
entry.setDate(dateMatcher.group(1).trim());
entryBuilder.date(dateMatcher.group(1).trim());
}
}
@@ -182,7 +183,7 @@ public class SentimentAnalyzer {
else if (line.startsWith("Comment:")) {
Matcher commentMatcher = COMMENT_PATTERN.matcher(line);
if (commentMatcher.find()) {
entry.setComment(commentMatcher.group(1).trim());
entryBuilder.comment(commentMatcher.group(1).trim());
}
}
@@ -190,7 +191,8 @@ public class SentimentAnalyzer {
}
// Validate that we have all required fields
if (entry.getId() > 0 && entry.getComment() != null) {
FeedbackEntry entry = entryBuilder.build();
if (entry.validate()) {
return entry;
} else {
log.error("Invalid entry - missing ID or comment");
@@ -212,6 +214,7 @@ public class SentimentAnalyzer {
* @return Sentiment (VERY_POSITIVE, POSITIVE, NEUTRAL, NEGATIVE, VERY_NEGATIVE)
*/
public String analyzeSentiment(String comment) {
// Create a document from the comment
CoreDocument doc = new CoreDocument(comment);
@@ -300,44 +303,4 @@ public class SentimentAnalyzer {
}
}
}
/**
* Inner class representing a feedback entry with sentiment analysis
*/
public static class FeedbackEntry {
private int id;
private String customer;
private String department;
private String date;
private String comment;
private String sentiment;
// Default values to avoid null
public FeedbackEntry() {
this.customer = "";
this.department = "";
this.date = "";
this.comment = "";
this.sentiment = "";
}
// Getters and setters
public int getId() { return id; }
public void setId(int id) { this.id = id; }
public String getCustomer() { return customer; }
public void setCustomer(String customer) { this.customer = customer; }
public String getDepartment() { return department; }
public void setDepartment(String department) { this.department = department; }
public String getDate() { return date; }
public void setDate(String date) { this.date = date; }
public String getComment() { return comment; }
public void setComment(String comment) { this.comment = comment; }
public String getSentiment() { return sentiment; }
public void setSentiment(String sentiment) { this.sentiment = sentiment; }
}
}

View File

@@ -20,8 +20,11 @@ import java.util.List;
@Controller
public class FeedbackController {
@Autowired
private FeedbackService feedbackService;
private final FeedbackService feedbackService;
public FeedbackController(FeedbackService feedbackService) {
this.feedbackService = feedbackService;
}
/**
* Displays the dashboard page.

View File

@@ -1,29 +1,14 @@
package com.retailstore.feedback.model;
import lombok.Getter;
import lombok.experimental.SuperBuilder;
/**
* Represents feedback enhanced with AI-generated category and actionable insights.
*/
@Getter
@SuperBuilder
public class EnhancedFeedback extends FeedbackEntry {
private String category;
private String actionableInsight;
// Default constructor
public EnhancedFeedback() {
super();
}
// Constructor based on FeedbackEntry
public EnhancedFeedback(FeedbackEntry entry) {
super(entry.getId(), entry.getCustomer(), entry.getDepartment(),
entry.getDate(), entry.getComment(), entry.getSentiment());
}
// Getters and setters for additional fields
public String getCategory() { return category; }
public void setCategory(String category) { this.category = category; }
public String getActionableInsight() { return actionableInsight; }
public void setActionableInsight(String actionableInsight) {
this.actionableInsight = actionableInsight;
}
}

View File

@@ -1,46 +1,34 @@
package com.retailstore.feedback.model;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.With;
import lombok.experimental.SuperBuilder;
/**
* Represents a feedback entry with sentiment analysis.
*/
@Getter
@SuperBuilder
@AllArgsConstructor
public class FeedbackEntry {
private int id;
private String customer;
private String department;
private String date;
private String comment;
private String sentiment;
// Default constructor
public FeedbackEntry() {}
@Builder.Default
private String customer = "";
@Builder.Default
private String department = "";
@Builder.Default
private String date = "";
@Builder.Default
private String comment = "";
@With
@Builder.Default
private String sentiment = "";
// Constructor with parameters
public FeedbackEntry(int id, String customer, String department,
String date, String comment, String sentiment) {
this.id = id;
this.customer = customer;
this.department = department;
this.date = date;
this.comment = comment;
this.sentiment = sentiment;
public boolean validate() {
return id > 0 && comment != null;
}
// Getters and setters
public int getId() { return id; }
public void setId(int id) { this.id = id; }
public String getCustomer() { return customer; }
public void setCustomer(String customer) { this.customer = customer; }
public String getDepartment() { return department; }
public void setDepartment(String department) { this.department = department; }
public String getDate() { return date; }
public void setDate(String date) { this.date = date; }
public String getComment() { return comment; }
public void setComment(String comment) { this.comment = comment; }
public String getSentiment() { return sentiment; }
public void setSentiment(String sentiment) { this.sentiment = sentiment; }
}

View File

@@ -1,44 +1,21 @@
package com.retailstore.feedback.model;
import lombok.Builder;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import java.util.List;
import java.util.Map;
/**
* Represents a summary of feedback data for the dashboard.
*/
@Getter
@Builder
public class FeedbackSummary {
private int totalFeedback;
private Map<String, Integer> sentimentCounts;
private Map<String, Integer> categoryCounts;
private Map<String, Integer> departmentCounts;
private List<EnhancedFeedback> recentFeedback;
// Default constructor
public FeedbackSummary() {}
// Getters and setters
public int getTotalFeedback() { return totalFeedback; }
public void setTotalFeedback(int totalFeedback) {
this.totalFeedback = totalFeedback;
}
public Map<String, Integer> getSentimentCounts() { return sentimentCounts; }
public void setSentimentCounts(Map<String, Integer> sentimentCounts) {
this.sentimentCounts = sentimentCounts;
}
public Map<String, Integer> getCategoryCounts() { return categoryCounts; }
public void setCategoryCounts(Map<String, Integer> categoryCounts) {
this.categoryCounts = categoryCounts;
}
public Map<String, Integer> getDepartmentCounts() { return departmentCounts; }
public void setDepartmentCounts(Map<String, Integer> departmentCounts) {
this.departmentCounts = departmentCounts;
}
public List<EnhancedFeedback> getRecentFeedback() { return recentFeedback; }
public void setRecentFeedback(List<EnhancedFeedback> recentFeedback) {
this.recentFeedback = recentFeedback;
}
private final int totalFeedback;
private final Map<String, Integer> sentimentCounts;
private final Map<String, Integer> categoryCounts;
private final Map<String, Integer> departmentCounts;
private final List<EnhancedFeedback> recentFeedback;
}

View File

@@ -5,6 +5,9 @@ import com.retailstore.feedback.model.FeedbackEntry;
import com.retailstore.feedback.model.EnhancedFeedback;
import com.retailstore.feedback.model.FeedbackSummary;
import com.retailstore.feedback.model.FeedbackSummary.FeedbackSummaryBuilder;
import org.springframework.boot.context.event.ApplicationStartedEvent;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Service;
import org.springframework.beans.factory.annotation.Autowired;
@@ -12,6 +15,7 @@ import java.io.BufferedReader;
import java.io.FileReader;
import java.io.IOException;
import java.util.*;
import java.util.concurrent.TimeUnit;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
@@ -82,7 +86,7 @@ public class FeedbackService {
}
// Process each line
if (line.trim().isEmpty() && entryText.length() > 0) {
if (line.trim().isEmpty() && !entryText.isEmpty()) {
// We've reached the end of an entry
FeedbackEntry entry = parseFeedbackEntry(entryText.toString());
if (entry != null) {
@@ -95,7 +99,7 @@ public class FeedbackService {
}
// Process the last entry if there is one
if (entryText.length() > 0) {
if (!entryText.isEmpty()) {
FeedbackEntry entry = parseFeedbackEntry(entryText.toString());
if (entry != null) {
entries.add(entry);
@@ -113,12 +117,12 @@ public class FeedbackService {
* @return FeedbackEntry object or null if parsing fails
*/
private FeedbackEntry parseFeedbackEntry(String text) {
FeedbackEntry entry = new FeedbackEntry();
FeedbackEntry.FeedbackEntryBuilder<?, ?> entryBuilder = FeedbackEntry.builder();
// Extract feedback ID
Matcher idMatcher = FEEDBACK_PATTERN.matcher(text);
if (idMatcher.find()) {
entry.setId(Integer.parseInt(idMatcher.group(1)));
entryBuilder.id(Integer.parseInt(idMatcher.group(1)));
} else {
return null;
}
@@ -126,34 +130,34 @@ public class FeedbackService {
// Extract customer
Matcher customerMatcher = CUSTOMER_PATTERN.matcher(text);
if (customerMatcher.find()) {
entry.setCustomer(customerMatcher.group(1));
entryBuilder.customer(customerMatcher.group(1));
}
// Extract department
Matcher departmentMatcher = DEPARTMENT_PATTERN.matcher(text);
if (departmentMatcher.find()) {
entry.setDepartment(departmentMatcher.group(1));
entryBuilder.department(departmentMatcher.group(1));
}
// Extract date
Matcher dateMatcher = DATE_PATTERN.matcher(text);
if (dateMatcher.find()) {
entry.setDate(dateMatcher.group(1));
entryBuilder.date(dateMatcher.group(1));
}
// Extract comment
Matcher commentMatcher = COMMENT_PATTERN.matcher(text);
if (commentMatcher.find()) {
entry.setComment(commentMatcher.group(1));
entryBuilder.comment(commentMatcher.group(1));
}
// Extract sentiment
Matcher sentimentMatcher = SENTIMENT_PATTERN.matcher(text);
if (sentimentMatcher.find()) {
entry.setSentiment(sentimentMatcher.group(1));
entryBuilder.sentiment(sentimentMatcher.group(1));
}
return entry;
return entryBuilder.build();
}
/**
@@ -162,14 +166,16 @@ public class FeedbackService {
* @return List of EnhancedFeedback objects
* @throws IOException If an I/O error occurs
*/
public synchronized List<EnhancedFeedback> getEnhancedFeedback() throws IOException {
@EventListener(ApplicationStartedEvent.class)
public List<EnhancedFeedback> getEnhancedFeedback() throws IOException {
// Return cached data if available
if (enhancedFeedbackCache != null) {
return enhancedFeedbackCache;
}
enhancedFeedbackCache = Collections.emptyList();
List<FeedbackEntry> entries = readFeedbackData();
List<EnhancedFeedback> enhancedEntries = new ArrayList<>();
List<FeedbackEntry> entries = readFeedbackData();
for (FeedbackEntry entry : entries) {
EnhancedFeedback enhancedEntry = enhanceFeedback(entry);
@@ -189,14 +195,20 @@ public class FeedbackService {
* @return EnhancedFeedback with AI-generated category and actionable insight
*/
private EnhancedFeedback enhanceFeedback(FeedbackEntry entry) {
EnhancedFeedback enhancedEntry = new EnhancedFeedback(entry);
EnhancedFeedback.EnhancedFeedbackBuilder<?, ?> enhancedEntryBuilder = EnhancedFeedback.builder()
.id(entry.getId())
.date(entry.getDate())
.comment(entry.getComment())
.customer(entry.getCustomer())
.department(entry.getDepartment())
.sentiment(entry.getSentiment());
// Create a prompt for Gemini
String prompt = PROMPT.formatted(entry.getComment(), entry.getDepartment(), entry.getSentiment());
try {
// Call Gemini API and parse the response
String response = geminiService.generateContent(prompt);
String response = geminiService.generateContent(prompt).get(3, TimeUnit.SECONDS);
// Parse JSON response
// This is a simple parsing approach - for production, use a proper JSON parser
@@ -206,27 +218,27 @@ public class FeedbackService {
Pattern categoryPattern = Pattern.compile("\"category\"\\s*:\\s*\"([^\"]+)\"");
Matcher categoryMatcher = categoryPattern.matcher(jsonResponse);
if (categoryMatcher.find()) {
enhancedEntry.setCategory(categoryMatcher.group(1));
enhancedEntryBuilder.category(categoryMatcher.group(1));
} else {
enhancedEntry.setCategory("Uncategorized");
enhancedEntryBuilder.category("Uncategorized");
}
// Extract actionable insight
Pattern insightPattern = Pattern.compile("\"actionableInsight\"\\s*:\\s*\"([^\"]+)\"");
Matcher insightMatcher = insightPattern.matcher(jsonResponse);
if (insightMatcher.find()) {
enhancedEntry.setActionableInsight(insightMatcher.group(1));
enhancedEntryBuilder.actionableInsight(insightMatcher.group(1));
} else {
enhancedEntry.setActionableInsight("No specific action recommended.");
enhancedEntryBuilder.actionableInsight("No specific action recommended.");
}
} catch (Exception e) {
// Handle API errors gracefully
enhancedEntry.setCategory("Error in processing");
enhancedEntry.setActionableInsight("Could not generate insight due to API error: " + e.getMessage());
enhancedEntryBuilder.category("Error in processing");
enhancedEntryBuilder.actionableInsight("Could not generate insight due to API error: " + e.getMessage());
}
return enhancedEntry;
return enhancedEntryBuilder.build();
}
/**
@@ -237,10 +249,8 @@ public class FeedbackService {
*/
public FeedbackSummary generateFeedbackSummary() throws IOException {
List<EnhancedFeedback> allFeedback = getEnhancedFeedback();
FeedbackSummary summary = new FeedbackSummary();
// Set total feedback count
summary.setTotalFeedback(allFeedback.size());
FeedbackSummaryBuilder summaryBuilder = FeedbackSummary.builder()
.totalFeedback(allFeedback.size());
// Count sentiments
Map<String, Integer> sentimentCounts = new HashMap<>();
@@ -248,7 +258,7 @@ public class FeedbackService {
String sentiment = feedback.getSentiment();
sentimentCounts.put(sentiment, sentimentCounts.getOrDefault(sentiment, 0) + 1);
}
summary.setSentimentCounts(sentimentCounts);
summaryBuilder.sentimentCounts(sentimentCounts);
// Count categories
Map<String, Integer> categoryCounts = new HashMap<>();
@@ -256,7 +266,7 @@ public class FeedbackService {
String category = feedback.getCategory();
categoryCounts.put(category, categoryCounts.getOrDefault(category, 0) + 1);
}
summary.setCategoryCounts(categoryCounts);
summaryBuilder.categoryCounts(categoryCounts);
// Count departments
Map<String, Integer> departmentCounts = new HashMap<>();
@@ -264,15 +274,15 @@ public class FeedbackService {
String department = feedback.getDepartment();
departmentCounts.put(department, departmentCounts.getOrDefault(department, 0) + 1);
}
summary.setDepartmentCounts(departmentCounts);
summaryBuilder.departmentCounts(departmentCounts);
// Get recent feedback (last 5 entries)
List<EnhancedFeedback> recentFeedback = allFeedback.stream()
.sorted(Comparator.comparing(EnhancedFeedback::getId).reversed())
.limit(5)
.collect(Collectors.toList());
summary.setRecentFeedback(recentFeedback);
summaryBuilder.recentFeedback(recentFeedback);
return summary;
return summaryBuilder.build();
}
}

View File

@@ -4,6 +4,8 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.json.JsonMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.retailstore.feedback.PausableThreadPoolExecutor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.web.client.RestTemplateBuilder;
import org.springframework.http.client.ClientHttpRequestExecution;
@@ -16,18 +18,23 @@ import org.springframework.http.*;
import org.springframework.web.util.UriComponentsBuilder;
import java.io.IOException;
import java.util.Queue;
import java.util.concurrent.*;
/**
* Service for interacting with Google's Gemini AI using REST API
*/
@Slf4j
@Service
public class GeminiService {
private final RestTemplate restTemplate;
private final ObjectMapper objectMapper;
private final String serviceUri;
private static final PausableThreadPoolExecutor promptExecution = new PausableThreadPoolExecutor(1,1,30, TimeUnit.SECONDS);
private static final ScheduledExecutorService scheduleExecution = Executors.newSingleThreadScheduledExecutor();
public GeminiService(@Value("${gemini.api.url}") String url, @Value("${gemini.api.key}") String apiKey) {
this.objectMapper = new JsonMapper();
serviceUri = UriComponentsBuilder
@@ -45,7 +52,11 @@ public class GeminiService {
* @param prompt The prompt to send to Gemini
* @return The response from Gemini
*/
public String generateContent(String prompt) {
public Future<String> generateContent(String prompt) {
return promptExecution.submit(() -> executePrompt(prompt));
}
private String executePrompt(String prompt) {
try {
// Create the request body using Jackson
ObjectNode requestBody = objectMapper.createObjectNode();
@@ -91,18 +102,11 @@ public class GeminiService {
return "No response from Gemini";
} catch (Exception e) {
System.err.println("Error calling Gemini API: " + e.getMessage());
e.printStackTrace();
log.error("Error calling Gemini API: {}", e.getMessage(), e);
log.info("Pausing Gemini API calls for 10 seconds...");
promptExecution.pause();
scheduleExecution.schedule(promptExecution::resume, 10, TimeUnit.SECONDS);
return "Error: " + e.getMessage();
}
}
/**
* Test method to verify API connection
*/
public void testConnection() {
String testPrompt = "Say 'Hello, World!' if you can hear me.";
String response = generateContent(testPrompt);
System.out.println("Gemini API Test Response: " + response);
}
}