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); } /**