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:
parent
f21f1e7520
commit
b79334ee67
155
docs/OPEN_TASKS_PLAN.md
Normal file
155
docs/OPEN_TASKS_PLAN.md
Normal 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.
|
||||||
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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"
|
||||||
|
+ "}";
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user