Negotiating with AI Managers: Build a Role‑Playing Simulator Using Quarkus and Langchain4j
A hands‑on guide for Java developers to create an AI‑powered negotiation training app with a local LLM and a clean frontend.
In challenging times with a lot of friction in the market and an increasing need to prove and document value, I wanted to see how helpful AI can be in a realistic workplace scenario. As someone who has spent years working with enterprise software, I often think about the soft skills that go along with our technical roles. Negotiation and communication with managers are key aspects of career growth, yet they are difficult to practice in a safe environment.
This project is based on the well-known DISC personality model, which categorizes behavior into Dominance, Influence, Steadiness, and Conscientiousness. By simulating different manager personalities using AI, we can experiment with how negotiations might play out in various situations.
This is not a replacement for proper negotiation or communication training. It is simply an experiment, a way to explore what AI can do in this context. Use it at your own discretion.
Building an AI Manager Negotiation Simulator is also a practical way to learn how to combine Quarkus, Langchain4j, and a local LLM. In this tutorial, we will create a backend that uses an AI model to simulate different manager personalities and scenarios, and a Pico.css-based frontend to interact with it. Along the way, we will discuss the key design decisions and highlight the parts that matter most.
A little warning. This is slightly more code than usual, so if you want to just sneak through the Github repository, you can do this. Please leave a star, while you’re there.
Project Setup
We start by generating a new Quarkus project with the required extensions for REST endpoints, Langchain4j integration, and OpenAPI documentation:
mvn io.quarkus.platform:quarkus-maven-plugin:create \
-DprojectGroupId=org.acme \
-DprojectArtifactId=negotiation-simulator \
-Dextensions="rest-jackson,quarkus-langchain4j-ollama,smallrye-openapi"
cd negotiation-simulator
After creating the project, configure the Ollama model in application.properties
so that Langchain4j knows what model to use. We also give it a little more thinking time for execution on slower, local maschines.
quarkus.langchain4j.ollama.chat-model.model-name=llama3
quarkus.langchain4j.ollama.timeout=120s
quarkus.langchain4j.ollama.log-requests=true
quarkus.langchain4j.ollama.log-responses=true
quarkus.langchain4j.ollama.chat-model.log-requests=true
quarkus.langchain4j.ollama.chat-model.log-responses=true
# Logging configuration for debugging
quarkus.log.level=INFO
quarkus.log.category."org.acme".level=DEBUG
quarkus.log.category."org.acme.SessionResource".level=DEBUG
quarkus.log.category."org.acme.memory".level=DEBUG
# Console logging format for better readability
quarkus.log.console.format=%d{HH:mm:ss} %-5p [%c{1}] %s%e%n
Data Models and Enums
The first step in our domain model is to capture the personalities, scenarios, messages, and sessions. These enums and records give our application structure.
Personality Types
We define PersonalityType
to represent the different manager personalities. Each type includes a label, its DISC type, and a short prompt to describe the behavior. The DISC mapping helps make the personalities relatable to real-world management styles.
package org.acme.model;
public enum PersonalityType {
SUPPORTIVE("Supportive", "S", "You are a supportive manager. You are encouraging, ask how you can help, and focus on collaborative solutions."),
ANALYTICAL("Analytical", "C", "You are an analytical manager. You are data-driven, ask for specific numbers and evidence, and want logical arguments."),
DIRECT("Direct", "D", "You are a direct manager. You are blunt, time-conscious, and cut to the chase. Keep your responses short and to the point."),
ACCOMMODATING("Accommodating", "S", "You are an accommodating manager. You are agreeable and polite but cautious about making commitments. You often say you need to 'think about it' or 'check with others'.");
private final String personalityName;
private final String discType;
private final String systemPrompt;
PersonalityType(String personalityName, String discType, String systemPrompt) {
this.personalityName = personalityName;
this.discType = discType;
this.systemPrompt = systemPrompt;
}
public String getPersonalityName() {
return personalityName;
}
public String getDiscType() {
return discType;
}
public String getSystemPrompt() {
return systemPrompt;
}
public String getFullDiscType() {
return switch (discType) {
case "D" -> "Dominance (D)";
case "I" -> "Influence (I)";
case "S" -> "Steadiness (S)";
case "C" -> "Conscientiousness (C)";
default -> discType;
};
}
}
Scenarios
We also define a Scenario
enum to capture the two negotiation situations:
package org.acme.model;
public enum Scenario {
REQUESTING_RAISE("The user is your employee who has requested this meeting to discuss a salary increase. They will present arguments based on their performance, achievements, market research, or other factors. Listen to their case, ask clarifying questions, and respond according to your personality type. You have the authority to approve, deny, or negotiate the request."),
POOR_PERFORMANCE("You are conducting a performance review meeting because the user (your employee) has been experiencing performance issues that need to be addressed. You should discuss specific concerns about their work quality, productivity, missed deadlines, or other performance problems. Be constructive but clear about the issues and work toward solutions and expectations for improvement.");
private final String prompt;
Scenario(String prompt) {
this.prompt = prompt;
}
public String getPrompt() {
return prompt;
}
}
Messages and Sessions
A Message
record stores who said what and when.
package org.acme.model;
import java.time.Instant;
public record Message(String sender, String content, Instant timestamp) {
public Message(String sender, String content) {
this(sender, content, Instant.now());
}
}
A Session
keeps track of the ongoing negotiation, including userId, chosen personality, scenario, and messages. We keep them in memory for this example. In a real world implementation you would need a more sophisticated memory implementation backed by different persistence stores.
package org.acme.model;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
public record Session(
String id,
String userName,
PersonalityType personality,
Scenario scenario,
List<Message> messages,
String status,
Instant createdAt) {
public Session(String id, String userName, PersonalityType personality, Scenario scenario) {
this(id, userName, personality, scenario, new ArrayList<>(), "ACTIVE", Instant.now());
}
public void addMessage(Message message) {
this.messages.add(message);
}
}
AI Services
Langchain4j makes it easy to define AI-powered services by using annotated interfaces. These services automatically handle prompt injection and communication with the LLM.
Manager Assistant
This service drives the negotiation conversation. The @SystemMessage
annotation defines the context in which the AI will operate.
package org.acme.service;
import dev.langchain4j.service.MemoryId;
import dev.langchain4j.service.SystemMessage;
import dev.langchain4j.service.UserMessage;
import io.quarkiverse.langchain4j.RegisterAiService;
@RegisterAiService
public interface ManagerAssistant {
@SystemMessage("""
You are an AI role-playing as a manager in a performance conversation simulation.
SCENARIO CONTEXT:
{scenario}
PERSONALITY & BEHAVIOR:
{personality}
EMPLOYEE INFORMATION:
You are speaking with {userName}. Address them by name throughout the conversation.
IMPORTANT GUIDELINES:
- Stay completely in character throughout the conversation
- Respond naturally as a manager would in this specific situation
- Address {userName} directly by name as your employee
- Never break character or acknowledge that you are an AI
- Keep responses professional but authentic to your personality type
- Use the scenario context to guide the conversation flow and your decision-making
- Make the conversation personal by using {userName}'s name regularly
""")
String chat(@MemoryId String sessionId, @UserMessage String userMessage, String personality, String scenario, String userName);
}
Feedback Assistant
After the conversation, we want to analyze how well the user handled the negotiation. The FeedbackAssistant
instructs the AI to return JSON containing a score, strengths, and improvements.
package org.acme.service;
import dev.langchain4j.service.SystemMessage;
import dev.langchain4j.service.UserMessage;
import io.quarkiverse.langchain4j.RegisterAiService;
@RegisterAiService
public interface FeedbackAssistant {
@SystemMessage("""
You are a professional negotiation coach analyzing conversations between employees and AI managers.
TASK: Analyze the conversation and provide performance feedback.
PARTICIPANT: {userName}
SCENARIO: {scenario}
MANAGER PERSONALITY: {personality}
CRITICAL: Respond ONLY with valid JSON. Do not include any text before or after the JSON.
Required JSON format:
{
"overallScore": <number 0-100>,
"strengths": ["<strength1>", "<strength2>", "<strength3>"],
"improvements": ["<improvement1>", "<improvement2>", "<improvement3>"]
}
Evaluation criteria for {userName}:
- Communication clarity and professionalism
- Preparation and use of supporting evidence
- Negotiation strategy and timing
- Ability to handle the {personality} manager's personality type
- Achievement of {scenario} scenario objectives
- Personal engagement and rapport building
Provide specific, actionable feedback for {userName}'s performance.
Return ONLY the JSON object, nothing else.
""")
String analyze(@UserMessage String conversation, String scenario, String personality, String userName);
}
SessionChat Memory Implementation Overview
The SessionChat Memory system in this project is built on top of the LangChain4j framework and provides persistent conversation memory for AI-powered negotiation sessions. It consists of several key components working together:
Core Components
SessionChatMemoryStore (SessionChatMemoryStore.java)
This is the core storage implementation that maintains conversation history:
package org.acme.memory;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;
import dev.langchain4j.data.message.ChatMessage;
import dev.langchain4j.store.memory.chat.ChatMemoryStore;
import io.quarkus.logging.Log;
import jakarta.enterprise.context.ApplicationScoped;
@ApplicationScoped
public class SessionChatMemoryStore implements ChatMemoryStore {
// Thread-safe storage for session messages
private final Map<String, List<ChatMessage>> sessionMessages = new ConcurrentHashMap<>();
private final Map<String, ReadWriteLock> sessionLocks = new ConcurrentHashMap<>();
@Override
public List<ChatMessage> getMessages(Object memoryId) {
String sessionId = memoryId.toString();
Log.infof("Getting messages for session: %s", sessionId);
ReadWriteLock lock = getSessionLock(sessionId);
lock.readLock().lock();
try {
List<ChatMessage> messages = sessionMessages.getOrDefault(sessionId, Collections.emptyList());
Log.infof("Retrieved %d messages for session: %s", messages.size(), sessionId);
return new ArrayList<>(messages);
} finally {
lock.readLock().unlock();
}
}
@Override
public void updateMessages(Object memoryId, List<ChatMessage> messages) {
String sessionId = memoryId.toString();
Log.infof("Updating messages for session: %s, message count: %d", sessionId, messages.size());
ReadWriteLock lock = getSessionLock(sessionId);
lock.writeLock().lock();
try {
sessionMessages.put(sessionId, new ArrayList<>(messages));
Log.infof("Updated messages for session: %s. Total sessions in store: %d",
sessionId, sessionMessages.size());
} finally {
lock.writeLock().unlock();
}
}
@Override
public void deleteMessages(Object memoryId) {
String sessionId = memoryId.toString();
Log.infof("Delete request for session: %s - PRESERVING session but keeping messages for LLM memory", sessionId);
// DO NOT delete the session or clear messages - this preserves conversation
// history for LLM
// The AI framework calls this after each conversation turn, but we want to keep
// both:
// 1. The session entry (for /api/sessions endpoint)
// 2. The message history (for LLM context)
// Simply log that we're preserving everything
ReadWriteLock lock = getSessionLock(sessionId);
lock.readLock().lock();
try {
int currentMessages = sessionMessages.getOrDefault(sessionId, Collections.emptyList()).size();
Log.infof("Preserving session: %s with %d messages for continued LLM context. Total sessions: %d",
sessionId, currentMessages, sessionMessages.size());
} finally {
lock.readLock().unlock();
}
}
/**
* Get all active sessions
*/
public Set<String> getActiveSessions() {
Set<String> sessions = new HashSet<>(sessionMessages.keySet());
Log.infof("getActiveSessions() called. Found %d active sessions: %s", sessions.size(), sessions);
return sessions;
}
/**
* Get message count for a session
*/
public int getMessageCount(String sessionId) {
ReadWriteLock lock = getSessionLock(sessionId);
lock.readLock().lock();
try {
int count = sessionMessages.getOrDefault(sessionId, Collections.emptyList()).size();
Log.debugf("Message count for session %s: %d", sessionId, count);
return count;
} finally {
lock.readLock().unlock();
}
}
/**
* Actually delete a session and its memory (used by API endpoints)
*/
public void forceDeleteSession(String sessionId) {
Log.infof("Force deleting session: %s", sessionId);
ReadWriteLock lock = getSessionLock(sessionId);
lock.writeLock().lock();
try {
sessionMessages.remove(sessionId);
sessionLocks.remove(sessionId);
Log.infof("Force deleted session: %s. Remaining sessions: %d", sessionId, sessionMessages.size());
} finally {
lock.writeLock().unlock();
}
}
/**
* Clear all sessions (useful for testing or maintenance)
*/
public void clearAllSessions() {
Log.infof("Clearing all sessions. Current count: %d", sessionMessages.size());
sessionMessages.clear();
sessionLocks.clear();
Log.info("All sessions cleared");
}
private ReadWriteLock getSessionLock(String sessionId) {
return sessionLocks.computeIfAbsent(sessionId, k -> {
Log.debugf("Creating new lock for session: %s", sessionId);
return new ReentrantReadWriteLock();
});
}
}
Key Features:
Thread-safe: Uses ConcurrentHashMap and ReadWriteLock for safe concurrent access
Session isolation: Each session has its own lock to prevent interference
Memory preservation: Deliberately preserves message history even when deleteMessages() is called (for LLM context continuity)
SessionChatMemoryProvider (SessionChatMemoryProvider.java)
This component creates and configures memory instances for each session:
package org.acme.memory;
import dev.langchain4j.memory.ChatMemory;
import dev.langchain4j.memory.chat.ChatMemoryProvider;
import dev.langchain4j.memory.chat.MessageWindowChatMemory;
import io.quarkus.logging.Log;
import jakarta.inject.Inject;
import jakarta.inject.Singleton;
@Singleton
public class SessionChatMemoryProvider implements ChatMemoryProvider {
@Inject
SessionChatMemoryStore memoryStore;
// Configuration for memory window size
private static final int DEFAULT_MAX_MESSAGES = 50;
@Override
public ChatMemory get(Object memoryId) {
String sessionId = memoryId.toString();
Log.infof("Creating ChatMemory for session: %s with max messages: %d", sessionId, DEFAULT_MAX_MESSAGES);
ChatMemory memory = MessageWindowChatMemory.builder()
.id(memoryId)
.maxMessages(DEFAULT_MAX_MESSAGES)
.chatMemoryStore(memoryStore)
.build();
Log.infof("ChatMemory created successfully for session: %s", sessionId);
return memory;
}
}
Configuration:
Message window: Limited to 50 messages per session (DEFAULT_MAX_MESSAGES = 50)
Sliding window: Automatically maintains the most recent 50 messages
Lazy creation: Memory instances are created on-demand
The memory system integrates seamlessly with the AI service through the @MemoryId annotation. Compare above.
The @MemoryId annotation automatically:
Links the conversation to a specific session
Retrieves conversation history from the memory store
Appends new messages to the session's memory
Maintains context across multiple conversation turns
The application maintains two separate but related storage systems:
Application-level: Traditional session metadata in a ConcurrentHashMap
Stores Session objects with user info, personality, scenario
Used for API endpoints like /sessions and session management
LLM Memory: Chat messages in the SessionChatMemoryStore
Stores ChatMessage objects for LLM context
Automatically managed by LangChain4j framework
REST API Implementation
The SessionResource
class brings everything together. It manages personalities, scenarios, sessions, chat, and feedback.
package org.acme;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.stream.Collectors;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.acme.memory.SessionChatMemoryStore;
import org.acme.model.Message;
import org.acme.model.PersonalityType;
import org.acme.model.Scenario;
import org.acme.model.Session;
import org.acme.service.FeedbackAssistant;
import org.acme.service.ManagerAssistant;
import io.quarkus.logging.Log;
import jakarta.inject.Inject;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.DELETE;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.NotFoundException;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
/**
* REST API for the negotiation simulator providing endpoints for:
* - Session management (create sessions)
* - Conversation handling (send messages, get feedback)
* - Configuration (get personalities and scenarios)
*/
@Path("/api")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public class SessionResource {
private static final String USER_ROLE = "user";
private static final String AI_ROLE = "ai";
// In-memory storage for active sessions
private static final Map<String, Session> sessions = new ConcurrentHashMap<>();
@Inject
ManagerAssistant managerAssistant;
@Inject
FeedbackAssistant feedbackAssistant;
@Inject
ObjectMapper objectMapper;
@Inject
SessionChatMemoryStore memoryStore;
// Configuration endpoints
/**
* Get all available personality types for managers.
*/
@GET
@Path("/personalities")
public PersonalityType[] getPersonalities() {
return PersonalityType.values();
}
/**
* Get all available conversation scenarios.
*/
@GET
@Path("/scenarios")
public Scenario[] getScenarios() {
return Scenario.values();
}
// Session management
/**
* Create a new conversation session with specified personality and scenario.
*/
@POST
@Path("/sessions")
public Session startSession(CreateSessionRequest request) {
Log.infof("Creating new session with userId: %s, personality: %s, scenario: %s",
request != null ? request.userId() : "null",
request != null ? request.personality() : "null",
request != null ? request.scenario() : "null");
if (request == null || request.userId() == null || request.userId().trim().isEmpty() ||
request.personality() == null || request.scenario() == null) {
Log.error("Session creation failed: missing required parameters");
throw new IllegalArgumentException("User ID, personality and scenario are required");
}
String sessionId = UUID.randomUUID().toString();
Session session = new Session(sessionId, request.userId().trim(), request.personality(), request.scenario());
sessions.put(sessionId, session);
Log.infof("Session created successfully. ID: %s, User: %s, Total sessions in map: %d",
sessionId, request.userId(), sessions.size());
Log.infof("Active sessions in memory store: %d", memoryStore.getActiveSessions().size());
return session;
}
// Conversation endpoints
/**
* Send a message to the AI manager and get a response.
*/
@POST
@Path("/sessions/{id}/messages")
public Message sendMessage(@PathParam("id") String sessionId, UserMessageRequest messageRequest) {
Log.infof("Sending message to session: %s, content length: %d",
sessionId,
messageRequest != null && messageRequest.content() != null ? messageRequest.content().length() : 0);
if (messageRequest == null || messageRequest.content() == null || messageRequest.content().trim().isEmpty()) {
Log.error("Message sending failed: empty content");
throw new IllegalArgumentException("Message content is required");
}
Session session = getSessionOrThrow(sessionId);
Log.infof("Found session: %s, personality: %s, scenario: %s",
sessionId, session.personality().name(), session.scenario().name());
// Log user message
session.addMessage(new Message(USER_ROLE, messageRequest.content()));
Log.infof("Added user message to session log. Total messages in session: %d", session.messages().size());
// Get AI response with automatic memory management via @MemoryId
Log.infof("Calling AI assistant for session: %s, user: %s", sessionId, session.userName());
String aiResponse = managerAssistant.chat(
sessionId,
messageRequest.content(),
session.personality().getSystemPrompt(),
session.scenario().getPrompt(),
session.userName());
Log.infof("AI response received. Length: %d characters", aiResponse != null ? aiResponse.length() : 0);
Log.infof("Active sessions in memory store after AI call: %d", memoryStore.getActiveSessions().size());
// Log AI response
Message aiMessage = new Message(AI_ROLE, aiResponse);
session.addMessage(aiMessage);
Log.infof("Added AI message to session log. Total messages in session: %d", session.messages().size());
return aiMessage;
}
/**
* Get performance feedback analysis for a completed conversation.
*/
@GET
@Path("/sessions/{id}/feedback")
public Feedback getFeedback(@PathParam("id") String sessionId) {
Session session = getSessionOrThrow(sessionId);
Log.infof("Getting feedback for session: %s, user: %s", sessionId, session.userName());
Log.infof("Session messages count: %d", session.messages().size());
Log.infof("Memory store messages count: %d", memoryStore.getMessageCount(sessionId));
if (session.messages().isEmpty()) {
return new Feedback(null, List.of(), List.of("No conversation to analyze"));
}
// Format conversation history for analysis with better role labeling
String conversationHistory = session.messages().stream()
.map(msg -> {
String role = "user".equals(msg.sender()) ? session.userName() : "Manager";
return role + ": " + msg.content();
})
.collect(Collectors.joining("\n"));
Log.infof("Conversation history for analysis (length: %d chars): %s",
conversationHistory.length(),
conversationHistory.length() > 200 ? conversationHistory.substring(0, 200) + "..."
: conversationHistory);
// Get AI-generated feedback with user context
Log.infof("Calling FeedbackAssistant for session: %s, user: %s", sessionId, session.userName());
String feedbackJson = feedbackAssistant.analyze(
conversationHistory,
session.scenario().name(),
session.personality().name(),
session.userName());
Log.infof("Received feedback JSON (length: %d chars): %s",
feedbackJson.length(),
feedbackJson.length() > 300 ? feedbackJson.substring(0, 300) + "..." : feedbackJson);
try {
// Try to parse the response as-is first
return objectMapper.readValue(feedbackJson, Feedback.class);
} catch (Exception e) {
Log.warnf("Failed to parse feedback JSON directly: %s", e.getMessage());
// If direct parsing fails, try to extract JSON from the response
try {
String extractedJson = extractJsonFromResponse(feedbackJson);
Log.infof("Extracted JSON: %s", extractedJson);
return objectMapper.readValue(extractedJson, Feedback.class);
} catch (Exception ex) {
Log.errorf("Failed to extract and parse JSON: %s", ex.getMessage());
// If all parsing fails, return a fallback response
return new Feedback(
null,
List.of("Conversation completed"),
List.of("Unable to generate detailed feedback. Raw response: " +
(feedbackJson.length() > 200 ? feedbackJson.substring(0, 200) + "..." : feedbackJson)));
}
}
}
// Session management endpoints
/**
* Get all active sessions with their basic information.
*/
@GET
@Path("/sessions")
public List<SessionInfo> getAllSessions() {
Set<String> activeSessions = memoryStore.getActiveSessions();
Set<String> sessionMapKeys = sessions.keySet();
Log.infof("GET /sessions called. Sessions in map: %d, Sessions in memory store: %d",
sessionMapKeys.size(), activeSessions.size());
Log.infof("Session map keys: %s", sessionMapKeys);
Log.infof("Memory store session keys: %s", activeSessions);
// Combine sessions from both sources
Set<String> allSessionIds = new java.util.HashSet<>(sessionMapKeys);
allSessionIds.addAll(activeSessions);
Log.infof("Total unique sessions to return: %d", allSessionIds.size());
return allSessionIds.stream()
.map(sessionId -> {
Session session = sessions.get(sessionId);
int messageCount = memoryStore.getMessageCount(sessionId);
Log.debugf("Processing session %s: session exists=%s, message count=%d",
sessionId, session != null, messageCount);
if (session != null) {
return new SessionInfo(
sessionId,
session.userName(),
session.personality().name(),
session.scenario().name(),
session.status(),
messageCount,
session.createdAt());
} else {
// Memory exists but session metadata is missing
Log.warnf("Orphaned session found in memory store: %s", sessionId);
return new SessionInfo(
sessionId,
"Unknown User",
"Unknown",
"Unknown",
"ORPHANED",
messageCount,
null);
}
})
.collect(Collectors.toList());
}
/**
* Get detailed information about a specific session.
*/
@GET
@Path("/sessions/{id}")
public SessionInfo getSession(@PathParam("id") String sessionId) {
Session session = sessions.get(sessionId);
int messageCount = memoryStore.getMessageCount(sessionId);
if (session == null && messageCount == 0) {
throw new NotFoundException("Session not found: " + sessionId);
}
if (session != null) {
return new SessionInfo(
sessionId,
session.userName(),
session.personality().name(),
session.scenario().name(),
session.status(),
messageCount,
session.createdAt());
} else {
return new SessionInfo(
sessionId,
"Unknown User",
"Unknown",
"Unknown",
"ORPHANED",
messageCount,
null);
}
}
/**
* Delete a session and its memory.
*/
@DELETE
@Path("/sessions/{id}")
public void deleteSession(@PathParam("id") String sessionId) {
Log.infof("API request to delete session: %s", sessionId);
// Remove from both session storage and memory store
sessions.remove(sessionId);
memoryStore.forceDeleteSession(sessionId);
Log.infof("Session %s deleted successfully", sessionId);
}
/**
* Clear all sessions (useful for testing/maintenance).
*/
@DELETE
@Path("/sessions")
public void clearAllSessions() {
Log.infof("API request to clear all sessions. Sessions in map: %d, Sessions in memory: %d",
sessions.size(), memoryStore.getActiveSessions().size());
sessions.clear();
memoryStore.clearAllSessions();
Log.info("All sessions cleared successfully");
}
/**
* Debug endpoint to show current session state.
*/
@GET
@Path("/sessions/debug")
public Map<String, Object> getSessionDebugInfo() {
Set<String> sessionMapKeys = sessions.keySet();
Set<String> memoryStoreKeys = memoryStore.getActiveSessions();
Map<String, Object> debugInfo = new java.util.HashMap<>();
debugInfo.put("sessionsInMap", sessionMapKeys.size());
debugInfo.put("sessionMapKeys", sessionMapKeys);
debugInfo.put("sessionsInMemoryStore", memoryStoreKeys.size());
debugInfo.put("memoryStoreKeys", memoryStoreKeys);
// Detailed session info
Map<String, Object> sessionDetails = new java.util.HashMap<>();
for (String sessionId : sessionMapKeys) {
Session session = sessions.get(sessionId);
if (session != null) {
Map<String, Object> detail = new java.util.HashMap<>();
detail.put("userName", session.userName());
detail.put("personality", session.personality().name());
detail.put("scenario", session.scenario().name());
detail.put("status", session.status());
detail.put("createdAt", session.createdAt());
detail.put("messagesInSession", session.messages().size());
detail.put("messagesInMemoryStore", memoryStore.getMessageCount(sessionId));
sessionDetails.put(sessionId, detail);
}
}
debugInfo.put("sessionDetails", sessionDetails);
Log.infof("Debug info requested. Returning: %s", debugInfo);
return debugInfo;
}
// Helper methods
private Session getSessionOrThrow(String sessionId) {
Session session = sessions.get(sessionId);
if (session == null) {
throw new NotFoundException("Session not found: " + sessionId);
}
return session;
}
/**
* Extract JSON object from AI response that might contain extra text.
*/
private String extractJsonFromResponse(String response) {
if (response == null)
return "{}";
// Find the first opening brace
int startIndex = response.indexOf('{');
if (startIndex == -1) {
throw new IllegalArgumentException("No JSON object found in response");
}
// Find the matching closing brace
int braceCount = 0;
int endIndex = -1;
for (int i = startIndex; i < response.length(); i++) {
char c = response.charAt(i);
if (c == '{') {
braceCount++;
} else if (c == '}') {
braceCount--;
if (braceCount == 0) {
endIndex = i;
break;
}
}
}
if (endIndex == -1) {
throw new IllegalArgumentException("No complete JSON object found in response");
}
return response.substring(startIndex, endIndex + 1);
}
// DTOs
record CreateSessionRequest(String userId, PersonalityType personality, Scenario scenario) {
}
record UserMessageRequest(String content) {
}
record SessionInfo(
String id,
String userName,
String personality,
String scenario,
String status,
int messageCount,
java.time.Instant createdAt) {
}
record Feedback(Integer overallScore, List<String> strengths, List<String> improvements) {
// Constructor for fallback cases without score
public Feedback(List<String> strengths, List<String> improvements) {
this(null, strengths, improvements);
}
}
}
Important endpoints include:
GET /api/personalities: Get all available personality types for AI managers
GET /api/scenarios: Get all available conversation scenarios
POST /api/sessions: Create a new negotiation session
GET /api/sessions: Get all active sessions with basic information
GET /api/sessions/{id}: Get detailed information about a specific session
DELETE /api/sessions/{id}: Delete a specific session and its memory
DELETE /api/sessions: Clear all sessions (maintenance/testing)
POST /api/sessions/{id}/messages: Send a message to the AI manager and get response
GET /api/sessions/{id}/feedback: Get AI-generated performance feedback for completed conversation
GET /api/sessions/debug: Debug endpoint showing current session state
The critical part is how we build the message memory for the AI service in sendMessage
.
(Minimal) Frontend
With the backend ready, we create a UI that allows users to pick a personality and scenario, chat with the AI, and see the feedback.
We use Pico.css to get a clean, responsive UI. Grab the style.css from my Github repository. Create index.html
in src/main/resources/META-INF/resources
:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>AI Manager Negotiation Simulator</title>
<link rel="stylesheet" href="https://unpkg.com/@picocss/pico@1.5.10/css/pico.min.css">
<link rel="stylesheet" href="styles.css">
</head>
<body>
<main class="container">
<div class="header">
<h1><i class="fas fa-handshake"></i> HR Performance Conversation Simulator</h1>
<p>Practice your negotiation and performance discussion skills with AI managers</p>
</div>
<section id="setup">
<div class="setup-card">
<form id="setupForm">
<div class="setup-grid">
<div class="selection-section">
<h3><i class="fas fa-user-tie"></i> Manager Personality</h3>
<div id="personalities" class="selection-options"></div>
</div>
<div class="selection-section">
<h3><i class="fas fa-clipboard-list"></i> Conversation Scenario</h3>
<div id="scenarios" class="selection-options"></div>
</div>
</div>
<div class="user-input-section">
<label for="userId"><i class="fas fa-user"></i> Your Name</label>
<input type="text" id="userId" placeholder="Enter your name" required>
</div>
<button type="submit" class="start-button">
<i class="fas fa-play"></i> Start Conversation
</button>
</form>
</div>
</section>
<section id="chat" class="hidden">
<div class="chat-container">
<div class="chat-header">
<i class="fas fa-comments"></i>
<span>Performance Conversation in Progress</span>
</div>
<div id="messages" class="chat-messages"></div>
<form id="messageForm" class="chat-input">
<input type="text" id="messageInput" placeholder="Type your message..." required>
<button type="submit"><i class="fas fa-paper-plane"></i> Send</button>
</form>
</div>
<div style="text-align: center; margin-top: 1rem;">
<button id="endSessionBtn" class="start-button" style="width: auto; background: var(--danger-color);">
<i class="fas fa-stop"></i> End Session & Get Feedback
</button>
</div>
</section>
<section id="feedback" class="hidden">
<div class="feedback-card">
<div class="score-display">
<div class="score-circle" style="--score: 0">
<div class="score-text" id="score">0</div>
</div>
<h3>Performance Score</h3>
</div>
<div class="feedback-section">
<div class="feedback-list">
<h4><i class="fas fa-thumbs-up" style="color: var(--success-color);"></i> Strengths</h4>
<ul id="strengths"></ul>
</div>
<div class="feedback-list">
<h4><i class="fas fa-lightbulb" style="color: var(--warning-color);"></i> Areas for Improvement</h4>
<ul id="improvements"></ul>
</div>
</div>
<div style="text-align: center;">
<button onclick="location.reload()" class="start-button">
<i class="fas fa-redo"></i> Start New Session
</button>
</div>
</div>
</section>
</main>
<script src="app.js"></script>
</body>
This page includes three sections: setup, chat, and feedback. Each section is shown or hidden based on the app state.
app.js
Create app.js
in the same folder. It handles API calls and updates the DOM.
let sessionId = null;
// Helper function to get personality display names and descriptions
function getPersonalityInfo(personality) {
const info = {
'SUPPORTIVE': {
title: 'Supportive Manager',
description: 'Encouraging, collaborative, and helpful approach to discussions'
},
'ANALYTICAL': {
title: 'Analytical Manager',
description: 'Data-driven, seeks evidence and logical arguments'
},
'DIRECT': {
title: 'Direct Manager',
description: 'Blunt, time-conscious, and straight to the point'
},
'ACCOMMODATING': {
title: 'Accommodating Manager',
description: 'Agreeable but cautious, often needs time to consider decisions'
}
};
return info[personality] || { title: personality, description: '' };
}
// Helper function to get scenario display names and descriptions
function getScenarioInfo(scenario) {
const info = {
'REQUESTING_RAISE': {
title: 'Salary Increase Request',
description: 'You are requesting a salary increase based on your performance'
},
'POOR_PERFORMANCE': {
title: 'Performance Discussion',
description: 'Manager is addressing performance concerns with you'
}
};
return info[scenario] || { title: scenario, description: '' };
}
async function fetchOptions() {
try {
const personalities = await fetch('/api/personalities').then(r => r.json());
const scenarios = await fetch('/api/scenarios').then(r => r.json());
// Render personality options as cards
document.getElementById('personalities').innerHTML = personalities.map(p => {
const info = getPersonalityInfo(p);
return `
<div class="option-card" onclick="selectOption('personality', '${p}', this)">
<input type="radio" name="personality" value="${p}">
<div class="option-title">${info.title}</div>
<div class="option-description">${info.description}</div>
</div>
`;
}).join('');
// Render scenario options as cards
document.getElementById('scenarios').innerHTML = scenarios.map(s => {
const info = getScenarioInfo(s);
return `
<div class="option-card" onclick="selectOption('scenario', '${s}', this)">
<input type="radio" name="scenario" value="${s}">
<div class="option-title">${info.title}</div>
<div class="option-description">${info.description}</div>
</div>
`;
}).join('');
} catch (error) {
console.error('Error fetching options:', error);
showError('Failed to load options. Please refresh the page.');
}
}
// Handle option selection with visual feedback
function selectOption(type, value, element) {
// Remove selected class from all options of this type
const container = element.closest('.selection-options');
container.querySelectorAll('.option-card').forEach(card => {
card.classList.remove('selected');
});
// Add selected class to clicked option
element.classList.add('selected');
// Set the radio button value
const radio = element.querySelector('input[type="radio"]');
radio.checked = true;
// Enable start button if both options are selected
updateStartButton();
}
function updateStartButton() {
const personality = document.querySelector('input[name="personality"]:checked');
const scenario = document.querySelector('input[name="scenario"]:checked');
const userId = document.getElementById('userId').value.trim();
const startButton = document.querySelector('.start-button');
startButton.disabled = !(personality && scenario && userId);
}
// Add event listener for name input
document.addEventListener('DOMContentLoaded', () => {
document.getElementById('userId').addEventListener('input', updateStartButton);
fetchOptions();
});
document.getElementById('setupForm').addEventListener('submit', async e => {
e.preventDefault();
const userId = document.getElementById('userId').value.trim();
const personalityElement = document.querySelector('input[name="personality"]:checked');
const scenarioElement = document.querySelector('input[name="scenario"]:checked');
if (!personalityElement || !scenarioElement || !userId) {
showError('Please select both a personality and scenario, and enter your name.');
return;
}
const personality = personalityElement.value;
const scenario = scenarioElement.value;
try {
showLoading(true);
const res = await fetch('/api/sessions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userId, personality, scenario })
});
if (!res.ok) {
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
}
const data = await res.json();
sessionId = data.id;
// Hide setup and show chat
document.getElementById('setup').classList.add('hidden');
document.getElementById('chat').classList.remove('hidden');
// Clear messages
document.getElementById('messages').innerHTML = '';
showLoading(false);
} catch (error) {
console.error('Error starting session:', error);
showError('Failed to start session. Please try again.');
showLoading(false);
}
});
document.getElementById('messageForm').addEventListener('submit', async e => {
e.preventDefault();
const messageInput = document.getElementById('messageInput');
const content = messageInput.value.trim();
if (!content) return;
try {
// Disable input while sending
messageInput.disabled = true;
// Add user message to chat
addMessage('user', content);
const res = await fetch(`/api/sessions/${sessionId}/messages`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content })
});
if (!res.ok) {
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
}
const msg = await res.json();
// Add AI response to chat
addMessage('ai', msg.content);
messageInput.value = '';
} catch (error) {
console.error('Error sending message:', error);
showError('Failed to send message. Please try again.');
} finally {
messageInput.disabled = false;
messageInput.focus();
}
});
function addMessage(sender, content) {
const messagesDiv = document.getElementById('messages');
const messageElement = document.createElement('div');
messageElement.className = `message ${sender}`;
const senderLabel = sender === 'user' ? 'You' : 'Manager';
messageElement.innerHTML = `<strong>${senderLabel}:</strong> ${content}`;
messagesDiv.appendChild(messageElement);
messagesDiv.scrollTop = messagesDiv.scrollHeight;
}
document.getElementById('endSessionBtn').addEventListener('click', async () => {
try {
showLoading(true);
const feedback = await fetch(`/api/sessions/${sessionId}/feedback`).then(r => {
if (!r.ok) {
throw new Error(`HTTP ${r.status}: ${r.statusText}`);
}
return r.json();
});
// Hide chat and show feedback
document.getElementById('chat').classList.add('hidden');
document.getElementById('feedback').classList.remove('hidden');
// Display score with animation
const score = feedback.overallScore || 0;
document.getElementById('score').textContent = score;
// Update the circular progress
const scoreCircle = document.querySelector('.score-circle');
scoreCircle.style.setProperty('--score', score);
// Display strengths
const strengthsList = document.getElementById('strengths');
strengthsList.innerHTML = (feedback.strengths || []).map(s => `<li>${s}</li>`).join('');
// Display improvements
const improvementsList = document.getElementById('improvements');
improvementsList.innerHTML = (feedback.improvements || []).map(i => `<li>${i}</li>`).join('');
showLoading(false);
} catch (error) {
console.error('Error getting feedback:', error);
showError('Failed to get feedback. Please try again.');
showLoading(false);
}
});
function showError(message) {
// Simple error display - you could enhance this with a proper modal
alert(`Error: ${message}`);
}
function showLoading(show) {
// Simple loading state - you could enhance this with a proper spinner
const buttons = document.querySelectorAll('button');
buttons.forEach(button => {
button.disabled = show;
if (show) {
button.style.opacity = '0.6';
button.style.cursor = 'not-allowed';
} else {
button.style.opacity = '';
button.style.cursor = '';
}
});
}
This script dynamically loads personalities and scenarios, starts a session, sends messages, and displays feedback at the end.
Running the App
Run Quarkus in dev mode:
./mvnw quarkus:dev
Open http://localhost:8080 to try out the simulator. You can now interact with different manager personalities and scenarios, and receive AI-generated negotiation feedback.
If you end the session, you can see the overall feedback and performance score. I was doing bad, I guess. Well 🤷♂️
You can extend this tutorial by adding persistence, authentication, and other goodies. I wanted to show you how to integrate custom memory and use different AI Services to get a conversion going. Enjoy coding.