diff --git a/docs/OPEN_TASKS_PLAN.md b/docs/OPEN_TASKS_PLAN.md
new file mode 100644
index 0000000..6398af8
--- /dev/null
+++ b/docs/OPEN_TASKS_PLAN.md
@@ -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.
diff --git a/src/main/java/de/neitzel/roleplay/business/InMemorySessionService.java b/src/main/java/de/neitzel/roleplay/business/InMemorySessionService.java
index e77fc59..af11552 100644
--- a/src/main/java/de/neitzel/roleplay/business/InMemorySessionService.java
+++ b/src/main/java/de/neitzel/roleplay/business/InMemorySessionService.java
@@ -1,5 +1,9 @@
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.CharacterState;
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.UpdateSessionRequest;
import jakarta.enterprise.context.ApplicationScoped;
+import jakarta.inject.Inject;
import java.util.ArrayList;
import java.util.List;
@@ -23,32 +28,48 @@ import java.util.concurrent.ConcurrentHashMap;
* {@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.
+ *
Session creation and turn submission use the two-call Ollama pattern
+ * (narrative then state extraction) when scenario or turn data is provided.
*/
@ApplicationScoped
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.
*/
private final Map 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}
*
* 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.
+ * metadata, and stores the session. When a scenario is provided, runs the
+ * two-call Ollama pattern to produce an opening narrative and initial state.
*/
@Override
public SessionResponse createSession(final CreateSessionRequest request) {
String sessionId = UUID.randomUUID().toString();
+ String model = request.getModel();
SessionResponse session = new SessionResponse(
sessionId,
- request.getModel(),
+ model,
request.getLanguage() != null ? request.getLanguage() : "en",
request.getSafetyLevel() != null
? request.getSafetyLevel().value()
@@ -56,13 +77,21 @@ public class InMemorySessionService implements SessionService {
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) {
session.setSituation(buildSituationFromScenario(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);
@@ -155,8 +184,9 @@ public class InMemorySessionService implements SessionService {
/**
* {@inheritDoc}
*
- *
Increments the turn counter and returns a stub {@link TurnResponse}.
- * The Ollama two-call pattern is not yet invoked.
+ *
Increments the turn counter, runs the two-call Ollama pattern with
+ * the current session state and turn request, merges the state update into
+ * the session, and returns the turn response.
*/
@Override
public Optional submitTurn(final String sessionId,
@@ -166,20 +196,23 @@ public class InMemorySessionService implements SessionService {
return Optional.empty();
}
- // Increment turn counter
int nextTurn = session.getTurnNumber() + 1;
session.setTurnNumber(nextTurn);
+ String model = session.getModel();
- // 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);
+ try {
+ String contextJson = objectMapper.writeValueAsString(OllamaContextBuilder.forTurn(session, turnRequest));
+ String narrative = ollamaClient.generateNarrative(model, OllamaPrompts.TURN_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);
+ TurnResponse response = StateUpdateMapper.toTurnResponse(nextTurn, narrative, stateUpdate);
+ sessions.put(sessionId, session);
+ return Optional.of(response);
+ } catch (JsonProcessingException e) {
+ throw new OllamaParseException("Failed to serialize turn context for Ollama", e);
+ }
}
}
diff --git a/src/main/java/de/neitzel/roleplay/business/OllamaContextBuilder.java b/src/main/java/de/neitzel/roleplay/business/OllamaContextBuilder.java
new file mode 100644
index 0000000..96e439c
--- /dev/null
+++ b/src/main/java/de/neitzel/roleplay/business/OllamaContextBuilder.java
@@ -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 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 characters) {
+ if (characters == null || characters.isEmpty()) {
+ return CharacterSet.builder()
+ .userCharacter(null)
+ .aiCharacters(Collections.emptyList())
+ .build();
+ }
+ CharacterSnapshot userCharacter = null;
+ List 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 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();
+ }
+}
diff --git a/src/main/java/de/neitzel/roleplay/business/OllamaPrompts.java b/src/main/java/de/neitzel/roleplay/business/OllamaPrompts.java
new file mode 100644
index 0000000..57edcc8
--- /dev/null
+++ b/src/main/java/de/neitzel/roleplay/business/OllamaPrompts.java
@@ -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"
+ + "}";
+}
diff --git a/src/main/java/de/neitzel/roleplay/business/StateUpdateMapper.java b/src/main/java/de/neitzel/roleplay/business/StateUpdateMapper.java
new file mode 100644
index 0000000..391f247
--- /dev/null
+++ b/src/main/java/de/neitzel/roleplay/business/StateUpdateMapper.java
@@ -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 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 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 flags = situation.getWorldStateFlags();
+ if (flags == null) {
+ situation.setWorldStateFlags(new HashMap<>(update.getWorldStateFlags()));
+ } else {
+ flags.putAll(update.getWorldStateFlags());
+ }
+ }
+ }
+
+ private static void mergeCharacters(final List characters,
+ final List 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 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 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;
+ };
+ }
+}
diff --git a/src/test/java/de/neitzel/roleplay/business/InMemorySessionServiceTest.java b/src/test/java/de/neitzel/roleplay/business/InMemorySessionServiceTest.java
index 48cce78..0e6c335 100644
--- a/src/test/java/de/neitzel/roleplay/business/InMemorySessionServiceTest.java
+++ b/src/test/java/de/neitzel/roleplay/business/InMemorySessionServiceTest.java
@@ -1,5 +1,8 @@
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.CharacterState;
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 org.junit.jupiter.api.BeforeEach;
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.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.assertNotNull;
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}.
*/
+@ExtendWith(MockitoExtension.class)
class InMemorySessionServiceTest {
- /**
- * Instance under test – no CDI dependencies to mock.
- */
+ @Mock
+ private OllamaClient ollamaClient;
+
+ private final ObjectMapper objectMapper = new ObjectMapper();
+
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
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);
}
/**