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):
|
||||
"""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({
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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,20 +1,14 @@
|
|||
"""
|
||||
Speaker Format Detector for RPGMaker MV/MZ
|
||||
"""Speaker utilities shared across engine modules.
|
||||
|
||||
Mirrors the detection priority of rpgmakermvmz.py searchCodes() exactly:
|
||||
One home for everything speaker-related so ``util/`` does not sprawl:
|
||||
|
||||
Pass 1 — scan 401/405 codes in order:
|
||||
1. \\n<Name> / \\k<Name> 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.
|
||||
* **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
|
||||
|
|
@ -23,7 +17,67 @@ import json
|
|||
import re
|
||||
from pathlib import Path
|
||||
|
||||
# ── Regexes matching rpgmakermvmz.py exactly ────────────────────────────────
|
||||
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<Name> / \\k<Name> 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<Name> / \\k<Name> nametag codes (always active in module)
|
||||
_NAMETAG_RE = re.compile(
|
||||
|
|
@ -204,8 +258,6 @@ def detect_speaker_format(
|
|||
}
|
||||
|
||||
|
||||
# ── 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:
|
||||
|
|
@ -282,3 +334,114 @@ _JP_RE = re.compile(
|
|||
|
||||
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