Translation Layer
Overview
The translation layer converts a player’s out-of-character (OOC) message into in-character (IC) dialogue before it is stored in the chat log. It is rendered by a locally-hosted language model (Ollama) using a character-specific system prompt built from the character’s current axis scores.
Design principles:
Non-authoritative — the layer cannot change axis scores or any other game state. It is flavour text, not mechanics.
Gracefully degrading — any failure (Ollama unavailable, validation error, missing character profile) returns
Noneand the engine stores the original OOC message unmodified.Ledger-linked — every call emits a
chat.translationevent to the JSONL ledger (success and failure alike), carrying the sameipc_hashproduced by the axis engine for the same turn.
See JSONL Ledger for the full ledger event format and Axis State System
for the axis engine that produces the ipc_hash.
Configuration
Translation is configured per-world in world.json:
{
"translation_layer": {
"enabled": true,
"model": "gemma2:2b",
"ollama_base_url": "http://localhost:11434",
"timeout_seconds": 10.0,
"strict_mode": true,
"max_output_chars": 280,
"prompt_policy_id": "prompt:translation.prompts.ic:default",
"active_axes": ["demeanor", "health", "physique",
"wealth", "facial_signal"],
"deterministic": false
}
}
Field reference:
Field |
Description |
|---|---|
|
Must be |
|
Ollama model tag (e.g. |
|
Ollama API base URL. Default |
|
Request timeout for Ollama API calls. Default 10.0. |
|
If |
|
Maximum length of the validated IC output. Longer strings are rejected. Default 280. |
|
Authoritative runtime selector for prompt content from canonical policy DB activation state. |
|
List of axis names included in the character profile dict. Other axes are excluded from the system prompt context. |
|
If |
Legacy prompt_template_path values in older world.json files are
ignored by the runtime and should be removed during world package cleanup.
There is also a server-level master switch in config/server.ini:
[ollama_translation]
enabled = true
Setting this to false disables translation for all worlds
regardless of their individual world.json settings.
Translation Pipeline
OOCToICTranslationService.translate(character_name, ooc_message, *, channel, ipc_hash)
executes the following pipeline:
1. Build character axis profile (DB lookup via CharacterProfileBuilder)
└── Returns None → no ledger event, return None (no profile data)
2. Arm deterministic mode if ipc_hash is set and config.deterministic
└── seed = int(ipc_hash[:16], 16) → OllamaRenderer.set_deterministic(seed)
3. Inject channel into profile dict ("say" | "yell" | "whisper")
4. Resolve effective prompt policy from DB activation, then render system prompt
└── {{key}} substitution from profile dict
└── {{ooc_message}} substituted last
5. Call Ollama /api/chat (synchronous HTTP via requests)
└── Failure → emit "fallback.api_error" ledger event → return None
6. Validate raw output (OutputValidator)
├── PASSTHROUGH sentinel → emit "fallback.validation_failed" → return None
├── Empty string → emit "fallback.validation_failed" → return None
└── Exceeds max_output_chars → emit "fallback.validation_failed" → return None
7. Emit "success" chat.translation ledger event
└── Carries ipc_hash (may be None in pre-axis-engine era)
Return: IC text string (or None on any failure)
The caller (GameEngine.chat/yell/whisper) uses the returned text as
the stored message; if None, the OOC message is stored unchanged.
System Prompt Template
The active template content comes from the effective canonical
prompt:* policy activation for the request scope. The template uses
{{key}} placeholders substituted from the character profile dict.
Available placeholders include all fields produced by
CharacterProfileBuilder, plus {{channel}} injected by the
service and {{ooc_message}} substituted last.
Built-in placeholders:
Placeholder |
Source |
|---|---|
|
Character’s name from DB |
|
Axis float score (0.0–1.0) |
|
Axis label (e.g. |
|
Same pattern for each axis in |
|
Same pattern for each axis in |
|
|
|
The raw OOC input from the player |
|
Pre-formatted axis summary block (if the template uses it) |
If canonical prompt resolution fails at runtime, the service falls back to a built-in minimal template and logs a WARNING.
Canonical Prompt Management
Canonical prompt authoring and activation now run through policy APIs
(/api/policies + /api/policy-activations). Lab route surface is
diagnostic/compile-oriented and does not expose file draft/promote endpoints.
See Axis Descriptor Lab Integration for the current DB-first lab workflow.
Example template content:
You are a narrative rendering engine for a text-based role-playing game
set in the Undertaking — a ledger-driven, procedural city where every
transaction is recorded and failure is carved in stone. Your sole
function is to translate the user's out-of-character (OOC) message into
a single line of in-character (IC) dialogue.
CHARACTER PROFILE (current state):
{{profile_summary}}
Delivery Mode: {{channel}}
TRANSLATION RULES (non-negotiable):
1. Output exactly one line of spoken dialogue. No stage directions.
2. The dialogue must reflect the character's profile.
3. The city register matters: gritty, transactional, exhausted.
4. Adjust for delivery mode: "say" is direct; "yell" is raw and
urgent; "whisper" is conspiratorial and clipped.
5. If the message cannot be rendered as IC dialogue, output only
the word: PASSTHROUGH
PASSTHROUGH Sentinel
If the model outputs only the word PASSTHROUGH (case-insensitive
by default), the validator treats it as a signal that the OOC message
cannot be rendered as IC dialogue (e.g. a game command, meta-question,
or pure punctuation).
In strict_mode (the default):
PASSTHROUGH→validate()returnsNoneA
"fallback.validation_failed"ledger event is emittedThe engine falls back to the original OOC message
In lenient mode (strict_mode: false):
PASSTHROUGHis returned as-is to the caller
Character Profile Builder
CharacterProfileBuilder
builds the flat dict injected into the system prompt.
It performs a world-scoped DB lookup for the character’s current axis scores, resolves labels using the world’s threshold policy, and returns a dict of the form:
{
"character_name": "Mira Voss",
"demeanor_score": 0.87,
"demeanor_label": "proud",
"health_score": 0.72,
"health_label": "hale",
...
}
Only axes listed in active_axes (from world.json) are included.
Axis Snapshot in Ledger Events
The chat.translation ledger event includes an axis_snapshot
field derived from the character profile at translation time. This
captures the character’s mechanical state before any axis mutations
applied during the same turn:
"axis_snapshot": {
"demeanor": {"score": 0.87, "label": "proud"},
"health": {"score": 0.72, "label": "hale"}
}
Because the axis engine runs before translation, the snapshot
reflects scores that are already post-mutation for this turn. For the
pre-mutation snapshot, see axis_snapshot_before in the
chat.mechanical_resolution event.
IPC Hash and Deterministic Mode
When the axis engine is active, GameEngine.chat/yell/whisper calls
the axis engine first and retrieves an ipc_hash:
result = axis_engine.resolve_chat_interaction(
speaker_name=username,
listener_name=co_present[0],
channel="say",
world_id=world_id,
)
ipc_hash = result.ipc_hash # e.g. "a3f91c9e..."
This hash is passed to translate():
ic_text = translation_service.translate(
character_name=username,
ooc_message=message,
channel="say",
ipc_hash=ipc_hash,
)
Inside translate():
The hash is embedded in the
chat.translationledger event.If
config.deterministic = True, the first 16 hex characters are converted to an integer seed for Ollama’soptions.seedparameter, making the model output reproducible given the same mechanical state.
If the axis engine is disabled or fails, ipc_hash is None and
deterministic mode is silently skipped.
Per-World Enable/Disable
{"translation_layer": {"enabled": true}} ← pipeworks_web (test world)
{"translation_layer": {"enabled": false}} ← daily_undertaking (production)
The service is instantiated lazily at world load time.
world.get_translation_service() returns None when disabled.
Hardening Notes
Current PoC trade-offs:
Translation is synchronous — the Ollama HTTP call blocks the request thread. An async upgrade path is documented in
translation/renderer.py.Ledger write failure is non-fatal (same as the axis engine).
No retry logic for transient Ollama errors.