Implement Ollama integration for session management and turn processing

- Enhance InMemorySessionService to utilize the two-call Ollama pattern for session creation and turn submissions, generating narratives and state updates based on provided scenarios.
- Introduce OllamaContextBuilder to construct turn contexts for both session initialization and turn continuation.
- Add OllamaPrompts class to define system prompts for narrative generation and state extraction.
- Implement StateUpdateMapper to handle merging state updates into session responses.
- Create unit tests for InMemorySessionService to validate Ollama interactions and ensure correct session state management.
This commit is contained in:
Konrad Neitzel 2026-02-21 12:45:20 +01:00
parent f21f1e7520
commit b79334ee67
6 changed files with 783 additions and 28 deletions

155
docs/OPEN_TASKS_PLAN.md Normal file
View File

@ -0,0 +1,155 @@
# Plan: Open Tasks (Ollama Integration in InMemorySessionService)
This document plans the two remaining backend open tasks: wiring the two-call Ollama pattern into **createSession** and **submitTurn** in [InMemorySessionService](src/main/java/de/neitzel/roleplay/business/InMemorySessionService.java).
---
## Overview
| Task | Method | Goal |
|------|--------|------|
| 1 | `createSession` | Produce opening narrative and initial state via Ollama (narrative + state extraction). |
| 2 | `submitTurn` | Produce turn narrative and state update via Ollama (narrative + state update), then merge into session. |
Both follow the same pattern: build **TurnContext** (JSON) → **Call 1** (narrative) → **Call 2** (state update with narrative in user message) → map **StateUpdateResponse** and narrative into API responses and session state.
---
## Prerequisites and Dependencies
- **[OllamaClient](src/main/java/de/neitzel/roleplay/fascade/OllamaClient.java)** already provides `generateNarrative(model, systemPrompt, userContent)` and `generateStateUpdate(model, systemPrompt, userContent)`.
- **Common types** ([common](src/main/java/de/neitzel/roleplay/common/)) `TurnContext`, `StateUpdateResponse`, `SituationSnapshot`, `CharacterSet`, `CharacterSnapshot`, `UserAction`, `Recommendation`, `SituationUpdate`, `CharacterUpdate`, `OpenThreadsChanges`, `CharacterResponse`, `Suggestion` (with `SuggestionType`, `RiskLevel`).
- **API models** (`de.neitzel.roleplay.fascade.model`) generated from OpenAPI: `SessionResponse`, `TurnResponse`, `SituationState`, `CharacterState`, `Suggestion`, `CharacterResponseItem`, etc.
- **Prompts** defined in [ROLEPLAY_CONCEPT.md](docs/ROLEPLAY_CONCEPT.md) (§4.3 for init, §4.4 for turn). Should be extracted to constants or a small prompt provider (e.g. in `business` or `common`) so they stay consistent and maintainable.
---
## Task 1: createSession Opening scene and initial state
### Flow
1. Create `SessionResponse` with sessionId, model, language, safetyLevel, turnNumber (0) **unchanged**.
2. Build initial **situation** and **characters** from `request.getScenario()` (reuse existing `buildSituationFromScenario` / `buildCharactersFromScenario`).
3. Build **TurnContext** for session init:
- **currentSituation**: Map from `SituationState` (API) to `SituationSnapshot` (common). Initial values: setting, currentScene from scenario; timeline/openThreads/externalPressures/worldStateFlags can be empty or minimal.
- **characters**: Map from `CharacterState` list to `CharacterSet` (userCharacter + aiCharacters). Map each to `CharacterSnapshot` (id, name, role, personalityTraits, speakingStyle, goals from scenario; currentMood/status/knowledge/relationships/recentActionsSummary can be null or default).
- **userAction**: `null` (no user action at init).
- **recommendation**: `null`.
- **recentHistorySummary**: `null` or `""`.
4. Serialise `TurnContext` to JSON (snake_case) via Jackson `ObjectMapper`.
5. **Call 1** `ollamaClient.generateNarrative(model, INIT_SYSTEM_PROMPT, contextJson)`.
6. **Call 2** `ollamaClient.generateStateUpdate(model, STATE_EXTRACT_SYSTEM_PROMPT, contextJson + "\n\nNarrative that was just generated:\n" + narrative)`.
7. Set **session** from Call 1 + Call 2:
- `session.setNarrative(narrative)` (from Call 1).
- Apply **StateUpdateResponse** to session:
- **updated_situation** → merge into `session.getSituation()` (or build new `SituationState`): currentScene, append newTimelineEntries to timeline, apply openThreadsChanges (add/remove from openThreads), set worldStateFlags.
- **updated_characters** → merge into `session.getCharacters()` by characterId: update currentMood, append knowledgeGained to knowledge, apply relationshipChanges to relationships; keep id/name/role/isUserCharacter from existing list.
- **suggestions** → map common `Suggestion` list to API `Suggestion` list and `session.setSuggestions(...)`.
8. Store session and return.
### Design choices
- **Context builder**: Introduce a small helper (or service) to build `TurnContext` from scenario + optional session state, and to serialise it. Keeps `InMemorySessionService` focused on orchestration.
- **Situation/character mapping**: Reuse or introduce mappers between API models (`SituationState`, `CharacterState`) and common types (`SituationSnapshot`, `CharacterSnapshot`) so both createSession and submitTurn can share the same logic.
- **Error handling**: If Ollama fails (network, parse), decide: throw and fail session creation, or fall back to placeholder narrative and log. Prefer throwing and letting the resource layer return 5xx, with a clear message.
---
## Task 2: submitTurn Turn narrative and state update
### Flow
1. Load session; if missing return `Optional.empty()` **unchanged**.
2. Increment turn number **unchanged**.
3. Build **TurnContext** for this turn:
- **currentSituation**: From `session.getSituation()``SituationSnapshot` (setting, currentScene, timeline, openThreads, externalPressures, worldStateFlags).
- **characters**: From `session.getCharacters()``CharacterSet` (userCharacter + aiCharacters as `CharacterSnapshot` list).
- **userAction**: From `turnRequest.getUserAction()` → map API `UserActionRequest` to common `UserAction` (type, content, selectedSuggestionId).
- **recommendation**: From `turnRequest.getRecommendation()` → map to common `Recommendation` (desiredTone, preferredDirection, focusCharacters), or null.
- **recentHistorySummary**: For now empty string or a short placeholder; later can be derived from stored turn history if added.
4. Serialise `TurnContext` to JSON.
5. **Call 1** `ollamaClient.generateNarrative(model, TURN_NARRATIVE_SYSTEM_PROMPT, contextJson)`.
6. **Call 2** `ollamaClient.generateStateUpdate(model, STATE_EXTRACT_SYSTEM_PROMPT, contextJson + "\n\nNarrative that was just generated:\n" + narrative)`.
7. Build **TurnResponse**:
- turnNumber, narrative (from Call 1).
- characterResponses, updatedSituation, updatedCharacters, suggestions from `StateUpdateResponse` (mapped to API model types).
8. **Merge** state update into session (same as createSession): apply updated_situation to `session.getSituation()`, updated_characters to `session.getCharacters()`, replace `session.setSuggestions(...)`, set `session.setNarrative(narrative)`, `session.setTurnNumber(nextTurn)`.
9. Save session and return `Optional.of(turnResponse)`.
### Design choices
- **Shared merge logic**: Extract “apply StateUpdateResponse to SessionResponse” into a private method (or small helper) used by both createSession and submitTurn.
- **TurnResponse mapping**: Map `StateUpdateResponse` (common) to `TurnResponse` (API): responses → characterResponses, updatedSituation, updatedCharacters, suggestions. API uses same structure; ensure enum/value names match (e.g. speech/action/reaction, risk_level).
---
## Shared implementation elements
### 1. Prompts
- **INIT_SYSTEM_PROMPT** (opening narrative) from concept §4.3 Call 1.
- **STATE_EXTRACT_SYSTEM_PROMPT** (JSON extraction) from concept §4.3 Call 2 (same for init and turn).
- **TURN_NARRATIVE_SYSTEM_PROMPT** (turn continuation) from concept §4.4 Call 1.
Store as constants in a single class (e.g. `OllamaPrompts` in `business`) or in a configurable provider.
### 2. Context building
- **Session init**: `TurnContext` from scenario-derived situation + characters; no userAction/recommendation.
- **Turn**: `TurnContext` from current session situation + characters + userAction + recommendation.
Both need **API → common** mapping for situation and characters when building the context. A dedicated mapper or builder class keeps the service clean.
### 3. State merge (StateUpdateResponse → SessionResponse)
- **Situation**: Apply `SituationUpdate`: set currentScene if present; append newTimelineEntries to timeline; apply openThreadsChanges (add/remove from openThreads); replace or merge worldStateFlags.
- **Characters**: For each `CharacterUpdate` find character by characterId; update currentMood; append knowledgeGained to knowledge; merge relationshipChanges into relationships.
- **Suggestions**: Replace session suggestions with the new list (mapped from common to API).
### 4. API ↔ common mapping
- **Common** uses snake_case (Jackson `@JsonNaming`); **API** models are OpenAPI-generated (camelCase). When mapping StateUpdateResponse (common) into SessionResponse / TurnResponse (API), copy fields and convert enums where needed (e.g. ResponseType, SuggestionType, RiskLevel).
- **TurnContext** is always built from common types and serialised to JSON as-is for Ollama.
---
## Suggested order of implementation
1. **Prompts** Add `OllamaPrompts` (or similar) with the three system prompt strings from the concept doc.
2. **Mappers** Add mapping from API `SituationState`/`CharacterState` (and scenario) to common `SituationSnapshot`/`CharacterSet`/`CharacterSnapshot`; and from common `StateUpdateResponse`/`Suggestion`/etc. to API types for SessionResponse/TurnResponse. Optionally a dedicated “context builder” that takes scenario or session + turnRequest and returns `TurnContext`.
3. **State merge** Implement “apply StateUpdateResponse to SessionResponse” (situation, characters, suggestions) in one place.
4. **createSession** Inject `OllamaClient` and `ObjectMapper`; build context from scenario; call two-step Ollama; merge state; set narrative/situation/characters/suggestions on session.
5. **submitTurn** Build context from session + turnRequest; call two-step Ollama; build TurnResponse; merge state into session; return TurnResponse.
6. **Tests** Update or add unit tests: mock `OllamaClient` in `InMemorySessionServiceTest` to verify two-call pattern and state merge; optionally integration test with a real or stubbed Ollama.
---
## Risks and mitigations
| Risk | Mitigation |
|------|------------|
| Ollama slow/unavailable | Timeouts already set in rest-client config; fail fast and return 502/503 from resource layer. |
| Model returns invalid JSON (Call 2) | Already throws `OllamaParseException`; map to 502 or 503 in a mapper if not already. |
| Large context / token limit | Keep recentHistorySummary short; later add condensing of older turns. |
| Enum mismatches (API vs common) | Use same enum names in OpenAPI as in common (e.g. speech, action, reaction); document mapping in one place. |
---
## Files to touch (summary)
| File / area | Change |
|------------|--------|
| New: e.g. `business/OllamaPrompts.java` | System prompt constants for init narrative, turn narrative, state extraction. |
| New or existing: mappers / context builder | Build `TurnContext` from scenario or session + turnRequest; map StateUpdateResponse → SessionResponse / TurnResponse. |
| [InMemorySessionService](src/main/java/de/neitzel/roleplay/business/InMemorySessionService.java) | Inject `OllamaClient`, `ObjectMapper`; implement createSession and submitTurn with two-call pattern and state merge; remove TODOs and placeholder narratives. |
| [InMemorySessionServiceTest](src/test/java/de/neitzel/roleplay/business/InMemorySessionServiceTest.java) | Add or adjust tests with mocked OllamaClient to verify createSession and submitTurn call Ollama and merge state. |
| Optional: exception mapper | Map `OllamaParseException` (and network errors) to a suitable HTTP response if not already. |
---
## Done criteria
- **createSession**: Opening narrative and initial situation/characters/suggestions come from Ollama (Call 1 + Call 2); no placeholder text.
- **submitTurn**: Turn narrative and updated situation/characters/suggestions come from Ollama; session state is updated; TurnResponse contains narrative and structured state (characterResponses, updatedSituation, updatedCharacters, suggestions).
- Unit tests cover the two-call flow with mocked OllamaClient and assert on state merge.
- No remaining TODOs in InMemorySessionService for Ollama integration.

View File

@ -1,5 +1,9 @@
package de.neitzel.roleplay.business; package de.neitzel.roleplay.business;
import com.fasterxml.jackson.core.JsonProcessingException;
import de.neitzel.roleplay.common.StateUpdateResponse;
import de.neitzel.roleplay.fascade.OllamaClient;
import de.neitzel.roleplay.fascade.OllamaParseException;
import de.neitzel.roleplay.fascade.model.CharacterDefinition; import de.neitzel.roleplay.fascade.model.CharacterDefinition;
import de.neitzel.roleplay.fascade.model.CharacterState; import de.neitzel.roleplay.fascade.model.CharacterState;
import de.neitzel.roleplay.fascade.model.CreateSessionRequest; import de.neitzel.roleplay.fascade.model.CreateSessionRequest;
@ -10,6 +14,7 @@ import de.neitzel.roleplay.fascade.model.TurnRequest;
import de.neitzel.roleplay.fascade.model.TurnResponse; import de.neitzel.roleplay.fascade.model.TurnResponse;
import de.neitzel.roleplay.fascade.model.UpdateSessionRequest; import de.neitzel.roleplay.fascade.model.UpdateSessionRequest;
import jakarta.enterprise.context.ApplicationScoped; import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
@ -23,32 +28,48 @@ import java.util.concurrent.ConcurrentHashMap;
* {@link ConcurrentHashMap}; suitable for development and testing. A * {@link ConcurrentHashMap}; suitable for development and testing. A
* production implementation would persist state in a database. * production implementation would persist state in a database.
* *
* <p>Turn orchestration (the two-call Ollama pattern) is not yet wired; the * <p>Session creation and turn submission use the two-call Ollama pattern
* methods return stub responses so the REST layer can be exercised end-to-end. * (narrative then state extraction) when scenario or turn data is provided.
* The {@code TODO} markers indicate where the Ollama integration must be added.
*/ */
@ApplicationScoped @ApplicationScoped
public class InMemorySessionService implements SessionService { public class InMemorySessionService implements SessionService {
private final OllamaClient ollamaClient;
private final com.fasterxml.jackson.databind.ObjectMapper objectMapper;
/** /**
* In-memory store mapping session IDs to their current state. * In-memory store mapping session IDs to their current state.
*/ */
private final Map<String, SessionResponse> sessions = new ConcurrentHashMap<>(); private final Map<String, SessionResponse> sessions = new ConcurrentHashMap<>();
/**
* Creates the service with required dependencies.
*
* @param ollamaClient client for Ollama narrative and state-update calls
* @param objectMapper mapper to serialize turn context to JSON
*/
@Inject
public InMemorySessionService(final OllamaClient ollamaClient,
final com.fasterxml.jackson.databind.ObjectMapper objectMapper) {
this.ollamaClient = ollamaClient;
this.objectMapper = objectMapper;
}
/** /**
* {@inheritDoc} * {@inheritDoc}
* *
* <p>Generates a new UUID as the session ID, populates default session * <p>Generates a new UUID as the session ID, populates default session
* metadata, and stores the session. The Ollama two-call pattern is not * metadata, and stores the session. When a scenario is provided, runs the
* yet invoked; a placeholder narrative is returned instead. * two-call Ollama pattern to produce an opening narrative and initial state.
*/ */
@Override @Override
public SessionResponse createSession(final CreateSessionRequest request) { public SessionResponse createSession(final CreateSessionRequest request) {
String sessionId = UUID.randomUUID().toString(); String sessionId = UUID.randomUUID().toString();
String model = request.getModel();
SessionResponse session = new SessionResponse( SessionResponse session = new SessionResponse(
sessionId, sessionId,
request.getModel(), model,
request.getLanguage() != null ? request.getLanguage() : "en", request.getLanguage() != null ? request.getLanguage() : "en",
request.getSafetyLevel() != null request.getSafetyLevel() != null
? request.getSafetyLevel().value() ? request.getSafetyLevel().value()
@ -56,13 +77,21 @@ public class InMemorySessionService implements SessionService {
0 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.");
if (request.getScenario() != null) { if (request.getScenario() != null) {
session.setSituation(buildSituationFromScenario(request.getScenario())); session.setSituation(buildSituationFromScenario(request.getScenario()));
session.setCharacters(buildCharactersFromScenario(request.getScenario())); session.setCharacters(buildCharactersFromScenario(request.getScenario()));
try {
String contextJson = objectMapper.writeValueAsString(OllamaContextBuilder.fromScenario(request.getScenario()));
String narrative = ollamaClient.generateNarrative(model, OllamaPrompts.INIT_NARRATIVE, contextJson);
String userContentForCall2 = contextJson + "\n\nNarrative that was just generated:\n" + narrative;
StateUpdateResponse stateUpdate = ollamaClient.generateStateUpdate(model, OllamaPrompts.STATE_EXTRACTION, userContentForCall2);
session.setNarrative(narrative);
StateUpdateMapper.mergeIntoSession(session, stateUpdate);
} catch (JsonProcessingException e) {
throw new OllamaParseException("Failed to serialize turn context for Ollama", e);
}
} else {
session.setNarrative("Session initialised. No scenario provided.");
} }
sessions.put(sessionId, session); sessions.put(sessionId, session);
@ -155,8 +184,9 @@ public class InMemorySessionService implements SessionService {
/** /**
* {@inheritDoc} * {@inheritDoc}
* *
* <p>Increments the turn counter and returns a stub {@link TurnResponse}. * <p>Increments the turn counter, runs the two-call Ollama pattern with
* The Ollama two-call pattern is not yet invoked. * the current session state and turn request, merges the state update into
* the session, and returns the turn response.
*/ */
@Override @Override
public Optional<TurnResponse> submitTurn(final String sessionId, public Optional<TurnResponse> submitTurn(final String sessionId,
@ -166,20 +196,23 @@ public class InMemorySessionService implements SessionService {
return Optional.empty(); return Optional.empty();
} }
// Increment turn counter
int nextTurn = session.getTurnNumber() + 1; int nextTurn = session.getTurnNumber() + 1;
session.setTurnNumber(nextTurn); session.setTurnNumber(nextTurn);
String model = session.getModel();
// TODO: Invoke OllamaClient two-call pattern (narrative + state update) try {
// using the current session state, turnRequest.getUserAction(), String contextJson = objectMapper.writeValueAsString(OllamaContextBuilder.forTurn(session, turnRequest));
// and turnRequest.getRecommendation(). String narrative = ollamaClient.generateNarrative(model, OllamaPrompts.TURN_NARRATIVE, contextJson);
TurnResponse response = new TurnResponse( String userContentForCall2 = contextJson + "\n\nNarrative that was just generated:\n" + narrative;
nextTurn, StateUpdateResponse stateUpdate = ollamaClient.generateStateUpdate(model, OllamaPrompts.STATE_EXTRACTION, userContentForCall2);
"Turn " + nextTurn + " processed. Ollama integration pending." session.setNarrative(narrative);
); StateUpdateMapper.mergeIntoSession(session, stateUpdate);
TurnResponse response = StateUpdateMapper.toTurnResponse(nextTurn, narrative, stateUpdate);
sessions.put(sessionId, session); sessions.put(sessionId, session);
return Optional.of(response); return Optional.of(response);
} catch (JsonProcessingException e) {
throw new OllamaParseException("Failed to serialize turn context for Ollama", e);
}
} }
} }

View File

@ -0,0 +1,240 @@
package de.neitzel.roleplay.business;
import de.neitzel.roleplay.common.CharacterSet;
import de.neitzel.roleplay.common.CharacterSnapshot;
import de.neitzel.roleplay.common.Recommendation;
import de.neitzel.roleplay.common.SituationSnapshot;
import de.neitzel.roleplay.common.TurnContext;
import de.neitzel.roleplay.common.UserAction;
import de.neitzel.roleplay.fascade.model.CharacterDefinition;
import de.neitzel.roleplay.fascade.model.CharacterState;
import de.neitzel.roleplay.fascade.model.RecommendationRequest;
import de.neitzel.roleplay.fascade.model.ScenarioSetup;
import de.neitzel.roleplay.fascade.model.SessionResponse;
import de.neitzel.roleplay.fascade.model.SituationState;
import de.neitzel.roleplay.fascade.model.TurnRequest;
import de.neitzel.roleplay.fascade.model.UserActionRequest;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
/**
* Builds {@link TurnContext} (common) from API request models for Ollama calls.
* Used for session initialization (scenario only) and turn continuation
* (session state + turn request).
*/
public final class OllamaContextBuilder {
private OllamaContextBuilder() {
}
/**
* Builds turn context for session initialization. No user action or
* recommendation; recent history is empty.
*
* @param situation situation state from scenario (may be null)
* @param characters character list from scenario (may be null or empty)
* @return context for the init two-call pattern
*/
public static TurnContext forSessionInit(final SituationState situation,
final List<CharacterState> characters) {
SituationSnapshot situationSnapshot = situationToSnapshot(situation);
CharacterSet characterSet = charactersToSet(characters);
return TurnContext.builder()
.currentSituation(situationSnapshot)
.characters(characterSet)
.userAction(null)
.recommendation(null)
.recentHistorySummary(null)
.build();
}
/**
* Builds turn context from scenario setup. Uses definitions for rich
* character snapshots (personality, goals) in the context.
*
* @param scenario the scenario from create request (must not be null)
* @return context for the init two-call pattern
*/
public static TurnContext fromScenario(final ScenarioSetup scenario) {
SituationState situation = buildSituationFromScenario(scenario);
CharacterSet characterSet = characterSetFromScenario(scenario);
SituationSnapshot situationSnapshot = situationToSnapshot(situation);
return TurnContext.builder()
.currentSituation(situationSnapshot)
.characters(characterSet)
.userAction(null)
.recommendation(null)
.recentHistorySummary(null)
.build();
}
/**
* Builds turn context for a turn continuation from current session state
* and the turn request.
*
* @param session current session (situation and characters used)
* @param turnRequest user action and optional recommendation
* @return context for the turn two-call pattern
*/
public static TurnContext forTurn(final SessionResponse session,
final TurnRequest turnRequest) {
SituationSnapshot situationSnapshot = situationToSnapshot(session.getSituation());
CharacterSet characterSet = charactersToSet(session.getCharacters());
UserAction userAction = turnRequest.getUserAction() != null
? toUserAction(turnRequest.getUserAction())
: null;
Recommendation recommendation = turnRequest.getRecommendation() != null
? toRecommendation(turnRequest.getRecommendation())
: null;
return TurnContext.builder()
.currentSituation(situationSnapshot)
.characters(characterSet)
.userAction(userAction)
.recommendation(recommendation)
.recentHistorySummary("")
.build();
}
private static SituationSnapshot situationToSnapshot(final SituationState s) {
if (s == null) {
return SituationSnapshot.builder().build();
}
return SituationSnapshot.builder()
.setting(s.getSetting())
.currentScene(s.getCurrentScene())
.timeline(s.getTimeline() != null ? new ArrayList<>(s.getTimeline()) : null)
.openThreads(s.getOpenThreads() != null ? new ArrayList<>(s.getOpenThreads()) : null)
.externalPressures(s.getExternalPressures() != null ? new ArrayList<>(s.getExternalPressures()) : null)
.worldStateFlags(s.getWorldStateFlags() != null ? Map.copyOf(s.getWorldStateFlags()) : null)
.build();
}
private static CharacterSet charactersToSet(final List<CharacterState> characters) {
if (characters == null || characters.isEmpty()) {
return CharacterSet.builder()
.userCharacter(null)
.aiCharacters(Collections.emptyList())
.build();
}
CharacterSnapshot userCharacter = null;
List<CharacterSnapshot> aiCharacters = new ArrayList<>();
for (CharacterState c : characters) {
CharacterSnapshot snap = characterStateToSnapshot(c);
if (Boolean.TRUE.equals(c.getIsUserCharacter())) {
userCharacter = snap;
} else {
aiCharacters.add(snap);
}
}
return CharacterSet.builder()
.userCharacter(userCharacter)
.aiCharacters(aiCharacters)
.build();
}
private static CharacterSnapshot characterStateToSnapshot(final CharacterState c) {
if (c == null) {
return null;
}
return CharacterSnapshot.builder()
.id(c.getId())
.name(c.getName())
.role(c.getRole())
.personalityTraits(null)
.speakingStyle(null)
.goals(null)
.currentMood(c.getCurrentMood())
.knowledge(c.getKnowledge() != null ? new ArrayList<>(c.getKnowledge()) : null)
.relationships(c.getRelationships() != null ? Map.copyOf(c.getRelationships()) : null)
.status(c.getStatus())
.recentActionsSummary(c.getRecentActionsSummary() != null ? new ArrayList<>(c.getRecentActionsSummary()) : null)
.build();
}
private static UserAction toUserAction(final UserActionRequest r) {
if (r == null) {
return null;
}
de.neitzel.roleplay.common.ActionType type = toActionType(r.getType());
return UserAction.builder()
.type(type)
.content(r.getContent())
.selectedSuggestionId(r.getSelectedSuggestionId())
.build();
}
private static de.neitzel.roleplay.common.ActionType toActionType(final UserActionRequest.TypeEnum e) {
if (e == null) {
return null;
}
return switch (e.value()) {
case "speech" -> de.neitzel.roleplay.common.ActionType.SPEECH;
case "action" -> de.neitzel.roleplay.common.ActionType.ACTION;
case "choice" -> de.neitzel.roleplay.common.ActionType.CHOICE;
default -> de.neitzel.roleplay.common.ActionType.ACTION;
};
}
private static Recommendation toRecommendation(final RecommendationRequest r) {
if (r == null) {
return null;
}
return Recommendation.builder()
.desiredTone(r.getDesiredTone())
.preferredDirection(r.getPreferredDirection())
.focusCharacters(r.getFocusCharacters() != null ? new ArrayList<>(r.getFocusCharacters()) : null)
.build();
}
private static SituationState buildSituationFromScenario(final ScenarioSetup scenario) {
SituationState situation = new SituationState();
situation.setSetting(scenario.getSetting());
situation.setCurrentScene(
scenario.getSetting() != null && scenario.getInitialConflict() != null
? scenario.getSetting() + " " + scenario.getInitialConflict()
: scenario.getSetting() != null
? scenario.getSetting()
: scenario.getInitialConflict());
return situation;
}
/** Builds character set for context from scenario definitions (rich snapshots). */
private static CharacterSet characterSetFromScenario(final ScenarioSetup scenario) {
CharacterSnapshot userCharacter = null;
List<CharacterSnapshot> aiCharacters = new ArrayList<>();
if (scenario.getUserCharacter() != null) {
userCharacter = definitionToSnapshot(scenario.getUserCharacter(), true);
}
if (scenario.getAiCharacters() != null) {
for (CharacterDefinition def : scenario.getAiCharacters()) {
aiCharacters.add(definitionToSnapshot(def, false));
}
}
return CharacterSet.builder()
.userCharacter(userCharacter)
.aiCharacters(aiCharacters)
.build();
}
private static CharacterSnapshot definitionToSnapshot(final CharacterDefinition def, final boolean isUser) {
if (def == null) {
return null;
}
return CharacterSnapshot.builder()
.id(def.getId())
.name(def.getName())
.role(def.getRole())
.personalityTraits(def.getPersonalityTraits() != null ? new ArrayList<>(def.getPersonalityTraits()) : null)
.speakingStyle(def.getSpeakingStyle())
.goals(def.getGoals() != null ? new ArrayList<>(def.getGoals()) : null)
.currentMood(null)
.knowledge(null)
.relationships(null)
.status(null)
.recentActionsSummary(null)
.build();
}
}

View File

@ -0,0 +1,58 @@
package de.neitzel.roleplay.business;
/**
* System prompts for the Ollama two-call pattern. Used by session creation
* (opening narrative + state extraction) and turn continuation (narrative +
* state update). Content is aligned with the ROLEPLAY_CONCEPT document.
*/
public final class OllamaPrompts {
private OllamaPrompts() {
}
/**
* System prompt for Call 1 during session initialization: produce an
* immersive opening scene from the structured context.
*/
public static final String INIT_NARRATIVE =
"You are a role playing game narrator. Your task is to write an immersive opening scene for a new story.\n\n"
+ "Rules:\n"
+ "1. Write from the user character's perspective.\n"
+ "2. Introduce the setting, atmosphere, and at least one AI character.\n"
+ "3. At least one AI character must speak.\n"
+ "4. Write 2-4 paragraphs of vivid, immersive prose.\n"
+ "5. Do NOT include any JSON, metadata, or out-of-character commentary.";
/**
* System prompt for Call 1 during turn continuation: produce narrative
* and dialogue for the next beat.
*/
public static final String TURN_NARRATIVE =
"You are a role playing game narrator continuing an ongoing story.\n\n"
+ "Rules:\n"
+ "1. Stay strictly in-character for all AI-controlled characters.\n"
+ "2. Respect and build on the provided character and situation state.\n"
+ "3. Do NOT contradict established facts unless the context explicitly says reality has changed.\n"
+ "4. Keep the text immersive and avoid meta commentary.\n"
+ "5. At least one AI character must speak or act.\n"
+ "6. If a recommendation is provided, treat it as a soft guideline for the scene's direction.\n\n"
+ "Style:\n"
+ "- Mix short descriptive narration with dialogue.\n"
+ "- Keep responses roughly 2-5 paragraphs.";
/**
* System prompt for Call 2 (state extraction): given context and narrative,
* return a JSON object with responses, updated_situation, updated_characters,
* and suggestions. Used for both session init and turn continuation.
*/
public static final String STATE_EXTRACTION =
"You are a role playing game engine. Given the story context and a narrative scene, extract structured state updates as JSON.\n\n"
+ "You must return a JSON object matching the schema described below. Do NOT include any text outside the JSON object.\n\n"
+ "Schema:\n"
+ "{\n"
+ " \"responses\": [{\"character_id\": \"string\", \"type\": \"speech|action|reaction\", \"content\": \"string|null\", \"action\": \"string|null\", \"mood_after\": \"string\"}],\n"
+ " \"updated_situation\": {\"current_scene\": \"string\", \"new_timeline_entries\": [\"string\"], \"open_threads_changes\": {\"added\": [\"string\"], \"resolved\": [\"string\"]}, \"world_state_flags\": {}},\n"
+ " \"updated_characters\": [{\"character_id\": \"string\", \"current_mood\": \"string\", \"knowledge_gained\": [\"string\"], \"relationship_changes\": {}}],\n"
+ " \"suggestions\": [{\"id\": \"string\", \"type\": \"player_action|world_event|npc_action|twist\", \"title\": \"string\", \"description\": \"string\", \"consequences\": [\"string\"], \"risk_level\": \"low|medium|high\"}]\n"
+ "}";
}

View File

@ -0,0 +1,245 @@
package de.neitzel.roleplay.business;
import de.neitzel.roleplay.common.CharacterResponse;
import de.neitzel.roleplay.common.CharacterUpdate;
import de.neitzel.roleplay.common.OpenThreadsChanges;
import de.neitzel.roleplay.common.ResponseType;
import de.neitzel.roleplay.common.RiskLevel;
import de.neitzel.roleplay.common.SituationUpdate;
import de.neitzel.roleplay.common.StateUpdateResponse;
import de.neitzel.roleplay.common.Suggestion;
import de.neitzel.roleplay.common.SuggestionType;
import de.neitzel.roleplay.fascade.model.CharacterResponseItem;
import de.neitzel.roleplay.fascade.model.CharacterState;
import de.neitzel.roleplay.fascade.model.SessionResponse;
import de.neitzel.roleplay.fascade.model.SituationState;
import de.neitzel.roleplay.fascade.model.TurnResponse;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
/**
* Maps {@link StateUpdateResponse} (common, from Ollama) to API model types and
* merges state updates into {@link SessionResponse}.
*/
public final class StateUpdateMapper {
private StateUpdateMapper() {
}
/**
* Builds a turn response from the narrative and state update.
*
* @param turnNumber turn number just completed
* @param narrative narrative from Call 1
* @param update state update from Call 2 (may be null)
* @return turn response with narrative and optional structured state
*/
public static TurnResponse toTurnResponse(final int turnNumber,
final String narrative,
final StateUpdateResponse update) {
TurnResponse response = new TurnResponse(turnNumber, narrative);
if (update != null) {
if (update.getResponses() != null) {
response.setCharacterResponses(update.getResponses().stream()
.map(StateUpdateMapper::toCharacterResponseItem)
.toList());
}
if (update.getUpdatedSituation() != null) {
response.setUpdatedSituation(toApiSituationUpdate(update.getUpdatedSituation()));
}
if (update.getUpdatedCharacters() != null) {
response.setUpdatedCharacters(update.getUpdatedCharacters().stream()
.map(StateUpdateMapper::toApiCharacterUpdate)
.toList());
}
if (update.getSuggestions() != null) {
response.setSuggestions(update.getSuggestions().stream()
.map(StateUpdateMapper::toApiSuggestion)
.toList());
}
}
return response;
}
/**
* Applies the state update to the session: merges situation, characters,
* and replaces suggestions.
*
* @param session the session to update (modified in place)
* @param update the state update from Ollama Call 2 (may be null)
*/
public static void mergeIntoSession(final SessionResponse session,
final StateUpdateResponse update) {
if (update == null) {
return;
}
if (update.getUpdatedSituation() != null) {
mergeSituation(session.getSituation(), update.getUpdatedSituation());
}
if (update.getUpdatedCharacters() != null) {
mergeCharacters(session.getCharacters(), update.getUpdatedCharacters());
}
if (update.getSuggestions() != null) {
session.setSuggestions(update.getSuggestions().stream()
.map(StateUpdateMapper::toApiSuggestion)
.toList());
}
}
private static void mergeSituation(final SituationState situation,
final SituationUpdate update) {
if (situation == null) {
return;
}
if (update.getCurrentScene() != null) {
situation.setCurrentScene(update.getCurrentScene());
}
if (update.getNewTimelineEntries() != null && !update.getNewTimelineEntries().isEmpty()) {
List<String> timeline = situation.getTimeline();
if (timeline == null) {
situation.setTimeline(new ArrayList<>(update.getNewTimelineEntries()));
} else {
timeline.addAll(update.getNewTimelineEntries());
}
}
if (update.getOpenThreadsChanges() != null) {
OpenThreadsChanges otc = update.getOpenThreadsChanges();
List<String> openThreads = situation.getOpenThreads();
if (openThreads == null) {
openThreads = new ArrayList<>();
situation.setOpenThreads(openThreads);
}
if (otc.getResolved() != null) {
openThreads.removeAll(otc.getResolved());
}
if (otc.getAdded() != null) {
openThreads.addAll(otc.getAdded());
}
}
if (update.getWorldStateFlags() != null && !update.getWorldStateFlags().isEmpty()) {
Map<String, Object> flags = situation.getWorldStateFlags();
if (flags == null) {
situation.setWorldStateFlags(new HashMap<>(update.getWorldStateFlags()));
} else {
flags.putAll(update.getWorldStateFlags());
}
}
}
private static void mergeCharacters(final List<CharacterState> characters,
final List<CharacterUpdate> updates) {
if (characters == null || updates == null) {
return;
}
for (CharacterUpdate u : updates) {
String id = u.getCharacterId();
CharacterState target = characters.stream()
.filter(c -> Objects.equals(c.getId(), id))
.findFirst()
.orElse(null);
if (target != null) {
if (u.getCurrentMood() != null) {
target.setCurrentMood(u.getCurrentMood());
}
if (u.getKnowledgeGained() != null && !u.getKnowledgeGained().isEmpty()) {
List<String> knowledge = target.getKnowledge();
if (knowledge == null) {
target.setKnowledge(new ArrayList<>(u.getKnowledgeGained()));
} else {
knowledge.addAll(u.getKnowledgeGained());
}
}
if (u.getRelationshipChanges() != null && !u.getRelationshipChanges().isEmpty()) {
Map<String, String> rels = target.getRelationships();
if (rels == null) {
target.setRelationships(new HashMap<>(u.getRelationshipChanges()));
} else {
rels.putAll(u.getRelationshipChanges());
}
}
}
}
}
private static CharacterResponseItem toCharacterResponseItem(final CharacterResponse r) {
CharacterResponseItem item = new CharacterResponseItem(
r.getCharacterId(),
toResponseTypeEnum(r.getType()));
item.setContent(r.getContent());
item.setAction(r.getAction());
item.setMoodAfter(r.getMoodAfter());
return item;
}
private static CharacterResponseItem.TypeEnum toResponseTypeEnum(final ResponseType t) {
if (t == null) {
return CharacterResponseItem.TypeEnum.ACTION;
}
return switch (t) {
case SPEECH -> CharacterResponseItem.TypeEnum.SPEECH;
case ACTION -> CharacterResponseItem.TypeEnum.ACTION;
case REACTION -> CharacterResponseItem.TypeEnum.REACTION;
};
}
private static de.neitzel.roleplay.fascade.model.SituationUpdate toApiSituationUpdate(final SituationUpdate u) {
de.neitzel.roleplay.fascade.model.SituationUpdate api = new de.neitzel.roleplay.fascade.model.SituationUpdate();
api.setCurrentScene(u.getCurrentScene());
api.setNewTimelineEntries(u.getNewTimelineEntries() != null ? new ArrayList<>(u.getNewTimelineEntries()) : null);
api.setWorldStateFlags(u.getWorldStateFlags() != null ? new HashMap<>(u.getWorldStateFlags()) : null);
if (u.getOpenThreadsChanges() != null) {
de.neitzel.roleplay.fascade.model.OpenThreadsChanges apiOtc = new de.neitzel.roleplay.fascade.model.OpenThreadsChanges();
apiOtc.setAdded(u.getOpenThreadsChanges().getAdded() != null ? new ArrayList<>(u.getOpenThreadsChanges().getAdded()) : null);
apiOtc.setResolved(u.getOpenThreadsChanges().getResolved() != null ? new ArrayList<>(u.getOpenThreadsChanges().getResolved()) : null);
api.setOpenThreadsChanges(apiOtc);
}
return api;
}
private static de.neitzel.roleplay.fascade.model.CharacterUpdate toApiCharacterUpdate(final CharacterUpdate u) {
de.neitzel.roleplay.fascade.model.CharacterUpdate api = new de.neitzel.roleplay.fascade.model.CharacterUpdate(u.getCharacterId());
api.setCurrentMood(u.getCurrentMood());
api.setKnowledgeGained(u.getKnowledgeGained() != null ? new ArrayList<>(u.getKnowledgeGained()) : null);
api.setRelationshipChanges(u.getRelationshipChanges() != null ? new HashMap<>(u.getRelationshipChanges()) : null);
return api;
}
private static de.neitzel.roleplay.fascade.model.Suggestion toApiSuggestion(final Suggestion s) {
if (s == null) {
return null;
}
de.neitzel.roleplay.fascade.model.Suggestion api = new de.neitzel.roleplay.fascade.model.Suggestion(
s.getId(), toSuggestionTypeEnum(s.getType()), s.getTitle());
api.setDescription(s.getDescription());
api.setConsequences(s.getConsequences() != null ? new ArrayList<>(s.getConsequences()) : null);
api.setRiskLevel(s.getRiskLevel() != null ? toRiskLevelEnum(s.getRiskLevel()) : null);
return api;
}
private static de.neitzel.roleplay.fascade.model.Suggestion.TypeEnum toSuggestionTypeEnum(final SuggestionType t) {
if (t == null) {
return de.neitzel.roleplay.fascade.model.Suggestion.TypeEnum.PLAYER_ACTION;
}
return switch (t) {
case PLAYER_ACTION -> de.neitzel.roleplay.fascade.model.Suggestion.TypeEnum.PLAYER_ACTION;
case WORLD_EVENT -> de.neitzel.roleplay.fascade.model.Suggestion.TypeEnum.WORLD_EVENT;
case NPC_ACTION -> de.neitzel.roleplay.fascade.model.Suggestion.TypeEnum.NPC_ACTION;
case TWIST -> de.neitzel.roleplay.fascade.model.Suggestion.TypeEnum.TWIST;
};
}
private static de.neitzel.roleplay.fascade.model.Suggestion.RiskLevelEnum toRiskLevelEnum(final RiskLevel r) {
if (r == null) {
return null;
}
return switch (r) {
case LOW -> de.neitzel.roleplay.fascade.model.Suggestion.RiskLevelEnum.LOW;
case MEDIUM -> de.neitzel.roleplay.fascade.model.Suggestion.RiskLevelEnum.MEDIUM;
case HIGH -> de.neitzel.roleplay.fascade.model.Suggestion.RiskLevelEnum.HIGH;
};
}
}

View File

@ -1,5 +1,8 @@
package de.neitzel.roleplay.business; package de.neitzel.roleplay.business;
import com.fasterxml.jackson.databind.ObjectMapper;
import de.neitzel.roleplay.common.StateUpdateResponse;
import de.neitzel.roleplay.fascade.OllamaClient;
import de.neitzel.roleplay.fascade.model.CharacterDefinition; import de.neitzel.roleplay.fascade.model.CharacterDefinition;
import de.neitzel.roleplay.fascade.model.CharacterState; import de.neitzel.roleplay.fascade.model.CharacterState;
import de.neitzel.roleplay.fascade.model.CreateSessionRequest; import de.neitzel.roleplay.fascade.model.CreateSessionRequest;
@ -12,6 +15,9 @@ import de.neitzel.roleplay.fascade.model.UpdateSessionRequest;
import de.neitzel.roleplay.fascade.model.UserActionRequest; import de.neitzel.roleplay.fascade.model.UserActionRequest;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
@ -20,23 +26,41 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.when;
/** /**
* Unit tests for {@link InMemorySessionService}. * Unit tests for {@link InMemorySessionService}.
*/ */
@ExtendWith(MockitoExtension.class)
class InMemorySessionServiceTest { class InMemorySessionServiceTest {
/** @Mock
* Instance under test no CDI dependencies to mock. private OllamaClient ollamaClient;
*/
private final ObjectMapper objectMapper = new ObjectMapper();
private InMemorySessionService sessionService; private InMemorySessionService sessionService;
/** /**
* Creates a fresh service instance before each test to ensure isolation. * Creates a fresh service instance with mocked Ollama client before each test.
* By default, Ollama is stubbed to return a short narrative and an empty
* state update so that createSession (with scenario) and submitTurn complete.
*/ */
@BeforeEach @BeforeEach
void setUp() { void setUp() {
sessionService = new InMemorySessionService(); sessionService = new InMemorySessionService(ollamaClient, objectMapper);
StateUpdateResponse emptyUpdate = StateUpdateResponse.builder()
.responses(null)
.updatedSituation(null)
.updatedCharacters(null)
.suggestions(null)
.build();
lenient().when(ollamaClient.generateNarrative(anyString(), anyString(), anyString()))
.thenReturn("A short narrative.");
lenient().when(ollamaClient.generateStateUpdate(anyString(), anyString(), anyString()))
.thenReturn(emptyUpdate);
} }
/** /**