diff --git a/pom.xml b/pom.xml index 7117c18..d5000a0 100644 --- a/pom.xml +++ b/pom.xml @@ -19,6 +19,7 @@ 1.18.42 5.10.3 5.12.0 + 7.11.0 UTF-8 @@ -39,6 +40,18 @@ io.quarkus quarkus-arc + + io.quarkus + quarkus-rest + + + io.quarkus + quarkus-rest-jackson + + + io.quarkus + quarkus-hibernate-validator + io.quarkus quarkus-liquibase @@ -76,6 +89,58 @@ ${quarkus.plugin.version} true + + org.codehaus.mojo + build-helper-maven-plugin + 3.6.0 + + + add-openapi-generated-sources + generate-sources + + add-source + + + + ${project.build.directory}/generated-sources/openapi/src/main/java + + + + + + + org.openapitools + openapi-generator-maven-plugin + ${openapi.generator.version} + + + generate-roleplay-api + + generate + + + ${project.basedir}/src/main/resources/openapi-roleplay-public-v1.yml + jaxrs-spec + ${project.build.directory}/generated-sources/openapi + de.neitzel.roleplay.fascade.api + de.neitzel.roleplay.fascade.model + + true + false + false + true + src/main/java + java8 + false + true + true + true + + false + + + + maven-compiler-plugin ${maven.compiler.plugin.version} diff --git a/src/main/java/de/neitzel/roleplay/business/InMemorySessionService.java b/src/main/java/de/neitzel/roleplay/business/InMemorySessionService.java new file mode 100644 index 0000000..25460ed --- /dev/null +++ b/src/main/java/de/neitzel/roleplay/business/InMemorySessionService.java @@ -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. + * + *

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 sessions = new ConcurrentHashMap<>(); + + /** + * {@inheritDoc} + * + *

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 getSession(final String sessionId) { + return Optional.ofNullable(sessions.get(sessionId)); + } + + /** + * {@inheritDoc} + * + *

Increments the turn counter and returns a stub {@link TurnResponse}. + * The Ollama two-call pattern is not yet invoked. + */ + @Override + public Optional 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); + } +} + diff --git a/src/main/java/de/neitzel/roleplay/business/ModelService.java b/src/main/java/de/neitzel/roleplay/business/ModelService.java new file mode 100644 index 0000000..b672750 --- /dev/null +++ b/src/main/java/de/neitzel/roleplay/business/ModelService.java @@ -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 ollamaModels = ollamaClient.listModels(); + List 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; + } +} + diff --git a/src/main/java/de/neitzel/roleplay/business/SessionService.java b/src/main/java/de/neitzel/roleplay/business/SessionService.java new file mode 100644 index 0000000..8ab5463 --- /dev/null +++ b/src/main/java/de/neitzel/roleplay/business/SessionService.java @@ -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 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 submitTurn(String sessionId, TurnRequest turnRequest); +} + diff --git a/src/main/java/de/neitzel/roleplay/fascade/ModelResource.java b/src/main/java/de/neitzel/roleplay/fascade/ModelResource.java new file mode 100644 index 0000000..faa1953 --- /dev/null +++ b/src/main/java/de/neitzel/roleplay/fascade/ModelResource.java @@ -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} + * + *

Delegates to {@link ModelService#listModels()} and returns the result + * directly. + */ + @Override + public ModelListResponse listModels() { + return modelService.listModels(); + } +} + diff --git a/src/main/java/de/neitzel/roleplay/fascade/NotFoundExceptionMapper.java b/src/main/java/de/neitzel/roleplay/fascade/NotFoundExceptionMapper.java new file mode 100644 index 0000000..14367ea --- /dev/null +++ b/src/main/java/de/neitzel/roleplay/fascade/NotFoundExceptionMapper.java @@ -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 { + + /** + * 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(); + } +} + diff --git a/src/main/java/de/neitzel/roleplay/fascade/SessionResource.java b/src/main/java/de/neitzel/roleplay/fascade/SessionResource.java new file mode 100644 index 0000000..7e7f3f7 --- /dev/null +++ b/src/main/java/de/neitzel/roleplay/fascade/SessionResource.java @@ -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} + * + *

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} + * + *

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)); + } +} + diff --git a/src/main/java/de/neitzel/roleplay/fascade/TurnResource.java b/src/main/java/de/neitzel/roleplay/fascade/TurnResource.java new file mode 100644 index 0000000..342b533 --- /dev/null +++ b/src/main/java/de/neitzel/roleplay/fascade/TurnResource.java @@ -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} + * + *

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)); + } +} + diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 9275b82..e35eb86 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -9,4 +9,5 @@ quarkus: url: http://debian:11434 connect-timeout: 5000 read-timeout: 120000 - + http: + root-path: /api/v1 diff --git a/src/main/resources/openapi-roleplay-public-v1.yml b/src/main/resources/openapi-roleplay-public-v1.yml new file mode 100644 index 0000000..c2f8158 --- /dev/null +++ b/src/main/resources/openapi-roleplay-public-v1.yml @@ -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 + diff --git a/src/test/java/de/neitzel/roleplay/business/InMemorySessionServiceTest.java b/src/test/java/de/neitzel/roleplay/business/InMemorySessionServiceTest.java new file mode 100644 index 0000000..e1447f4 --- /dev/null +++ b/src/test/java/de/neitzel/roleplay/business/InMemorySessionServiceTest.java @@ -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 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 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 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 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 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()); + } +} + diff --git a/src/test/java/de/neitzel/roleplay/business/ModelServiceTest.java b/src/test/java/de/neitzel/roleplay/business/ModelServiceTest.java new file mode 100644 index 0000000..5319caf --- /dev/null +++ b/src/test/java/de/neitzel/roleplay/business/ModelServiceTest.java @@ -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()); + } +} +