Implement session management and model discovery APIs
This commit is contained in:
parent
eed9d1da66
commit
b30a7e4770
65
pom.xml
65
pom.xml
@ -19,6 +19,7 @@
|
||||
<lombok.version>1.18.42</lombok.version>
|
||||
<junit.jupiter.version>5.10.3</junit.jupiter.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>
|
||||
</properties>
|
||||
|
||||
@ -39,6 +40,18 @@
|
||||
<groupId>io.quarkus</groupId>
|
||||
<artifactId>quarkus-arc</artifactId>
|
||||
</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>
|
||||
<groupId>io.quarkus</groupId>
|
||||
<artifactId>quarkus-liquibase</artifactId>
|
||||
@ -76,6 +89,58 @@
|
||||
<version>${quarkus.plugin.version}</version>
|
||||
<extensions>true</extensions>
|
||||
</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>
|
||||
<artifactId>maven-compiler-plugin</artifactId>
|
||||
<version>${maven.compiler.plugin.version}</version>
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
90
src/main/java/de/neitzel/roleplay/business/ModelService.java
Normal file
90
src/main/java/de/neitzel/roleplay/business/ModelService.java
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
43
src/main/java/de/neitzel/roleplay/fascade/ModelResource.java
Normal file
43
src/main/java/de/neitzel/roleplay/fascade/ModelResource.java
Normal 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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
46
src/main/java/de/neitzel/roleplay/fascade/TurnResource.java
Normal file
46
src/main/java/de/neitzel/roleplay/fascade/TurnResource.java
Normal 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));
|
||||
}
|
||||
}
|
||||
|
||||
@ -9,4 +9,5 @@ quarkus:
|
||||
url: http://debian:11434
|
||||
connect-timeout: 5000
|
||||
read-timeout: 120000
|
||||
|
||||
http:
|
||||
root-path: /api/v1
|
||||
|
||||
623
src/main/resources/openapi-roleplay-public-v1.yml
Normal file
623
src/main/resources/openapi-roleplay-public-v1.yml
Normal 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
|
||||
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
130
src/test/java/de/neitzel/roleplay/business/ModelServiceTest.java
Normal file
130
src/test/java/de/neitzel/roleplay/business/ModelServiceTest.java
Normal 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());
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user