diff --git a/CHANGELOG.md b/CHANGELOG.md index 82e9bd0..2328503 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,11 +7,14 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi ## [Unreleased] ### Added +- **Character info is translated to English before image prompts are built** (`core/prompt_translation.py`, `_english_prompt_inputs`). Free-text sheet fields (species, appearance, clothing, age, props, story-moment quote) and the book setting are translated in one batched, low-temperature call through the app's existing LLM, validated per field (non-empty, no error echo, length, names preserved, not still German), and cached in the new `prompt_translation_cache` table keyed by source hash, language, model and prompt version. Records and the displayed German text never change; species/age/dwarf/period checks now run on English. If the LLM is down or output is rejected the original text is used, a warning is logged, `prompt_translation.used_untranslated_text` is reported and generation continues. `PROMPT_TRANSLATION=off` disables it. New read-only dry run `POST /api/character-prompt-preview`. See architecture/character-visual-identity.md. - **Record/replay cache for vision OCR responses** (`core/vision_cache.py`, `MAGAZINE_VISION_CACHE=live|record|replay`). Freezes the raw model response per (model, prompt, image, parameters) in a git-ignored cache directory so OCR/layout/post-processing changes can be compared on identical vision output; replay fails loudly on a miss. `bench_ocr.py --vision-cache` and `compare_magazines.py` (`--baseline-vision-run`, `--new-vision-run`) report mode and models and say whether a comparison is frozen. See architecture/magazine-ingestion.md. ### Changed - `vision_ocr_page` routes its request through the cache layer; the default `live` mode behaves exactly as before. ### Fixed +- **A boy rendered as an adult:** `_character_age_category` read `age`, but sheets store `age_estimate`. +- **German "hat" (has) became a hood:** "hat dünne Arme" turned into "soft wool hood" in the prompt; `_period_wording` now only maps English "hat" and leaves German text alone. - **Dwarf body shape and beard no longer leak onto human characters' images.** The dwarf body lock ("one adult dwarf", short legs, thick full beard) was switched on by the word "Zwerg" anywhere in the species field, including extraction notes about other characters. In the real library this hit Marrad (a thin human boy, "Mensch (oder Zwerg, je nach Kontext …)") and Kolon and Arthag in book 02 ("Mensch (… 'Der Zwerg' wird separat erwähnt … Warte: 'Der Zwerg lächelte grimmig' …)"), whose prompt Subject also carried that sentence about another character. Species now comes only from the field's own leading assertion: long or quoting bracket notes are dropped, alternatives ("Mensch oder Zwerg") never count as a dwarf, and a *visual species* override is honoured for the body lock too. Seven of 270 records change; tests in `tests/test_character_species_isolation.py`. --- diff --git a/Manual.md b/Manual.md index a61eab5..b70170b 100644 --- a/Manual.md +++ b/Manual.md @@ -104,6 +104,29 @@ ComfyUI; nothing leaves your network. Without a reachable ComfyUI nothing is gen as its alternative text; the character's written profile next to it carries the same information. +### English prompts for character images + +**Purpose:** the image models work best with English, but character sheets from German books are German. +Before a profile or reference-sheet prompt is built, the app translates the character's text (species, +appearance, clothing, age, items, story moment, the book's setting) to English with your own LLM. + +**Prerequisites:** the LLM from Settings. Without it the original German text is used. + +**Usage:** nothing to do. Generate prompts or images as before. Your character sheet keeps its German text +and is never changed; translations are cached, so a character is translated once and again only after its +text changes. To inspect the result without generating anything, call `POST /api/character-prompt-preview` +with a character `id`. + +**Expected result:** English prompts; the response and the finished image job contain `prompt_translation`. + +**Errors:** if the LLM is unreachable or its answer fails validation, the original text is used for the +affected fields, a warning is logged, `prompt_translation.used_untranslated_text` is `true`, and generation +continues. Set `PROMPT_TRANSLATION=off` to disable translation entirely. + +**Mobile, accessibility, offline:** no interface change. Offline (no LLM) falls back to German text. + +**Privacy:** character text goes only to the LLM you configured; cached translations stay in the local database. + --- ## Magazines diff --git a/architecture/character-visual-identity.md b/architecture/character-visual-identity.md index 588735a..e315c6c 100644 --- a/architecture/character-visual-identity.md +++ b/architecture/character-visual-identity.md @@ -174,3 +174,38 @@ the face but not the hair, and the same-person check failed on all three sheet a description, no change. Open: robes can still come out with ragged hems; the story-moment prompt is still long. + + +## English prompt inputs (2026-10-03) + +Sheets are extracted from German books, so their free text is mostly German. Image models do best with English, +and German leaked into keyword logic ("hat dünne Arme", "has thin arms", became a hood; "Mensch (oder Zwerg …)" +confused species checks). Every image-prompt builder therefore reads an **English copy** of the free text. + +* **Layer:** `core/prompt_translation.py` (no network/DB code; the LLM is injected). Wiring: + `_english_prompt_inputs` in `routes/conversation.py`, called by `/api/character-generate-prompts` and + `/api/character-generate-consistent-images` *before* the builders and before species/age/dwarf/period logic run. +* **Fields:** species, appearance, clothing, age, title, profession, archetype, hair/eyes/build/marks, era, + mannerisms/capabilities, props/inventory, visual overrides, the chosen story-moment quote (as `visual_scene`) + and the book profile (genre, setting, era, visual context). Names and aliases are never translated. +* **Detection:** cheap stop-word/vocabulary heuristic; English or empty text is skipped without an LLM call. +* **Translation:** one batched JSON call per character through the app's existing LLM endpoint (`llm_url`, + model `prompt_translation_model` or `llm_model`; same retry/backoff helper), temperature 0.1, strict prompt + (faithful, keep names, no added or dropped traits, JSON only). +* **Validation (per field):** non-empty, not an error/refusal, length sanity, not an unchanged German echo, + not still German, names present in the output when present in the source, list shape kept. A rejected field + keeps its original text; the other fields still use their translation. +* **Cache:** table `prompt_translation_cache` in the existing SQLite DB (derived data, separate from + `characters`), key = `sha256(source)|lang|model|prompt version`. Editing the source text, switching the + model or bumping `PROMPT_VERSION` misses automatically. Records are never rewritten; the UI keeps showing + the German source of truth. The identity fingerprint and the image checks keep using the original sheet. +* **Fallback:** LLM down, invalid output or flag off: the original text is used (the German keyword rules + stay as the fallback), a warning is logged, and the response carries `prompt_translation` + (`used_untranslated_text`, `translated_fields`, `untranslated_fields`, `cache_hits`, `error`). Image + generation is never blocked. +* **Flag:** env `PROMPT_TRANSLATION=off` (or settings key `prompt_translation: false`); default on. +* **Dry run:** `POST /api/character-prompt-preview` with `{id}` or `{name, book, sheet, book_profile}` returns the + prompts built from the original and from the English text plus the changed fields; it generates nothing and + writes no record (only the translation cache). +* **Side fixes:** `_character_age_category` now reads `age_estimate` (sheets store that key; a boy used to render + adult); `_period_wording` no longer turns German "hat" (has) into a hood. diff --git a/core/database.py b/core/database.py index d777e0a..b4e4609 100644 --- a/core/database.py +++ b/core/database.py @@ -125,6 +125,15 @@ CREATE TABLE IF NOT EXISTS voice_index ( ); CREATE INDEX IF NOT EXISTS idx_voice_index_enabled ON voice_index(enabled); CREATE INDEX IF NOT EXISTS idx_voice_index_sort_key ON voice_index(sort_key); + +-- Derived data only: English versions of character text for image prompts. +-- Keyed by sha256(source)|lang|model|prompt-version, so editing the source text +-- (or changing the translator) simply misses; character records are never touched. +CREATE TABLE IF NOT EXISTS prompt_translation_cache ( + cache_key TEXT PRIMARY KEY, + value TEXT NOT NULL, + created TEXT NOT NULL +); """ @@ -186,6 +195,21 @@ def _now() -> str: return datetime.now(timezone.utc).isoformat() +def prompt_translation_cache_get(key: str) -> Any | None: + with _open() as conn: + row = conn.execute("SELECT value FROM prompt_translation_cache WHERE cache_key=?", (key,)).fetchone() + return json.loads(row["value"]) if row else None + + +def prompt_translation_cache_put(key: str, value: Any) -> None: + with _open() as conn: + conn.execute( + "INSERT OR REPLACE INTO prompt_translation_cache(cache_key, value, created) VALUES (?, ?, ?)", + (key, _j(value), _now()), + ) + conn.commit() + + def generation_history_add(*, kind: str, target_id: str = "", prompt: str = "", provider: str = "", status: str = "completed", result: Any = None) -> int: diff --git a/core/prompt_translation.py b/core/prompt_translation.py new file mode 100644 index 0000000..da92d0a --- /dev/null +++ b/core/prompt_translation.py @@ -0,0 +1,331 @@ +"""Translate character info to English before it reaches an image-prompt builder. + +Character sheets are extracted from German books, so their free-text fields are +mostly German. The image models work best with English, and German leaks into +keyword logic ("hat dünne Arme" = "has thin arms" became a hat). This module is +the single translation layer: + +* ``looks_german`` / ``needs_translation``: cheap, dependency-free detection. +* ``translate_fields``: one batched LLM call (JSON in, JSON out) for every field + of a character, with per-field validation, per-field fallback to the original + text, and a persistent cache keyed by (sha256 of the source, target language, + translator model, prompt version). The source records are never modified. +* The LLM is injected as a ``chat`` callable, so this module has no network or + database code of its own and tests never call a real model. + +Never raises: every failure degrades to the untranslated original, reported in +``TranslationResult`` so the UI can say the prompt used untranslated text. +""" +from __future__ import annotations + +import hashlib +import json +import logging +import os +import re +from dataclasses import dataclass, field +from typing import Any, Callable, Protocol + +logger = logging.getLogger(__name__) + +PROMPT_VERSION = "v1" +TARGET_LANG = "en" +ENV_FLAG = "PROMPT_TRANSLATION" # "off"/"0"/"false"/"no" disables; default on +MAX_BATCH_CHARS = 12000 + +ChatFn = Callable[[list, int], str] + + +class TranslationCache(Protocol): + def get(self, key: str) -> Any | None: ... + def put(self, key: str, value: Any) -> None: ... + + +class MemoryCache: + """In-process cache (tests, and a fallback when the database is unavailable).""" + + def __init__(self) -> None: + self.data: dict[str, Any] = {} + + def get(self, key: str) -> Any | None: + return self.data.get(key) + + def put(self, key: str, value: Any) -> None: + self.data[key] = value + + +def translation_enabled(settings: dict | None = None) -> bool: + """Default ON; the env var (or a settings key ``prompt_translation``) turns it off.""" + raw = os.environ.get(ENV_FLAG) + if raw is None and isinstance(settings, dict): + raw = settings.get("prompt_translation") + if raw is None: + return True + return str(raw).strip().lower() not in {"off", "0", "false", "no", "disabled"} + + +# ── language detection ───────────────────────────────────────────────────────── +_GERMAN_STOPWORDS = frozenset( + "der die das den dem des ein eine einen einem einer eines und oder aber nicht kein keine keinen " + "ist sind war waren wird werden wurde wurden sein ihr ihre ihren seine seinen sein er sie es wir " + "mit von zu zum zur bei nach aus für auf über unter vor hinter zwischen durch gegen ohne um " + "als wie auch sehr noch nur dass weil wenn dann hier dort mehr ihm ihn sich sein trägt tragen " + "hat hatte haben hatten".split() +) - {"hat", "haben", "um", "war", "sein"} # collide with English words or are too weak +# Words that are German-only in practice (kept apart from stop words that collide with English). +_GERMAN_VOCAB_WORDS = frozenset( + "mensch zwerg ork elf elfe mann frau junge mädchen kind bart haar haare augen arme arm beine " + "kleidung umhang mantel hemd hose stiefel rüstung kettenhemd schwert dolch axt bogen schild " + "alt jung groß klein dünn dick stark schwach schlank breit grau rot schwarz weiß braun blond " + "männlich weiblich krieger ritter könig königin hauptmann jahre jahr wolle leder".split() +) +_GERMAN_STEMS = ("zwerg", "mensch", "kleid", "umhang", "rüstung", "kettenhemd", "krieger") +_ENGLISH_STOPWORDS = frozenset( + "the a an and or but not no is are was were be been being has have had with without of to in on at " + "for from by his her their its he she they it we you who whom which that this these those very " + "wears wearing worn over under between through about as than then there here".split() +) +_UMLAUT_RE = re.compile(r"[äöüÄÖÜß]") +_WORD_RE = re.compile(r"[A-Za-zÄÖÜäöüß]+") + + +def _german_score(text: str) -> int: + words = [w.lower() for w in _WORD_RE.findall(text or "")] + score = sum(1 for w in words if w in _GERMAN_STOPWORDS or w in _GERMAN_VOCAB_WORDS) + score += sum(1 for w in words if len(w) >= 6 and any(stem in w for stem in _GERMAN_STEMS)) + score += 2 * len(_UMLAUT_RE.findall(text or "")) + return score + + +def _english_score(text: str) -> int: + return sum(1 for w in _WORD_RE.findall(text or "") if w.lower() in _ENGLISH_STOPWORDS) + + +def looks_german(text: str) -> bool: + """True when the text carries German markers and is not clearly English.""" + text = str(text or "") + german = _german_score(text) + if german == 0: + return False + # An English sentence with one German proper name/umlaut stays English. + return german > _english_score(text) or german >= 3 and _english_score(text) < german * 2 + + +def needs_translation(text: Any) -> bool: + """Skip empty text, numbers, and text that is already English.""" + if not isinstance(text, str): + return False + stripped = text.strip() + if not stripped or not re.search(r"[A-Za-zÄÖÜäöüß]", stripped): + return False + return looks_german(stripped) + + +# ── prompt ───────────────────────────────────────────────────────────────────── +_SYSTEM_PROMPT = ( + "You are a precise translator inside an image-generation pipeline. The input is a JSON object whose " + "values are short texts about a fictional book character, usually German. Translate every value into " + "natural English.\n" + "Rules:\n" + "- Translate faithfully and keep the visual meaning. Do NOT add, drop, soften, explain or reorder traits.\n" + "- Keep proper names (people, places, peoples, ships, titles that are part of a name) exactly as written.\n" + "- Read grammar, not look-alike words: German 'hat' is usually the verb 'has' (\"hat dünne Arme\" = " + "\"has thin arms\"), 'Hut' is a hat.\n" + "- Species words: Mensch = human, Zwerg = dwarf, Ork = orc, Elf = elf, Kind = child. Keep brackets, " + "quotes and punctuation structure.\n" + "- A value that is already English stays unchanged. A value that is a JSON array stays an array with " + "the same number of items, each translated in place.\n" + "- Output ONLY one JSON object with exactly the same keys. No markdown, no comments, no explanations." +) + +_ERROR_MARKERS = re.compile( + r"(as an ai|i cannot|i can't|i'm sorry|i am sorry|unable to (?:translate|comply)|" + r"error:|traceback|internal server error|bad gateway|model (?:not found|is loading))", + re.IGNORECASE, +) + + +def _hash_source(value: Any) -> str: + raw = value if isinstance(value, str) else json.dumps(value, ensure_ascii=False, sort_keys=True) + return hashlib.sha256(raw.encode("utf-8")).hexdigest() + + +def cache_key(source: Any, model: str, lang: str = TARGET_LANG, version: str = PROMPT_VERSION) -> str: + return f"{_hash_source(source)}|{lang}|{model or '-'}|{version}" + + +# ── validation ───────────────────────────────────────────────────────────────── +def _name_tokens(names: list[str]) -> list[str]: + out: list[str] = [] + for name in names or []: + name = str(name or "").strip() + if len(name) >= 3: + out.append(name) + out.extend(tok for tok in re.split(r"[\s,;]+", name) if len(tok) >= 4 and tok[:1].isupper()) + return list(dict.fromkeys(out)) + + +def validate_translation(source: str, result: Any, names: list[str] | None = None) -> str | None: + """Return None when the translation is acceptable, else a short reason.""" + if not isinstance(result, str) or not result.strip(): + return "empty" + out = result.strip() + if _ERROR_MARKERS.search(out) and not _ERROR_MARKERS.search(source): + return "looks like an error or refusal" + low_src, low_out = len(source.strip()), len(out) + if low_out > low_src * 3 + 40 or low_out * 4 + 20 < low_src: + return "length out of range" + if out == source.strip() and _german_score(source) >= 2: + return "unchanged German" + if _german_score(out) >= 3 and _german_score(out) > _english_score(out): + return "still German" + for name in _name_tokens(names or []): + if re.search(re.escape(name), source, re.IGNORECASE) and name.casefold() not in out.casefold(): + return f"name not preserved: {name}" + return None + + +def _validate_value(source: Any, result: Any, names: list[str]) -> str | None: + if isinstance(source, list): + if not isinstance(result, list) or len(result) != len(source): + return "list shape mismatch" + for src_item, out_item in zip(source, result): + if needs_translation(src_item): + reason = validate_translation(str(src_item), out_item, names) + if reason: + return reason + elif not isinstance(out_item, str) or not out_item.strip(): + return "empty item" + return None + return validate_translation(str(source), result, names) + + +# ── parsing ──────────────────────────────────────────────────────────────────── +def parse_json_object(raw: str) -> dict | None: + text = re.sub(r".*?", "", raw or "", flags=re.DOTALL).strip() + text = re.sub(r"^```(?:json)?\s*|\s*```$", "", text).strip() + candidates = [text] + start, end = text.find("{"), text.rfind("}") + if 0 <= start < end: + candidates.append(text[start:end + 1]) + for candidate in candidates: + try: + parsed = json.loads(candidate) + except Exception: + continue + if isinstance(parsed, dict): + return parsed + return None + + +# ── main entry point ─────────────────────────────────────────────────────────── +@dataclass +class TranslationResult: + values: dict[str, Any] + translated: list[str] = field(default_factory=list) # fields now in English (fresh or cached) + untranslated: list[str] = field(default_factory=list) # German fields that kept the original text + cache_hits: int = 0 + skipped: list[str] = field(default_factory=list) # already English / empty + enabled: bool = True + error: str = "" + + @property + def used_untranslated(self) -> bool: + return bool(self.untranslated) + + def info(self) -> dict: + return { + "enabled": self.enabled, + "used_untranslated_text": self.used_untranslated, + "translated_fields": list(self.translated), + "untranslated_fields": list(self.untranslated), + "cache_hits": self.cache_hits, + "error": self.error, + } + + +def translate_fields(fields: dict[str, Any], *, chat: ChatFn | None, model: str = "", + cache: TranslationCache | None = None, names: list[str] | None = None, + enabled: bool = True) -> TranslationResult: + """Return English versions of ``fields`` (str or list[str] values); never raises.""" + values = dict(fields) + result = TranslationResult(values=values, enabled=enabled) + todo: dict[str, Any] = {} + for key, value in fields.items(): + wants = (any(needs_translation(v) for v in value) if isinstance(value, list) else needs_translation(value)) + if not wants: + result.skipped.append(key) + else: + todo[key] = value + if not todo: + return result + if not enabled: + result.untranslated = list(todo) + return result + names = list(names or []) + cache = cache if cache is not None else MemoryCache() + + pending: dict[str, Any] = {} + for key, value in todo.items(): + try: + hit = cache.get(cache_key(value, model)) + except Exception as exc: # a broken cache must never block prompts + logger.warning("prompt translation cache read failed: %s", exc) + hit = None + if hit is not None and _validate_value(value, hit, names) is None: + values[key] = hit + result.translated.append(key) + result.cache_hits += 1 + else: + pending[key] = value + if not pending: + return result + if chat is None: + result.untranslated = list(pending) + result.error = "no translation LLM configured" + return result + + # Bounded batches keep one call inside the model's context. + batches: list[dict[str, Any]] = [{}] + size = 0 + for key, value in pending.items(): + length = len(json.dumps(value, ensure_ascii=False)) + if batches[-1] and size + length > MAX_BATCH_CHARS: + batches.append({}) + size = 0 + batches[-1][key] = value + size += length + + for batch in batches: + total = sum(len(json.dumps(v, ensure_ascii=False)) for v in batch.values()) + messages = [ + {"role": "system", "content": _SYSTEM_PROMPT}, + {"role": "user", "content": json.dumps(batch, ensure_ascii=False)}, + ] + try: + raw = chat(messages, min(3072, max(400, total))) + parsed = parse_json_object(raw) + if parsed is None: + raise ValueError("translator did not return a JSON object") + except Exception as exc: + logger.warning("prompt translation failed (%s); using original text", exc) + result.error = str(exc)[:200] + result.untranslated.extend(batch) + continue + for key, source in batch.items(): + candidate = parsed.get(key) + reason = _validate_value(source, candidate, names) + if reason: + logger.warning("prompt translation of %r rejected (%s); using original text", key, reason) + result.untranslated.append(key) + result.error = result.error or f"{key}: {reason}" + continue + if isinstance(candidate, str): + candidate = candidate.strip() + values[key] = candidate + result.translated.append(key) + try: + cache.put(cache_key(source, model), candidate) + except Exception as exc: + logger.warning("prompt translation cache write failed: %s", exc) + return result diff --git a/routes/conversation.py b/routes/conversation.py index 2b06429..93571bb 100644 --- a/routes/conversation.py +++ b/routes/conversation.py @@ -8,6 +8,7 @@ import copy import hashlib import io import json +import logging import re import threading import time @@ -35,6 +36,7 @@ from core.voice import ( from core.voice_index import rebuild_voice_index from core.database import background_job_create, background_job_get, background_job_store_artifact, background_job_update from core.retry import request_with_retries +from core import prompt_translation as _pt from core.character_image_check import checker_unavailable, failure_count, summarise from core.attribution_spans import ( SpanRequestError, build_messages as _span_build_messages, max_tokens_for as _span_max_tokens, @@ -460,6 +462,110 @@ def _post_llm_chat_completion(llm_url: str, payload: dict, headers: dict, timeou raise RuntimeError("LLM request failed") +# ── English prompt inputs ──────────────────────────────────────────────────── +# Character sheets are extracted from German books; the image models work best with +# English and German leaks into keyword logic. Free-text fields are translated lazily, +# one batched LLM call per character, before any prompt builder runs. The stored +# record is never changed (see architecture/character-visual-identity.md). +_PROMPT_TRANSLATE_SHEET_FIELDS = ( + "race_species", "visual_species", "physical", "visual_appearance", "clothing", "visual_costume", + "age_estimate", "age", "title", "profession", "archetype", "hair", "eyes", "build", "skin_tone", + "marks", "scars", "era", "nationality_background", "mannerisms", "capabilities", + "conflict_style", "communication_style", "motivation", "visual_scene", "visual_role", + "inventory", "visual_props", +) +_PROMPT_TRANSLATE_BOOK_FIELDS = ("genre", "setting", "era", "visual_context") +_DEFAULT_SCENE_EVIDENCE_PREFIX = "The character performs their established everyday role" + + +class _DbTranslationCache: + def get(self, key: str): + from core.database import prompt_translation_cache_get + return prompt_translation_cache_get(key) + + def put(self, key: str, value) -> None: + from core.database import prompt_translation_cache_put + prompt_translation_cache_put(key, value) + + +def _translation_chat(settings: dict, timeout: float = 90.0): + """The app's existing LLM endpoint, used for a low-temperature translation call.""" + llm_url = str(settings.get("llm_url") or "").rstrip("/") + if not llm_url: + return None + model = str(settings.get("prompt_translation_model") or settings.get("llm_model") or "") + headers = {"Authorization": f"Bearer {settings.get('llm_api_key') or 'sk-dummy-key'}"} + + def chat(messages: list, max_tokens: int) -> str: + payload = {"model": model, "messages": messages, "temperature": 0.1, "max_tokens": max_tokens, + "chat_template_kwargs": {"enable_thinking": False}} + resp = _post_llm_chat_completion(llm_url, payload, headers, timeout) + if resp.status_code in (400, 422): # a backend that rejects the (Qwen-style) thinking switch + payload.pop("chat_template_kwargs", None) + resp = _post_llm_chat_completion(llm_url, payload, headers, timeout) + if resp.status_code >= 400: + raise RuntimeError(f"HTTP {resp.status_code}: {_response_error_text(resp)[:160]}") + msg = resp.json()["choices"][0]["message"] + return (msg.get("content") or _reasoning_text(msg) or "").strip() + + return chat + + +def _english_prompt_inputs(name: str, sheet: dict, book_profile: dict | None = None, + extras: dict | None = None, settings: dict | None = None, + chat=None, cache=None) -> tuple[dict, dict, dict, dict]: + """Return (english sheet, english book profile, english extras, info). + + Blocking (LLM call on a cache miss): call from a thread in async routes. Never raises; + on any failure the original German text is kept and ``info`` says so. + """ + settings = settings if settings is not None else _load_settings() + sheet = dict(sheet or {}) + book_profile = dict(book_profile or {}) + extras = dict(extras or {}) + fields: dict = {} + for key in _PROMPT_TRANSLATE_SHEET_FIELDS: + value = sheet.get(key) + if isinstance(value, str) or (isinstance(value, list) and value and all(isinstance(v, str) for v in value)): + fields[f"sheet.{key}"] = value + if not _visual_prompt_value(sheet.get("visual_scene")).strip(): + evidence = _character_scene_evidence(sheet) + if evidence and not evidence.startswith(_DEFAULT_SCENE_EVIDENCE_PREFIX): + fields["scene_evidence"] = evidence + for key in _PROMPT_TRANSLATE_BOOK_FIELDS: + value = book_profile.get(key) + if isinstance(value, str): + fields[f"book.{key}"] = value + for key, value in extras.items(): + if isinstance(value, str) and value.strip(): + fields[f"extra.{key}"] = value + aliases = sheet.get("aliases") + names = [name] + ([str(a) for a in aliases] if isinstance(aliases, list) else + [a.strip() for a in str(aliases or "").split(",") if a.strip()]) + enabled = _pt.translation_enabled(settings) + model = str(settings.get("prompt_translation_model") or settings.get("llm_model") or "") + if chat is None and enabled and any(_pt.needs_translation(v) if isinstance(v, str) else + any(_pt.needs_translation(i) for i in v) for v in fields.values()): + chat = _translation_chat(settings) + result = _pt.translate_fields(fields, chat=chat, model=model, + cache=cache if cache is not None else _DbTranslationCache(), + names=names, enabled=enabled) + for key, value in result.values.items(): + if key.startswith("sheet."): + sheet[key[6:]] = value + elif key.startswith("book."): + book_profile[key[5:]] = value + elif key.startswith("extra."): + extras[key[6:]] = value + elif key == "scene_evidence" and key in result.translated: + sheet["visual_scene"] = value[:700] + info = result.info() + if info["used_untranslated_text"]: + logging.getLogger(__name__).warning( + "prompt translation: %r used untranslated text for %s", name, info["untranslated_fields"]) + return sheet, book_profile, extras, info + + def _fallback_attribute_response(text: str) -> dict: """Deterministic fallback for attribution when an upstream LLM fails.""" quote_re = re.compile( @@ -1767,7 +1873,7 @@ _PERIOD_WORDING = ( (r"westen?\b|waistcoat\w*|vest\b", "sleeveless leather jerkin"), (r"schals?\b|scarf|scarves", "wool neck-wrap"), (r"dreispitz\w*|dreimaster|tricorn\w*|dreieckshut|three-cornered hat", "plain iron helm"), - (r"h[üu]te?\b(?!\w)|\bhats?\b", "soft wool hood"), + (r"h[üu]te?\b(?!\w)", "soft wool hood"), (r"sporen|spurs?", "stitched leather boots"), (r"hosen?\b|trousers|pants", "wool leggings"), (r"orden\b|medals?", "guild badge"), @@ -1777,9 +1883,16 @@ _PERIOD_WORDING = ( _PERIOD_WORDING_RES = tuple((re.compile(r"\b(?:" + pat + r")", re.IGNORECASE), repl) for pat, repl in _PERIOD_WORDING) +_ENGLISH_HAT_RE = re.compile(r"\bhats?\b", re.IGNORECASE) + + def _period_wording(text: str) -> str: for pattern, repl in _PERIOD_WORDING_RES: text = pattern.sub(repl, text) + # English "hat" is a hat; German "hat" is the verb "has" ("hat dünne Arme"), so on + # untranslated (fallback) German text it must be left alone. + if not _pt.looks_german(text): + text = _ENGLISH_HAT_RE.sub("soft wool hood", text) return text @@ -2074,6 +2187,8 @@ def _character_age_category(sheet: dict) -> str: words anywhere in the established profile text. """ text = " ".join(filter(None, ( + # Sheets store "age_estimate"; "age" is the older/alternate key. + _visual_prompt_value(sheet.get("age_estimate")), _visual_prompt_value(sheet.get("age")), _visual_prompt_value(sheet.get("physical")), _visual_prompt_value(sheet.get("archetype")), @@ -3227,10 +3342,15 @@ async def character_generate_prompts(request: Request): # only structured character facts vary. This is intentionally # deterministic instead of asking the LLM to redesign each production. deterministic = {} + translation_info: dict | None = None + if "image_prompt" in wanted or "concept_art_prompt" in wanted: + # Image prompts read ENGLISH versions of the free text; the record stays German. + prompt_sheet, prompt_book_profile, _, translation_info = await asyncio.to_thread( + _english_prompt_inputs, name, sheet, book_profile, None, _settings) if "image_prompt" in wanted: - deterministic["image_prompt"] = _build_character_portrait_prompt(name, book, sheet, book_profile, visual_style) + deterministic["image_prompt"] = _build_character_portrait_prompt(name, book, prompt_sheet, prompt_book_profile, visual_style) if "concept_art_prompt" in wanted: - deterministic["concept_art_prompt"] = _build_character_sheet_prompt(name, book, sheet, book_profile, visual_style) + deterministic["concept_art_prompt"] = _build_character_sheet_prompt(name, book, prompt_sheet, prompt_book_profile, visual_style) field_groups = [((f,), all_groups[f]) for f in wanted if f not in deterministic] # The SillyTavern card is a full structured card + scenario + first # message now, not a few short labelled lines — needs a much bigger @@ -3298,9 +3418,50 @@ async def character_generate_prompts(request: Request): ) except Exception as exc: print(f"[generation-history] prompt audit failed: {exc}") + if translation_info is not None: + out["prompt_translation"] = translation_info return out +@router.post("/api/character-prompt-preview") +async def character_prompt_preview(request: Request): + """Read-only dry run: the image prompts a character would get, from the original text and + from its English translation. Generates no image and changes no record. + + Body: {id} (a stored character) or {name, book, sheet, book_profile}; optional visual_style. + """ + data = await request.json() + name = str(data.get("name") or "").strip() + book = str(data.get("book") or "").strip() + sheet = data.get("sheet") or {} + if data.get("id"): + from core.database import char_get + record = char_get(str(data["id"])) + if not record: + raise HTTPException(404, "Character not found") + name, book, sheet = record.get("name") or name, record.get("book") or book, record.get("sheet") or {} + if not name: + raise HTTPException(400, "Character name required") + sheet = _character_form_sheet(sheet) + book_profile = data.get("book_profile") or {} + visual_style = "concept" if str(data.get("visual_style") or "").strip().lower() == "concept" else "cinematic" + settings = _load_settings() + sheet_en, book_en, _, info = await asyncio.to_thread(_english_prompt_inputs, name, sheet, book_profile, None, settings) + + def render(sh: dict, bp: dict) -> dict: + portrait = _build_character_portrait_prompt(name, book, sh, bp, visual_style) + turnaround = _build_character_sheet_prompt(name, book, sh, bp, visual_style) + profile, front, _, _, scene, props = _build_compact_character_render_prompts( + name, book, sh, bp, visual_style, portrait, turnaround) + return {"profile": profile, "front": front, "story_moment": scene, "props": props, + "dwarf": _character_is_dwarf(sh), "age_category": _character_age_category(sh)} + + changed = {k: {"original": sheet.get(k), "english": sheet_en.get(k)} + for k in sorted(set(sheet) | set(sheet_en)) if sheet.get(k) != sheet_en.get(k)} + return {"name": name, "prompt_translation": info, "translated_sheet_fields": changed, + "original_prompts": render(sheet, book_profile), "english_prompts": render(sheet_en, book_en)} + + @router.post("/api/audiobook-consistency-check") async def audiobook_consistency_check(request: Request): """Check whether every line already attributed to ONE character across the @@ -4058,23 +4219,34 @@ async def character_generate_consistent_images(request: Request): # Rebuild the fixed production frame from structured data. Saved prompts # stay usable as user-editable direction, but cannot override the book # lock, headwear rule, camera, or turnaround layout. - portrait_master = _build_character_portrait_prompt(name, book, sheet, book_profile, visual_style) - sheet_master = _build_character_sheet_prompt(name, book, sheet, book_profile, visual_style) + # Prompts are built from ENGLISH versions of the free text (translated once per character, + # cached); `sheet` itself stays the German source of truth for fingerprints and checks. + prompt_sheet, prompt_book_profile, prompt_extras, translation_info = await asyncio.to_thread( + _english_prompt_inputs, name, sheet, book_profile, + {"profile_direction": saved_profile_prompt if sheet.get("image_prompt_user_edited") is True else "", + "reference_direction": saved_reference_prompt if sheet.get("concept_art_prompt_user_edited") is True else ""}, + settings) + if translation_info.get("used_untranslated_text"): + logging.getLogger(__name__).warning("character images for %r use untranslated text", name) + saved_profile_prompt_en = prompt_extras.get("profile_direction") or saved_profile_prompt + saved_reference_prompt_en = prompt_extras.get("reference_direction") or saved_reference_prompt + portrait_master = _build_character_portrait_prompt(name, book, prompt_sheet, prompt_book_profile, visual_style) + sheet_master = _build_character_sheet_prompt(name, book, prompt_sheet, prompt_book_profile, visual_style) # Prompts created before the standardized v3 masters may contain the old # hats, modern clothing, variable layouts and conflicting art styles. Do # not import those into the corrected render. Once a v3 prompt has been # generated, user edits retain its marker and are honored here. - profile_direction = saved_profile_prompt if sheet.get("image_prompt_user_edited") is True and saved_profile_prompt.startswith( + profile_direction = saved_profile_prompt_en if sheet.get("image_prompt_user_edited") is True and saved_profile_prompt.startswith( "Task: Generate one historical-fantasy audiobook cast portrait." ) else "" - reference_direction = saved_reference_prompt if sheet.get("concept_art_prompt_user_edited") is True and saved_reference_prompt.startswith( + reference_direction = saved_reference_prompt_en if sheet.get("concept_art_prompt_user_edited") is True and saved_reference_prompt.startswith( "Task: Generate one production character turnaround and equipment reference sheet." ) else "" # The UI stores the full editable production masters. The local image # encoder receives compact, layout-first versions so its limited context # cannot truncate FRONT/SIDE/BACK or the period/headwear constraints. profile_prompt, front_prompt, side_prompt, back_prompt, scene_prompt, prop_prompts = _build_compact_character_render_prompts( - name, book, sheet, book_profile, visual_style, + name, book, prompt_sheet, prompt_book_profile, visual_style, profile_direction or portrait_master, reference_direction or sheet_master, ) @@ -4186,7 +4358,7 @@ async def character_generate_consistent_images(request: Request): look = _describe_portrait_look(_profile_crop_uri(identity_image), settings_now) if look: rebuilt = _build_compact_character_render_prompts( - name, book, sheet, book_profile, visual_style, + name, book, prompt_sheet, prompt_book_profile, visual_style, profile_direction or portrait_master, reference_direction or sheet_master, look) sheet_front, sheet_scene = rebuilt[1], rebuilt[4] best_sheet = None @@ -4229,6 +4401,7 @@ async def character_generate_consistent_images(request: Request): if checking: result["image_check"] = summarise(verdicts, attempts_used, verified) result = _consistent_image_result_metadata(sheet, result) + result["prompt_translation"] = translation_info result = _persist_consistent_image_artifacts(job_id, result) _cij_set(job_id, stage="done", detail="Done", done=True, result=result) except HTTPException as exc: diff --git a/tests/test_prompt_translation.py b/tests/test_prompt_translation.py new file mode 100644 index 0000000..d52bdf7 --- /dev/null +++ b/tests/test_prompt_translation.py @@ -0,0 +1,232 @@ +"""Character info is translated to English before image prompts are built. + +All LLM calls are mocked; no image generation, no network. +""" +from __future__ import annotations + +import json +import os +import tempfile +import unittest +from unittest import mock + +os.environ.setdefault("CONFIG_DIR", tempfile.mkdtemp(prefix="ttvc-translation-test-config-")) + +import routes.conversation as conv +from core import prompt_translation as pt + +TRANSLATIONS = { + "Mensch (oder Zwerg, je nach Kontext 'Amboßzwerg', hier als junger Krieger beschrieben)": + "Human (or dwarf, depending on context 'Anvil dwarf', described here as a young warrior)", + "Klein, dünn, schwach; hat dünne Arme.": "Small, thin, weak; has thin arms.", + "Ein roter Umhang aus Wolle.": "A red cloak made of wool.", + "Zwerg (Amboßzwerg)": "Dwarf (anvil dwarf)", + "Breit gebaut, mit langem rotem Bart.": "Broadly built, with a long red beard.", + "Ein Kettenhemd über einer Wolltunika.": "A chainmail shirt over a wool tunic.", + "Kind, etwa 10 Jahre": "Child, about 10 years old", + "Er trägt einen Hut": "He wears a hat", +} + + +class FakeChat: + def __init__(self, table=None, raw=None, exc=None): + self.table, self.raw, self.exc, self.calls = table or TRANSLATIONS, raw, exc, [] + + def __call__(self, messages, max_tokens): + self.calls.append(messages) + if self.exc: + raise self.exc + if self.raw is not None: + return self.raw + batch = json.loads(messages[-1]["content"]) + out = {} + for key, value in batch.items(): + out[key] = [self.table.get(v, v) for v in value] if isinstance(value, list) else self.table.get(value, value) + return json.dumps(out, ensure_ascii=False) + + +def run(fields, chat, cache=None, names=None, enabled=True): + return pt.translate_fields(fields, chat=chat, model="m1", cache=cache if cache is not None else pt.MemoryCache(), + names=names, enabled=enabled) + + +class DetectionTests(unittest.TestCase): + def test_german_and_english(self): + self.assertTrue(pt.needs_translation("hat dünne Arme")) + self.assertTrue(pt.needs_translation("Mensch (oder Zwerg)")) + self.assertFalse(pt.needs_translation("A tall man with a long grey beard, wearing a wool cloak.")) + self.assertFalse(pt.needs_translation("Human (or dwarf, depending on context)")) + self.assertFalse(pt.needs_translation("")) + self.assertFalse(pt.needs_translation("42")) + self.assertFalse(pt.needs_translation(None)) + + +class TranslateFieldsTests(unittest.TestCase): + def test_batch_is_one_call_and_cache_hit_miss_invalidate(self): + cache, chat = pt.MemoryCache(), FakeChat() + fields = {"physical": "Klein, dünn, schwach; hat dünne Arme.", "clothing": "Ein roter Umhang aus Wolle.", + "x": "already English text of the thing"} + first = run(fields, chat, cache) + self.assertEqual(len(chat.calls), 1) + self.assertEqual(first.values["physical"], "Small, thin, weak; has thin arms.") + self.assertEqual(first.skipped, ["x"]) + second = run(fields, chat, cache) + self.assertEqual(len(chat.calls), 1, "cache hit must not call the LLM") + self.assertEqual(second.cache_hits, 2) + changed = dict(fields, physical="Klein, dünn, schwach; hat dünne Arme.", clothing="Ein roter Umhang aus Wolle.") + chat.table = dict(TRANSLATIONS, **{"Ein grüner Umhang aus Wolle.": "A green cloak made of wool."}) + changed["clothing"] = "Ein grüner Umhang aus Wolle." + third = run(changed, chat, cache) + self.assertEqual(len(chat.calls), 2, "changed source text must miss") + self.assertEqual(third.values["clothing"], "A green cloak made of wool.") + self.assertEqual(third.cache_hits, 1) + # a different translator model is a different key + run(fields, chat, cache) and pt.translate_fields(fields, chat=chat, model="other", cache=cache) + self.assertEqual(len(chat.calls), 3) + + def test_list_fields(self): + chat = FakeChat(table={"Ein Schwert": "A sword", "Ein Dolch": "A dagger"}) + res = run({"inventory": ["Ein Schwert", "Ein Dolch"]}, chat) + self.assertEqual(res.values["inventory"], ["A sword", "A dagger"]) + + def test_invalid_json_falls_back(self): + res = run({"physical": "Klein, dünn, schwach; hat dünne Arme."}, FakeChat(raw="Sorry, no JSON")) + self.assertEqual(res.values["physical"], "Klein, dünn, schwach; hat dünne Arme.") + self.assertTrue(res.used_untranslated) + self.assertTrue(res.error) + + def test_validation_failures_fall_back_per_field(self): + raw = json.dumps({"physical": "", "clothing": "I'm sorry, I cannot translate this text."}) + res = run({"physical": "Klein, dünn, schwach; hat dünne Arme.", "clothing": "Ein roter Umhang aus Wolle."}, + FakeChat(raw=raw)) + self.assertEqual(sorted(res.untranslated), ["clothing", "physical"]) + self.assertEqual(res.values["clothing"], "Ein roter Umhang aus Wolle.") + + def test_echo_and_length_rejected(self): + src = "Klein, dünn, schwach; hat dünne Arme." + self.assertIsNotNone(pt.validate_translation(src, src)) + self.assertIsNotNone(pt.validate_translation(src, "x" * 500)) + self.assertIsNone(pt.validate_translation(src, "Small, thin, weak; has thin arms.")) + + def test_names_preserved(self): + src = "Marrad trägt einen roten Umhang." + self.assertIsNone(pt.validate_translation(src, "Marrad wears a red cloak.", ["Marrad"])) + self.assertIn("name not preserved", pt.validate_translation(src, "Marat wears a red cloak.", ["Marrad"])) + chat = FakeChat(table={src: "Marat wears a red cloak."}) + res = run({"physical": src}, chat, names=["Marrad"]) + self.assertEqual(res.values["physical"], src) + + def test_english_only_never_calls_llm(self): + chat = FakeChat() + res = run({"physical": "A thin boy with short hair."}, chat) + self.assertEqual(chat.calls, []) + self.assertFalse(res.used_untranslated) + + def test_flag_off_and_llm_down(self): + chat = FakeChat() + off = run({"physical": "Klein, dünn, schwach; hat dünne Arme."}, chat, enabled=False) + self.assertEqual(chat.calls, []) + self.assertTrue(off.used_untranslated) + down = run({"physical": "Klein, dünn, schwach; hat dünne Arme."}, FakeChat(exc=ConnectionError("down"))) + self.assertEqual(down.values["physical"], "Klein, dünn, schwach; hat dünne Arme.") + self.assertTrue(down.used_untranslated) + none = run({"physical": "Klein, dünn, schwach; hat dünne Arme."}, None) + self.assertTrue(none.used_untranslated) + + def test_env_flag(self): + with mock.patch.dict(os.environ, {"PROMPT_TRANSLATION": "off"}): + self.assertFalse(pt.translation_enabled({})) + with mock.patch.dict(os.environ, {}, clear=False): + os.environ.pop("PROMPT_TRANSLATION", None) + self.assertTrue(pt.translation_enabled({})) + self.assertFalse(pt.translation_enabled({"prompt_translation": False})) + + +BOY = { + "race_species": "Mensch (oder Zwerg, je nach Kontext 'Amboßzwerg', hier als junger Krieger beschrieben)", + "gender": "male", + "physical": "Klein, dünn, schwach; hat dünne Arme.", + "clothing": "Ein roter Umhang aus Wolle.", + "age_estimate": "Kind, etwa 10 Jahre", +} + + +def prompts(name, sheet): + portrait = conv._build_character_portrait_prompt(name, "Book", sheet, {}, "cinematic") + turnaround = conv._build_character_sheet_prompt(name, "Book", sheet, {}, "cinematic") + out = conv._build_compact_character_render_prompts(name, "Book", sheet, {}, "cinematic", portrait, turnaround) + return " ".join([portrait, turnaround, *out[:5], *out[5]]) + + +class ConversationWiringTests(unittest.TestCase): + def english(self, sheet, chat=None, **kw): + return conv._english_prompt_inputs("Marrad", sheet, {}, None, {"llm_url": ""}, chat=chat or FakeChat(), + cache=pt.MemoryCache(), **kw) + + def test_hat_duenne_arme_regression(self): + sheet_en, _, _, info = self.english(BOY) + self.assertEqual(sheet_en["physical"], "Small, thin, weak; has thin arms.") + self.assertFalse(info["used_untranslated_text"]) + self.assertNotIn("hood", prompts("Marrad", sheet_en)) + # fallback path with the German text must not turn "hat" into a hood either + self.assertNotIn("hood", conv._period_wording("Klein, dünn, schwach; hat dünne Arme.")) + self.assertNotIn("hood", conv._period_wording("hat dünne Arme")) + self.assertIn("hood", conv._period_wording("He wears a hat")) + + def test_mixed_species_note_is_not_a_dwarf_with_and_without_translation(self): + self.assertFalse(conv._character_is_dwarf(BOY)) + sheet_en, *_ = self.english(BOY) + self.assertEqual(sheet_en["race_species"].split("(")[0].strip(), "Human") + self.assertFalse(conv._character_is_dwarf(sheet_en)) + self.assertNotIn("DWARF", prompts("Marrad", sheet_en).upper()) + + def test_real_dwarf_still_dwarf_both_ways(self): + dwarf = {"race_species": "Zwerg (Amboßzwerg)", "gender": "male", "physical": "Breit gebaut, mit langem rotem Bart.", + "clothing": "Ein Kettenhemd über einer Wolltunika."} + self.assertTrue(conv._character_is_dwarf(dwarf)) + sheet_en, *_ = self.english(dwarf) + self.assertEqual(sheet_en["race_species"], "Dwarf (anvil dwarf)") + self.assertTrue(conv._character_is_dwarf(sheet_en)) + + def test_age_estimate_is_read(self): + self.assertEqual(conv._character_age_category({"age_estimate": "Kind, etwa 10 Jahre"}), "child") + self.assertEqual(conv._character_age_category({"age_estimate": "about 15 years old"}), "teen") + self.assertEqual(conv._character_age_category({"age_estimate": "Mitte 40"}), "adult") + self.assertEqual(conv._character_age_category({"age": "9 Jahre"}), "child") + sheet_en, *_ = self.english(BOY) + self.assertEqual(conv._age_body_lock(sheet_en)[0], "EXACTLY ONE CHILD.") + + def test_record_not_modified(self): + original = json.loads(json.dumps(BOY)) + self.english(BOY) + self.assertEqual(BOY, original) + + def test_llm_down_falls_back_and_still_builds(self): + sheet_en, _, _, info = self.english(BOY, chat=FakeChat(exc=ConnectionError("down"))) + self.assertEqual(sheet_en["physical"], BOY["physical"]) + self.assertTrue(info["used_untranslated_text"]) + self.assertFalse(conv._character_is_dwarf(sheet_en)) + self.assertTrue(prompts("Marrad", sheet_en)) + + def test_flag_off(self): + chat = FakeChat() + with mock.patch.dict(os.environ, {"PROMPT_TRANSLATION": "0"}): + sheet_en, _, _, info = self.english(BOY, chat=chat) + self.assertEqual(chat.calls, []) + self.assertFalse(info["enabled"]) + self.assertTrue(info["used_untranslated_text"]) + self.assertEqual(sheet_en["physical"], BOY["physical"]) + + def test_scene_evidence_and_book_profile_translated(self): + table = {"Der Zwerg hob die Axt und rief laut.": "The dwarf raised the axe and shouted loudly.", + "Ein dunkler Wald im Norden": "A dark forest in the north"} + sheet = {"sources": [{"quote": "Der Zwerg hob die Axt und rief laut.", "line_hint": "action"}]} + sheet_en, book_en, _, _ = conv._english_prompt_inputs( + "Kolon", sheet, {"setting": "Ein dunkler Wald im Norden"}, None, {"llm_url": ""}, + chat=FakeChat(table=table), cache=pt.MemoryCache()) + self.assertEqual(sheet_en["visual_scene"], "The dwarf raised the axe and shouted loudly.") + self.assertEqual(book_en["setting"], "A dark forest in the north") + + +if __name__ == "__main__": + unittest.main()