get Speaker for wolf
This commit is contained in:
parent
04c6fb63bb
commit
f25ce9f990
3 changed files with 293 additions and 38 deletions
|
|
@ -35,9 +35,11 @@ When unset, all database lines are translated.
|
||||||
|
|
||||||
Speakers: WolfDawn tags each line with ``speaker`` / ``speaker_src``. For the
|
Speakers: WolfDawn tags each line with ``speaker`` / ``speaker_src``. For the
|
||||||
first-line formats (``literal_line1`` / ``literal_line1_lowconf``) the speaker
|
first-line formats (``literal_line1`` / ``literal_line1_lowconf``) the speaker
|
||||||
name is baked into line 1 of ``source``. Those lines are reshaped into the shared
|
name is baked into line 1 of ``source``. Those names are resolved to English first
|
||||||
``[Speaker]: line`` convention (which the prompt already translates) and restored
|
(vocab hit or a cached short-string ``getSpeaker`` call, same pattern as the other
|
||||||
to WOLF's native ``Speaker\nline`` layout on write-back. See ``util.speakers``.
|
engines), then the line is reshaped into ``[Speaker]: line`` with the English tag
|
||||||
|
and restored to WOLF's native ``Speaker\nline`` layout on write-back using that
|
||||||
|
resolved name (not whatever the dialogue model echoed). See ``util.speakers``.
|
||||||
Detection is WolfDawn's, so the reliable nameplate (``literal_line1``) is always
|
Detection is WolfDawn's, so the reliable nameplate (``literal_line1``) is always
|
||||||
reshaped; only the low-confidence guess (``literal_line1_lowconf``) is gated by a
|
reshaped; only the low-confidence guess (``literal_line1_lowconf``) is gated by a
|
||||||
per-game, AI-recommended setting from the workflow.
|
per-game, AI-recommended setting from the workflow.
|
||||||
|
|
@ -62,6 +64,7 @@ from util.translation import (
|
||||||
translateAI as sharedtranslateAI,
|
translateAI as sharedtranslateAI,
|
||||||
getPricingConfig,
|
getPricingConfig,
|
||||||
calculateCost,
|
calculateCost,
|
||||||
|
parseVocabWithCategories,
|
||||||
)
|
)
|
||||||
from util import speakers as wolf_speakers
|
from util import speakers as wolf_speakers
|
||||||
from util import vocab as wolf_vocab
|
from util import vocab as wolf_vocab
|
||||||
|
|
@ -98,11 +101,15 @@ _WOLF_CODE_RE = re.compile(
|
||||||
r"\\(?:r\[[^\]]*\]|c(?:self)?\[[^\]]*\]|[A-Za-z]+\[[^\]]*\]|[A-Za-z])"
|
r"\\(?:r\[[^\]]*\]|c(?:self)?\[[^\]]*\]|[A-Za-z]+\[[^\]]*\]|[A-Za-z])"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Speaker handling: for first-line-speaker formats, reshape the line into the
|
# Speaker handling: for first-line-speaker formats, resolve the nameplate to
|
||||||
# shared "[Speaker]: line" transport before translating and restore WOLF's
|
# English (vocab / cached short-string translation), reshape into the shared
|
||||||
# native "Speaker\nline" layout on write-back. Which formats are reshaped is
|
# "[Speaker]: line" transport, then restore WOLF's native "Speaker\nline"
|
||||||
# configurable from the workflow (data/wolf_speakers.json).
|
# layout on write-back. Which formats are reshaped is configurable from the
|
||||||
|
# workflow (data/wolf_speakers.json).
|
||||||
SPEAKER_CONFIG = wolf_speakers.load_config()
|
SPEAKER_CONFIG = wolf_speakers.load_config()
|
||||||
|
NAMESLIST = [] # [[jp, en], ...] session glossary of resolved speaker names
|
||||||
|
_speakerCache = {}
|
||||||
|
_speakerCacheLock = threading.Lock()
|
||||||
|
|
||||||
# Pricing / batching from the configured model
|
# Pricing / batching from the configured model
|
||||||
PRICING_CONFIG = getPricingConfig(MODEL)
|
PRICING_CONFIG = getPricingConfig(MODEL)
|
||||||
|
|
@ -250,9 +257,10 @@ def collectEntries(data):
|
||||||
def _text_check_body(text: str, speaker_src: str = "") -> str:
|
def _text_check_body(text: str, speaker_src: str = "") -> str:
|
||||||
"""Body text used for skip-translated checks (nameplates / codes ignored).
|
"""Body text used for skip-translated checks (nameplates / codes ignored).
|
||||||
|
|
||||||
Already-translated WOLF dialogue often keeps a Japanese speaker name on
|
Already-translated WOLF dialogue may still have a Japanese speaker name on
|
||||||
line 1 (``司祭\\nSorry to keep you...``) and may embed Japanese only inside
|
line 1 (``司祭\\nSorry to keep you...``) and may embed Japanese only inside
|
||||||
control codes like ``\\r[我,わ]``. Those lines are finished translations.
|
control codes like ``\\r[我,わ]``. Body-only checks treat those as finished
|
||||||
|
dialogue; ``_maybe_fix_nameplate`` then swaps the nameplate to English.
|
||||||
"""
|
"""
|
||||||
if not isinstance(text, str):
|
if not isinstance(text, str):
|
||||||
return ""
|
return ""
|
||||||
|
|
@ -285,6 +293,149 @@ def _text_still_needs_translation(entry) -> bool:
|
||||||
return bool(re.search(LANGREGEX, _text_check_body(txt, entry.get("speaker_src", ""))))
|
return bool(re.search(LANGREGEX, _text_check_body(txt, entry.get("speaker_src", ""))))
|
||||||
|
|
||||||
|
|
||||||
|
def _vocab_speaker_lookup(speaker: str) -> str | None:
|
||||||
|
"""Return an English gloss for *speaker* from vocab.txt, or None."""
|
||||||
|
if not speaker or not VOCAB:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
for item in parseVocabWithCategories(VOCAB):
|
||||||
|
# parseVocabWithCategories yields ((jp, en), line, category) or (term, ...)
|
||||||
|
term = item[0]
|
||||||
|
if not isinstance(term, tuple) or len(term) != 2:
|
||||||
|
continue
|
||||||
|
jp, en = term
|
||||||
|
if jp == speaker and isinstance(en, str) and en.strip():
|
||||||
|
# Prefer a gloss that is not still Japanese.
|
||||||
|
if not re.search(LANGREGEX, en):
|
||||||
|
return en.strip()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_speaker_name(translated: str) -> str:
|
||||||
|
"""Title-case / tidy a short NPC-name translation (mirrors other engines)."""
|
||||||
|
out = translated.strip().title().replace("'S", "'s").replace("Speaker: ", "")
|
||||||
|
return re.sub(
|
||||||
|
r"(\d)(St|Nd|Rd|Th)\b",
|
||||||
|
lambda m: m.group(1) + m.group(2).lower(),
|
||||||
|
out,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def getSpeaker(speaker: str):
|
||||||
|
"""Resolve a WolfDawn nameplate to English with caching.
|
||||||
|
|
||||||
|
Order: session cache / NAMESLIST -> vocab.txt -> live short-string
|
||||||
|
translation (same prompt as the other engines). Single-string calls go
|
||||||
|
through the live API even during batch collect, so later dialogue batches
|
||||||
|
embed a stable English tag.
|
||||||
|
"""
|
||||||
|
global NAMESLIST
|
||||||
|
if not speaker:
|
||||||
|
return ["", [0, 0]]
|
||||||
|
# Already English / no target-language characters - keep as-is.
|
||||||
|
if not re.search(LANGREGEX, speaker):
|
||||||
|
return [speaker, [0, 0]]
|
||||||
|
|
||||||
|
with _speakerCacheLock:
|
||||||
|
cached = _speakerCache.get(speaker)
|
||||||
|
if cached is not None:
|
||||||
|
return [cached, [0, 0]]
|
||||||
|
for jp, en in NAMESLIST:
|
||||||
|
if jp == speaker and en:
|
||||||
|
_speakerCache[speaker] = en
|
||||||
|
return [en, [0, 0]]
|
||||||
|
|
||||||
|
vocab_hit = _vocab_speaker_lookup(speaker)
|
||||||
|
if vocab_hit is not None:
|
||||||
|
with _speakerCacheLock:
|
||||||
|
_speakerCache[speaker] = vocab_hit
|
||||||
|
if not any(jp == speaker for jp, _en in NAMESLIST):
|
||||||
|
NAMESLIST.append([speaker, vocab_hit])
|
||||||
|
return [vocab_hit, [0, 0]]
|
||||||
|
|
||||||
|
# Estimate / preflight: do not spend tokens; leave Japanese for a real run.
|
||||||
|
if ESTIMATE:
|
||||||
|
return [speaker, [0, 0]]
|
||||||
|
|
||||||
|
response = translateAI(
|
||||||
|
speaker,
|
||||||
|
"Reply with the " + LANGUAGE + " translation of the NPC name.",
|
||||||
|
)
|
||||||
|
translated = _normalize_speaker_name(
|
||||||
|
response[0] if isinstance(response[0], str) else str(response[0])
|
||||||
|
)
|
||||||
|
|
||||||
|
# Retry once if the model returned something with no Latin / ? characters.
|
||||||
|
if re.search(r"([a-zA-Z??])", translated) is None:
|
||||||
|
response = translateAI(
|
||||||
|
speaker,
|
||||||
|
"Reply with the " + LANGUAGE + " translation of the NPC name.",
|
||||||
|
)
|
||||||
|
translated = _normalize_speaker_name(
|
||||||
|
response[0] if isinstance(response[0], str) else str(response[0])
|
||||||
|
)
|
||||||
|
|
||||||
|
# Still Japanese after retries - keep original rather than inventing junk.
|
||||||
|
if re.search(LANGREGEX, translated) and not re.search(r"[A-Za-z]", translated):
|
||||||
|
translated = speaker
|
||||||
|
|
||||||
|
with _speakerCacheLock:
|
||||||
|
if speaker not in _speakerCache:
|
||||||
|
_speakerCache[speaker] = translated
|
||||||
|
if not any(jp == speaker for jp, _en in NAMESLIST):
|
||||||
|
NAMESLIST.append([speaker, translated])
|
||||||
|
return [translated, response[1]]
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_nameplate(speaker: str, total_tokens: list) -> str:
|
||||||
|
"""Translate *speaker* and accumulate token cost into *total_tokens*."""
|
||||||
|
en, tokens = getSpeaker(speaker)
|
||||||
|
total_tokens[0] += tokens[0]
|
||||||
|
total_tokens[1] += tokens[1]
|
||||||
|
return en
|
||||||
|
|
||||||
|
|
||||||
|
def _body_after_dropped_tag(text: str, original_speaker: str) -> str:
|
||||||
|
"""When the model drops ``[Speaker]:``, peel a leading JP nameplate if present."""
|
||||||
|
if not isinstance(text, str) or "\n" not in text:
|
||||||
|
return text
|
||||||
|
line1, rest = text.split("\n", 1)
|
||||||
|
if not line1.strip():
|
||||||
|
return text
|
||||||
|
if original_speaker and line1 == original_speaker:
|
||||||
|
return rest
|
||||||
|
# Short Japanese first line - treat as an echoed nameplate.
|
||||||
|
if 0 < len(line1.strip()) <= 20 and re.search(LANGREGEX, line1):
|
||||||
|
return rest
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def _maybe_fix_nameplate(entry, total_tokens: list) -> None:
|
||||||
|
"""Swap a Japanese first-line nameplate for English on an otherwise-done line."""
|
||||||
|
if ESTIMATE or _batch_phase() == "collect":
|
||||||
|
return
|
||||||
|
speaker_src = entry.get("speaker_src", "")
|
||||||
|
if not wolf_speakers.is_firstline_enabled(speaker_src, SPEAKER_CONFIG):
|
||||||
|
return
|
||||||
|
txt = entry.get("text")
|
||||||
|
if not isinstance(txt, str) or not txt.strip():
|
||||||
|
return
|
||||||
|
# Only touch finished dialogue (body already English / skip-translated).
|
||||||
|
if _text_still_needs_translation(entry):
|
||||||
|
return
|
||||||
|
split = wolf_speakers.split_source(txt, speaker_src, SPEAKER_CONFIG)
|
||||||
|
if split is None:
|
||||||
|
return
|
||||||
|
prefix, speaker, body = split
|
||||||
|
if not re.search(LANGREGEX, speaker):
|
||||||
|
return
|
||||||
|
speaker_en = _resolve_nameplate(speaker, total_tokens)
|
||||||
|
if speaker_en and speaker_en != speaker:
|
||||||
|
entry["text"] = wolf_speakers.restore_source(prefix, speaker_en, body)
|
||||||
|
|
||||||
|
|
||||||
def parseDocument(data, filename):
|
def parseDocument(data, filename):
|
||||||
"""Translate every translatable leaf entry and return [data, tokens, error]."""
|
"""Translate every translatable leaf entry and return [data, tokens, error]."""
|
||||||
global PBAR
|
global PBAR
|
||||||
|
|
@ -306,6 +457,16 @@ def parseDocument(data, filename):
|
||||||
return False
|
return False
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
# Fix Japanese nameplates on lines whose dialogue body is already done, so
|
||||||
|
# resume / re-wrap runs do not leave ``セルリア\\nPlease hold on...`` behind.
|
||||||
|
if IGNORETLTEXT and not ESTIMATE and _batch_phase() != "collect":
|
||||||
|
for entry in entries:
|
||||||
|
if _translatable(entry):
|
||||||
|
continue
|
||||||
|
src = entry.get("source")
|
||||||
|
if isinstance(src, str) and re.search(LANGREGEX, src):
|
||||||
|
_maybe_fix_nameplate(entry, totalTokens)
|
||||||
|
|
||||||
# Only translate entries that still need work; names.json also requires a
|
# Only translate entries that still need work; names.json also requires a
|
||||||
# safe badge. Untouched leaves keep ``text == source`` so WolfDawn
|
# safe badge. Untouched leaves keep ``text == source`` so WolfDawn
|
||||||
# treats them as no-ops on inject.
|
# treats them as no-ops on inject.
|
||||||
|
|
@ -316,10 +477,10 @@ def parseDocument(data, filename):
|
||||||
PBAR = pbar
|
PBAR = pbar
|
||||||
if translatable:
|
if translatable:
|
||||||
# Reshape first-line-speaker lines into the shared "[Speaker]: line"
|
# Reshape first-line-speaker lines into the shared "[Speaker]: line"
|
||||||
# transport format. plans[i] carries what is needed to restore each
|
# transport format with a pre-resolved English nameplate. plans[i]
|
||||||
# entry after translation.
|
# carries what is needed to restore each entry after translation.
|
||||||
sources = []
|
sources = []
|
||||||
plans = [] # (entry, prefix, has_speaker, is_firstline, code_map)
|
plans = [] # (entry, prefix, has_speaker, is_firstline, code_map, speaker_en)
|
||||||
for entry in translatable:
|
for entry in translatable:
|
||||||
src = entry["source"]
|
src = entry["source"]
|
||||||
protected_src, code_map = wolf_codes.protect_wolf_codes(src)
|
protected_src, code_map = wolf_codes.protect_wolf_codes(src)
|
||||||
|
|
@ -329,11 +490,12 @@ def parseDocument(data, filename):
|
||||||
)
|
)
|
||||||
if split is not None:
|
if split is not None:
|
||||||
prefix, speaker, body = split
|
prefix, speaker, body = split
|
||||||
sources.append(wolf_speakers.to_prefixed(speaker, body))
|
speaker_en = _resolve_nameplate(speaker, totalTokens)
|
||||||
plans.append((entry, prefix, True, is_firstline, code_map))
|
sources.append(wolf_speakers.to_prefixed(speaker_en, body))
|
||||||
|
plans.append((entry, prefix, True, is_firstline, code_map, speaker_en))
|
||||||
else:
|
else:
|
||||||
sources.append(protected_src)
|
sources.append(protected_src)
|
||||||
plans.append((entry, "", False, is_firstline, code_map))
|
plans.append((entry, "", False, is_firstline, code_map, ""))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
response = translateAI(sources, [])
|
response = translateAI(sources, [])
|
||||||
|
|
@ -354,7 +516,7 @@ def parseDocument(data, filename):
|
||||||
and isinstance(translated, list)
|
and isinstance(translated, list)
|
||||||
and len(translated) == len(plans)
|
and len(translated) == len(plans)
|
||||||
):
|
):
|
||||||
for (entry, prefix, has_speaker, is_firstline, code_map), text, src in zip(
|
for (entry, prefix, has_speaker, is_firstline, code_map, speaker_en), text, src in zip(
|
||||||
plans, translated, sources
|
plans, translated, sources
|
||||||
):
|
):
|
||||||
if not isinstance(text, str):
|
if not isinstance(text, str):
|
||||||
|
|
@ -364,13 +526,27 @@ def parseDocument(data, filename):
|
||||||
continue
|
continue
|
||||||
text = wolf_codes.restore_wolf_code_placeholders(text, code_map)
|
text = wolf_codes.restore_wolf_code_placeholders(text, code_map)
|
||||||
if has_speaker:
|
if has_speaker:
|
||||||
speaker_en, body_en = wolf_speakers.parse_prefixed(text)
|
model_speaker, body_en = wolf_speakers.parse_prefixed(text)
|
||||||
if speaker_en is not None:
|
if model_speaker is None:
|
||||||
entry["text"] = wolf_speakers.restore_source(
|
body_en = _body_after_dropped_tag(
|
||||||
prefix, speaker_en, body_en
|
text, entry.get("speaker") or ""
|
||||||
)
|
)
|
||||||
else:
|
# Always prefer the pre-resolved English nameplate. Only
|
||||||
entry["text"] = prefix + text
|
# take the model's tag when ours is still Japanese and
|
||||||
|
# the model produced a Latin gloss.
|
||||||
|
final_speaker = speaker_en or model_speaker or (
|
||||||
|
entry.get("speaker") or ""
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
re.search(LANGREGEX, final_speaker)
|
||||||
|
and isinstance(model_speaker, str)
|
||||||
|
and model_speaker
|
||||||
|
and not re.search(LANGREGEX, model_speaker)
|
||||||
|
):
|
||||||
|
final_speaker = model_speaker
|
||||||
|
entry["text"] = wolf_speakers.restore_source(
|
||||||
|
prefix, final_speaker, body_en
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
entry["text"] = text
|
entry["text"] = text
|
||||||
wolf_codes.repair_entry(entry)
|
wolf_codes.repair_entry(entry)
|
||||||
|
|
|
||||||
|
|
@ -52,12 +52,18 @@ class _WolfTranslateHarness:
|
||||||
orig_t = wd.translateAI
|
orig_t = wd.translateAI
|
||||||
orig_estimate = wd.ESTIMATE
|
orig_estimate = wd.ESTIMATE
|
||||||
orig_ignore = wd.IGNORETLTEXT
|
orig_ignore = wd.IGNORETLTEXT
|
||||||
|
orig_vocab = wd.VOCAB
|
||||||
orig_update = wd.wolf_vocab.update_vocab_section
|
orig_update = wd.wolf_vocab.update_vocab_section
|
||||||
orig_labels = wd.wolf_names.derive_db_labels
|
orig_labels = wd.wolf_names.derive_db_labels
|
||||||
orig_db_filter = wd.wolf_db.load_db_filter_config
|
orig_db_filter = wd.wolf_db.load_db_filter_config
|
||||||
|
orig_names = list(wd.NAMESLIST)
|
||||||
|
orig_cache = dict(wd._speakerCache)
|
||||||
wd.translateAI = translate
|
wd.translateAI = translate
|
||||||
wd.ESTIMATE = estimate
|
wd.ESTIMATE = estimate
|
||||||
wd.IGNORETLTEXT = ignore_tl_text
|
wd.IGNORETLTEXT = ignore_tl_text
|
||||||
|
wd.VOCAB = "" # isolate speaker lookup from the real glossary
|
||||||
|
wd.NAMESLIST = []
|
||||||
|
wd._speakerCache.clear()
|
||||||
# Never touch the real glossary / DB files during tests.
|
# Never touch the real glossary / DB files during tests.
|
||||||
wd.wolf_vocab.update_vocab_section = capture_vocab
|
wd.wolf_vocab.update_vocab_section = capture_vocab
|
||||||
wd.wolf_names.derive_db_labels = lambda _p: {}
|
wd.wolf_names.derive_db_labels = lambda _p: {}
|
||||||
|
|
@ -70,9 +76,13 @@ class _WolfTranslateHarness:
|
||||||
wd.translateAI = orig_t
|
wd.translateAI = orig_t
|
||||||
wd.ESTIMATE = orig_estimate
|
wd.ESTIMATE = orig_estimate
|
||||||
wd.IGNORETLTEXT = orig_ignore
|
wd.IGNORETLTEXT = orig_ignore
|
||||||
|
wd.VOCAB = orig_vocab
|
||||||
wd.wolf_vocab.update_vocab_section = orig_update
|
wd.wolf_vocab.update_vocab_section = orig_update
|
||||||
wd.wolf_names.derive_db_labels = orig_labels
|
wd.wolf_names.derive_db_labels = orig_labels
|
||||||
wd.wolf_db.load_db_filter_config = orig_db_filter
|
wd.wolf_db.load_db_filter_config = orig_db_filter
|
||||||
|
wd.NAMESLIST = orig_names
|
||||||
|
wd._speakerCache.clear()
|
||||||
|
wd._speakerCache.update(orig_cache)
|
||||||
|
|
||||||
|
|
||||||
MAP_DOC = {
|
MAP_DOC = {
|
||||||
|
|
@ -453,9 +463,12 @@ class TestTranslationWriteback(unittest.TestCase):
|
||||||
(data, _t, err), captured = _WolfTranslateHarness().run(doc, "nameplate.mps.json")
|
(data, _t, err), captured = _WolfTranslateHarness().run(doc, "nameplate.mps.json")
|
||||||
self.assertIsNone(err)
|
self.assertIsNone(err)
|
||||||
lines = data["scenes"][0]["lines"]
|
lines = data["scenes"][0]["lines"]
|
||||||
self.assertEqual(lines[0]["text"], "司祭\nSorry to keep you all waiting......")
|
# Dialogue body is skipped; Japanese nameplate is fixed via getSpeaker
|
||||||
|
# (mock returns EN_*, then title-cased like the other engines).
|
||||||
|
self.assertEqual(lines[0]["text"], "En_司祭\nSorry to keep you all waiting......")
|
||||||
self.assertEqual(lines[1]["text"], "EN_まだだ")
|
self.assertEqual(lines[1]["text"], "EN_まだだ")
|
||||||
self.assertEqual(captured, [["まだだ"]])
|
# Short-string speaker resolve, then the remaining dialogue batch.
|
||||||
|
self.assertEqual(captured, ["司祭", ["まだだ"]])
|
||||||
|
|
||||||
def test_ignore_tl_text_false_retranslates(self):
|
def test_ignore_tl_text_false_retranslates(self):
|
||||||
doc = {
|
doc = {
|
||||||
|
|
@ -627,15 +640,23 @@ class _SpeakerHarness:
|
||||||
self.captured.append(copy.deepcopy(text))
|
self.captured.append(copy.deepcopy(text))
|
||||||
return _mock_translate_speaker(text, history, history_ctx)
|
return _mock_translate_speaker(text, history, history_ctx)
|
||||||
|
|
||||||
orig = (wd.translateAI, wd.ESTIMATE, wd.SPEAKER_CONFIG)
|
orig = (wd.translateAI, wd.ESTIMATE, wd.SPEAKER_CONFIG, wd.VOCAB)
|
||||||
|
orig_names = list(wd.NAMESLIST)
|
||||||
|
orig_cache = dict(wd._speakerCache)
|
||||||
wd.translateAI = translate
|
wd.translateAI = translate
|
||||||
wd.ESTIMATE = False
|
wd.ESTIMATE = False
|
||||||
wd.SPEAKER_CONFIG = self.config
|
wd.SPEAKER_CONFIG = self.config
|
||||||
|
wd.VOCAB = ""
|
||||||
|
wd.NAMESLIST = []
|
||||||
|
wd._speakerCache.clear()
|
||||||
try:
|
try:
|
||||||
result = wd.parseDocument(copy.deepcopy(data), filename)
|
result = wd.parseDocument(copy.deepcopy(data), filename)
|
||||||
return result, self.captured
|
return result, self.captured
|
||||||
finally:
|
finally:
|
||||||
(wd.translateAI, wd.ESTIMATE, wd.SPEAKER_CONFIG) = orig
|
(wd.translateAI, wd.ESTIMATE, wd.SPEAKER_CONFIG, wd.VOCAB) = orig
|
||||||
|
wd.NAMESLIST = orig_names
|
||||||
|
wd._speakerCache.clear()
|
||||||
|
wd._speakerCache.update(orig_cache)
|
||||||
|
|
||||||
|
|
||||||
class TestSpeakerReshaping(unittest.TestCase):
|
class TestSpeakerReshaping(unittest.TestCase):
|
||||||
|
|
@ -643,15 +664,18 @@ class TestSpeakerReshaping(unittest.TestCase):
|
||||||
cfg = {"literal_line1": True, "literal_line1_lowconf": True}
|
cfg = {"literal_line1": True, "literal_line1_lowconf": True}
|
||||||
(data, _t, err), captured = _SpeakerHarness(cfg).run(SPEAKER_MAP_DOC)
|
(data, _t, err), captured = _SpeakerHarness(cfg).run(SPEAKER_MAP_DOC)
|
||||||
self.assertIsNone(err)
|
self.assertIsNone(err)
|
||||||
# Model saw the [Speaker]: transport for the two nameplate lines.
|
# Speakers are resolved first (live short-string), then the batch uses
|
||||||
|
# English nameplates in the [Speaker]: transport.
|
||||||
|
self.assertEqual(captured[0], "市民")
|
||||||
|
self.assertEqual(captured[1], "セルリア")
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
captured[0],
|
captured[2],
|
||||||
["[市民]: おはよう\n元気?", "[セルリア]: ふふふ", "むかしむかし"],
|
["[En_市民]: おはよう\n元気?", "[En_セルリア]: ふふふ", "むかしむかし"],
|
||||||
)
|
)
|
||||||
lines = data["scenes"][0]["lines"]
|
lines = data["scenes"][0]["lines"]
|
||||||
# Restored to WOLF's native Speaker\nbody layout.
|
# Restored with the pre-resolved English nameplate (not the model's tag).
|
||||||
self.assertEqual(lines[0]["text"], "EN_市民\nEN_おはよう\n元気?")
|
self.assertEqual(lines[0]["text"], "En_市民\nEN_おはよう\n元気?")
|
||||||
self.assertEqual(lines[1]["text"], "EN_セルリア\nEN_ふふふ")
|
self.assertEqual(lines[1]["text"], "En_セルリア\nEN_ふふふ")
|
||||||
# Narration was translated as a plain blob.
|
# Narration was translated as a plain blob.
|
||||||
self.assertEqual(lines[2]["text"], "EN_むかしむかし")
|
self.assertEqual(lines[2]["text"], "EN_むかしむかし")
|
||||||
# Sources are preserved for the inject drift guard.
|
# Sources are preserved for the inject drift guard.
|
||||||
|
|
@ -663,12 +687,66 @@ class TestSpeakerReshaping(unittest.TestCase):
|
||||||
cfg = {"literal_line1": True, "literal_line1_lowconf": False}
|
cfg = {"literal_line1": True, "literal_line1_lowconf": False}
|
||||||
(data, _t, err), captured = _SpeakerHarness(cfg).run(SPEAKER_MAP_DOC)
|
(data, _t, err), captured = _SpeakerHarness(cfg).run(SPEAKER_MAP_DOC)
|
||||||
self.assertIsNone(err)
|
self.assertIsNone(err)
|
||||||
# Low-confidence line is sent as the raw source (no reshaping).
|
# High-confidence speaker is resolved; low-confidence line stays raw.
|
||||||
self.assertIn("市民\nおはよう\n元気?", captured[0])
|
self.assertEqual(captured[0], "セルリア")
|
||||||
self.assertIn("[セルリア]: ふふふ", captured[0])
|
batch = captured[1]
|
||||||
|
self.assertIn("市民\nおはよう\n元気?", batch)
|
||||||
|
self.assertIn("[En_セルリア]: ふふふ", batch)
|
||||||
lines = data["scenes"][0]["lines"]
|
lines = data["scenes"][0]["lines"]
|
||||||
self.assertEqual(lines[0]["text"], "EN_市民\nおはよう\n元気?")
|
self.assertEqual(lines[0]["text"], "EN_市民\nおはよう\n元気?")
|
||||||
|
|
||||||
|
def test_writeback_keeps_preresolved_speaker_when_model_leaves_japanese(self):
|
||||||
|
"""Model echoing a JP tag must not overwrite the pre-resolved English name."""
|
||||||
|
cfg = {"literal_line1": True, "literal_line1_lowconf": True}
|
||||||
|
doc = {
|
||||||
|
"kind": "map",
|
||||||
|
"scenes": [
|
||||||
|
{
|
||||||
|
"event": 0,
|
||||||
|
"name": "ev",
|
||||||
|
"lines": [
|
||||||
|
{
|
||||||
|
"cmd": 59,
|
||||||
|
"str": 0,
|
||||||
|
"speaker": "セルリア",
|
||||||
|
"speaker_src": "literal_line1_lowconf",
|
||||||
|
"source": "セルリア\nも、もう少し、耐えてください!",
|
||||||
|
"text": "セルリア\nも、もう少し、耐えてください!",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
def bad_model(text, history=None, history_ctx=None):
|
||||||
|
# Speakers resolve normally; dialogue batch keeps the JP tag.
|
||||||
|
if isinstance(text, str):
|
||||||
|
return [f"EN_{text}", [1, 1]]
|
||||||
|
return [["[セルリア]: Please hold on just a little longer!"], [1, 1]]
|
||||||
|
|
||||||
|
orig = (wd.translateAI, wd.ESTIMATE, wd.SPEAKER_CONFIG, wd.VOCAB)
|
||||||
|
orig_names = list(wd.NAMESLIST)
|
||||||
|
orig_cache = dict(wd._speakerCache)
|
||||||
|
wd.translateAI = bad_model
|
||||||
|
wd.ESTIMATE = False
|
||||||
|
wd.SPEAKER_CONFIG = cfg
|
||||||
|
wd.VOCAB = ""
|
||||||
|
wd.NAMESLIST = []
|
||||||
|
wd._speakerCache.clear()
|
||||||
|
try:
|
||||||
|
data, _t, err = wd.parseDocument(copy.deepcopy(doc), "bad.mps.json")
|
||||||
|
finally:
|
||||||
|
(wd.translateAI, wd.ESTIMATE, wd.SPEAKER_CONFIG, wd.VOCAB) = orig
|
||||||
|
wd.NAMESLIST = orig_names
|
||||||
|
wd._speakerCache.clear()
|
||||||
|
wd._speakerCache.update(orig_cache)
|
||||||
|
|
||||||
|
self.assertIsNone(err)
|
||||||
|
self.assertEqual(
|
||||||
|
data["scenes"][0]["lines"][0]["text"],
|
||||||
|
"En_セルリア\nPlease hold on just a little longer!",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class TestOpenFiles(unittest.TestCase):
|
class TestOpenFiles(unittest.TestCase):
|
||||||
def test_rejects_unknown_kind(self):
|
def test_rejects_unknown_kind(self):
|
||||||
|
|
|
||||||
|
|
@ -346,9 +346,10 @@ def _has_japanese(text: str) -> bool:
|
||||||
# "市民\nおぉっ!来た!帰ってきたぞ!" speaker_src = literal_line1_lowconf
|
# "市民\nおぉっ!来た!帰ってきたぞ!" speaker_src = literal_line1_lowconf
|
||||||
# "セルリア\nほーら、ローザも手を振って。" speaker_src = literal_line1
|
# "セルリア\nほーら、ローザも手を振って。" speaker_src = literal_line1
|
||||||
#
|
#
|
||||||
# When translating we reshape those into ``[Speaker]: body`` (the prompt already
|
# When translating we resolve the nameplate to English first (vocab /
|
||||||
# knows to translate the tag) and, on write-back, restore WOLF's native
|
# ``getSpeaker``), reshape into ``[Speaker]: body`` with that English tag, and
|
||||||
# ``Speaker\nbody`` layout so injection stays byte-faithful.
|
# on write-back restore WOLF's native ``Speaker\nbody`` layout using the
|
||||||
|
# pre-resolved name so a model that echoes Japanese cannot poison ``text``.
|
||||||
#
|
#
|
||||||
# WolfDawn does the detection, so there is nothing to configure for the reliable
|
# WolfDawn does the detection, so there is nothing to configure for the reliable
|
||||||
# format: ``literal_line1`` is a real nameplate (a face window precedes the line),
|
# format: ``literal_line1`` is a real nameplate (a face window precedes the line),
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue