diff --git a/gui/wolf_workflow_tab.py b/gui/wolf_workflow_tab.py index 8354c7f..38b8678 100644 --- a/gui/wolf_workflow_tab.py +++ b/gui/wolf_workflow_tab.py @@ -828,7 +828,7 @@ class WolfWorkflowTab(QWidget): def _add_speaker_options(self, layout: QVBoxLayout): """Toggles for which speaker formats are reshaped into '[Speaker]: line'.""" - from util import wolf_speakers + from util import speakers as wolf_speakers layout.addWidget(_make_hr()) layout.addWidget(self._subheading("Speaker handling")) @@ -857,7 +857,7 @@ class WolfWorkflowTab(QWidget): layout.addWidget(self._speaker_lo_cb) def _save_speaker_options(self): - from util import wolf_speakers + from util import speakers as wolf_speakers try: wolf_speakers.save_config({ diff --git a/modules/csv.py b/modules/csv.py index ade91cf..898b4b4 100644 --- a/modules/csv.py +++ b/modules/csv.py @@ -14,7 +14,7 @@ from dotenv import load_dotenv from retry import retry from tqdm import tqdm from util.translation import TranslationConfig, translateAI as sharedtranslateAI, getPricingConfig, calculateCost -from util.speaker_prefix import strip_speaker_prefix +from util.speakers import strip_speaker_prefix import tempfile # Globals diff --git a/modules/json.py b/modules/json.py index 1186a5f..c528aad 100644 --- a/modules/json.py +++ b/modules/json.py @@ -13,7 +13,7 @@ from dotenv import load_dotenv from retry import retry from tqdm import tqdm from util.translation import TranslationConfig, translateAI as sharedtranslateAI, getPricingConfig, calculateCost, getPricingConfig, calculateCost -from util.speaker_prefix import strip_speaker_prefix +from util.speakers import strip_speaker_prefix import tempfile # Globals diff --git a/modules/kirikiri.py b/modules/kirikiri.py index 66a2500..1749744 100644 --- a/modules/kirikiri.py +++ b/modules/kirikiri.py @@ -13,7 +13,7 @@ from dotenv import load_dotenv from retry import retry from tqdm import tqdm from util.translation import TranslationConfig, translateAI as sharedtranslateAI, getPricingConfig, calculateCost, getPricingConfig, calculateCost -from util.speaker_prefix import strip_speaker_prefix +from util.speakers import strip_speaker_prefix import tempfile # Globals diff --git a/modules/regex.py b/modules/regex.py index 0ab8c3f..35be6ab 100644 --- a/modules/regex.py +++ b/modules/regex.py @@ -13,7 +13,7 @@ from dotenv import load_dotenv from retry import retry from tqdm import tqdm from util.translation import TranslationConfig, translateAI as sharedtranslateAI, getPricingConfig, calculateCost -from util.speaker_prefix import strip_speaker_prefix +from util.speakers import strip_speaker_prefix import tempfile # Globals diff --git a/modules/renpy.py b/modules/renpy.py index c475c37..b9e87ab 100644 --- a/modules/renpy.py +++ b/modules/renpy.py @@ -13,7 +13,7 @@ from dotenv import load_dotenv from retry import retry from tqdm import tqdm from util.translation import TranslationConfig, translateAI as sharedtranslateAI, getPricingConfig, calculateCost, getPricingConfig, calculateCost -from util.speaker_prefix import strip_speaker_prefix +from util.speakers import strip_speaker_prefix import tempfile # Globals diff --git a/modules/rpgmakermvmz.py b/modules/rpgmakermvmz.py index c468964..dface78 100644 --- a/modules/rpgmakermvmz.py +++ b/modules/rpgmakermvmz.py @@ -14,7 +14,7 @@ from dotenv import load_dotenv from retry import retry from tqdm import tqdm from util.translation import TranslationConfig, translateAI as sharedtranslateAI, getPricingConfig, calculateCost, getPricingConfig, calculateCost, get_var_translation, set_var_translations_batch -from util.speaker_prefix import SPEAKER_BRACKET_INNER, strip_speaker_prefix +from util.speakers import SPEAKER_BRACKET_INNER, strip_speaker_prefix # Globals MODEL = os.getenv("model") diff --git a/modules/srpg.py b/modules/srpg.py index 483a9a0..a767f2b 100644 --- a/modules/srpg.py +++ b/modules/srpg.py @@ -14,7 +14,7 @@ from dotenv import load_dotenv from retry import retry from tqdm import tqdm from util.translation import TranslationConfig, translateAI as sharedtranslateAI, getPricingConfig, calculateCost -from util.speaker_prefix import strip_speaker_prefix +from util.speakers import strip_speaker_prefix # Globals MODEL = os.getenv("model") diff --git a/modules/text.py b/modules/text.py index bf964d1..770fd72 100644 --- a/modules/text.py +++ b/modules/text.py @@ -13,7 +13,7 @@ from dotenv import load_dotenv from retry import retry from tqdm import tqdm from util.translation import TranslationConfig, translateAI as sharedtranslateAI, getPricingConfig, calculateCost, getPricingConfig, calculateCost -from util.speaker_prefix import SPEAKER_TAG_RE, extract_dialogue_after_speaker, strip_speaker_prefix +from util.speakers import SPEAKER_TAG_RE, extract_dialogue_after_speaker, strip_speaker_prefix import tempfile # Globals diff --git a/modules/tyrano.py b/modules/tyrano.py index adac9c8..c8e7361 100644 --- a/modules/tyrano.py +++ b/modules/tyrano.py @@ -13,7 +13,7 @@ from dotenv import load_dotenv from retry import retry from tqdm import tqdm from util.translation import TranslationConfig, translateAI as sharedtranslateAI, getPricingConfig, calculateCost -from util.speaker_prefix import strip_speaker_prefix +from util.speakers import strip_speaker_prefix import tempfile # OpenAI initialization centralized in util/translation.py diff --git a/modules/wolf.py b/modules/wolf.py index 5010498..8a768a5 100644 --- a/modules/wolf.py +++ b/modules/wolf.py @@ -14,7 +14,7 @@ from dotenv import load_dotenv from retry import retry from tqdm import tqdm from util.translation import TranslationConfig, translateAI as sharedtranslateAI, getPricingConfig, calculateCost, getPricingConfig, calculateCost -from util.speaker_prefix import strip_speaker_prefix +from util.speakers import strip_speaker_prefix # OpenAI initialization centralized in util/translation.py diff --git a/modules/wolf2.py b/modules/wolf2.py index 64700ed..d93bac4 100644 --- a/modules/wolf2.py +++ b/modules/wolf2.py @@ -14,7 +14,7 @@ from dotenv import load_dotenv from retry import retry from tqdm import tqdm from util.translation import TranslationConfig, translateAI as sharedtranslateAI, getPricingConfig, calculateCost -from util.speaker_prefix import strip_speaker_prefix +from util.speakers import strip_speaker_prefix import tempfile # OpenAI initialization centralized in util/translation.py diff --git a/modules/wolfdawn.py b/modules/wolfdawn.py index 897099c..64382ab 100644 --- a/modules/wolfdawn.py +++ b/modules/wolfdawn.py @@ -22,7 +22,7 @@ Speakers: WolfDawn tags each line with ``speaker`` / ``speaker_src``. For the 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 ``[Speaker]: line`` convention (which the prompt already translates) and restored -to WOLF's native ``Speaker\nline`` layout on write-back. See ``util.wolf_speakers``; +to WOLF's native ``Speaker\nline`` layout on write-back. See ``util.speakers``; which formats are reshaped is configurable from the workflow. """ @@ -43,7 +43,7 @@ from util.translation import ( getPricingConfig, calculateCost, ) -from util import wolf_speakers +from util import speakers as wolf_speakers # Globals (mirror the other engine modules; populated from .env at import time) MODEL = os.getenv("model") diff --git a/modules/yuris.py b/modules/yuris.py index 343f5be..f9b151b 100644 --- a/modules/yuris.py +++ b/modules/yuris.py @@ -11,7 +11,7 @@ from colorama import Fore from tqdm import tqdm import util.dazedwrap as dazedwrap -from util.speaker_prefix import strip_speaker_prefix +from util.speakers import strip_speaker_prefix from util.translation import ( TranslationConfig, calculateCost, diff --git a/tests/test_speaker_prefix.py b/tests/test_speaker_prefix.py index 2124ad6..888d1b8 100644 --- a/tests/test_speaker_prefix.py +++ b/tests/test_speaker_prefix.py @@ -1,6 +1,6 @@ import unittest -from util.speaker_prefix import ( +from util.speakers import ( SPEAKER_TAG_RE, extract_dialogue_after_speaker, strip_speaker_prefix, diff --git a/tests/test_wolf_speakers.py b/tests/test_wolf_speakers.py index 56fb9d8..2da18ce 100644 --- a/tests/test_wolf_speakers.py +++ b/tests/test_wolf_speakers.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Unit tests for util/wolf_speakers.py (first-line speaker reshaping).""" +"""Unit tests for the WOLF first-line speaker reshaping in util/speakers.py.""" from __future__ import annotations @@ -12,7 +12,7 @@ ROOT = Path(__file__).resolve().parents[1] os.chdir(ROOT) sys.path.insert(0, str(ROOT)) -from util import wolf_speakers as ws # noqa: E402 +from util import speakers as ws # noqa: E402 ALL_ON = {"literal_line1": True, "literal_line1_lowconf": True} ALL_OFF = {"literal_line1": False, "literal_line1_lowconf": False} diff --git a/util/speaker_prefix.py b/util/speaker_prefix.py deleted file mode 100644 index fc4aa02..0000000 --- a/util/speaker_prefix.py +++ /dev/null @@ -1,43 +0,0 @@ -"""Shared [Speaker]: prefix parsing for dialogue lines. - -Handles RPG Maker control codes inside speaker brackets, e.g. -``[\\C[10]Hp Drink\\C[0]]: dialogue`` where inner ``[10]`` must not end the match early. -""" - -from __future__ import annotations - -import re - -# Inner text of [Speaker] — allows \C[n], \c[n], \i[n], \n[actorId], etc. -# The control-code branch (\X[...]) keeps the inner [..] from ending the bracket early. -SPEAKER_BRACKET_INNER = r"(?:\\[A-Za-z]+\[[^\]]*\]|[^\]\n])+" - -# A bare [Speaker]: / [Speaker]| / [Speaker]: prefix. -_PREFIX_PATTERN = rf"^\[{SPEAKER_BRACKET_INNER}\]\s*[|::]\s*" -SPEAKER_PREFIX_RE = re.compile(_PREFIX_PATTERN, re.IGNORECASE) - -SPEAKER_TAG_RE = re.compile( - rf"^\[({SPEAKER_BRACKET_INNER})\]", - re.IGNORECASE, -) - -# Dialogue body after an optional [Speaker]: prefix (text.py lineTextRegex, etc.) -SPEAKER_PREFIX_OPTIONAL_RE = re.compile( - rf"(?:{_PREFIX_PATTERN})?(.+)", - re.IGNORECASE, -) - - -def strip_speaker_prefix(text: str) -> str: - """Remove a leading ``[Speaker]:`` / ``[Speaker]|`` / ``[Speaker]:`` prefix.""" - if not text: - return text - return SPEAKER_PREFIX_RE.sub("", text, count=1) - - -def extract_dialogue_after_speaker(text: str) -> str | None: - """Return dialogue body after an optional ``[Speaker]:`` prefix, or ``None``.""" - if not text: - return None - m = SPEAKER_PREFIX_OPTIONAL_RE.match(text) - return m.group(1) if m else None diff --git a/util/speaker_detector.py b/util/speakers.py similarity index 56% rename from util/speaker_detector.py rename to util/speakers.py index e1ab703..d4c6f99 100644 --- a/util/speaker_detector.py +++ b/util/speakers.py @@ -1,284 +1,447 @@ -""" -Speaker Format Detector for RPGMaker MV/MZ - -Mirrors the detection priority of rpgmakermvmz.py searchCodes() exactly: - - Pass 1 — scan 401/405 codes in order: - 1. \\n / \\k inline nametag codes (always active, no flag needed) - 2. 【Name】 alone on a 401 line (always active, no flag needed) - 3. 【Name】dialogue on same 401 line (always active, no flag needed) - 4. Name「dialogue」 inline quote -> INLINE401SPEAKERS - 5. Short 401 (<40 chars) followed by 401 whose - text starts with 「 " ( ( * [ -> FIRSTLINESPEAKERS - - Pass 2 — only if Pass 1 produced no reliable hits: - 6. 101 code param[0] is a non-empty name string -> FACENAME101 - -Returns the best mode and confidence scores. -""" - -from __future__ import annotations - -import json -import re -from pathlib import Path - -# ── Regexes matching rpgmakermvmz.py exactly ──────────────────────────────── - -# \\n / \\k nametag codes (always active in module) -_NAMETAG_RE = re.compile( - r"[\\]+[kKnN][wWcCrRrEe]?[\[<](?:[\\]*\w\[\d+\])?(.*?)(?:[\\]*\w\[\d+\])?[>]" -) - -# 【Name】 alone on the line (with optional trailing control codes) -_BRACKET_ALONE_RE = re.compile( - r"^\s*【[^】]+】(?:\s*|(?:[\\]+[A-Za-z]+(?:\[(?:[^\[\]]|\[[^\]]*\])*\])+\s*)*)$" -) - -# 【Name】dialogue on the same line -_BRACKET_INLINE_RE = re.compile(r"^\s*【([^】]+)】(.+)", re.DOTALL) - -# Inline quote: Name「dialogue… -_INLINE_QUOTE_RE = re.compile(r"^([^\s「」。、!?…\\\n]{1,20})「") - -# Dialogue starters that follow a FIRSTLINESPEAKERS name line -_DIALOGUE_STARTERS = ("「", '"', "(", "(", "*", "[") - -# Minimum hits to trust a result -_MIN_HITS = 3 - - -def detect_speaker_format( - files_dir: str | Path = "files", - sample_size: int = 20, -) -> dict: - """Scan up to *sample_size* map files and determine the speaker format. - - Returns: - { - "best_mode": "INLINE401SPEAKERS" | "FIRSTLINESPEAKERS" | "FACENAME101" - | "ALWAYS_ON" | "NONE", - "scores": { - "nametag_codes": int, # \\n hits — always handled, no flag - "bracket_401": int, # 【Name】 hits — always handled, no flag - "INLINE401SPEAKERS": int, - "FIRSTLINESPEAKERS": int, - "FACENAME101": int, - }, - "total_401_groups": int, - "files_scanned": int, - "recommended_config": { - "INLINE401SPEAKERS": bool, - "FIRSTLINESPEAKERS": bool, - "FACENAME101": bool, - }, - "confidence": "high" | "medium" | "low", - "note": str, - } - """ - files_dir = Path(files_dir) - scores = { - "nametag_codes": 0, - "bracket_401": 0, - "INLINE401SPEAKERS": 0, - "FIRSTLINESPEAKERS": 0, - "FACENAME101": 0, - } - total_groups = 0 - files_scanned = 0 - - # Collect map files (Maps only, not MapInfos), largest first for better signal - map_files = sorted( - [p for p in files_dir.glob("Map[0-9]*.json") if p.is_file()], - key=lambda p: p.stat().st_size, - reverse=True, - )[:sample_size] - - for fp in map_files: - try: - with open(fp, "r", encoding="utf-8-sig") as fh: - data = json.load(fh) - except Exception: - continue - - files_scanned += 1 - events = data.get("events") or [] - for evt in events: - if not evt: - continue - for page in evt.get("pages") or []: - cmd_list = (page or {}).get("list") or [] - _score_command_list(cmd_list, scores) - # Count 401 groups - i = 0 - while i < len(cmd_list): - if cmd_list[i].get("code") in (401, 405): - total_groups += 1 - while i < len(cmd_list) and cmd_list[i].get("code") in (401, 405, -1): - i += 1 - continue - i += 1 - - # Also scan CommonEvents - ce_path = files_dir / "CommonEvents.json" - if ce_path.is_file(): - try: - with open(ce_path, "r", encoding="utf-8-sig") as fh: - ce_data = json.load(fh) - files_scanned += 1 - for entry in ce_data or []: - if not entry: - continue - _score_command_list(entry.get("list") or [], scores) - except Exception: - pass - - # ── Decision: 401-based patterns first, FACENAME101 only as fallback ──── - always_on_hits = scores["nametag_codes"] + scores["bracket_401"] - inline_hits = scores["INLINE401SPEAKERS"] - first_hits = scores["FIRSTLINESPEAKERS"] - face_hits = scores["FACENAME101"] - total_401_hits = always_on_hits + inline_hits + first_hits - - recommended = { - "INLINE401SPEAKERS": False, - "FIRSTLINESPEAKERS": False, - "FACENAME101": False, - } - - if total_401_hits >= _MIN_HITS: - if always_on_hits >= _MIN_HITS and always_on_hits >= inline_hits and always_on_hits >= first_hits: - best_mode = "ALWAYS_ON" - confidence = "high" if always_on_hits > (inline_hits + first_hits) else "medium" - note = ( - f"Speakers detected via \\\\n codes or 【Name】 brackets " - f"({always_on_hits} hits) — no extra flags needed. " - f"INLINE={inline_hits}, FIRSTLINE={first_hits}. " - f"Scanned {files_scanned} file(s), {total_groups} dialogue group(s)." - ) - elif inline_hits >= first_hits: - best_mode = "INLINE401SPEAKERS" - recommended["INLINE401SPEAKERS"] = True - confidence = "high" if inline_hits > first_hits * 2 else "medium" - note = ( - f"INLINE401SPEAKERS: {inline_hits} hits " - f"(FIRSTLINESPEAKERS: {first_hits}, always-on: {always_on_hits}). " - f"Scanned {files_scanned} file(s), {total_groups} dialogue group(s)." - ) - else: - best_mode = "FIRSTLINESPEAKERS" - recommended["FIRSTLINESPEAKERS"] = True - confidence = "high" if first_hits > inline_hits * 2 else "medium" - note = ( - f"FIRSTLINESPEAKERS: {first_hits} hits " - f"(INLINE401SPEAKERS: {inline_hits}, always-on: {always_on_hits}). " - f"Scanned {files_scanned} file(s), {total_groups} dialogue group(s)." - ) - elif face_hits >= _MIN_HITS: - best_mode = "FACENAME101" - recommended["FACENAME101"] = True - confidence = "high" if face_hits > _MIN_HITS * 2 else "medium" - note = ( - f"No 401-based speaker pattern found (401 hits: {total_401_hits}). " - f"FACENAME101 recommended based on {face_hits} 101-code name hits. " - f"Scanned {files_scanned} file(s), {total_groups} dialogue group(s)." - ) - else: - best_mode = "NONE" - confidence = "low" - note = ( - f"No reliable speaker pattern detected " - f"(401 hits: {total_401_hits}, FACENAME101 hits: {face_hits}). " - f"Scanned {files_scanned} file(s), {total_groups} dialogue group(s). " - "Check speaker settings manually." - ) - - return { - "best_mode": best_mode, - "scores": scores, - "total_401_groups": total_groups, - "files_scanned": files_scanned, - "recommended_config": recommended, - "confidence": confidence, - "note": note, - } - - -# ── Internal helpers ───────────────────────────────────────────────────────── - -def _score_command_list(cmd_list: list, scores: dict) -> None: - """Walk a command list and score speaker patterns in module priority order.""" - if not cmd_list: - return - - i = 0 - while i < len(cmd_list): - cmd = cmd_list[i] - code = cmd.get("code") - params = cmd.get("parameters") or [] - - # ── 101: Show Text / face name ──────────────────────────────────────── - if code == 101: - # FACENAME101: param[0] is a non-empty face-name string - face = params[0] if params else "" - if isinstance(face, str) and face.strip(): - scores["FACENAME101"] += 1 - i += 1 - continue - - # ── 401 / 405: dialogue line ────────────────────────────────────────── - if code in (401, 405): - text = (params[0] if params else "") or "" - - # 1. \\n nametag codes (always active — no flag needed) - if _NAMETAG_RE.search(text): - scores["nametag_codes"] += 1 - i += 1 - continue # module strips the nametag before further processing - - # 2+3. 【Name】 alone on line, or 【Name】dialogue inline (always active) - if _BRACKET_ALONE_RE.match(text) or _BRACKET_INLINE_RE.match(text): - scores["bracket_401"] += 1 - i += 1 - continue - - # 4. INLINE401SPEAKERS: Name「dialogue - if _INLINE_QUOTE_RE.match(text): - scores["INLINE401SPEAKERS"] += 1 - i += 1 - continue - - # 5. FIRSTLINESPEAKERS: short line followed by 401 starting with - # a dialogue-starter character - if ( - len(text) < 40 - and _has_japanese(text) - and not any(text.lstrip().startswith(s) for s in _DIALOGUE_STARTERS) - ): - j = i + 1 - while j < len(cmd_list) and cmd_list[j].get("code") == -1: - j += 1 - if j < len(cmd_list) and cmd_list[j].get("code") in (401, 405): - next_params = cmd_list[j].get("parameters") or [] - next_text = (next_params[0] if next_params else "") or "" - # Strip leading RPGMaker control codes before checking starter - next_stripped = re.sub( - r"^(?:[\\]+[^cCnNiIkKvVSs{}]+?\[[\d\w\W]+?\]?\])+", - "", next_text, - ).lstrip() - if next_stripped and next_stripped[0] in _DIALOGUE_STARTERS: - scores["FIRSTLINESPEAKERS"] += 1 - - i += 1 - continue - - i += 1 - - -_JP_RE = re.compile( - r"[\u3000-\u303F\u3040-\u309F\u30A0-\u30FF\u4E00-\u9FFF\uF900-\uFAFF\uFF61-\uFF9F]" -) - - -def _has_japanese(text: str) -> bool: - return bool(_JP_RE.search(text)) +"""Speaker utilities shared across engine modules. + +One home for everything speaker-related so ``util/`` does not sprawl: + +* **Prefix parsing** - the ``[Speaker]: line`` convention the shared prompt uses. + Widely imported by the engine modules to strip the tag off translated text. +* **RPG Maker MV/MZ format detection** - :func:`detect_speaker_format` scans a + project's map/common-event JSON to recommend which speaker flags to enable. +* **WOLF first-line reshaping** - WolfDawn bakes some speaker names into line 1 + of the source; these helpers reshape those lines into ``[Speaker]: line`` for + translation and restore WOLF's native ``Speaker\nline`` layout afterwards. +""" + +from __future__ import annotations + +import json +import re +from pathlib import Path + +from util.paths import DATA_DIR + +# ============================================================================ +# [Speaker]: prefix parsing +# ---------------------------------------------------------------------------- +# Handles RPG Maker control codes inside speaker brackets, e.g. +# ``[\\C[10]Hp Drink\\C[0]]: dialogue`` where inner ``[10]`` must not end the +# match early. +# ============================================================================ + +# Inner text of [Speaker] - allows \C[n], \c[n], \i[n], \n[actorId], etc. +# The control-code branch (\X[...]) keeps the inner [..] from ending the bracket early. +SPEAKER_BRACKET_INNER = r"(?:\\[A-Za-z]+\[[^\]]*\]|[^\]\n])+" + +# A bare [Speaker]: / [Speaker]| / [Speaker]: prefix. +_PREFIX_PATTERN = rf"^\[{SPEAKER_BRACKET_INNER}\]\s*[|::]\s*" +SPEAKER_PREFIX_RE = re.compile(_PREFIX_PATTERN, re.IGNORECASE) + +SPEAKER_TAG_RE = re.compile( + rf"^\[({SPEAKER_BRACKET_INNER})\]", + re.IGNORECASE, +) + +# Dialogue body after an optional [Speaker]: prefix (text.py lineTextRegex, etc.) +SPEAKER_PREFIX_OPTIONAL_RE = re.compile( + rf"(?:{_PREFIX_PATTERN})?(.+)", + re.IGNORECASE, +) + + +def strip_speaker_prefix(text: str) -> str: + """Remove a leading ``[Speaker]:`` / ``[Speaker]|`` / ``[Speaker]:`` prefix.""" + if not text: + return text + return SPEAKER_PREFIX_RE.sub("", text, count=1) + + +def extract_dialogue_after_speaker(text: str) -> str | None: + """Return dialogue body after an optional ``[Speaker]:`` prefix, or ``None``.""" + if not text: + return None + m = SPEAKER_PREFIX_OPTIONAL_RE.match(text) + return m.group(1) if m else None + + +# ============================================================================ +# RPG Maker MV/MZ speaker format detection +# ---------------------------------------------------------------------------- +# Mirrors the detection priority of rpgmakermvmz.py searchCodes() exactly: +# +# Pass 1 - scan 401/405 codes in order: +# 1. \\n / \\k inline nametag codes (always active, no flag needed) +# 2. 【Name】 alone on a 401 line (always active, no flag needed) +# 3. 【Name】dialogue on same 401 line (always active, no flag needed) +# 4. Name「dialogue」 inline quote -> INLINE401SPEAKERS +# 5. Short 401 (<40 chars) followed by 401 whose +# text starts with 「 " ( ( * [ -> FIRSTLINESPEAKERS +# +# Pass 2 - only if Pass 1 produced no reliable hits: +# 6. 101 code param[0] is a non-empty name string -> FACENAME101 +# ============================================================================ + +# \\n / \\k nametag codes (always active in module) +_NAMETAG_RE = re.compile( + r"[\\]+[kKnN][wWcCrRrEe]?[\[<](?:[\\]*\w\[\d+\])?(.*?)(?:[\\]*\w\[\d+\])?[>]" +) + +# 【Name】 alone on the line (with optional trailing control codes) +_BRACKET_ALONE_RE = re.compile( + r"^\s*【[^】]+】(?:\s*|(?:[\\]+[A-Za-z]+(?:\[(?:[^\[\]]|\[[^\]]*\])*\])+\s*)*)$" +) + +# 【Name】dialogue on the same line +_BRACKET_INLINE_RE = re.compile(r"^\s*【([^】]+)】(.+)", re.DOTALL) + +# Inline quote: Name「dialogue… +_INLINE_QUOTE_RE = re.compile(r"^([^\s「」。、!?…\\\n]{1,20})「") + +# Dialogue starters that follow a FIRSTLINESPEAKERS name line +_DIALOGUE_STARTERS = ("「", '"', "(", "(", "*", "[") + +# Minimum hits to trust a result +_MIN_HITS = 3 + + +def detect_speaker_format( + files_dir: str | Path = "files", + sample_size: int = 20, +) -> dict: + """Scan up to *sample_size* map files and determine the speaker format. + + Returns: + { + "best_mode": "INLINE401SPEAKERS" | "FIRSTLINESPEAKERS" | "FACENAME101" + | "ALWAYS_ON" | "NONE", + "scores": { + "nametag_codes": int, # \\n hits — always handled, no flag + "bracket_401": int, # 【Name】 hits — always handled, no flag + "INLINE401SPEAKERS": int, + "FIRSTLINESPEAKERS": int, + "FACENAME101": int, + }, + "total_401_groups": int, + "files_scanned": int, + "recommended_config": { + "INLINE401SPEAKERS": bool, + "FIRSTLINESPEAKERS": bool, + "FACENAME101": bool, + }, + "confidence": "high" | "medium" | "low", + "note": str, + } + """ + files_dir = Path(files_dir) + scores = { + "nametag_codes": 0, + "bracket_401": 0, + "INLINE401SPEAKERS": 0, + "FIRSTLINESPEAKERS": 0, + "FACENAME101": 0, + } + total_groups = 0 + files_scanned = 0 + + # Collect map files (Maps only, not MapInfos), largest first for better signal + map_files = sorted( + [p for p in files_dir.glob("Map[0-9]*.json") if p.is_file()], + key=lambda p: p.stat().st_size, + reverse=True, + )[:sample_size] + + for fp in map_files: + try: + with open(fp, "r", encoding="utf-8-sig") as fh: + data = json.load(fh) + except Exception: + continue + + files_scanned += 1 + events = data.get("events") or [] + for evt in events: + if not evt: + continue + for page in evt.get("pages") or []: + cmd_list = (page or {}).get("list") or [] + _score_command_list(cmd_list, scores) + # Count 401 groups + i = 0 + while i < len(cmd_list): + if cmd_list[i].get("code") in (401, 405): + total_groups += 1 + while i < len(cmd_list) and cmd_list[i].get("code") in (401, 405, -1): + i += 1 + continue + i += 1 + + # Also scan CommonEvents + ce_path = files_dir / "CommonEvents.json" + if ce_path.is_file(): + try: + with open(ce_path, "r", encoding="utf-8-sig") as fh: + ce_data = json.load(fh) + files_scanned += 1 + for entry in ce_data or []: + if not entry: + continue + _score_command_list(entry.get("list") or [], scores) + except Exception: + pass + + # ── Decision: 401-based patterns first, FACENAME101 only as fallback ──── + always_on_hits = scores["nametag_codes"] + scores["bracket_401"] + inline_hits = scores["INLINE401SPEAKERS"] + first_hits = scores["FIRSTLINESPEAKERS"] + face_hits = scores["FACENAME101"] + total_401_hits = always_on_hits + inline_hits + first_hits + + recommended = { + "INLINE401SPEAKERS": False, + "FIRSTLINESPEAKERS": False, + "FACENAME101": False, + } + + if total_401_hits >= _MIN_HITS: + if always_on_hits >= _MIN_HITS and always_on_hits >= inline_hits and always_on_hits >= first_hits: + best_mode = "ALWAYS_ON" + confidence = "high" if always_on_hits > (inline_hits + first_hits) else "medium" + note = ( + f"Speakers detected via \\\\n codes or 【Name】 brackets " + f"({always_on_hits} hits) — no extra flags needed. " + f"INLINE={inline_hits}, FIRSTLINE={first_hits}. " + f"Scanned {files_scanned} file(s), {total_groups} dialogue group(s)." + ) + elif inline_hits >= first_hits: + best_mode = "INLINE401SPEAKERS" + recommended["INLINE401SPEAKERS"] = True + confidence = "high" if inline_hits > first_hits * 2 else "medium" + note = ( + f"INLINE401SPEAKERS: {inline_hits} hits " + f"(FIRSTLINESPEAKERS: {first_hits}, always-on: {always_on_hits}). " + f"Scanned {files_scanned} file(s), {total_groups} dialogue group(s)." + ) + else: + best_mode = "FIRSTLINESPEAKERS" + recommended["FIRSTLINESPEAKERS"] = True + confidence = "high" if first_hits > inline_hits * 2 else "medium" + note = ( + f"FIRSTLINESPEAKERS: {first_hits} hits " + f"(INLINE401SPEAKERS: {inline_hits}, always-on: {always_on_hits}). " + f"Scanned {files_scanned} file(s), {total_groups} dialogue group(s)." + ) + elif face_hits >= _MIN_HITS: + best_mode = "FACENAME101" + recommended["FACENAME101"] = True + confidence = "high" if face_hits > _MIN_HITS * 2 else "medium" + note = ( + f"No 401-based speaker pattern found (401 hits: {total_401_hits}). " + f"FACENAME101 recommended based on {face_hits} 101-code name hits. " + f"Scanned {files_scanned} file(s), {total_groups} dialogue group(s)." + ) + else: + best_mode = "NONE" + confidence = "low" + note = ( + f"No reliable speaker pattern detected " + f"(401 hits: {total_401_hits}, FACENAME101 hits: {face_hits}). " + f"Scanned {files_scanned} file(s), {total_groups} dialogue group(s). " + "Check speaker settings manually." + ) + + return { + "best_mode": best_mode, + "scores": scores, + "total_401_groups": total_groups, + "files_scanned": files_scanned, + "recommended_config": recommended, + "confidence": confidence, + "note": note, + } + + +def _score_command_list(cmd_list: list, scores: dict) -> None: + """Walk a command list and score speaker patterns in module priority order.""" + if not cmd_list: + return + + i = 0 + while i < len(cmd_list): + cmd = cmd_list[i] + code = cmd.get("code") + params = cmd.get("parameters") or [] + + # ── 101: Show Text / face name ──────────────────────────────────────── + if code == 101: + # FACENAME101: param[0] is a non-empty face-name string + face = params[0] if params else "" + if isinstance(face, str) and face.strip(): + scores["FACENAME101"] += 1 + i += 1 + continue + + # ── 401 / 405: dialogue line ────────────────────────────────────────── + if code in (401, 405): + text = (params[0] if params else "") or "" + + # 1. \\n nametag codes (always active — no flag needed) + if _NAMETAG_RE.search(text): + scores["nametag_codes"] += 1 + i += 1 + continue # module strips the nametag before further processing + + # 2+3. 【Name】 alone on line, or 【Name】dialogue inline (always active) + if _BRACKET_ALONE_RE.match(text) or _BRACKET_INLINE_RE.match(text): + scores["bracket_401"] += 1 + i += 1 + continue + + # 4. INLINE401SPEAKERS: Name「dialogue + if _INLINE_QUOTE_RE.match(text): + scores["INLINE401SPEAKERS"] += 1 + i += 1 + continue + + # 5. FIRSTLINESPEAKERS: short line followed by 401 starting with + # a dialogue-starter character + if ( + len(text) < 40 + and _has_japanese(text) + and not any(text.lstrip().startswith(s) for s in _DIALOGUE_STARTERS) + ): + j = i + 1 + while j < len(cmd_list) and cmd_list[j].get("code") == -1: + j += 1 + if j < len(cmd_list) and cmd_list[j].get("code") in (401, 405): + next_params = cmd_list[j].get("parameters") or [] + next_text = (next_params[0] if next_params else "") or "" + # Strip leading RPGMaker control codes before checking starter + next_stripped = re.sub( + r"^(?:[\\]+[^cCnNiIkKvVSs{}]+?\[[\d\w\W]+?\]?\])+", + "", next_text, + ).lstrip() + if next_stripped and next_stripped[0] in _DIALOGUE_STARTERS: + scores["FIRSTLINESPEAKERS"] += 1 + + i += 1 + continue + + i += 1 + + +_JP_RE = re.compile( + r"[\u3000-\u303F\u3040-\u309F\u30A0-\u30FF\u4E00-\u9FFF\uF900-\uFAFF\uFF61-\uFF9F]" +) + + +def _has_japanese(text: str) -> bool: + return bool(_JP_RE.search(text)) + + +# ============================================================================ +# WOLF (WolfDawn) first-line speaker reshaping +# ---------------------------------------------------------------------------- +# WolfDawn attributes a speaker to every extracted line (``speaker`` / +# ``speaker_src`` fields). For the two "first-line" formats the speaker name is +# baked into line 1 of the ``source`` text, e.g.:: +# +# "市民\nおぉっ!来た!帰ってきたぞ!" speaker_src = literal_line1_lowconf +# "セルリア\nほーら、ローザも手を振って。" speaker_src = literal_line1 +# +# When translating we reshape those into ``[Speaker]: body`` (the prompt already +# knows to translate the tag) and, on write-back, restore WOLF's native +# ``Speaker\nbody`` layout so injection stays byte-faithful. Which formats are +# reshaped is configurable (``data/wolf_speakers.json``) so the workflow can +# toggle the high-confidence nameplate and the low-confidence guess separately. +# ============================================================================ + +# speaker_src values whose name is baked into line 1 of the source text. +FIRSTLINE_SRCS = ("literal_line1", "literal_line1_lowconf") + +# Default: reshape both first-line formats. WolfDawn already gates the +# low-confidence one (short line 1, no control codes, no sentence punctuation), +# so it is safe enough to enable by default. +DEFAULT_CONFIG = { + "literal_line1": True, + "literal_line1_lowconf": True, +} + +CONFIG_PATH = DATA_DIR / "wolf_speakers.json" + +# Optional leading window-option prefix (``@