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 None and the engine stores the original OOC message unmodified.

  • Ledger-linked — every call emits a chat.translation event to the JSONL ledger (success and failure alike), carrying the same ipc_hash produced 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

enabled

Must be true to activate the layer. Default false.

model

Ollama model tag (e.g. "gemma2:2b").

ollama_base_url

Ollama API base URL. Default "http://localhost:11434".

timeout_seconds

Request timeout for Ollama API calls. Default 10.0.

strict_mode

If true, the PASSTHROUGH sentinel causes the call to return None. If false, PASSTHROUGH is passed through as-is.

max_output_chars

Maximum length of the validated IC output. Longer strings are rejected. Default 280.

prompt_policy_id

Authoritative runtime selector for prompt content from canonical policy DB activation state.

active_axes

List of axis names included in the character profile dict. Other axes are excluded from the system prompt context.

deterministic

If true and an ipc_hash is available, the renderer is seeded with int(ipc_hash[:16], 16) for reproducible output.

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_name}}

Character’s name from DB

{{demeanor_score}}

Axis float score (0.0–1.0)

{{demeanor_label}}

Axis label (e.g. "proud", "guarded")

{{health_score}}

Same pattern for each axis in active_axes

{{health_label}}

Same pattern for each axis in active_axes

{{channel}}

"say", "yell", or "whisper"

{{ooc_message}}

The raw OOC input from the player

{{profile_summary}}

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):

  • PASSTHROUGHvalidate() returns None

  • A "fallback.validation_failed" ledger event is emitted

  • The engine falls back to the original OOC message

In lenient mode (strict_mode: false):

  • PASSTHROUGH is 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.translation ledger event.

  • If config.deterministic = True, the first 16 hex characters are converted to an integer seed for Ollama’s options.seed parameter, 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.