Implement session management and model discovery APIs

This commit is contained in:
Konrad Neitzel 2026-02-20 15:04:28 +01:00
parent eed9d1da66
commit b30a7e4770
12 changed files with 1400 additions and 1 deletions

65
pom.xml
View File

@ -19,6 +19,7 @@
<lombok.version>1.18.42</lombok.version> <lombok.version>1.18.42</lombok.version>
<junit.jupiter.version>5.10.3</junit.jupiter.version> <junit.jupiter.version>5.10.3</junit.jupiter.version>
<mockito.version>5.12.0</mockito.version> <mockito.version>5.12.0</mockito.version>
<openapi.generator.version>7.11.0</openapi.generator.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties> </properties>
@ -39,6 +40,18 @@
<groupId>io.quarkus</groupId> <groupId>io.quarkus</groupId>
<artifactId>quarkus-arc</artifactId> <artifactId>quarkus-arc</artifactId>
</dependency> </dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-rest</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-rest-jackson</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-hibernate-validator</artifactId>
</dependency>
<dependency> <dependency>
<groupId>io.quarkus</groupId> <groupId>io.quarkus</groupId>
<artifactId>quarkus-liquibase</artifactId> <artifactId>quarkus-liquibase</artifactId>
@ -76,6 +89,58 @@
<version>${quarkus.plugin.version}</version> <version>${quarkus.plugin.version}</version>
<extensions>true</extensions> <extensions>true</extensions>
</plugin> </plugin>
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>build-helper-maven-plugin</artifactId>
<version>3.6.0</version>
<executions>
<execution>
<id>add-openapi-generated-sources</id>
<phase>generate-sources</phase>
<goals>
<goal>add-source</goal>
</goals>
<configuration>
<sources>
<source>${project.build.directory}/generated-sources/openapi/src/main/java</source>
</sources>
</configuration>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.openapitools</groupId>
<artifactId>openapi-generator-maven-plugin</artifactId>
<version>${openapi.generator.version}</version>
<executions>
<execution>
<id>generate-roleplay-api</id>
<goals>
<goal>generate</goal>
</goals>
<configuration>
<inputSpec>${project.basedir}/src/main/resources/openapi-roleplay-public-v1.yml</inputSpec>
<generatorName>jaxrs-spec</generatorName>
<output>${project.build.directory}/generated-sources/openapi</output>
<apiPackage>de.neitzel.roleplay.fascade.api</apiPackage>
<modelPackage>de.neitzel.roleplay.fascade.model</modelPackage>
<configOptions>
<interfaceOnly>true</interfaceOnly>
<returnResponse>false</returnResponse>
<useSwaggerAnnotations>false</useSwaggerAnnotations>
<useBeanValidation>true</useBeanValidation>
<sourceFolder>src/main/java</sourceFolder>
<dateLibrary>java8</dateLibrary>
<openApiNullable>false</openApiNullable>
<useTags>true</useTags>
<jackson>true</jackson>
<useJakartaEe>true</useJakartaEe>
</configOptions>
<generateSupportingFiles>false</generateSupportingFiles>
</configuration>
</execution>
</executions>
</plugin>
<plugin> <plugin>
<artifactId>maven-compiler-plugin</artifactId> <artifactId>maven-compiler-plugin</artifactId>
<version>${maven.compiler.plugin.version}</version> <version>${maven.compiler.plugin.version}</version>

View File

@ -0,0 +1,98 @@
package de.neitzel.roleplay.business;
import de.neitzel.roleplay.fascade.model.CreateSessionRequest;
import de.neitzel.roleplay.fascade.model.SessionResponse;
import de.neitzel.roleplay.fascade.model.TurnRequest;
import de.neitzel.roleplay.fascade.model.TurnResponse;
import jakarta.enterprise.context.ApplicationScoped;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
/**
* In-memory implementation of {@link SessionService}. Stores sessions in a
* {@link ConcurrentHashMap}; suitable for development and testing. A
* production implementation would persist state in a database.
*
* <p>Turn orchestration (the two-call Ollama pattern) is not yet wired; the
* methods return stub responses so the REST layer can be exercised end-to-end.
* The {@code TODO} markers indicate where the Ollama integration must be added.
*/
@ApplicationScoped
public class InMemorySessionService implements SessionService {
/**
* In-memory store mapping session IDs to their current state.
*/
private final Map<String, SessionResponse> sessions = new ConcurrentHashMap<>();
/**
* {@inheritDoc}
*
* <p>Generates a new UUID as the session ID, populates default session
* metadata, and stores the session. The Ollama two-call pattern is not
* yet invoked; a placeholder narrative is returned instead.
*/
@Override
public SessionResponse createSession(final CreateSessionRequest request) {
String sessionId = UUID.randomUUID().toString();
SessionResponse session = new SessionResponse(
sessionId,
request.getModel(),
request.getLanguage() != null ? request.getLanguage() : "en",
request.getSafetyLevel() != null
? request.getSafetyLevel().value()
: CreateSessionRequest.SafetyLevelEnum.STANDARD.value(),
0
);
// TODO: Invoke OllamaClient two-call pattern (narrative + state extraction)
// to produce a real opening scene and initial state.
session.setNarrative("Session initialised. Ollama integration pending.");
sessions.put(sessionId, session);
return session;
}
/**
* {@inheritDoc}
*/
@Override
public Optional<SessionResponse> getSession(final String sessionId) {
return Optional.ofNullable(sessions.get(sessionId));
}
/**
* {@inheritDoc}
*
* <p>Increments the turn counter and returns a stub {@link TurnResponse}.
* The Ollama two-call pattern is not yet invoked.
*/
@Override
public Optional<TurnResponse> submitTurn(final String sessionId,
final TurnRequest turnRequest) {
SessionResponse session = sessions.get(sessionId);
if (session == null) {
return Optional.empty();
}
// Increment turn counter
int nextTurn = session.getTurnNumber() + 1;
session.setTurnNumber(nextTurn);
// TODO: Invoke OllamaClient two-call pattern (narrative + state update)
// using the current session state, turnRequest.getUserAction(),
// and turnRequest.getRecommendation().
TurnResponse response = new TurnResponse(
nextTurn,
"Turn " + nextTurn + " processed. Ollama integration pending."
);
sessions.put(sessionId, session);
return Optional.of(response);
}
}

View File

@ -0,0 +1,90 @@
package de.neitzel.roleplay.business;
import de.neitzel.roleplay.fascade.OllamaClient;
import de.neitzel.roleplay.fascade.OllamaModelInfo;
import de.neitzel.roleplay.fascade.model.ModelInfo;
import de.neitzel.roleplay.fascade.model.ModelListResponse;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import java.util.List;
/**
* Business service responsible for discovering available Ollama AI models and
* converting them into public API representations.
*/
@ApplicationScoped
public class ModelService {
/**
* Ollama facade used to query the installed model list.
*/
private final OllamaClient ollamaClient;
/**
* Creates a new {@code ModelService}.
*
* @param ollamaClient the Ollama client facade
*/
@Inject
public ModelService(final OllamaClient ollamaClient) {
this.ollamaClient = ollamaClient;
}
/**
* Returns all models currently installed on the Ollama server, mapped to
* the public {@link ModelListResponse} API schema.
*
* @return list response containing zero or more {@link ModelInfo} entries
*/
public ModelListResponse listModels() {
List<OllamaModelInfo> ollamaModels = ollamaClient.listModels();
List<ModelInfo> apiModels = ollamaModels.stream()
.map(this::toApiModel)
.toList();
ModelListResponse response = new ModelListResponse();
response.setModels(apiModels);
return response;
}
/**
* Maps a single Ollama model info to the public API {@link ModelInfo} schema.
*
* @param ollamaModelInfo the source Ollama model metadata
* @return the mapped API model info
*/
private ModelInfo toApiModel(final OllamaModelInfo ollamaModelInfo) {
ModelInfo info = new ModelInfo(ollamaModelInfo.getName());
info.setSize(ollamaModelInfo.getSize());
if (ollamaModelInfo.getDetails() != null) {
info.setFamily(ollamaModelInfo.getDetails().getFamily());
}
// Derive a human-readable display name from the technical name
String displayName = deriveDisplayName(ollamaModelInfo.getName());
info.setDisplayName(displayName);
return info;
}
/**
* Derives a human-readable display name from a technical model identifier.
* For example {@code "llama3:latest"} becomes {@code "Llama3 (latest)"}.
*
* @param technicalName the raw model name including optional tag
* @return a human-readable display name
*/
private String deriveDisplayName(final String technicalName) {
if (technicalName == null || technicalName.isBlank()) {
return technicalName;
}
String[] parts = technicalName.split(":", 2);
String baseName = parts[0];
// Capitalise first letter
String capitalised = Character.toUpperCase(baseName.charAt(0)) + baseName.substring(1);
if (parts.length == 2) {
return capitalised + " (" + parts[1] + ")";
}
return capitalised;
}
}

View File

@ -0,0 +1,44 @@
package de.neitzel.roleplay.business;
import de.neitzel.roleplay.fascade.model.CreateSessionRequest;
import de.neitzel.roleplay.fascade.model.SessionResponse;
import de.neitzel.roleplay.fascade.model.TurnRequest;
import de.neitzel.roleplay.fascade.model.TurnResponse;
import java.util.Optional;
/**
* Defines the contract for managing role-play sessions. Implementations are
* responsible for session lifecycle (creation, retrieval, turn processing) and
* state persistence.
*/
public interface SessionService {
/**
* Creates a new role-play session based on the provided request. Runs the
* two-call Ollama pattern to produce an opening narrative and initial state.
*
* @param request the session creation parameters
* @return the full initial session state including opening narrative and suggestions
*/
SessionResponse createSession(CreateSessionRequest request);
/**
* Retrieves the current state of an existing session.
*
* @param sessionId the unique session identifier
* @return an {@link Optional} containing the session response, or empty if not found
*/
Optional<SessionResponse> getSession(String sessionId);
/**
* Processes a user's turn within an existing session. Runs the two-call
* Ollama pattern and returns the resulting narrative with updated state.
*
* @param sessionId the unique session identifier
* @param turnRequest the user action and optional recommendation
* @return an {@link Optional} containing the turn response, or empty if session not found
*/
Optional<TurnResponse> submitTurn(String sessionId, TurnRequest turnRequest);
}

View File

@ -0,0 +1,43 @@
package de.neitzel.roleplay.fascade;
import de.neitzel.roleplay.business.ModelService;
import de.neitzel.roleplay.fascade.api.ModelsApi;
import de.neitzel.roleplay.fascade.model.ModelListResponse;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
/**
* JAX-RS resource that implements the {@link ModelsApi} interface generated
* from the OpenAPI specification. Delegates all business logic to
* {@link ModelService}.
*/
@ApplicationScoped
public class ModelResource implements ModelsApi {
/**
* Business service providing model discovery logic.
*/
private final ModelService modelService;
/**
* Creates a new {@code ModelResource}.
*
* @param modelService the model discovery service
*/
@Inject
public ModelResource(final ModelService modelService) {
this.modelService = modelService;
}
/**
* {@inheritDoc}
*
* <p>Delegates to {@link ModelService#listModels()} and returns the result
* directly.
*/
@Override
public ModelListResponse listModels() {
return modelService.listModels();
}
}

View File

@ -0,0 +1,33 @@
package de.neitzel.roleplay.fascade;
import de.neitzel.roleplay.fascade.model.ErrorResponse;
import jakarta.ws.rs.NotFoundException;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.ext.ExceptionMapper;
import jakarta.ws.rs.ext.Provider;
/**
* JAX-RS {@link ExceptionMapper} that converts a {@link NotFoundException}
* into a well-formed JSON {@link ErrorResponse} with HTTP status 404.
*/
@Provider
public class NotFoundExceptionMapper implements ExceptionMapper<NotFoundException> {
/**
* Maps a {@link NotFoundException} to a 404 response containing a
* standardised {@link ErrorResponse} body.
*
* @param exception the not-found exception thrown by a resource method
* @return a 404 response with a JSON error body
*/
@Override
public Response toResponse(final NotFoundException exception) {
ErrorResponse error = new ErrorResponse("NOT_FOUND", exception.getMessage());
return Response.status(Response.Status.NOT_FOUND)
.type(MediaType.APPLICATION_JSON)
.entity(error)
.build();
}
}

View File

@ -0,0 +1,58 @@
package de.neitzel.roleplay.fascade;
import de.neitzel.roleplay.business.SessionService;
import de.neitzel.roleplay.fascade.api.SessionsApi;
import de.neitzel.roleplay.fascade.model.CreateSessionRequest;
import de.neitzel.roleplay.fascade.model.SessionResponse;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.ws.rs.NotFoundException;
/**
* JAX-RS resource that implements the {@link SessionsApi} interface generated
* from the OpenAPI specification. Delegates all business logic to
* {@link SessionService}.
*/
@ApplicationScoped
public class SessionResource implements SessionsApi {
/**
* Business service providing session lifecycle management.
*/
private final SessionService sessionService;
/**
* Creates a new {@code SessionResource}.
*
* @param sessionService the session management service
*/
@Inject
public SessionResource(final SessionService sessionService) {
this.sessionService = sessionService;
}
/**
* {@inheritDoc}
*
* <p>Delegates to {@link SessionService#createSession(CreateSessionRequest)}.
* The HTTP 201 status is set by annotating with {@link jakarta.ws.rs.core.Response}
* in the generated interface; the resource simply returns the body.
*/
@Override
public SessionResponse createSession(final CreateSessionRequest createSessionRequest) {
return sessionService.createSession(createSessionRequest);
}
/**
* {@inheritDoc}
*
* <p>Returns 404 if the session is not found.
*/
@Override
public SessionResponse getSession(final String sessionId) {
return sessionService.getSession(sessionId)
.orElseThrow(() -> new NotFoundException(
"No session found with id: " + sessionId));
}
}

View File

@ -0,0 +1,46 @@
package de.neitzel.roleplay.fascade;
import de.neitzel.roleplay.business.SessionService;
import de.neitzel.roleplay.fascade.api.TurnsApi;
import de.neitzel.roleplay.fascade.model.TurnRequest;
import de.neitzel.roleplay.fascade.model.TurnResponse;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.ws.rs.NotFoundException;
/**
* JAX-RS resource that implements the {@link TurnsApi} interface generated
* from the OpenAPI specification. Delegates all business logic to
* {@link SessionService}.
*/
@ApplicationScoped
public class TurnResource implements TurnsApi {
/**
* Business service providing turn submission and processing.
*/
private final SessionService sessionService;
/**
* Creates a new {@code TurnResource}.
*
* @param sessionService the session management service
*/
@Inject
public TurnResource(final SessionService sessionService) {
this.sessionService = sessionService;
}
/**
* {@inheritDoc}
*
* <p>Returns 404 if the session is not found.
*/
@Override
public TurnResponse submitTurn(final String sessionId, final TurnRequest turnRequest) {
return sessionService.submitTurn(sessionId, turnRequest)
.orElseThrow(() -> new NotFoundException(
"No session found with id: " + sessionId));
}
}

View File

@ -9,4 +9,5 @@ quarkus:
url: http://debian:11434 url: http://debian:11434
connect-timeout: 5000 connect-timeout: 5000
read-timeout: 120000 read-timeout: 120000
http:
root-path: /api/v1

View File

@ -0,0 +1,623 @@
openapi: 3.0.3
info:
title: RolePlay API
description: |
Public REST API for the RolePlay service. Provides endpoints to discover
available AI models, manage role-play sessions, and submit user turns.
version: 1.0.0
contact:
name: RolePlay Project
servers:
- url: /api/v1
description: Default server base path
tags:
- name: models
description: Discover available Ollama models
- name: sessions
description: Manage role-play sessions
- name: turns
description: Submit user actions within a session
paths:
/models:
get:
operationId: listModels
summary: List available AI models
description: Returns all Ollama models currently installed on the backend server.
tags:
- models
responses:
"200":
description: Successful response with a list of available models.
content:
application/json:
schema:
$ref: '#/components/schemas/ModelListResponse'
"500":
description: Server error while contacting Ollama.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/sessions:
post:
operationId: createSession
summary: Start a new role-play session
description: |
Initialises a new session with the given model and optional scenario
setup. The backend performs the two-call Ollama pattern (narrative +
state extraction) and returns the opening scene.
tags:
- sessions
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/CreateSessionRequest'
responses:
"201":
description: Session created; opening narrative and initial state returned.
content:
application/json:
schema:
$ref: '#/components/schemas/SessionResponse'
"400":
description: Invalid request body.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
"500":
description: Server error during session initialisation.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/sessions/{sessionId}:
get:
operationId: getSession
summary: Retrieve a session
description: Returns the full current state of an existing role-play session.
tags:
- sessions
parameters:
- $ref: '#/components/parameters/SessionId'
responses:
"200":
description: Session found and returned.
content:
application/json:
schema:
$ref: '#/components/schemas/SessionResponse'
"404":
description: Session not found.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/sessions/{sessionId}/turns:
post:
operationId: submitTurn
summary: Submit a user action for the next turn
description: |
Submits the user's action (speech, physical action, or a chosen
suggestion) and an optional narrative recommendation. The backend
executes the two-call Ollama pattern and returns the resulting
narrative and updated suggestions.
tags:
- turns
parameters:
- $ref: '#/components/parameters/SessionId'
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/TurnRequest'
responses:
"200":
description: Turn processed; narrative and new suggestions returned.
content:
application/json:
schema:
$ref: '#/components/schemas/TurnResponse'
"400":
description: Invalid request body.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
"404":
description: Session not found.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
"500":
description: Server error during turn processing.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
components:
parameters:
SessionId:
name: sessionId
in: path
required: true
description: Unique identifier of the role-play session.
schema:
type: string
schemas:
# ─── Model discovery ──────────────────────────────────────────────────────
ModelInfo:
type: object
description: Metadata about a single Ollama model.
properties:
name:
type: string
description: Technical model identifier (e.g. "llama3:latest").
example: llama3:latest
displayName:
type: string
description: Human-readable model name derived from the identifier.
example: Llama 3 (latest)
size:
type: integer
format: int64
description: Model size in bytes.
example: 4000000000
family:
type: string
description: Model family (e.g. "llama").
example: llama
required:
- name
ModelListResponse:
type: object
description: Response containing all available models.
properties:
models:
type: array
items:
$ref: '#/components/schemas/ModelInfo'
required:
- models
# ─── Session ──────────────────────────────────────────────────────────────
CreateSessionRequest:
type: object
description: Request body for creating a new role-play session.
properties:
model:
type: string
description: The Ollama model name to use for this session.
example: llama3:latest
language:
type: string
description: BCP-47 language tag for the session (default "en").
example: en
default: en
safetyLevel:
type: string
description: Content safety level.
enum:
- standard
- strict
default: standard
scenario:
$ref: '#/components/schemas/ScenarioSetup'
required:
- model
ScenarioSetup:
type: object
description: |
Optional initial scenario definition. If omitted the backend uses
built-in defaults.
properties:
setting:
type: string
description: Place, time, and atmosphere description.
example: "A fog-covered harbour at dawn, 1923"
initialConflict:
type: string
description: The hook or starting conflict.
example: "Strange noises from the cargo hold"
userCharacter:
$ref: '#/components/schemas/CharacterDefinition'
aiCharacters:
type: array
items:
$ref: '#/components/schemas/CharacterDefinition'
CharacterDefinition:
type: object
description: Definition of a character for session initialisation.
properties:
id:
type: string
description: Unique character identifier.
example: captain_morgan
name:
type: string
description: Display name.
example: Captain Morgan
role:
type: string
description: Narrative role.
example: ship captain
backstory:
type: string
description: Character backstory (AI characters only).
personalityTraits:
type: array
items:
type: string
description: Personality descriptors (AI characters only).
speakingStyle:
type: string
description: Tone and speech quirks (AI characters only).
goals:
type: array
items:
type: string
description: Short- and long-term goals (AI characters only).
required:
- id
- name
- role
SessionResponse:
type: object
description: Current state of a role-play session.
properties:
sessionId:
type: string
description: Unique session identifier.
example: "550e8400-e29b-41d4-a716-446655440000"
model:
type: string
description: Ollama model in use.
example: llama3:latest
language:
type: string
description: Session language.
example: en
safetyLevel:
type: string
description: Content safety level.
example: standard
narrative:
type: string
description: Most recent narrative text (opening scene or latest turn).
situation:
$ref: '#/components/schemas/SituationState'
characters:
type: array
items:
$ref: '#/components/schemas/CharacterState'
suggestions:
type: array
items:
$ref: '#/components/schemas/Suggestion'
turnNumber:
type: integer
description: Current turn counter (0 = just initialised).
example: 0
required:
- sessionId
- model
- language
- safetyLevel
- turnNumber
SituationState:
type: object
description: Current world and scene state within a session.
properties:
setting:
type: string
description: Place, time, and atmosphere.
currentScene:
type: string
description: What is currently in focus for the scene.
timeline:
type: array
items:
type: string
description: Ordered list of important events so far.
openThreads:
type: array
items:
type: string
description: Unresolved plot lines, mysteries, or quests.
externalPressures:
type: array
items:
type: string
description: Time-sensitive pressures such as deadlines or approaching threats.
worldStateFlags:
type: object
additionalProperties: true
description: Boolean or tag-style flags representing key world states.
CharacterState:
type: object
description: Current dynamic state of a single character within a session.
properties:
id:
type: string
description: Unique character identifier.
name:
type: string
description: Display name.
role:
type: string
description: Narrative role.
isUserCharacter:
type: boolean
description: Whether this is the user-controlled character.
currentMood:
type: string
description: Current emotional state.
status:
type: string
description: Physical or narrative status.
knowledge:
type: array
items:
type: string
description: Facts the character has learned.
relationships:
type: object
additionalProperties:
type: string
description: Map from other character IDs to relationship descriptions.
recentActionsSummary:
type: array
items:
type: string
description: Brief summary of recent behaviour.
required:
- id
- name
- isUserCharacter
# ─── Turn ─────────────────────────────────────────────────────────────────
TurnRequest:
type: object
description: User action submitted for the next story turn.
properties:
userAction:
$ref: '#/components/schemas/UserActionRequest'
recommendation:
$ref: '#/components/schemas/RecommendationRequest'
required:
- userAction
UserActionRequest:
type: object
description: The action the user performed this turn.
properties:
type:
type: string
description: Type of user action.
enum:
- speech
- action
- choice
content:
type: string
description: Free-text content of the action or dialogue.
selectedSuggestionId:
type: string
description: If the user chose a suggestion, its ID; otherwise omitted.
required:
- type
RecommendationRequest:
type: object
description: |
Optional narrative guidance the user attaches to a turn. Treated as a
soft hint by the model.
properties:
desiredTone:
type: string
description: Desired mood for the next beat (e.g. "tense", "humorous").
example: tense
preferredDirection:
type: string
description: Free-text direction hint.
example: "The captain should reveal a partial truth"
focusCharacters:
type: array
items:
type: string
description: Character IDs that should play a prominent role.
TurnResponse:
type: object
description: Result of processing a user turn.
properties:
turnNumber:
type: integer
description: The turn number that was just completed.
example: 1
narrative:
type: string
description: Free-form narrative text produced by the model for this turn.
characterResponses:
type: array
items:
$ref: '#/components/schemas/CharacterResponseItem'
description: Per-character breakdown of what each AI character said or did.
updatedSituation:
$ref: '#/components/schemas/SituationUpdate'
updatedCharacters:
type: array
items:
$ref: '#/components/schemas/CharacterUpdate'
suggestions:
type: array
items:
$ref: '#/components/schemas/Suggestion'
required:
- turnNumber
- narrative
CharacterResponseItem:
type: object
description: What a single AI character said or did during a turn.
properties:
characterId:
type: string
description: ID of the character who acted.
type:
type: string
description: Type of response.
enum:
- speech
- action
- reaction
content:
type: string
description: Quoted dialogue if type is "speech", otherwise null.
action:
type: string
description: Description of physical action, or null if none.
moodAfter:
type: string
description: The character's mood after this turn.
required:
- characterId
- type
SituationUpdate:
type: object
description: Diff-style situation changes for a single turn.
properties:
currentScene:
type: string
description: Updated scene description.
newTimelineEntries:
type: array
items:
type: string
description: New events to append to the timeline.
openThreadsChanges:
$ref: '#/components/schemas/OpenThreadsChanges'
worldStateFlags:
type: object
additionalProperties: true
description: Current world state flags after this turn.
OpenThreadsChanges:
type: object
description: Diff-style changes to open narrative threads.
properties:
added:
type: array
items:
type: string
description: New threads introduced this turn.
resolved:
type: array
items:
type: string
description: Threads that were resolved or closed this turn.
CharacterUpdate:
type: object
description: Diff-style character state changes for a single character.
properties:
characterId:
type: string
description: ID of the character whose state changed.
currentMood:
type: string
description: The character's mood after this turn.
knowledgeGained:
type: array
items:
type: string
description: New facts the character learned this turn.
relationshipChanges:
type: object
additionalProperties:
type: string
description: Changed relationships (character ID to new descriptor).
required:
- characterId
Suggestion:
type: object
description: A suggestion for what could happen next in the story.
properties:
id:
type: string
description: Unique suggestion identifier.
type:
type: string
description: Category of the suggestion.
enum:
- player_action
- world_event
- npc_action
- twist
title:
type: string
description: Short human-readable label.
description:
type: string
description: One- or two-sentence explanation.
consequences:
type: array
items:
type: string
description: Brief list of possible outcomes.
riskLevel:
type: string
description: How risky this option is for the player.
enum:
- low
- medium
- high
required:
- id
- type
- title
# ─── Error ────────────────────────────────────────────────────────────────
ErrorResponse:
type: object
description: Standard error response body.
properties:
code:
type: string
description: Machine-readable error code.
example: SESSION_NOT_FOUND
message:
type: string
description: Human-readable error description.
example: "No session found with the given ID"
required:
- code
- message

View File

@ -0,0 +1,168 @@
package de.neitzel.roleplay.business;
import de.neitzel.roleplay.fascade.model.CreateSessionRequest;
import de.neitzel.roleplay.fascade.model.SessionResponse;
import de.neitzel.roleplay.fascade.model.TurnRequest;
import de.neitzel.roleplay.fascade.model.TurnResponse;
import de.neitzel.roleplay.fascade.model.UserActionRequest;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Unit tests for {@link InMemorySessionService}.
*/
class InMemorySessionServiceTest {
/**
* Instance under test no CDI dependencies to mock.
*/
private InMemorySessionService sessionService;
/**
* Creates a fresh service instance before each test to ensure isolation.
*/
@BeforeEach
void setUp() {
sessionService = new InMemorySessionService();
}
/**
* Verifies that creating a session returns a response with a non-null ID,
* the correct model name, and turn number 0.
*/
@Test
void createSessionReturnsSessionWithCorrectMetadata() {
// Arrange
CreateSessionRequest request = new CreateSessionRequest("llama3:latest");
request.setLanguage("de");
// Act
SessionResponse response = sessionService.createSession(request);
// Assert
assertNotNull(response.getSessionId());
assertFalse(response.getSessionId().isBlank());
assertEquals("llama3:latest", response.getModel());
assertEquals("de", response.getLanguage());
assertEquals(0, response.getTurnNumber());
}
/**
* Verifies that a created session can be retrieved by its ID.
*/
@Test
void getSessionReturnsPreviouslyCreatedSession() {
// Arrange
CreateSessionRequest request = new CreateSessionRequest("llama3:latest");
SessionResponse created = sessionService.createSession(request);
// Act
Optional<SessionResponse> found = sessionService.getSession(created.getSessionId());
// Assert
assertTrue(found.isPresent());
assertEquals(created.getSessionId(), found.get().getSessionId());
}
/**
* Verifies that retrieving an unknown session returns an empty Optional.
*/
@Test
void getSessionReturnsEmptyForUnknownId() {
// Act
Optional<SessionResponse> result = sessionService.getSession("unknown-session-id");
// Assert
assertTrue(result.isEmpty());
}
/**
* Verifies that submitting a turn increments the turn number and returns
* a turn response for the correct turn.
*/
@Test
void submitTurnIncreasesTurnNumber() {
// Arrange
CreateSessionRequest createRequest = new CreateSessionRequest("llama3:latest");
SessionResponse session = sessionService.createSession(createRequest);
UserActionRequest action = new UserActionRequest();
action.setType(UserActionRequest.TypeEnum.ACTION);
action.setContent("I look around the room.");
TurnRequest turnRequest = new TurnRequest(action);
// Act
Optional<TurnResponse> response = sessionService.submitTurn(session.getSessionId(), turnRequest);
// Assert
assertTrue(response.isPresent());
assertEquals(1, response.get().getTurnNumber());
assertNotNull(response.get().getNarrative());
}
/**
* Verifies that submitting a turn for an unknown session returns an empty Optional.
*/
@Test
void submitTurnReturnsEmptyForUnknownSession() {
// Arrange
UserActionRequest action = new UserActionRequest();
action.setType(UserActionRequest.TypeEnum.SPEECH);
action.setContent("Hello!");
TurnRequest turnRequest = new TurnRequest(action);
// Act
Optional<TurnResponse> response = sessionService.submitTurn("non-existent", turnRequest);
// Assert
assertTrue(response.isEmpty());
}
/**
* Verifies that the turn number increments correctly across multiple turns.
*/
@Test
void multipleSuccessiveTurnsIncrementTurnNumber() {
// Arrange
CreateSessionRequest createRequest = new CreateSessionRequest("llama3:latest");
SessionResponse session = sessionService.createSession(createRequest);
String sessionId = session.getSessionId();
UserActionRequest action = new UserActionRequest();
action.setType(UserActionRequest.TypeEnum.ACTION);
action.setContent("Next action.");
TurnRequest turnRequest = new TurnRequest(action);
// Act
sessionService.submitTurn(sessionId, turnRequest);
Optional<TurnResponse> secondTurn = sessionService.submitTurn(sessionId, turnRequest);
// Assert
assertTrue(secondTurn.isPresent());
assertEquals(2, secondTurn.get().getTurnNumber());
}
/**
* Verifies that the session language defaults to "en" when not specified.
*/
@Test
void createSessionUsesDefaultLanguageWhenNotProvided() {
// Arrange
CreateSessionRequest request = new CreateSessionRequest("llama3:latest");
// language intentionally left null
// Act
SessionResponse response = sessionService.createSession(request);
// Assert
assertEquals("en", response.getLanguage());
}
}

View File

@ -0,0 +1,130 @@
package de.neitzel.roleplay.business;
import de.neitzel.roleplay.fascade.OllamaClient;
import de.neitzel.roleplay.fascade.OllamaModelDetails;
import de.neitzel.roleplay.fascade.OllamaModelInfo;
import de.neitzel.roleplay.fascade.model.ModelInfo;
import de.neitzel.roleplay.fascade.model.ModelListResponse;
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 java.util.Collections;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* Unit tests for {@link ModelService}.
*/
@ExtendWith(MockitoExtension.class)
class ModelServiceTest {
@Mock
private OllamaClient ollamaClient;
@InjectMocks
private ModelService modelService;
/**
* Verifies that models returned by the Ollama client are mapped correctly
* to the API response format.
*/
@Test
void listModelsMapsFamilyAndDisplayName() {
// Arrange
OllamaModelDetails details = OllamaModelDetails.builder()
.family("llama")
.parameterSize("8B")
.build();
OllamaModelInfo ollamaModel = OllamaModelInfo.builder()
.name("llama3:latest")
.model("llama3:latest")
.size(4_000_000_000L)
.details(details)
.build();
when(ollamaClient.listModels()).thenReturn(List.of(ollamaModel));
// Act
ModelListResponse response = modelService.listModels();
// Assert
assertNotNull(response);
assertEquals(1, response.getModels().size());
ModelInfo info = response.getModels().get(0);
assertEquals("llama3:latest", info.getName());
assertEquals("llama", info.getFamily());
assertEquals(4_000_000_000L, info.getSize());
assertEquals("Llama3 (latest)", info.getDisplayName());
verify(ollamaClient).listModels();
}
/**
* Verifies that a model name without a tag receives a display name equal
* to the capitalised base name.
*/
@Test
void listModelsDerivesDisplayNameWithoutTag() {
// Arrange
OllamaModelInfo ollamaModel = OllamaModelInfo.builder()
.name("mistral")
.model("mistral")
.size(2_000_000_000L)
.build();
when(ollamaClient.listModels()).thenReturn(List.of(ollamaModel));
// Act
ModelListResponse response = modelService.listModels();
// Assert
assertEquals("Mistral", response.getModels().get(0).getDisplayName());
}
/**
* Verifies that the model list is empty when the Ollama client returns no
* models.
*/
@Test
void listModelsReturnsEmptyListWhenNoModelsAvailable() {
// Arrange
when(ollamaClient.listModels()).thenReturn(Collections.emptyList());
// Act
ModelListResponse response = modelService.listModels();
// Assert
assertNotNull(response);
assertTrue(response.getModels().isEmpty());
}
/**
* Verifies that a model without details does not cause a NullPointerException
* and maps the family as null.
*/
@Test
void listModelsHandlesModelWithoutDetails() {
// Arrange
OllamaModelInfo ollamaModel = OllamaModelInfo.builder()
.name("phi3:mini")
.model("phi3:mini")
.size(1_000_000_000L)
.build();
when(ollamaClient.listModels()).thenReturn(List.of(ollamaModel));
// Act
ModelListResponse response = modelService.listModels();
// Assert
ModelInfo info = response.getModels().get(0);
assertNull(info.getFamily());
assertEquals("Phi3 (mini)", info.getDisplayName());
}
}