Parse Speakers

This commit is contained in:
dazedanon 2026-03-31 11:35:27 -05:00
parent 23b4b97bda
commit c16d61498a
2 changed files with 200 additions and 21 deletions

View file

@ -779,7 +779,7 @@ class WorkflowTab(QWidget):
# ── Copilot prompt templates ────────────────────────────────────────────
_SPEAKER_PROMPT = (
"You are helping me configure a Japanese RPGMaker MV/MZ translation tool.\n"
"# RPGMaker MV/MZ Speaker Format Detection Prompt\n"
"\n"
"Look at a sample of the game's event files (Map*.json, CommonEvents.json, "
"Troops.json) and determine which speaker name format the game uses.\n"
@ -792,7 +792,9 @@ class WorkflowTab(QWidget):
"## IMPORTANT — Always-on formats (NO FLAG NEEDED, never enable any flag for these)\n"
"\n"
"The following formats are detected and translated automatically. "
"If the game uses ANY of these, do NOT enable any flag — output SKIP for every flag.\n"
"If the game uses ANY of these, do NOT enable any flag for lines matching that pattern. "
"However, **DO NOT STOP** — continue checking whether other speakers in the same game "
"use a different format that requires a flag.\n"
"\n"
" • 101 param[4] name — { \"code\": 101, \"parameters\": [\"\", 0, 2, 2, \"るな\"] }\n"
"\\\\n<Name> or \\\\k<Name> escape code anywhere in a 401 line\n"
@ -805,23 +807,27 @@ class WorkflowTab(QWidget):
"\n"
"## Flags that MAY need to be enabled\n"
"\n"
"Only set these if the game does NOT already use an always-on format above.\n"
"Only set these if at least SOME speakers do NOT already use an always-on format above.\n"
"\n"
"### INLINE401SPEAKERS\n"
"The speaker name is embedded at the start of the 401 text directly before 「 "
"with NO intervening markup or brackets.\n"
"Example:\n"
" { \"code\": 401, \"parameters\": [\"エレナ「今日は晴れですね。\"] }\n"
"→ ENABLE only if the game has no always-on format. "
"Do NOT enable if names come via 101 param[4], \\\\n<>, 【】, or [] — those are already handled.\n"
"→ ENABLE only if the game has dialogue lines matching this pattern.\n"
"\n"
"### FIRSTLINESPEAKERS\n"
"The very first 401 in a message group is a short standalone name (<40 chars), "
"and the following 401 starts with 「 \" ( * [.\n"
"This pattern commonly appears for NPCs/side characters even in games where the "
"protagonist uses an always-on format (e.g. \\\\c[N]Name\\\\c[0]).\n"
"The 101 command for these NPC lines typically has an empty face image (parameters[0] == \"\").\n"
"Example:\n"
" { \"code\": 401, \"parameters\": [\"アリス\"] }\n"
" { \"code\": 401, \"parameters\": [\"「こんにちは。\"] }\n"
"→ ENABLE only if the game has no always-on format and names appear this way.\n"
" { \"code\": 101, \"parameters\": [\"\", 0, 0, 2] }\n"
" { \"code\": 401, \"parameters\": [\"衛兵さん\"] } ← plain name, no color or brackets\n"
" { \"code\": 401, \"parameters\": [\"「……起きたか」\"] }\n"
"→ ENABLE if ANY speakers in the game use this pattern, even if other speakers "
"(e.g. the protagonist) use an always-on format.\n"
"\n"
"### FACENAME101 ⚠ LAST RESORT ONLY\n"
"Enable this ONLY when:\n"
@ -838,22 +844,31 @@ class WorkflowTab(QWidget):
"\n"
"## Decision process\n"
"\n"
"Step 1 — Check for always-on formats in the 401/101 blocks.\n"
" If found → output SKIP for all three flags and stop.\n"
"Step 2 — Check for INLINE401SPEAKERS or FIRSTLINESPEAKERS.\n"
" If found → ENABLE the matching flag(s). Stop — do not also enable FACENAME101.\n"
"Step 3 — Only if steps 1 and 2 both found nothing → consider FACENAME101.\n"
"Step 1 — Scan ALL 101→401 blocks across the sample files.\n"
" Identify every distinct speaker pattern present:\n"
" (a) Always-on patterns (list them — they need no flag)\n"
" (b) Flag-requiring patterns (list them separately)\n"
" A game may have BOTH simultaneously (e.g. protagonist via \\\\c[N]Name\\\\c[0],\n"
" NPCs via plain FIRSTLINESPEAKERS). Do not stop after finding one pattern.\n"
"\n"
"Step 2 — For each flag-requiring pattern found in Step 1:\n"
" • INLINE401SPEAKERS → ENABLE if name「 inline pattern exists\n"
" • FIRSTLINESPEAKERS → ENABLE if plain standalone name line exists\n"
" These can both be enabled together if both patterns exist.\n"
"\n"
"Step 3 — Only if no flag-requiring patterns were found in Steps 12, AND\n"
" no always-on format was found, → consider FACENAME101.\n"
"\n"
"---\n"
"\n"
"Please examine a sample of the event commands in the attached files and output:\n"
"\n"
" 1. Which format(s) were detected and which step of the decision process applied\n"
" 1. Which pattern(s) were detected for each speaker type (protagonist, NPCs, signs/narration)\n"
" 2. For each flag — ENABLE or SKIP, with a one-line reason:\n"
" INLINE401SPEAKERS : ENABLE / SKIP — <reason>\n"
" FIRSTLINESPEAKERS : ENABLE / SKIP — <reason>\n"
" FACENAME101 : ENABLE / SKIP — <reason>\n"
" 3. A short concrete example from the files confirming the dominant pattern\n"
" 3. A short concrete example from the files confirming each detected pattern\n"
" 4. If FACENAME101 is ENABLE: list every unique face filename found in "
"code 101 parameters[0] so the user can build the name mapping\n"
)
@ -889,6 +904,10 @@ class WorkflowTab(QWidget):
"# Game Characters\n"
"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n"
"Identify every NAMED CHARACTER that appears in dialogue or descriptions.\n"
"If a 'Known speakers' list was provided above, use ONLY those names — do not add\n"
"characters that are not on that list, and skip any unnamed NPCs or generic enemies.\n"
"If no list was provided, discover named characters from the files yourself, but still\n"
"skip unnamed NPCs, generic enemy types, and narration-only entries.\n"
"For each character provide:\n"
" - Japanese name (katakana/kanji as it appears in-game)\n"
" - English transliteration or translation\n"
@ -913,7 +932,7 @@ class WorkflowTab(QWidget):
"pursues Shiro throughout the story\n"
"\n"
"Rules:\n"
" - Named characters only — no generic enemy types or unnamed NPCs.\n"
" - If a Known speakers list was provided, only output entries for names on that list.\n"
" - If a character has a player-chosen name (e.g. Actors.json id 1), note it explicitly.\n"
"\n"
"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n"
@ -1530,8 +1549,43 @@ class WorkflowTab(QWidget):
layout.addWidget(_make_section_label("Step 3 — Vocab / Glossary"))
# ---- 3a: Parse Speakers ---------------------------------------------
parse_title = QLabel("3a — Parse Speakers")
parse_title.setStyleSheet("color:#4ec9b0;font-size:13px;font-weight:bold;")
layout.addWidget(parse_title)
parse_box = QWidget()
parse_box.setObjectName("tbox")
parse_box.setStyleSheet(self._task_box_style())
parse_inner = QVBoxLayout(parse_box)
parse_inner.setContentsMargins(10, 8, 10, 8)
parse_inner.setSpacing(4)
parse_hint = QLabel(
"Scan all event files (Maps, CommonEvents, Troops) for speaker names and "
"batch-translate them, then write them into the # Speakers section of vocab.txt.\n"
"Run this before the full translation so the AI already knows every character name."
)
parse_hint.setWordWrap(True)
parse_hint.setStyleSheet("color:#9d9d9d;font-size:13px;")
parse_inner.addWidget(parse_hint)
parse_row = QHBoxLayout()
parse_speakers_btn = _make_btn("🔍 Parse Speakers", "#5a3a7a")
parse_speakers_btn.setFixedWidth(180)
parse_speakers_btn.setToolTip(
"Sets mode to 'Parse Speakers', selects all event files, and starts the run.\n"
"No text is translated — only speaker names are collected and written to vocab.txt."
)
parse_speakers_btn.clicked.connect(self._run_parse_speakers)
parse_row.addWidget(parse_speakers_btn)
parse_row.addStretch()
parse_inner.addLayout(parse_row)
layout.addWidget(parse_box)
# ---- Copilot / Cursor prompt helpers --------------------------------
prompt_box_title = QLabel("3a — AI Prompt Helpers (Copilot / Cursor)")
prompt_box_title = QLabel("3b — AI Prompt Helpers (Copilot / Cursor)")
prompt_box_title.setStyleSheet("color:#4ec9b0;font-size:13px;font-weight:bold;")
layout.addWidget(prompt_box_title)
prompt_box = QWidget()
@ -1561,7 +1615,7 @@ class WorkflowTab(QWidget):
layout.addWidget(prompt_box)
# ---- vocab.txt editor -----------------------------------------------
vocab_title = QLabel("3b — vocab.txt editor")
vocab_title = QLabel("3c — vocab.txt editor")
vocab_title.setStyleSheet("color:#4ec9b0;font-size:13px;font-weight:bold;")
layout.addWidget(vocab_title)
format_hint = QLabel(
@ -1602,6 +1656,52 @@ class WorkflowTab(QWidget):
row.addStretch()
layout.addLayout(row)
def _run_parse_speakers(self):
"""Configure Translation tab for Parse Speakers mode and auto-start."""
try:
pw = self.parent_window
tt = getattr(pw, "translation_tab", None) if pw else None
if tt is None:
self._log("❌ Translation tab not found.")
return
# 1. Set engine to RPG Maker MV/MZ
try:
combo = tt.module_combo
for i in range(combo.count()):
if "RPG Maker MV/MZ" in combo.itemText(i):
combo.setCurrentIndex(i)
break
except Exception:
pass
# 2. Set mode to Parse Speakers
try:
mc = tt.mode_combo
idx = mc.findText("Parse Speakers")
if idx >= 0:
mc.setCurrentIndex(idx)
else:
self._log("'Parse Speakers' mode not available — make sure RPG Maker MV/MZ is selected.")
return
except Exception as exc:
self._log(f"❌ Could not set Parse Speakers mode: {exc}")
return
self._log("")
self._log("" * 54)
self._log("🔍 Switching to Parse Speakers mode…")
self._log(" Event files selected. Speaker names will be")
self._log(" collected and written to vocab.txt # Speakers.")
self._log("" * 54)
except Exception as exc:
self._log(f"❌ _run_parse_speakers error: {exc}")
return
# 3. Select event files and auto-start
self._navigate_to_translation("events", auto_start=True)
# ── Step 2: Speaker Detection ───────────────────────────────────────────
def _build_step2_speaker(self, layout: QVBoxLayout):
@ -2578,8 +2678,56 @@ class WorkflowTab(QWidget):
_BASE_SEPARATOR = "# ── Base Vocabulary (auto-appended from vocab_base.txt — do not edit below) ──\n"
def _copy_glossary_prompt(self):
"""Copy the glossary prompt to clipboard."""
self._copy_to_clipboard(self._PROMPT_GLOSSARY, "Glossary prompt copied.")
"""Copy the glossary prompt to clipboard, injecting known speakers from vocab.txt."""
speakers = self._read_vocab_speakers()
if speakers:
speaker_lines = "\n".join(f" {orig} ({tl})" for orig, tl in speakers)
known_block = (
"## Known speakers (identified by Parse Speakers)\n"
"\n"
"The following character names were already extracted from the game files.\n"
"For the '# Game Characters' section, focus ONLY on these names — do NOT add\n"
"extra characters that are not in this list.\n"
"Skip any unnamed NPCs, generic enemies, or narration-only entries.\n"
"\n"
+ speaker_lines
+ "\n\n---\n\n"
)
final_prompt = known_block + self._PROMPT_GLOSSARY
else:
final_prompt = self._PROMPT_GLOSSARY
self._copy_to_clipboard(final_prompt, "Glossary prompt copied.")
def _read_vocab_speakers(self) -> list[tuple[str, str]]:
"""Parse the '# Speakers' section from vocab.txt and return (orig, tl) pairs."""
vocab_path = Path("vocab.txt")
if not vocab_path.exists():
return []
try:
content = vocab_path.read_text(encoding="utf-8")
except Exception:
return []
import re as _re
# Find the # Speakers block (ends at next # header or EOF)
m = _re.search(
r"^[\t ]*#\s*Speakers\s*$\r?\n(.*?)(?=^[\t ]*#|\Z)",
content,
_re.MULTILINE | _re.DOTALL,
)
if not m:
return []
results = []
for line in m.group(1).splitlines():
line = line.strip()
if not line:
continue
# Expected format: "日本語 (English)"
pm = _re.match(r"^(.+?)\s+\((.+?)\)\s*$", line)
if pm:
results.append((pm.group(1), pm.group(2)))
return results
def _reload_vocab(self):
vocab_path = Path("vocab.txt")

View file

@ -4226,6 +4226,37 @@ def searchSystem(data, pbar):
return totalTokens
# Regex that matches one or more markup codes like \c[1], \n[2], \ow[3], etc.
_MARKUP_STRIP_RE = re.compile(r"[\\]+[a-zA-Z]+\[[\w\d]*\]")
def _is_plausible_speaker(name: str) -> bool:
"""Return True only if *name* looks like a character name rather than dialogue or junk.
Called during SPEAKER_PARSE_MODE to filter false positives before they
enter SPEAKER_COLLECTED. Heuristics (applied after stripping markup):
120 characters long
Contains at least one Japanese character (kana / kanji)
No sentence-ending / mid-sentence punctuation ()
No dialogue-opening quotes (")
No newlines, underscores, slashes, or dots
"""
clean = _MARKUP_STRIP_RE.sub("", name).strip()
if not clean:
return False
if len(clean) > 20:
return False
# Must have at least one Japanese character
if not re.search(r"[\u3040-\u30FA\u31F0-\u31FF\u3400-\u4DBF\u4E00-\u9FFF\uFF61-\uFF9F]", clean):
return False
# Reject sentence-like strings
if re.search(r"[。!?…、]", clean):
return False
# Reject dialogue openers / structural characters
if re.search(r"[「」""\n\r_/\\.]", clean):
return False
return True
# Save some money and enter the character before translation
def getSpeaker(speaker: str):
"""Return (and possibly collect) speaker name.
@ -4247,7 +4278,7 @@ def getSpeaker(speaker: str):
with _speakerCacheLock:
if speaker in _speakerCache:
return [_speakerCache[speaker], [0, 0]]
if speaker not in SPEAKER_COLLECTED:
if speaker not in SPEAKER_COLLECTED and _is_plausible_speaker(speaker):
SPEAKER_COLLECTED.append(speaker)
return [speaker, [0, 0]]