Speakers in one

This commit is contained in:
DazedAnon 2026-07-03 16:01:31 -05:00
parent 625c0a83d3
commit 54640da4e4
19 changed files with 466 additions and 467 deletions

View file

@ -828,7 +828,7 @@ class WolfWorkflowTab(QWidget):
def _add_speaker_options(self, layout: QVBoxLayout): def _add_speaker_options(self, layout: QVBoxLayout):
"""Toggles for which speaker formats are reshaped into '[Speaker]: line'.""" """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(_make_hr())
layout.addWidget(self._subheading("Speaker handling")) layout.addWidget(self._subheading("Speaker handling"))
@ -857,7 +857,7 @@ class WolfWorkflowTab(QWidget):
layout.addWidget(self._speaker_lo_cb) layout.addWidget(self._speaker_lo_cb)
def _save_speaker_options(self): def _save_speaker_options(self):
from util import wolf_speakers from util import speakers as wolf_speakers
try: try:
wolf_speakers.save_config({ wolf_speakers.save_config({

View file

@ -14,7 +14,7 @@ from dotenv import load_dotenv
from retry import retry from retry import retry
from tqdm import tqdm from tqdm import tqdm
from util.translation import TranslationConfig, translateAI as sharedtranslateAI, getPricingConfig, calculateCost 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 import tempfile
# Globals # Globals

View file

@ -13,7 +13,7 @@ from dotenv import load_dotenv
from retry import retry from retry import retry
from tqdm import tqdm from tqdm import tqdm
from util.translation import TranslationConfig, translateAI as sharedtranslateAI, getPricingConfig, calculateCost, getPricingConfig, calculateCost 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 import tempfile
# Globals # Globals

View file

@ -13,7 +13,7 @@ from dotenv import load_dotenv
from retry import retry from retry import retry
from tqdm import tqdm from tqdm import tqdm
from util.translation import TranslationConfig, translateAI as sharedtranslateAI, getPricingConfig, calculateCost, getPricingConfig, calculateCost 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 import tempfile
# Globals # Globals

View file

@ -13,7 +13,7 @@ from dotenv import load_dotenv
from retry import retry from retry import retry
from tqdm import tqdm from tqdm import tqdm
from util.translation import TranslationConfig, translateAI as sharedtranslateAI, getPricingConfig, calculateCost 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 import tempfile
# Globals # Globals

View file

@ -13,7 +13,7 @@ from dotenv import load_dotenv
from retry import retry from retry import retry
from tqdm import tqdm from tqdm import tqdm
from util.translation import TranslationConfig, translateAI as sharedtranslateAI, getPricingConfig, calculateCost, getPricingConfig, calculateCost 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 import tempfile
# Globals # Globals

View file

@ -14,7 +14,7 @@ from dotenv import load_dotenv
from retry import retry from retry import retry
from tqdm import tqdm 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.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 # Globals
MODEL = os.getenv("model") MODEL = os.getenv("model")

View file

@ -14,7 +14,7 @@ from dotenv import load_dotenv
from retry import retry from retry import retry
from tqdm import tqdm from tqdm import tqdm
from util.translation import TranslationConfig, translateAI as sharedtranslateAI, getPricingConfig, calculateCost 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 # Globals
MODEL = os.getenv("model") MODEL = os.getenv("model")

View file

@ -13,7 +13,7 @@ from dotenv import load_dotenv
from retry import retry from retry import retry
from tqdm import tqdm from tqdm import tqdm
from util.translation import TranslationConfig, translateAI as sharedtranslateAI, getPricingConfig, calculateCost, getPricingConfig, calculateCost 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 import tempfile
# Globals # Globals

View file

@ -13,7 +13,7 @@ from dotenv import load_dotenv
from retry import retry from retry import retry
from tqdm import tqdm from tqdm import tqdm
from util.translation import TranslationConfig, translateAI as sharedtranslateAI, getPricingConfig, calculateCost 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 import tempfile
# OpenAI initialization centralized in util/translation.py # OpenAI initialization centralized in util/translation.py

View file

@ -14,7 +14,7 @@ from dotenv import load_dotenv
from retry import retry from retry import retry
from tqdm import tqdm from tqdm import tqdm
from util.translation import TranslationConfig, translateAI as sharedtranslateAI, getPricingConfig, calculateCost, getPricingConfig, calculateCost 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 # OpenAI initialization centralized in util/translation.py

View file

@ -14,7 +14,7 @@ from dotenv import load_dotenv
from retry import retry from retry import retry
from tqdm import tqdm from tqdm import tqdm
from util.translation import TranslationConfig, translateAI as sharedtranslateAI, getPricingConfig, calculateCost 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 import tempfile
# OpenAI initialization centralized in util/translation.py # OpenAI initialization centralized in util/translation.py

View file

@ -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 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 lines are reshaped into the shared
``[Speaker]: line`` convention (which the prompt already translates) and restored ``[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. which formats are reshaped is configurable from the workflow.
""" """
@ -43,7 +43,7 @@ from util.translation import (
getPricingConfig, getPricingConfig,
calculateCost, 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) # Globals (mirror the other engine modules; populated from .env at import time)
MODEL = os.getenv("model") MODEL = os.getenv("model")

View file

@ -11,7 +11,7 @@ from colorama import Fore
from tqdm import tqdm from tqdm import tqdm
import util.dazedwrap as dazedwrap import util.dazedwrap as dazedwrap
from util.speaker_prefix import strip_speaker_prefix from util.speakers import strip_speaker_prefix
from util.translation import ( from util.translation import (
TranslationConfig, TranslationConfig,
calculateCost, calculateCost,

View file

@ -1,6 +1,6 @@
import unittest import unittest
from util.speaker_prefix import ( from util.speakers import (
SPEAKER_TAG_RE, SPEAKER_TAG_RE,
extract_dialogue_after_speaker, extract_dialogue_after_speaker,
strip_speaker_prefix, strip_speaker_prefix,

View file

@ -1,5 +1,5 @@
#!/usr/bin/env python3 #!/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 from __future__ import annotations
@ -12,7 +12,7 @@ ROOT = Path(__file__).resolve().parents[1]
os.chdir(ROOT) os.chdir(ROOT)
sys.path.insert(0, str(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_ON = {"literal_line1": True, "literal_line1_lowconf": True}
ALL_OFF = {"literal_line1": False, "literal_line1_lowconf": False} ALL_OFF = {"literal_line1": False, "literal_line1_lowconf": False}

View file

@ -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

View file

@ -1,284 +1,447 @@
""" """Speaker utilities shared across engine modules.
Speaker Format Detector for RPGMaker MV/MZ
One home for everything speaker-related so ``util/`` does not sprawl:
Mirrors the detection priority of rpgmakermvmz.py searchCodes() exactly:
* **Prefix parsing** - the ``[Speaker]: line`` convention the shared prompt uses.
Pass 1 scan 401/405 codes in order: Widely imported by the engine modules to strip the tag off translated text.
1. \\n<Name> / \\k<Name> inline nametag codes (always active, no flag needed) * **RPG Maker MV/MZ format detection** - :func:`detect_speaker_format` scans a
2. Name alone on a 401 line (always active, no flag needed) project's map/common-event JSON to recommend which speaker flags to enable.
3. Namedialogue on same 401 line (always active, no flag needed) * **WOLF first-line reshaping** - WolfDawn bakes some speaker names into line 1
4. Namedialogue inline quote -> INLINE401SPEAKERS of the source; these helpers reshape those lines into ``[Speaker]: line`` for
5. Short 401 (<40 chars) followed by 401 whose translation and restore WOLF's native ``Speaker\nline`` layout afterwards.
text starts with " ( * [ -> FIRSTLINESPEAKERS """
Pass 2 only if Pass 1 produced no reliable hits: from __future__ import annotations
6. 101 code param[0] is a non-empty name string -> FACENAME101
import json
Returns the best mode and confidence scores. import re
""" from pathlib import Path
from __future__ import annotations from util.paths import DATA_DIR
import json # ============================================================================
import re # [Speaker]: prefix parsing
from pathlib import Path # ----------------------------------------------------------------------------
# Handles RPG Maker control codes inside speaker brackets, e.g.
# ── Regexes matching rpgmakermvmz.py exactly ──────────────────────────────── # ``[\\C[10]Hp Drink\\C[0]]: dialogue`` where inner ``[10]`` must not end the
# match early.
# \\n<Name> / \\k<Name> nametag codes (always active in module) # ============================================================================
_NAMETAG_RE = re.compile(
r"[\\]+[kKnN][wWcCrRrEe]?[\[<](?:[\\]*\w\[\d+\])?(.*?)(?:[\\]*\w\[\d+\])?[>]" # 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])+"
# 【Name】 alone on the line (with optional trailing control codes)
_BRACKET_ALONE_RE = re.compile( # A bare [Speaker]: / [Speaker]| / [Speaker] prefix.
r"^\s*【[^】]+】(?:\s*|(?:[\\]+[A-Za-z]+(?:\[(?:[^\[\]]|\[[^\]]*\])*\])+\s*)*)$" _PREFIX_PATTERN = rf"^\[{SPEAKER_BRACKET_INNER}\]\s*[|:]\s*"
) SPEAKER_PREFIX_RE = re.compile(_PREFIX_PATTERN, re.IGNORECASE)
# 【Name】dialogue on the same line SPEAKER_TAG_RE = re.compile(
_BRACKET_INLINE_RE = re.compile(r"^\s*【([^】]+)】(.+)", re.DOTALL) rf"^\[({SPEAKER_BRACKET_INNER})\]",
re.IGNORECASE,
# Inline quote: Name「dialogue… )
_INLINE_QUOTE_RE = re.compile(r"^([^\s「」。、\\\n]{1,20})「")
# Dialogue body after an optional [Speaker]: prefix (text.py lineTextRegex, etc.)
# Dialogue starters that follow a FIRSTLINESPEAKERS name line SPEAKER_PREFIX_OPTIONAL_RE = re.compile(
_DIALOGUE_STARTERS = ("", '"', "(", "", "*", "[") rf"(?:{_PREFIX_PATTERN})?(.+)",
re.IGNORECASE,
# Minimum hits to trust a result )
_MIN_HITS = 3
def strip_speaker_prefix(text: str) -> str:
def detect_speaker_format( """Remove a leading ``[Speaker]:`` / ``[Speaker]|`` / ``[Speaker]`` prefix."""
files_dir: str | Path = "files", if not text:
sample_size: int = 20, return text
) -> dict: return SPEAKER_PREFIX_RE.sub("", text, count=1)
"""Scan up to *sample_size* map files and determine the speaker format.
Returns: def extract_dialogue_after_speaker(text: str) -> str | None:
{ """Return dialogue body after an optional ``[Speaker]:`` prefix, or ``None``."""
"best_mode": "INLINE401SPEAKERS" | "FIRSTLINESPEAKERS" | "FACENAME101" if not text:
| "ALWAYS_ON" | "NONE", return None
"scores": { m = SPEAKER_PREFIX_OPTIONAL_RE.match(text)
"nametag_codes": int, # \\n<Name> hits — always handled, no flag return m.group(1) if m else None
"bracket_401": int, # 【Name】 hits — always handled, no flag
"INLINE401SPEAKERS": int,
"FIRSTLINESPEAKERS": int, # ============================================================================
"FACENAME101": int, # RPG Maker MV/MZ speaker format detection
}, # ----------------------------------------------------------------------------
"total_401_groups": int, # Mirrors the detection priority of rpgmakermvmz.py searchCodes() exactly:
"files_scanned": int, #
"recommended_config": { # Pass 1 - scan 401/405 codes in order:
"INLINE401SPEAKERS": bool, # 1. \\n<Name> / \\k<Name> inline nametag codes (always active, no flag needed)
"FIRSTLINESPEAKERS": bool, # 2. 【Name】 alone on a 401 line (always active, no flag needed)
"FACENAME101": bool, # 3. 【Name】dialogue on same 401 line (always active, no flag needed)
}, # 4. Name「dialogue」 inline quote -> INLINE401SPEAKERS
"confidence": "high" | "medium" | "low", # 5. Short 401 (<40 chars) followed by 401 whose
"note": str, # text starts with 「 " ( * [ -> FIRSTLINESPEAKERS
} #
""" # Pass 2 - only if Pass 1 produced no reliable hits:
files_dir = Path(files_dir) # 6. 101 code param[0] is a non-empty name string -> FACENAME101
scores = { # ============================================================================
"nametag_codes": 0,
"bracket_401": 0, # \\n<Name> / \\k<Name> nametag codes (always active in module)
"INLINE401SPEAKERS": 0, _NAMETAG_RE = re.compile(
"FIRSTLINESPEAKERS": 0, r"[\\]+[kKnN][wWcCrRrEe]?[\[<](?:[\\]*\w\[\d+\])?(.*?)(?:[\\]*\w\[\d+\])?[>]"
"FACENAME101": 0, )
}
total_groups = 0 # 【Name】 alone on the line (with optional trailing control codes)
files_scanned = 0 _BRACKET_ALONE_RE = re.compile(
r"^\s*【[^】]+】(?:\s*|(?:[\\]+[A-Za-z]+(?:\[(?:[^\[\]]|\[[^\]]*\])*\])+\s*)*)$"
# 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()], # 【Name】dialogue on the same line
key=lambda p: p.stat().st_size, _BRACKET_INLINE_RE = re.compile(r"^\s*【([^】]+)】(.+)", re.DOTALL)
reverse=True,
)[:sample_size] # Inline quote: Name「dialogue…
_INLINE_QUOTE_RE = re.compile(r"^([^\s「」。、\\\n]{1,20})「")
for fp in map_files:
try: # Dialogue starters that follow a FIRSTLINESPEAKERS name line
with open(fp, "r", encoding="utf-8-sig") as fh: _DIALOGUE_STARTERS = ("", '"', "(", "", "*", "[")
data = json.load(fh)
except Exception: # Minimum hits to trust a result
continue _MIN_HITS = 3
files_scanned += 1
events = data.get("events") or [] def detect_speaker_format(
for evt in events: files_dir: str | Path = "files",
if not evt: sample_size: int = 20,
continue ) -> dict:
for page in evt.get("pages") or []: """Scan up to *sample_size* map files and determine the speaker format.
cmd_list = (page or {}).get("list") or []
_score_command_list(cmd_list, scores) Returns:
# Count 401 groups {
i = 0 "best_mode": "INLINE401SPEAKERS" | "FIRSTLINESPEAKERS" | "FACENAME101"
while i < len(cmd_list): | "ALWAYS_ON" | "NONE",
if cmd_list[i].get("code") in (401, 405): "scores": {
total_groups += 1 "nametag_codes": int, # \\n<Name> hits — always handled, no flag
while i < len(cmd_list) and cmd_list[i].get("code") in (401, 405, -1): "bracket_401": int, # 【Name】 hits — always handled, no flag
i += 1 "INLINE401SPEAKERS": int,
continue "FIRSTLINESPEAKERS": int,
i += 1 "FACENAME101": int,
},
# Also scan CommonEvents "total_401_groups": int,
ce_path = files_dir / "CommonEvents.json" "files_scanned": int,
if ce_path.is_file(): "recommended_config": {
try: "INLINE401SPEAKERS": bool,
with open(ce_path, "r", encoding="utf-8-sig") as fh: "FIRSTLINESPEAKERS": bool,
ce_data = json.load(fh) "FACENAME101": bool,
files_scanned += 1 },
for entry in ce_data or []: "confidence": "high" | "medium" | "low",
if not entry: "note": str,
continue }
_score_command_list(entry.get("list") or [], scores) """
except Exception: files_dir = Path(files_dir)
pass scores = {
"nametag_codes": 0,
# ── Decision: 401-based patterns first, FACENAME101 only as fallback ──── "bracket_401": 0,
always_on_hits = scores["nametag_codes"] + scores["bracket_401"] "INLINE401SPEAKERS": 0,
inline_hits = scores["INLINE401SPEAKERS"] "FIRSTLINESPEAKERS": 0,
first_hits = scores["FIRSTLINESPEAKERS"] "FACENAME101": 0,
face_hits = scores["FACENAME101"] }
total_401_hits = always_on_hits + inline_hits + first_hits total_groups = 0
files_scanned = 0
recommended = {
"INLINE401SPEAKERS": False, # Collect map files (Maps only, not MapInfos), largest first for better signal
"FIRSTLINESPEAKERS": False, map_files = sorted(
"FACENAME101": False, [p for p in files_dir.glob("Map[0-9]*.json") if p.is_file()],
} key=lambda p: p.stat().st_size,
reverse=True,
if total_401_hits >= _MIN_HITS: )[:sample_size]
if always_on_hits >= _MIN_HITS and always_on_hits >= inline_hits and always_on_hits >= first_hits:
best_mode = "ALWAYS_ON" for fp in map_files:
confidence = "high" if always_on_hits > (inline_hits + first_hits) else "medium" try:
note = ( with open(fp, "r", encoding="utf-8-sig") as fh:
f"Speakers detected via \\\\n<Name> codes or 【Name】 brackets " data = json.load(fh)
f"({always_on_hits} hits) — no extra flags needed. " except Exception:
f"INLINE={inline_hits}, FIRSTLINE={first_hits}. " continue
f"Scanned {files_scanned} file(s), {total_groups} dialogue group(s)."
) files_scanned += 1
elif inline_hits >= first_hits: events = data.get("events") or []
best_mode = "INLINE401SPEAKERS" for evt in events:
recommended["INLINE401SPEAKERS"] = True if not evt:
confidence = "high" if inline_hits > first_hits * 2 else "medium" continue
note = ( for page in evt.get("pages") or []:
f"INLINE401SPEAKERS: {inline_hits} hits " cmd_list = (page or {}).get("list") or []
f"(FIRSTLINESPEAKERS: {first_hits}, always-on: {always_on_hits}). " _score_command_list(cmd_list, scores)
f"Scanned {files_scanned} file(s), {total_groups} dialogue group(s)." # Count 401 groups
) i = 0
else: while i < len(cmd_list):
best_mode = "FIRSTLINESPEAKERS" if cmd_list[i].get("code") in (401, 405):
recommended["FIRSTLINESPEAKERS"] = True total_groups += 1
confidence = "high" if first_hits > inline_hits * 2 else "medium" while i < len(cmd_list) and cmd_list[i].get("code") in (401, 405, -1):
note = ( i += 1
f"FIRSTLINESPEAKERS: {first_hits} hits " continue
f"(INLINE401SPEAKERS: {inline_hits}, always-on: {always_on_hits}). " i += 1
f"Scanned {files_scanned} file(s), {total_groups} dialogue group(s)."
) # Also scan CommonEvents
elif face_hits >= _MIN_HITS: ce_path = files_dir / "CommonEvents.json"
best_mode = "FACENAME101" if ce_path.is_file():
recommended["FACENAME101"] = True try:
confidence = "high" if face_hits > _MIN_HITS * 2 else "medium" with open(ce_path, "r", encoding="utf-8-sig") as fh:
note = ( ce_data = json.load(fh)
f"No 401-based speaker pattern found (401 hits: {total_401_hits}). " files_scanned += 1
f"FACENAME101 recommended based on {face_hits} 101-code name hits. " for entry in ce_data or []:
f"Scanned {files_scanned} file(s), {total_groups} dialogue group(s)." if not entry:
) continue
else: _score_command_list(entry.get("list") or [], scores)
best_mode = "NONE" except Exception:
confidence = "low" pass
note = (
f"No reliable speaker pattern detected " # ── Decision: 401-based patterns first, FACENAME101 only as fallback ────
f"(401 hits: {total_401_hits}, FACENAME101 hits: {face_hits}). " always_on_hits = scores["nametag_codes"] + scores["bracket_401"]
f"Scanned {files_scanned} file(s), {total_groups} dialogue group(s). " inline_hits = scores["INLINE401SPEAKERS"]
"Check speaker settings manually." first_hits = scores["FIRSTLINESPEAKERS"]
) face_hits = scores["FACENAME101"]
total_401_hits = always_on_hits + inline_hits + first_hits
return {
"best_mode": best_mode, recommended = {
"scores": scores, "INLINE401SPEAKERS": False,
"total_401_groups": total_groups, "FIRSTLINESPEAKERS": False,
"files_scanned": files_scanned, "FACENAME101": False,
"recommended_config": recommended, }
"confidence": confidence,
"note": note, 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"
# ── Internal helpers ───────────────────────────────────────────────────────── note = (
f"Speakers detected via \\\\n<Name> codes or 【Name】 brackets "
def _score_command_list(cmd_list: list, scores: dict) -> None: f"({always_on_hits} hits) — no extra flags needed. "
"""Walk a command list and score speaker patterns in module priority order.""" f"INLINE={inline_hits}, FIRSTLINE={first_hits}. "
if not cmd_list: f"Scanned {files_scanned} file(s), {total_groups} dialogue group(s)."
return )
elif inline_hits >= first_hits:
i = 0 best_mode = "INLINE401SPEAKERS"
while i < len(cmd_list): recommended["INLINE401SPEAKERS"] = True
cmd = cmd_list[i] confidence = "high" if inline_hits > first_hits * 2 else "medium"
code = cmd.get("code") note = (
params = cmd.get("parameters") or [] f"INLINE401SPEAKERS: {inline_hits} hits "
f"(FIRSTLINESPEAKERS: {first_hits}, always-on: {always_on_hits}). "
# ── 101: Show Text / face name ──────────────────────────────────────── f"Scanned {files_scanned} file(s), {total_groups} dialogue group(s)."
if code == 101: )
# FACENAME101: param[0] is a non-empty face-name string else:
face = params[0] if params else "" best_mode = "FIRSTLINESPEAKERS"
if isinstance(face, str) and face.strip(): recommended["FIRSTLINESPEAKERS"] = True
scores["FACENAME101"] += 1 confidence = "high" if first_hits > inline_hits * 2 else "medium"
i += 1 note = (
continue f"FIRSTLINESPEAKERS: {first_hits} hits "
f"(INLINE401SPEAKERS: {inline_hits}, always-on: {always_on_hits}). "
# ── 401 / 405: dialogue line ────────────────────────────────────────── f"Scanned {files_scanned} file(s), {total_groups} dialogue group(s)."
if code in (401, 405): )
text = (params[0] if params else "") or "" elif face_hits >= _MIN_HITS:
best_mode = "FACENAME101"
# 1. \\n<Name> nametag codes (always active — no flag needed) recommended["FACENAME101"] = True
if _NAMETAG_RE.search(text): confidence = "high" if face_hits > _MIN_HITS * 2 else "medium"
scores["nametag_codes"] += 1 note = (
i += 1 f"No 401-based speaker pattern found (401 hits: {total_401_hits}). "
continue # module strips the nametag before further processing f"FACENAME101 recommended based on {face_hits} 101-code name hits. "
f"Scanned {files_scanned} file(s), {total_groups} dialogue group(s)."
# 2+3. 【Name】 alone on line, or 【Name】dialogue inline (always active) )
if _BRACKET_ALONE_RE.match(text) or _BRACKET_INLINE_RE.match(text): else:
scores["bracket_401"] += 1 best_mode = "NONE"
i += 1 confidence = "low"
continue note = (
f"No reliable speaker pattern detected "
# 4. INLINE401SPEAKERS: Name「dialogue f"(401 hits: {total_401_hits}, FACENAME101 hits: {face_hits}). "
if _INLINE_QUOTE_RE.match(text): f"Scanned {files_scanned} file(s), {total_groups} dialogue group(s). "
scores["INLINE401SPEAKERS"] += 1 "Check speaker settings manually."
i += 1 )
continue
return {
# 5. FIRSTLINESPEAKERS: short line followed by 401 starting with "best_mode": best_mode,
# a dialogue-starter character "scores": scores,
if ( "total_401_groups": total_groups,
len(text) < 40 "files_scanned": files_scanned,
and _has_japanese(text) "recommended_config": recommended,
and not any(text.lstrip().startswith(s) for s in _DIALOGUE_STARTERS) "confidence": confidence,
): "note": note,
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): def _score_command_list(cmd_list: list, scores: dict) -> None:
next_params = cmd_list[j].get("parameters") or [] """Walk a command list and score speaker patterns in module priority order."""
next_text = (next_params[0] if next_params else "") or "" if not cmd_list:
# Strip leading RPGMaker control codes before checking starter return
next_stripped = re.sub(
r"^(?:[\\]+[^cCnNiIkKvVSs{}]+?\[[\d\w\W]+?\]?\])+", i = 0
"", next_text, while i < len(cmd_list):
).lstrip() cmd = cmd_list[i]
if next_stripped and next_stripped[0] in _DIALOGUE_STARTERS: code = cmd.get("code")
scores["FIRSTLINESPEAKERS"] += 1 params = cmd.get("parameters") or []
i += 1 # ── 101: Show Text / face name ────────────────────────────────────────
continue if code == 101:
# FACENAME101: param[0] is a non-empty face-name string
i += 1 face = params[0] if params else ""
if isinstance(face, str) and face.strip():
scores["FACENAME101"] += 1
_JP_RE = re.compile( i += 1
r"[\u3000-\u303F\u3040-\u309F\u30A0-\u30FF\u4E00-\u9FFF\uF900-\uFAFF\uFF61-\uFF9F]" continue
)
# ── 401 / 405: dialogue line ──────────────────────────────────────────
if code in (401, 405):
def _has_japanese(text: str) -> bool: text = (params[0] if params else "") or ""
return bool(_JP_RE.search(text))
# 1. \\n<Name> 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 (``@<option>\n``) that WolfDawn keeps in
# the raw source; preserved verbatim so the reshaped/restored text still matches.
_WINDOW_PREFIX_RE = re.compile(r"^@[^\n]*\n")
def load_config() -> dict:
"""Return the WOLF speaker-format config, filling in defaults for missing keys."""
cfg = dict(DEFAULT_CONFIG)
try:
if CONFIG_PATH.is_file():
data = json.loads(CONFIG_PATH.read_text(encoding="utf-8"))
if isinstance(data, dict):
for key in DEFAULT_CONFIG:
if key in data:
cfg[key] = bool(data[key])
except Exception:
pass
return cfg
def save_config(config: dict) -> None:
"""Persist the WOLF speaker-format config (only known keys are written)."""
out = {key: bool(config.get(key, DEFAULT_CONFIG[key])) for key in DEFAULT_CONFIG}
DATA_DIR.mkdir(parents=True, exist_ok=True)
CONFIG_PATH.write_text(json.dumps(out, indent=4), encoding="utf-8")
def is_firstline_enabled(speaker_src: str, config: dict | None = None) -> bool:
"""True if *speaker_src* is a first-line format that is enabled in *config*."""
if speaker_src not in FIRSTLINE_SRCS:
return False
cfg = config if config is not None else DEFAULT_CONFIG
return bool(cfg.get(speaker_src, DEFAULT_CONFIG.get(speaker_src, False)))
def split_source(source: str, speaker_src: str, config: dict | None = None):
"""Split a first-line-speaker source into (prefix, speaker, body).
Returns ``None`` when the line is not an enabled first-line-speaker format or
cannot be split (no body after the name).
"""
if not isinstance(source, str) or not is_firstline_enabled(speaker_src, config):
return None
prefix = ""
rest = source
m = _WINDOW_PREFIX_RE.match(source)
if m:
prefix = m.group(0)
rest = source[m.end():]
if "\n" not in rest:
return None
line1, body = rest.split("\n", 1)
if not line1.strip():
return None
return prefix, line1, body
def to_prefixed(speaker: str, body: str) -> str:
"""Build the ``[Speaker]: body`` transport string sent to the model."""
return f"[{speaker}]: {body}"
def parse_prefixed(text: str):
"""Parse a translated ``[Speaker]: body`` string.
Returns (speaker, body). ``speaker`` is ``None`` when the model did not emit a
``[Speaker]:`` prefix, in which case ``body`` is the whole string.
"""
if not isinstance(text, str):
return None, text
m = SPEAKER_TAG_RE.match(text)
if not m:
return None, text
return m.group(1).strip(), strip_speaker_prefix(text)
def restore_source(prefix: str, speaker: str, body: str) -> str:
"""Rebuild WOLF's native ``Speaker\nbody`` layout (with any window prefix)."""
return f"{prefix}{speaker}\n{body}"

View file

@ -1,121 +0,0 @@
"""Speaker handling for the WolfDawn translation module.
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
The shared translation prompt (``data/prompt.txt``) is built around the RPG
Maker ``[Speaker]: line`` convention and already knows to translate the speaker
tag ("Always translate speaker tags to English: ``[クロネ]:`` -> ``[Kurone]:``").
So when translating we reshape those lines into ``[Speaker]: body`` and, on
write-back, restore WOLF's native ``Speaker\nbody`` structure so injection stays
byte-faithful to the original layout.
Which formats are reshaped is configurable (``data/wolf_speakers.json``) so the
workflow can toggle the high-confidence nameplate format and the lower-confidence
first-line guess independently.
"""
from __future__ import annotations
import json
import re
from util.paths import DATA_DIR
from util.speaker_prefix import SPEAKER_TAG_RE, strip_speaker_prefix
# 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 (``@<option>\n``) that WolfDawn keeps in
# the raw source; preserved verbatim so the reshaped/restored text still matches.
_WINDOW_PREFIX_RE = re.compile(r"^@[^\n]*\n")
def load_config() -> dict:
"""Return the speaker-format config, filling in defaults for missing keys."""
cfg = dict(DEFAULT_CONFIG)
try:
if CONFIG_PATH.is_file():
data = json.loads(CONFIG_PATH.read_text(encoding="utf-8"))
if isinstance(data, dict):
for key in DEFAULT_CONFIG:
if key in data:
cfg[key] = bool(data[key])
except Exception:
pass
return cfg
def save_config(config: dict) -> None:
"""Persist the speaker-format config (only known keys are written)."""
out = {key: bool(config.get(key, DEFAULT_CONFIG[key])) for key in DEFAULT_CONFIG}
DATA_DIR.mkdir(parents=True, exist_ok=True)
CONFIG_PATH.write_text(json.dumps(out, indent=4), encoding="utf-8")
def is_firstline_enabled(speaker_src: str, config: dict | None = None) -> bool:
"""True if *speaker_src* is a first-line format that is enabled in *config*."""
if speaker_src not in FIRSTLINE_SRCS:
return False
cfg = config if config is not None else DEFAULT_CONFIG
return bool(cfg.get(speaker_src, DEFAULT_CONFIG.get(speaker_src, False)))
def split_source(source: str, speaker_src: str, config: dict | None = None):
"""Split a first-line-speaker source into (prefix, speaker, body).
Returns ``None`` when the line is not an enabled first-line-speaker format or
cannot be split (no body after the name).
"""
if not isinstance(source, str) or not is_firstline_enabled(speaker_src, config):
return None
prefix = ""
rest = source
m = _WINDOW_PREFIX_RE.match(source)
if m:
prefix = m.group(0)
rest = source[m.end():]
if "\n" not in rest:
return None
line1, body = rest.split("\n", 1)
if not line1.strip():
return None
return prefix, line1, body
def to_prefixed(speaker: str, body: str) -> str:
"""Build the ``[Speaker]: body`` transport string sent to the model."""
return f"[{speaker}]: {body}"
def parse_prefixed(text: str):
"""Parse a translated ``[Speaker]: body`` string.
Returns (speaker, body). ``speaker`` is ``None`` when the model did not emit a
``[Speaker]:`` prefix, in which case ``body`` is the whole string.
"""
if not isinstance(text, str):
return None, text
m = SPEAKER_TAG_RE.match(text)
if not m:
return None, text
return m.group(1).strip(), strip_speaker_prefix(text)
def restore_source(prefix: str, speaker: str, body: str) -> str:
"""Rebuild WOLF's native ``Speaker\nbody`` layout (with any window prefix)."""
return f"{prefix}{speaker}\n{body}"