Speakers in one
This commit is contained in:
parent
625c0a83d3
commit
54640da4e4
19 changed files with 466 additions and 467 deletions
|
|
@ -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({
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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. 【Name】dialogue on same 401 line (always active, no flag needed)
|
* **WOLF first-line reshaping** - WolfDawn bakes some speaker names into line 1
|
||||||
4. Name「dialogue」 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}"
|
||||||
|
|
@ -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}"
|
|
||||||
Loading…
Reference in a new issue