From fe31c711305a0e0d6b86e83db43bd79f23f91532 Mon Sep 17 00:00:00 2001 From: DazedAnon Date: Sat, 11 Jul 2026 11:33:14 -0500 Subject: [PATCH] Reworking prompts/skills --- README.md | 6 +- TODO.md | 2 +- data/skills/project_setup.md | 165 ++++++ data/{prompt.txt => skills/system.md} | 5 +- gui/main.py | 18 +- gui/skills_tab.py | 247 ++++++++ gui/translation_tab.py | 14 + gui/workflow_tab.py | 784 ++++++++++---------------- modules/rpgmakermvmz.py | 91 +-- modules/wolfdawn.py | 14 +- util/paths.py | 22 +- util/skills/__init__.py | 21 + util/skills/contexts.py | 63 +++ util/skills/setup.py | 62 ++ util/skills/system.py | 66 +++ util/translation.py | 10 +- 16 files changed, 1050 insertions(+), 540 deletions(-) create mode 100644 data/skills/project_setup.md rename data/{prompt.txt => skills/system.md} (89%) create mode 100644 gui/skills_tab.py create mode 100644 util/skills/__init__.py create mode 100644 util/skills/contexts.py create mode 100644 util/skills/setup.py create mode 100644 util/skills/system.py diff --git a/README.md b/README.md index e32d19a..78c628d 100644 --- a/README.md +++ b/README.md @@ -176,8 +176,8 @@ Format: Japanese name, English name in parentheses, then gender. > **Note:** A very large vocab file can increase API costs and potentially reduce quality. Focus on the most important characters and terms. -### data/prompt.txt -This is the system prompt sent to the AI. A default `data/prompt.txt` is included and works well for most games. You generally don't need to edit it unless you want to customize the translation style. +### data/skills/system.md +This is the system prompt skill sent to the AI on every translation call. A default `data/skills/system.md` is included and works well for most games. You generally don't need to edit it unless you want to customize the translation style. Per-game voice rules go in `/skills/quirks.md` instead. --- @@ -230,7 +230,7 @@ How it works (all engine modules are supported automatically): 1. **Pass 1 (collect)** — files are processed normally, but instead of calling the API each request is queued to `log/batch_requests.json`. Requests are byte-identical to live ones: - the static `prompt.txt` block is cached with a 1h TTL, matched vocab and translation + the static `data/skills/system.md` block is cached with a 1h TTL, matched vocab and translation history ride along per request, and structured output enforces the exact line count. Speaker/variable names still translate live during this pass (they get embedded into the dialogue payloads, so both passes must resolve them identically) — they're a tiny share diff --git a/TODO.md b/TODO.md index b54bf83..92aaf67 100644 --- a/TODO.md +++ b/TODO.md @@ -2,7 +2,7 @@ ## Custom game prompt -- Keep the default prompt (`prompt.txt`) as the base for every game. +- Keep the default prompt (`data/skills/system.md`) as the base for every game. - Add an optional **per-game custom prompt** in the UI that merges with the default (custom rules layered on top of shared instructions, not a full replacement unless explicitly chosen). - Surface this in settings / project setup so each game folder can carry its own tone, terminology notes, or content warnings without forking the global prompt. diff --git a/data/skills/project_setup.md b/data/skills/project_setup.md new file mode 100644 index 0000000..54ae94d --- /dev/null +++ b/data/skills/project_setup.md @@ -0,0 +1,165 @@ +# DazedMTLTool — Project Setup + +You are analysing a Japanese game project to produce configuration artifacts for DazedMTLTool. +Work in the game repository. Scan files; do not invent content you did not see. + +--- + +## Task + +In **one pass**, discover everything needed for translation setup and emit the labeled output blocks below. +Do **not** translate the game. Do **not** rewrite formatting codes, wrap widths, or tool pipeline settings. + +Default: emit **all four** blocks. +Regenerate mode: if the user asks for only one block (`glossary`, `speakers`, `translation_quirks`, or `game_skill`), emit **only** that block using the same schema and exclusivity rules. + +--- + +## Ownership (no duplication) + +| Block | Owns | Must NOT include | +|-------|------|------------------| +| `glossary` | Named characters (gender, role, **per-character** speech register) + worldbuilding terms | Global dialect / person rules; honorific policy; speaker-format flags; formatting | +| `speakers` | Tool flag ENABLE/SKIP + short evidence | Character bios; quirks; full glossary | +| `translation_quirks` | Cross-cutting voice rules (battle-log person, global dialect, item-description style, **unusual** honorific habits) | Per-character register; "always keep -san" (tool base prompt already does); codes/wrap/line counts; speaker flags | +| `game_skill` | Game identity, which files matter, pointer to `skills/quirks.md`, what the tool owns | Verbatim quirks list; verbatim glossary; restating formatting/honorific base policy | + +Hard rules: +1. Per-character voice → `glossary` only. +2. Category-wide / cross-cutting voice → `translation_quirks` only. +3. Default honorifics policy is owned by the tool base prompt - only note **unusual** honorific habits in quirks. +4. `game_skill` **references** `skills/quirks.md`; do not reprint those rules. +5. `speakers` is config, not lore. + +--- + +## Shared scan strategy + +Map / event files can be huge. Do **not** read them sequentially end-to-end. + +1. Read small DB files in full first (richest, always small). +2. For large event/map files: **search/grep**; sample early story maps; stop when patterns stabilize. +3. One scan feeds all four blocks - do not rescan from scratch per block. + + + +### RPG Maker file strategy + +**DB (read in full):** +- `Actors.json` (mandatory for major characters, actor IDs, `\\N[n]` mappings) +- `Classes.json`, `Troops.json`, `Skills.json`, `Items.json`, `Armors.json`, `Weapons.json`, `States.json`, `System.json` + +**Events (grep / sample):** +- `CommonEvents.json`, `Troops.json`, `Map001.json`–`Map010.json` (early maps first) +- Prefer code `401` dialogue + nearby `101` speaker/name params; `405` scrolling text when present +- Speaker markup evidence: `【Name】`, `[Name]`, `Name:`, `\\n`, `\\k`, colour-wrapped name lines + +--- attach your game data files / open the game repo before continuing --- + +### Speakers analysis (for `speakers` block) + +Code `101` opens the text window. Code `401` is a dialogue line. Multiple `401`s form one message box. + +**Always-on formats (NO FLAG needed):** +- `101` param[4] name +- `\\n` or `\\k` (angle brackets) +- `【Name】` alone or with dialogue +- `[Name]` alone or with dialogue +- `\\c[N]Name\\c[0]` colour-wrapped name on its own `401` line +- `Name:` full-width colon name line + +**Critical:** `\\N[X]` / `\\n[X]` (square brackets + number) are actor variable codes, NOT speaker formats. Do not count them as always-on speaker hits. + +**Flags (only when some speakers lack an always-on format):** +- `INLINE401SPEAKERS` — name immediately before `「` on a `401` line (e.g. `エレナ「今日は…`) +- `FIRSTLINESPEAKERS` — first `401` is a short plain name (< 40 chars, has Japanese); next line starts with `「` `"`` `(` `*` `[`; often empty face on `101` +- `FACENAME101` — last resort only when no always-on and neither flag above; face filename in `101` param[0], empty param[4]. If ENABLE, list every unique face filename from `101` param[0]. + +### Glossary rules (for `glossary` block) + +- Separator: plain hyphen-minus `-` only (never em/en dash). +- Descriptions entirely in English; refer to other characters by English name. +- Commit to one spelling - never `Sylfia / Sylphia`. +- Characters: gender, role, speech register, personality, player-chosen name (Actors.json ID 1). +- Real named actors get full `# Game Characters` entries, not only `\\N[n]` placeholders. +- Worldbuilding: factions, lore locations, unique systems/titles - exclude skill/item/weapon/armour names and generic RPG words. + +### Quirks rules (for `translation_quirks` block) + +Find translation-only quirks, for example: +- Battle log / system messages consistently 3rd person (or other fixed person) +- Global dialect (old-timer speech, archaic narration) +- Recurring item/skill description style +- Unusual honorific habits (who uses what with whom) + +Exclude: formatting codes, wrap, line counts, speaker flags, character name lists. + +Output as short imperative bullets suitable to paste into `skills/quirks.md`. + +### Game skill rules (for `game_skill` block) + +Produce a durable IDE skill for this title saved at `skills/translation.md`: +- Short game identity (tone/genre/content notes) +- Which files matter for dialogue/DB in this repo +- Explicit pointer: follow `skills/quirks.md` for voice rules (authoritative) +- What not to change (codes, formatting - tool-owned via base prompt) +- Do **not** reprint quirks or the glossary + + + + + +### Wolf file strategy (stub) + +When analysing a WolfDawn / WOLF project later: use `wolf_json/` extracts (`names.json`, DB sheets, map/common scenes). Prefer foundation DB + narrative sheets; grep large map dumps. Same four-block ownership rules apply. + + + +--- + +## Output format + +Emit **only** labeled fenced code blocks (plus a one-line heading before each block for navigation). +No essay outside the blocks. + +### Block `glossary` + +Label the fence language as `glossary`. Inside: + +``` +# Game Characters +Japanese (English) - description + +# Worldbuilding Terms +Japanese (English) - description +``` + +### Block `speakers` + +Label the fence language as `speakers`. Inside: + +``` +Patterns detected: +- ... + +Flag decisions: +INLINE401SPEAKERS : ENABLE|SKIP — +FIRSTLINESPEAKERS : ENABLE|SKIP — +FACENAME101 : ENABLE|SKIP — + +Examples: +- ... + +Face filenames (only if FACENAME101 ENABLE): +- ... +``` + +### Block `translation_quirks` + +Label the fence language as `translation_quirks`. Inside: imperative bullet list only (no preamble). + +### Block `game_skill` + +Label the fence language as `game_skill`. Inside: full markdown for `skills/translation.md` (identity, file map, quirks pointer, tool boundaries). + +If known speakers were prepended in a `` block above this skill, prefer those names in the glossary, then cross-check Actors.json for other major named actors. diff --git a/data/prompt.txt b/data/skills/system.md similarity index 89% rename from data/prompt.txt rename to data/skills/system.md index c4e4fe8..3bfb4ad 100644 --- a/data/prompt.txt +++ b/data/skills/system.md @@ -30,6 +30,7 @@ You will be translating erotic and sexual content. You will receive lines of dia - **Speech register:** If the entry describes how a character speaks (flustered, blunt, formal, childlike, crude, etc.), mirror that register in their English dialogue. A character described as speaking in a "cute, flustered register" should sound different from one described as "cold and terse". - **Role & context:** Use the role/personality notes to inform tone — a villain's lines should feel threatening, a comic-relief NPC's lines should feel goofy, etc. - Japanese omits pronouns constantly. Infer the correct subject and pronoun from context, translation history, and the character list. +- **Person / perspective consistency (1st / 2nd / 3rd):** Keep the grammatical person consistent with the source and with surrounding lines. Before writing English, verify what perspective the Japanese line actually uses - do not assume first-person dialogue when the line is third-person narration, second-person address (お前 / 君 / あなた), or impersonal UI text. Match "I / you / he / she / they" (and possessives) to that verified perspective; do not flip person mid-scene unless the Japanese does. - **Preserve third-person self-reference.** Some characters refer to themselves by name instead of using "I" (e.g. ワタシ used as a name, or a character saying their own name). When a character is clearly speaking about themselves in the third person as a stylistic trait, maintain that in English (e.g. "Feris doesn't know" rather than "I don't know"). - Third-person pronouns (彼, 彼女, あいつ, こいつ, そいつ, コイツ) should match the known gender of the person being referenced. - Translate **コイツ** as "this bastard" (male) or "this bitch" (female) depending on the referenced character's gender. @@ -38,7 +39,9 @@ You will be translating erotic and sexual content. You will receive lines of dia ## Honorifics and Names -- Preserve Japanese honorifics in the translation: -san, -kun, -chan, -senpai, -sensei, -sama, -dono, etc. +- **Always preserve and enforce Japanese honorifics** in the English translation: -san, -kun, -chan, -senpai, -sensei, -sama, -dono, etc. +- Do **not** drop honorifics to make the English sound more "natural" unless the source Japanese also lacks them. +- If `# Game Characters` (or game quirks) specifies how a character addresses someone, follow that address pattern. - The `=` or `=` character in a Japanese name marks a foreign or nickname component. Wrap that part in parentheses. - Example: `バンカー=ベット` → `Bunker (Bet)` - Always translate speaker tags to English: `[クロネ]:` → `[Kurone]:` diff --git a/gui/main.py b/gui/main.py index 3cceaeb..5436c97 100644 --- a/gui/main.py +++ b/gui/main.py @@ -595,6 +595,7 @@ from gui.config_tab import ConfigTab from gui.translation_tab import TranslationTab from gui.workflow_tab import WorkflowTab from gui.wolf_workflow_tab import WolfWorkflowTab +from gui.skills_tab import SkillsTab from gui.batch_tab import BatchTab class DazedMTLGUI(QMainWindow): @@ -838,9 +839,16 @@ class DazedMTLGUI(QMainWindow): sidebar_layout.addWidget(btn_batches) self.nav_buttons.append(btn_batches) - # Configuration button (fourth) + # Skills button (fourth) + btn_skills = self.create_nav_button("📚", "Skills") + btn_skills.setToolTip("Skills — edit system prompt, Project Setup, and translation contexts") + btn_skills.clicked.connect(lambda: self.switch_page(3)) + sidebar_layout.addWidget(btn_skills) + self.nav_buttons.append(btn_skills) + + # Configuration button (fifth) btn_config = self.create_nav_button("⚙️", "Configuration") - btn_config.clicked.connect(lambda: self.switch_page(3)) + btn_config.clicked.connect(lambda: self.switch_page(4)) sidebar_layout.addWidget(btn_config) self.nav_buttons.append(btn_config) @@ -906,7 +914,11 @@ class DazedMTLGUI(QMainWindow): self.batch_tab = BatchTab(self) self.content_stack.addWidget(self.batch_tab) - # Configuration Tab (index 3) + # Skills Tab (index 3) + self.skills_tab = SkillsTab(self) + self.content_stack.addWidget(self.skills_tab) + + # Configuration Tab (index 4) self.config_tab = ConfigTab() self.config_tab.config_changed.connect(self.on_config_changed) self.content_stack.addWidget(self.config_tab) diff --git a/gui/skills_tab.py b/gui/skills_tab.py new file mode 100644 index 0000000..aa4fbb8 --- /dev/null +++ b/gui/skills_tab.py @@ -0,0 +1,247 @@ +"""Skills Tab - edit tool-level skills and translation contexts under data/.""" + +from __future__ import annotations + +import json +from pathlib import Path + +from PyQt5.QtCore import Qt +from PyQt5.QtGui import QFont +from PyQt5.QtWidgets import ( + QHBoxLayout, + QLabel, + QMessageBox, + QPushButton, + QTabWidget, + QTextEdit, + QVBoxLayout, + QWidget, +) + +from util.paths import PROMPT_PATH, SKILLS_DIR, TRANSLATION_CONTEXTS_PATH +from util.skills.contexts import reload_contexts + + +class _PlainPasteTextEdit(QTextEdit): + """QTextEdit that always pastes as plain text.""" + + def insertFromMimeData(self, source): # noqa: N802 + self.insertPlainText(source.text()) + + +_EDITOR_STYLE = ( + "QTextEdit{background-color:#1e1e1e;color:#d4d4d4;" + "border:1px solid #3c3c3c;border-radius:4px;padding:10px;" + "selection-background-color:#264f78;}" +) + +_HINT_STYLE = "color:#9d9d9d;font-size:13px;" +_PATH_STYLE = "color:#569cd6;font-size:12px;font-family:Consolas,monospace;" + + +class SkillsTab(QWidget): + """Edit ``data/skills/*.md`` and ``data/translation_contexts.json``.""" + + def __init__(self, parent=None): + super().__init__(parent) + self._pages: dict[str, dict] = {} + self._build_ui() + self.reload_all() + + def _build_ui(self): + root = QVBoxLayout(self) + root.setContentsMargins(20, 16, 20, 16) + root.setSpacing(10) + + title = QLabel("Skills") + title.setStyleSheet("color:#ffffff;font-size:18px;font-weight:bold;") + root.addWidget(title) + + intro = QLabel( + "Tool-level AI instructions live here. " + "Per-game quirks and the game IDE skill are edited in Workflow Step 3 " + "(they travel with the game folder)." + ) + intro.setWordWrap(True) + intro.setStyleSheet(_HINT_STYLE) + root.addWidget(intro) + + self.tabs = QTabWidget() + self.tabs.setStyleSheet( + "QTabWidget::pane{border:1px solid #3c3c3c;border-radius:4px;top:-1px;}" + "QTabBar::tab{background:#2d2d30;color:#cccccc;padding:8px 14px;" + "border:1px solid #3c3c3c;border-bottom:none;margin-right:2px;}" + "QTabBar::tab:selected{background:#1e1e1e;color:#ffffff;" + "border-top:2px solid #007acc;}" + ) + root.addWidget(self.tabs, 1) + + self._add_file_page( + key="system", + tab_title="System", + path=PROMPT_PATH, + hint=( + "Runtime system prompt for every translation API call. " + "Honorifics, formatting, and universal quality rules live here. " + "Do not put game-specific voice quirks here - use /skills/quirks.md." + ), + is_json=False, + ) + self._add_file_page( + key="project_setup", + tab_title="Project Setup", + path=SKILLS_DIR / "project_setup.md", + hint=( + "Clipboard skill copied from Workflow. " + "Instructs the IDE to emit glossary, speakers, quirks, and game_skill blocks." + ), + is_json=False, + ) + self._add_file_page( + key="contexts", + tab_title="Contexts", + path=TRANSLATION_CONTEXTS_PATH, + hint=( + "Per-call history templates used by RPG Maker / WolfDawn " + "(names.npc, database.item, events.label_108, …). " + "Edit carefully - keys must match what the engine modules look up." + ), + is_json=True, + ) + + btn_row = QHBoxLayout() + reload_btn = QPushButton("↺ Reload All") + reload_btn.setFixedWidth(140) + reload_btn.clicked.connect(self.reload_all) + btn_row.addWidget(reload_btn) + btn_row.addStretch() + open_btn = QPushButton("Open skills folder") + open_btn.setFixedWidth(160) + open_btn.clicked.connect(self._open_skills_folder) + btn_row.addWidget(open_btn) + root.addLayout(btn_row) + + def _add_file_page( + self, + *, + key: str, + tab_title: str, + path: Path, + hint: str, + is_json: bool, + ): + page = QWidget() + layout = QVBoxLayout(page) + layout.setContentsMargins(12, 12, 12, 12) + layout.setSpacing(8) + + path_lbl = QLabel(str(path)) + path_lbl.setStyleSheet(_PATH_STYLE) + path_lbl.setTextInteractionFlags(Qt.TextSelectableByMouse) + layout.addWidget(path_lbl) + + hint_lbl = QLabel(hint) + hint_lbl.setWordWrap(True) + hint_lbl.setStyleSheet(_HINT_STYLE) + layout.addWidget(hint_lbl) + + editor = _PlainPasteTextEdit() + editor.setFont(QFont("Consolas", 10)) + editor.setStyleSheet(_EDITOR_STYLE) + layout.addWidget(editor, 1) + + row = QHBoxLayout() + save_btn = QPushButton("💾 Save") + save_btn.setFixedWidth(120) + save_btn.clicked.connect(lambda _=False, k=key: self._save_page(k)) + row.addWidget(save_btn) + + reload_btn = QPushButton("↺ Reload") + reload_btn.setFixedWidth(120) + reload_btn.clicked.connect(lambda _=False, k=key: self._reload_page(k)) + row.addWidget(reload_btn) + + status = QLabel("") + status.setStyleSheet("color:#6a9955;font-size:12px;") + row.addWidget(status) + row.addStretch() + layout.addLayout(row) + + self.tabs.addTab(page, tab_title) + self._pages[key] = { + "path": path, + "editor": editor, + "status": status, + "is_json": is_json, + } + + def reload_all(self): + for key in self._pages: + self._reload_page(key) + + def _reload_page(self, key: str): + page = self._pages[key] + path: Path = page["path"] + editor: QTextEdit = page["editor"] + status: QLabel = page["status"] + try: + if not path.is_file(): + editor.setPlainText("") + status.setText(f"Missing: {path.name}") + status.setStyleSheet("color:#f0ad4e;font-size:12px;") + return + editor.setPlainText(path.read_text(encoding="utf-8")) + status.setText("Loaded") + status.setStyleSheet("color:#6a9955;font-size:12px;") + except Exception as exc: + status.setText(f"Load failed: {exc}") + status.setStyleSheet("color:#f44747;font-size:12px;") + + def _save_page(self, key: str): + page = self._pages[key] + path: Path = page["path"] + editor: QTextEdit = page["editor"] + status: QLabel = page["status"] + text = editor.toPlainText() + if page["is_json"]: + try: + parsed = json.loads(text) + text = json.dumps(parsed, ensure_ascii=False, indent=2) + "\n" + editor.setPlainText(text.rstrip("\n") + "\n") + except json.JSONDecodeError as exc: + QMessageBox.warning( + self, + "Invalid JSON", + f"Cannot save {path.name}:\n{exc}", + ) + status.setText("Invalid JSON") + status.setStyleSheet("color:#f44747;font-size:12px;") + return + try: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text if text.endswith("\n") else text + "\n", encoding="utf-8") + if key == "contexts": + reload_contexts() + status.setText(f"Saved {path.name}") + status.setStyleSheet("color:#6a9955;font-size:12px;") + except Exception as exc: + QMessageBox.warning(self, "Save failed", str(exc)) + status.setText(f"Save failed: {exc}") + status.setStyleSheet("color:#f44747;font-size:12px;") + + def _open_skills_folder(self): + folder = SKILLS_DIR + folder.mkdir(parents=True, exist_ok=True) + import os + import subprocess + import sys + + try: + if sys.platform.startswith("win"): + os.startfile(str(folder)) # type: ignore[attr-defined] + elif sys.platform == "darwin": + subprocess.run(["open", str(folder)], check=False) + else: + subprocess.run(["xdg-open", str(folder)], check=False) + except Exception as exc: + QMessageBox.information(self, "Skills folder", f"{folder}\n\n{exc}") diff --git a/gui/translation_tab.py b/gui/translation_tab.py index f13850f..0b16a89 100644 --- a/gui/translation_tab.py +++ b/gui/translation_tab.py @@ -3018,6 +3018,20 @@ class TranslationTab(QWidget): except Exception: pass + # Per-game quirks overlay: Workflow Step 0 folder → DAZED_GAME_ROOT + try: + game_root = "" + if self.settings: + game_root = str( + self.settings.value("last_game_folder", "") or "" + ).strip() + if game_root and Path(game_root).is_dir(): + os.environ["DAZED_GAME_ROOT"] = game_root + else: + os.environ.pop("DAZED_GAME_ROOT", None) + except Exception: + pass + # Try to create a hard link at legacy location so modules that # still write to log/translationHistory.txt end up in this file. # This will be created when the run_log_path file is first written to diff --git a/gui/workflow_tab.py b/gui/workflow_tab.py index ba51f6a..1c548f9 100644 --- a/gui/workflow_tab.py +++ b/gui/workflow_tab.py @@ -5,13 +5,12 @@ Provides a guided, step-by-step interface: Step 0 – Select game project folder and import data files into files/ Step 1 – (Optional) Pre-process game files - Step 2 – Auto-detect speaker format and apply to module settings - Step 3 – Build glossary: parse speakers, then enrich with AI prompt - Step 4 – Translation: Phase 0 (DB), Phase 1 (dialogue), Phase 1b (111 cache) - Step 5 – Translation Phase 2 (risky codes) - Step 6 – Translate visible strings in js/plugins.js (or Ace scripts) - Step 7 – Export translated/ back to the game folder - Step 8 – Install TL Inspector and/or Forge playtest plugins + Step 2 – Setup: speaker flags, Project Setup skill, vocab / quirks / game skill + Step 3 – Translation: Phase 0 (DB), Phase 1 (dialogue), Phase 1b (111 cache) + Step 4 – Translation Phase 2 (risky codes) + Step 5 – Translate visible strings in js/plugins.js (or Ace scripts) + Step 6 – Export translated/ back to the game folder + Step 7 – Install TL Inspector and/or Forge playtest plugins """ from __future__ import annotations @@ -23,6 +22,11 @@ import threading from pathlib import Path from util.paths import VOCAB_PATH +from util.skills import ( + game_skill_path_for_game, + load_project_setup, + quirks_path_for_game, +) from util.vocab import BASE_SEPARATOR as _SHARED_BASE_SEPARATOR from util.vocab import read_game_vocab, write_game_vocab @@ -619,13 +623,12 @@ class WorkflowTab(QWidget): _tab_defs = [ ("0 Project", self._build_step0), ("1 Pre-process", self._build_step1_preprocess), - ("2 Speaker", self._build_step2_speaker), - ("3 Glossary", self._build_step3_glossary), - ("4 TL Phase 1", self._build_step4_translation), - ("5 TL Phase 2", self._build_step5_tl_phase2), - ("6 Plugins.js", self._build_step6_plugins_js), - ("7 Export", self._build_step7_export), - ("8 Playtest", self._build_step8_playtest), + ("2 Setup", self._build_step2_setup), + ("3 TL Phase 1", self._build_step4_translation), + ("4 TL Phase 2", self._build_step5_tl_phase2), + ("5 Plugins.js", self._build_step6_plugins_js), + ("6 Export", self._build_step7_export), + ("7 Playtest", self._build_step8_playtest), ] self._step_labels = [label for label, _ in _tab_defs] @@ -793,21 +796,20 @@ class WorkflowTab(QWidget): short_names = ( "Project", "Prep", - "Speaker", - "Glossary", + "Setup", "Phase1", "Phase2", "Plugins", "Export", "Playtest", ) - # Step 6 label swaps for Ace scripts. - if idx == 6 and len(self._step_labels) > 6: - raw = self._step_labels[6] + # Step 5 label swaps for Ace scripts. + if idx == 5 and len(self._step_labels) > 5: + raw = self._step_labels[5] if "Script" in raw: name = "Scripts" else: - name = short_names[6] + name = short_names[5] else: name = short_names[idx] if 0 <= idx < len(short_names) else str(idx) mark = "✓" if done else "" @@ -868,9 +870,9 @@ class WorkflowTab(QWidget): if previous_index == 0 and index != 0: self._auto_import_if_needed() - if index == 5: + if index == 4: self._populate_p2_checkboxes() - if index == 8: + if index == 7: self._refresh_playtest_status() self._load_playtest_settings() @@ -1024,226 +1026,6 @@ class WorkflowTab(QWidget): # ── Copilot prompt templates ──────────────────────────────────────────── - _SPEAKER_PROMPT = ( - "You are an expert RPGMaker MV/MZ analyst helping configure a Japanese game translation tool.\n" - "\n" - "\n" - "Examine the game's event files (Map*.json, CommonEvents.json, Troops.json) and determine " - "which speaker name format(s) the game uses. Output flag recommendations based on what you find.\n" - "\n" - "\n" - "\n" - "Code 101 opens the text window. Code 401 is a dialogue line. Multiple 401s in a row form " - "one message box. The translation tool needs to know how character names appear in the event " - "data so it can extract and translate them correctly.\n" - "\n" - "\n" - "--- attach your game files here before continuing ---\n" - "\n" - "\n" - "The following formats are detected and translated automatically — no flag is needed. " - "If the game uses any of these, do NOT enable a flag for lines matching that exact pattern. " - "However, continue scanning to check whether other speakers use a flag-requiring pattern.\n" - "\n" - "## How dialogue commands work\n" - "\n" - "Code 101 opens the text window. Code 401 is a dialogue line. " - "Multiple 401s in a row form one message box.\n" - "\n" - "## 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 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" - "\n" - " 101 param[4] name { \"code\": 101, \"parameters\": [\"\", 0, 2, 2, \"るな\"] }\n" - " \\\\n or \\\\k escape code with ANGLE BRACKETS anywhere inside a 401 line\n" - " 【Name】 alone on a line, or 【Name】dialogue on the same line\n" - " [Name] alone on a line, or [Name]dialogue on the same line\n" - " \\\\c[N]Name\\\\c[0] color-wrapped name on its own 401 line\n" - " Name: line ending with a full-width colon\n" - "\n" - "## CRITICAL — \\\\N[X] / \\\\n[X] (square bracket + number) is NOT a speaker format\n" - "\n" - " \\\\N[ActorID] and \\\\n[ActorID] (e.g. \\\\N[1], \\\\n[2]) are RPGMaker actor variable\n" - " substitution codes. They expand to an actor's name at runtime, but the translation tool\n" - " handles them purely as text substitution during translation — they are NEVER treated as\n" - " a speaker name tag.\n" - "\n" - " A 401 line containing ONLY \\\\N[X], or narration text that embeds \\\\N[X] (e.g.\n" - " \"ローターが\\\\N[1]の乳首に装着される!\"), does NOT match any always-on speaker format.\n" - " Do NOT count \\\\N[X] / \\\\n[X] codes as always-on speaker detection hits.\n" - "\n" - " If a game's ONLY speaker indicator is a standalone \\\\N[X] line followed by dialogue,\n" - " check whether FIRSTLINESPEAKERS would catch it (the standalone line must be < 40 chars\n" - " AND contain at least one Japanese character — a bare \\\\N[1] with no Japanese text does\n" - " NOT qualify for FIRSTLINESPEAKERS either).\n" - "\n" - "\n" - "\n" - "Only enable these when at least some speakers do NOT use an always-on format.\n" - "\n" - "\n" - "The speaker name is embedded at the start of a 401 line directly before 「, " - "with no intervening markup or brackets.\n" - "\n" - "{ \"code\": 401, \"parameters\": [\"エレナ「今日は晴れですね。\"] }\n" - "\n" - "Enable if the game has dialogue lines matching this pattern.\n" - "\n" - "\n" - "\n" - "The very first 401 in a message group is a short standalone name (under 40 chars), " - "and the following 401 starts with 「 \" ( ( * [. This commonly appears for NPCs even " - "in games where the protagonist uses an always-on format. The 101 command for these " - "lines typically has an empty face image (parameters[0] == \"\").\n" - "\n" - "{ \"code\": 101, \"parameters\": [\"\", 0, 0, 2] }\n" - "{ \"code\": 401, \"parameters\": [\"衛兵さん\"] } ← plain name, no color or brackets\n" - "{ \"code\": 401, \"parameters\": [\"「……起きたか」\"] }\n" - "\n" - "Enable if ANY speakers in the game use this pattern, even if others use an always-on format.\n" - "\n" - "\n" - "\n" - "Enable ONLY when: (a) the game has no always-on format, AND (b) neither INLINE401SPEAKERS " - "nor FIRSTLINESPEAKERS applies, AND (c) the 101 code has a face image filename in " - "parameters[0] while parameters[4] is empty.\n" - "\n" - "{ \"code\": 101, \"parameters\": [\"face_alice\", 0, 0, 2, \"\"] }\n" - "\n" - "If you recommend this, you MUST list every unique face filename found in parameters[0] " - "of code 101 across all attached files.\n" - "\n" - "\n" - "\n" - "\n" - "Follow these steps in order:\n" - "\n" - "1. Scan ALL 101→401 blocks across the sample files.\n" - " (a) List every always-on pattern found — these need no flag.\n" - " (b) List every flag-requiring pattern found separately.\n" - " A game may use both simultaneously (e.g. protagonist via \\\\c[N]Name\\\\c[0], " - "NPCs via plain standalone name lines). Do not stop after finding one pattern.\n" - "\n" - "2. For each flag-requiring pattern from step 1:\n" - " - INLINE401SPEAKERS → ENABLE if name「 inline pattern exists\n" - " - FIRSTLINESPEAKERS → ENABLE if plain standalone name line exists\n" - " Both may be enabled together if both patterns exist.\n" - "\n" - "3. Only if steps 1–2 found no flag-requiring patterns AND no always-on format " - "→ consider FACENAME101.\n" - "\n" - "\n" - "\n" - "Provide exactly four sections:\n" - "\n" - "1. Patterns detected — one entry per speaker type (protagonist, NPCs, signs/narration)\n" - "2. Flag decisions:\n" - " INLINE401SPEAKERS : ENABLE / SKIP — \n" - " FIRSTLINESPEAKERS : ENABLE / SKIP — \n" - " FACENAME101 : ENABLE / SKIP — \n" - "3. A short concrete example from the actual files for each detected pattern\n" - "4. (Only if FACENAME101 is ENABLE) Every unique face filename from parameters[0] of code 101\n" - "\n" - ) - - _PROMPT_GLOSSARY = ( - "You are an expert Japanese RPGMaker game analyst building a translation glossary.\n" - "\n" - "\n" - "Extract named characters and lore-specific worldbuilding terms from this game's data files. " - "Produce a structured glossary in the exact format specified below. It will be loaded directly " - "into a translation tool, so strict format compliance is required.\n" - "\n" - "\n" - "--- attach your game data files here before continuing ---\n" - "\n" - "\n" - "Map files and CommonEvents.json can be extremely large. Do NOT read them sequentially — " - "you will hit context limits. Use this strategy:\n" - "\n" - "1. Read these small DB files IN FULL first — richest source of names, always small:\n" - " Actors.json is mandatory for major character vocab. Use it as the canonical source " - "for actor IDs, Japanese names, nicknames, profiles, and \\N[n] name mappings.\n" - " Then read Classes.json, Troops.json, Skills.json, Items.json,\n" - " Armors.json, Weapons.json, States.json, System.json\n" - "\n" - "2. For large files (CommonEvents.json, Map*.json), SEARCH (grep) instead of reading " - "sequentially. Prioritise dialogue commands because they are the best evidence for " - "character voice:\n" - " - Code 401 dialogue lines, plus nearby code 101 speaker/name parameters\n" - " - Code 405 scrolling text when present\n" - " - Speaker patterns such as 【Name】, [Name], Name:, \\\\n, and \\\\k\n" - " - Capitalised katakana clusters or kanji compound proper nouns in dialogue/parameters fields\n" - " Scan Map001.json through Map010.json at most — early maps have the most story content.\n" - "\n" - "3. Stop once you stop finding new names or terms. Do not pad the output.\n" - "\n" - "\n" - "\n" - "Apply to both sections:\n" - "- Separator: use a plain hyphen-minus (-). Never use — or –. " - "The translation tool only recognises the plain hyphen.\n" - "- Descriptions must be entirely in English. Refer to other characters by English name only " - "(write 'her sister Ruin was kidnapped', not 'her sister ルイン was kidnapped').\n" - "- Never give two spelling options (e.g. 'Sylfia / Sylphia' is wrong). Commit to one translation.\n" - "\n" - "# Game Characters — rules:\n" - "- If a list was provided, output entries for those names, then cross-check " - "Actors.json for major named actors that should also be included. Skip unnamed NPCs, generic " - "enemies, and narration-only entries.\n" - "- If no list was provided, discover named characters from the files, but still skip " - "unnamed NPCs and generic enemy types.\n" - "- For each character include: gender, role, speech register, personality, and whether " - "the name is player-chosen (check Actors.json ID 1).\n" - "- Any actor in Actors.json with a real name should get a full # Game Characters entry, " - "not only a \\N[n] placeholder mapping. If events reference \\N[3] or [EnglishName], " - "resolve it through Actors.json ID 3 and preserve the full character context.\n" - "- Event references are supporting evidence for personality/speech, but they must not " - "replace actor-database discovery. Do not miss major characters just because they appear " - "primarily in Actors.json.\n" - "\n" - "# Worldbuilding Terms — rules:\n" - "- Include: faction/organisation names, locations mentioned in dialogue but not on maps, " - "unique magic systems, lore titles, recurring in-universe concepts.\n" - "- Exclude: skill names, item names, weapon names, armour names (the tool handles those). " - "Skip generic RPG words (\u30dd\u30fc\u30b7\u30e7\u30f3, \u30ec\u30d9\u30eb, \u30b9\u30c6\u30fc\u30bf\u30b9, etc.). " - "Do not repeat character names here.\n" - "\n" - "\n" - "\n" - "Return the ENTIRE glossary inside ONE fenced code block (a ``` block), with nothing before " - "or after it, so it can be copied in a single click and pasted straight into the tool.\n" - "Inside the code block, output EXACTLY two sections with these headers. Do not add any " - "preamble, explanation, or text outside the entries.\n" - "\n" - "# Game Characters\n" - "# Worldbuilding Terms\n" - "\n" - "Entry format: Japanese (English) - description\n" - "\n" - "\n" - "# Game Characters\n" - "アリア (Aria) - Female; protagonist; player-chosen name (Actors.json ID 1); " - "speaks cheerfully in casual feminine speech; nicknamed アリアちゃん by her sister\n" - "ゼクス (Zex) - Male; antagonist; cold and commanding; addresses others with contempt; " - "uses archaic formal register\n" - "カナエ (Kanae) - Female; NPC shopkeeper; warm and motherly; ends sentences with わね\n" - "\n" - "# Worldbuilding Terms\n" - "虚無の穴 (Void Rift) - Dimensional tear referenced repeatedly in Act 2 NPC dialogue; " - "not a named map location\n" - "鋼の誓約 (Iron Vow) - Sacred oath-binding ritual unique to the knightly order; " - "appears in story cutscenes\n" - "裁定者 (Arbiter) - Title held by the ruling council; lore-specific rank with no " - "real-world equivalent\n" - "\n" - "\n" - ) - _WRAP_PROMPT = ( "You are an expert RPGMaker MV/MZ configuration analyst.\n" "\n" @@ -1789,116 +1571,201 @@ class WorkflowTab(QWidget): "QCheckBox{border:none;background-color:transparent;}" ) - def _build_step3_glossary(self, layout: QVBoxLayout): - layout.addWidget(_make_section_label("Step 3 — Vocab / Glossary")) + def _build_step2_setup(self, layout: QVBoxLayout): + """Combined speaker flags + Project Setup + vocab/quirks/game-skill editors.""" + layout.addWidget(_make_section_label("Step 2 — Setup (Speakers + 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) + # ---- Compact action bar -------------------------------------------- + top = QWidget() + top.setObjectName("tbox") + top.setStyleSheet(self._task_box_style()) + top_l = QVBoxLayout(top) + top_l.setContentsMargins(10, 8, 10, 8) + top_l.setSpacing(6) - 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." + hint = QLabel( + "1) Set speaker flags · 2) Parse Speakers into vocab · " + "3) Copy Project Setup → IDE → paste glossary / quirks / game skill below" ) - parse_hint.setWordWrap(True) - parse_hint.setStyleSheet("color:#9d9d9d;font-size:13px;") - parse_inner.addWidget(parse_hint) + hint.setWordWrap(True) + hint.setStyleSheet("color:#9d9d9d;font-size:12px;") + top_l.addWidget(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." + from gui import qt_icons + actions = QHBoxLayout() + actions.setSpacing(8) + + import_btn = QPushButton() + qt_icons.apply_button_icon(import_btn, "↓ Import → files/", color="#4da8f0") + import_btn.setStyleSheet( + "QPushButton{background-color:#2d2d30;color:#4da8f0;border:1px solid #007acc;" + "padding:0px 10px;border-radius:4px;font-size:12px;font-weight:bold;" + "font-family:'Segoe UI',sans-serif;}" + "QPushButton:hover{background-color:#1a2d3a;border-color:#1a9aff;color:#7ac8ff;}" + "QPushButton:pressed{background-color:#0a1a2a;}" + "QPushButton:disabled{background-color:#2d2d30;color:#555555;border-color:#444444;}" ) - parse_speakers_btn.clicked.connect(self._run_parse_speakers) - parse_row.addWidget(parse_speakers_btn) - parse_row.addStretch() - parse_inner.addLayout(parse_row) + import_btn.setFixedHeight(32) + import_btn.setEnabled(False) + import_btn.clicked.connect(lambda _checked=False: self._import_files()) + self._register_import_button(import_btn) + actions.addWidget(import_btn) - layout.addWidget(parse_box) - - # ---- Copilot / Cursor prompt helpers -------------------------------- - 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() - prompt_box.setObjectName("tbox") - prompt_box.setStyleSheet(self._task_box_style()) - pb_inner = QVBoxLayout(prompt_box) - pb_inner.setContentsMargins(10, 8, 10, 8) - pb_inner.setSpacing(4) - - prompt_hint = QLabel( - "Copy the prompt and paste it into Copilot Chat or Cursor with your game files open. " - "Paste the AI's output back into vocab.txt." + clear_translated_btn = QPushButton() + qt_icons.apply_button_icon(clear_translated_btn, "✕ Clear TL", color="#cc4444") + clear_translated_btn.setStyleSheet( + "QPushButton{background-color:#2d2d30;color:#cc4444;border:1px solid #8b0000;" + "padding:0px 10px;border-radius:4px;font-size:12px;font-weight:bold;" + "font-family:'Segoe UI',sans-serif;}" + "QPushButton:hover{background-color:#3a2020;border-color:#cc2222;color:#ff6666;}" + "QPushButton:pressed{background-color:#4a1010;}" ) - prompt_hint.setWordWrap(True) - prompt_hint.setStyleSheet("color:#9d9d9d;font-size:13px;") - pb_inner.addWidget(prompt_hint) + clear_translated_btn.setFixedHeight(32) + clear_translated_btn.setToolTip("Delete all files inside the translated/ folder") + clear_translated_btn.clicked.connect(self._clear_translated) + actions.addWidget(clear_translated_btn) - glossary_row = QHBoxLayout() - copy_glossary_btn = _make_btn("📋 Copy Prompt for Copilot", "#555") - copy_glossary_btn.setFixedWidth(220) - copy_glossary_btn.setToolTip("Copy the full glossary discovery prompt to clipboard") - copy_glossary_btn.clicked.connect(self._copy_glossary_prompt) - glossary_row.addWidget(copy_glossary_btn) - glossary_row.addStretch() - pb_inner.addLayout(glossary_row) - - layout.addWidget(prompt_box) - - # ---- 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( - "Format: Japanese (English) - Gender; role; speech register / personality notes\n" - "Example: シロ (Shiro) - Female; protagonist; speaks in a flustered, cute register with feminine speech markers" + parse_btn = _make_btn("🔍 Parse Speakers", "#5a3a7a") + parse_btn.setFixedWidth(160) + parse_btn.setFixedHeight(32) + parse_btn.setToolTip( + "Collect speaker names from event files into vocab.txt # Speakers " + "(no dialogue translation)." ) - format_hint.setFont(QFont("Consolas", 9)) - format_hint.setStyleSheet( - "color:#569cd6;background-color:#1a1e2a;border:1px solid #2a3a5a;" - "padding:5px 10px;border-radius:4px;font-size:13px;" - "border-left:3px solid #2a6a9a;" - ) - layout.addWidget(format_hint) + parse_btn.clicked.connect(self._run_parse_speakers) + actions.addWidget(parse_btn) - self.vocab_editor = _PlainPasteTextEdit() - self.vocab_editor.setMinimumHeight(80) - self.vocab_editor.setMaximumHeight(160) - self.vocab_editor.setFont(QFont("Consolas", 9)) - self.vocab_editor.setStyleSheet( - "QTextEdit{background-color:#252526;color:#d4d4d4;" - "border:1px solid #3c3c3c;border-radius:4px;padding:8px;" + copy_btn = _make_btn("📋 Copy Project Setup", "#555") + copy_btn.setFixedWidth(190) + copy_btn.setFixedHeight(32) + copy_btn.setToolTip( + "Clipboard skill for the game repo IDE. Returns glossary, speakers, " + "translation_quirks, and game_skill code blocks." + ) + copy_btn.clicked.connect(self._copy_project_setup_prompt) + actions.addWidget(copy_btn) + actions.addStretch() + top_l.addLayout(actions) + + flags_row = QHBoxLayout() + flags_row.setSpacing(14) + flags_lbl = QLabel("Flags:") + flags_lbl.setStyleSheet("color:#4ec9b0;font-size:12px;font-weight:bold;") + flags_row.addWidget(flags_lbl) + + self.spk_inline_cb = QCheckBox("INLINE401SPEAKERS") + self.spk_inline_cb.setToolTip("Speaker name inline before 「 in the 401 text") + self.spk_inline_cb.stateChanged.connect(self._apply_speaker_flags) + flags_row.addWidget(self.spk_inline_cb) + + self.spk_firstline_cb = QCheckBox("FIRSTLINESPEAKERS") + self.spk_firstline_cb.setToolTip("First 401 line is a short speaker name") + self.spk_firstline_cb.stateChanged.connect(self._apply_speaker_flags) + flags_row.addWidget(self.spk_firstline_cb) + + self.spk_face_cb = QCheckBox("FACENAME101") + self.spk_face_cb.setToolTip("Speaker inferred from 101 face-image filename") + self.spk_face_cb.stateChanged.connect(self._apply_speaker_flags) + flags_row.addWidget(self.spk_face_cb) + flags_row.addStretch() + top_l.addLayout(flags_row) + + layout.addWidget(top) + self._populate_speaker_flags() + + # ---- Editors (tabbed to save vertical space) ----------------------- + editors = QTabWidget() + editors.setObjectName("setupEditors") + editors.setDocumentMode(False) + tab_bar = editors.tabBar() + tab_bar.setExpanding(False) + tab_bar.setMinimumHeight(34) + tab_bar.setStyleSheet( + "QTabBar::tab{background:#333337;color:#c0c0c0;min-width:90px;min-height:28px;" + "padding:6px 16px;border:1px solid #3c3c3c;border-bottom:none;" + "margin-right:3px;font-size:12px;font-weight:bold;}" + "QTabBar::tab:selected{background:#1e1e1e;color:#4ec9b0;" + "border-top:2px solid #4ec9b0;}" + "QTabBar::tab:hover{color:#ffffff;}" + ) + editors.setStyleSheet( + "QTabWidget::pane{border:1px solid #3c3c3c;background:#1e1e1e;}" + ) + _ed_ss = ( + "QTextEdit{background-color:#1e1e1e;color:#d4d4d4;" + "border:none;padding:8px;" "selection-background-color:#264f78;}" ) + + def _editor_page(path_hint: str, tip: str, save_slot, reload_slot, attr: str): + page = QWidget() + vl = QVBoxLayout(page) + vl.setContentsMargins(8, 8, 8, 8) + vl.setSpacing(6) + tip_lbl = QLabel(tip) + tip_lbl.setWordWrap(True) + tip_lbl.setStyleSheet("color:#9d9d9d;font-size:12px;") + vl.addWidget(tip_lbl) + path_lbl = QLabel(path_hint) + path_lbl.setStyleSheet("color:#569cd6;font-size:11px;font-family:Consolas,monospace;") + vl.addWidget(path_lbl) + ed = _PlainPasteTextEdit() + ed.setMinimumHeight(160) + ed.setFont(QFont("Consolas", 9)) + ed.setStyleSheet(_ed_ss) + setattr(self, attr, ed) + vl.addWidget(ed, 1) + row = QHBoxLayout() + row.setSpacing(8) + save_btn = _make_btn("💾 Save", "#3a7a3a") + save_btn.setFixedWidth(110) + save_btn.clicked.connect(save_slot) + row.addWidget(save_btn) + reload_btn = _make_btn("↺ Reload", "#555") + reload_btn.setFixedWidth(110) + reload_btn.clicked.connect(reload_slot) + row.addWidget(reload_btn) + row.addStretch() + vl.addLayout(row) + return page + + editors.addTab( + _editor_page( + "data/vocab.txt (game section)", + "Paste the glossary code block. Format: Japanese (English) - notes", + self._save_vocab, + self._reload_vocab, + "vocab_editor", + ), + "Vocab", + ) + editors.addTab( + _editor_page( + "/skills/quirks.md", + "Paste the translation_quirks block. Merged onto the system prompt at translate time.", + self._save_quirks, + self._reload_quirks, + "quirks_editor", + ), + "Quirks", + ) + editors.addTab( + _editor_page( + "/skills/translation.md", + "Paste the game_skill block. IDE companion skill for this game repo.", + self._save_game_skill, + self._reload_game_skill, + "game_skill_editor", + ), + "Game skill", + ) + layout.addWidget(editors, 1) + self._reload_vocab() - layout.addWidget(self.vocab_editor, 1) + self._reload_quirks() + self._reload_game_skill() - row = QHBoxLayout() - row.setSpacing(8) - save_vocab = _make_btn("💾 Save vocab.txt", "#3a7a3a") - save_vocab.setFixedWidth(180) - save_vocab.clicked.connect(self._save_vocab) - row.addWidget(save_vocab) - - reload_vocab = _make_btn("↺ Reload", "#555") - reload_vocab.setFixedWidth(110) - reload_vocab.clicked.connect(self._reload_vocab) - row.addWidget(reload_vocab) - row.addStretch() - layout.addLayout(row) def _run_parse_speakers(self): """Configure Translation tab for Parse Speakers mode and auto-start.""" @@ -1948,102 +1815,6 @@ class WorkflowTab(QWidget): # ── Step 2: Speaker Detection ─────────────────────────────────────────── - def _build_step2_speaker(self, layout: QVBoxLayout): - layout.addWidget(_make_section_label("Step 2 — Speaker Format Detection")) - - # Import button row - _IMP_W = 220 - _IMP_H = 36 - _IMP_SS = ( - "QPushButton{{" - "background-color:{bg};color:white;border:none;" - "padding:0px;border-radius:4px;font-size:12px;font-weight:bold;" - "font-family:'Segoe UI',sans-serif;}}" - "QPushButton:hover{{background-color:{hover};}}" - "QPushButton:pressed{{background-color:{press};}}" - "QPushButton:disabled{{background-color:#404040;color:#666666;}}" - ) - import_row = QHBoxLayout() - import_row.setSpacing(8) - import_row.setAlignment(Qt.AlignVCenter) - from gui import qt_icons - - import_btn = QPushButton() - qt_icons.apply_button_icon(import_btn, "↓ Import Selected → files/", color="#4da8f0") - import_btn.setStyleSheet( - "QPushButton{background-color:#2d2d30;color:#4da8f0;border:1px solid #007acc;" - "padding:0px;border-radius:4px;font-size:12px;font-weight:bold;" - "font-family:'Segoe UI',sans-serif;}" - "QPushButton:hover{background-color:#1a2d3a;border-color:#1a9aff;color:#7ac8ff;}" - "QPushButton:pressed{background-color:#0a1a2a;}" - "QPushButton:disabled{background-color:#2d2d30;color:#555555;border-color:#444444;}" - ) - import_btn.setFixedSize(_IMP_W, _IMP_H) - import_btn.setEnabled(False) - import_btn.clicked.connect(lambda _checked=False: self._import_files()) - self._register_import_button(import_btn) - import_row.addWidget(import_btn) - clear_translated_btn = QPushButton() - qt_icons.apply_button_icon(clear_translated_btn, "✕ Clear translated/", color="#cc4444") - clear_translated_btn.setStyleSheet( - "QPushButton{background-color:#2d2d30;color:#cc4444;border:1px solid #8b0000;" - "padding:0px;border-radius:4px;font-size:12px;font-weight:bold;" - "font-family:'Segoe UI',sans-serif;}" - "QPushButton:hover{background-color:#3a2020;border-color:#cc2222;color:#ff6666;}" - "QPushButton:pressed{background-color:#4a1010;}" - ) - clear_translated_btn.setFixedSize(_IMP_W, _IMP_H) - clear_translated_btn.setToolTip("Delete all files inside the translated/ folder") - clear_translated_btn.clicked.connect(self._clear_translated) - import_row.addWidget(clear_translated_btn) - import_row.addStretch() - layout.addLayout(import_row) - - hint = QLabel( - "Copy the prompt below, open Copilot (or any AI), attach a few of the game's " - "Map*.json / CommonEvents.json files, paste the prompt, and ask it which " - "speaker flags to enable. Then tick the matching boxes and click Apply." - ) - hint.setWordWrap(True) - hint.setStyleSheet("color:#9d9d9d;font-size:13px;padding-bottom:6px;") - layout.addWidget(hint) - - copy_row = QHBoxLayout() - copy_btn = _make_btn("📋 Copy Prompt for Copilot", "#555") - copy_btn.setFixedWidth(220) - copy_btn.setToolTip("Copies a prompt explaining all speaker formats — paste into Copilot with game files") - copy_btn.clicked.connect(self._copy_speaker_prompt) - copy_row.addWidget(copy_btn) - copy_row.addStretch() - layout.addLayout(copy_row) - - # ---- Flag checkboxes ------------------------------------------------ - cb_box_title = QLabel("Speaker flags") - cb_box_title.setStyleSheet("color:#4ec9b0;font-size:13px;font-weight:bold;") - layout.addWidget(cb_box_title) - cb_box = QWidget() - cb_box.setObjectName("tbox") - cb_box.setStyleSheet(self._task_box_style()) - cb_inner = QVBoxLayout(cb_box) - cb_inner.setContentsMargins(10, 8, 10, 8) - cb_inner.setSpacing(3) - - self.spk_inline_cb = QCheckBox("INLINE401SPEAKERS — speaker name inline before 「 in the 401 text") - self.spk_inline_cb.stateChanged.connect(self._apply_speaker_flags) - cb_inner.addWidget(self.spk_inline_cb) - - self.spk_firstline_cb = QCheckBox("FIRSTLINESPEAKERS — first 401 line is a short speaker name") - self.spk_firstline_cb.stateChanged.connect(self._apply_speaker_flags) - cb_inner.addWidget(self.spk_firstline_cb) - - self.spk_face_cb = QCheckBox("FACENAME101 — speaker inferred from 101 face-image filename") - self.spk_face_cb.stateChanged.connect(self._apply_speaker_flags) - cb_inner.addWidget(self.spk_face_cb) - - layout.addWidget(cb_box) - - self._populate_speaker_flags() - # ── Step 4: Translation ───────────────────────────────────────────────── def _add_tl_mode_selector(self, layout: QVBoxLayout): @@ -2113,7 +1884,7 @@ class WorkflowTab(QWidget): def _build_step4_translation(self, layout: QVBoxLayout): - layout.addWidget(_make_section_label("Step 4 — TL Phase 1")) + layout.addWidget(_make_section_label("Step 3 — TL Phase 1")) self._add_tl_mode_selector(layout) @@ -2265,7 +2036,7 @@ class WorkflowTab(QWidget): def _build_step5_tl_phase2(self, layout: QVBoxLayout): - layout.addWidget(_make_section_label("Step 5 — TL Phase 2")) + layout.addWidget(_make_section_label("Step 4 — TL Phase 2")) self._step5_mode_hint = QLabel(f"Translation mode: {BATCH_MODE_LABEL}") self._step5_mode_hint.setStyleSheet("color:#8fbc8f;font-size:12px;margin-bottom:4px;") @@ -2571,7 +2342,7 @@ class WorkflowTab(QWidget): # ── Step 5: plugins.js / Ace scripts Translation ─────────────────────── def _build_step6_plugins_js(self, layout: QVBoxLayout): - self._step6_section_label = _make_section_label("Step 6 — Translate plugins.js") + self._step6_section_label = _make_section_label("Step 5 — Translate plugins.js") layout.addWidget(self._step6_section_label) self._step6_hint = QLabel( @@ -2612,7 +2383,7 @@ class WorkflowTab(QWidget): # ── Step 6: Export ────────────────────────────────────────────────────── def _build_step7_export(self, layout: QVBoxLayout): - layout.addWidget(_make_section_label("Step 7 — Export to Game")) + layout.addWidget(_make_section_label("Step 6 — Export to Game")) hint = QLabel( "Copy translated files back into the game's data folder to patch the game in-place." ) @@ -2643,7 +2414,7 @@ class WorkflowTab(QWidget): # ── Step 8: Playtest (TL Inspector) ───────────────────────────────────── def _build_step8_playtest(self, layout: QVBoxLayout): - self._step8_section_label = _make_section_label("Step 8 — Playtest Tools") + self._step8_section_label = _make_section_label("Step 7 — Playtest Tools") layout.addWidget(self._step8_section_label) self._step8_main_hint = QLabel( @@ -3257,6 +3028,8 @@ class WorkflowTab(QWidget): self._detected_on_show = True # new folder chosen — treat as already-shown self._ask_clear_old_files() self._detect_folder() + self._reload_quirks() + self._reload_game_skill() def _ask_clear_old_files(self): """Prompt the user to clear /files and /translated to avoid stale data conflicts.""" @@ -3310,18 +3083,18 @@ class WorkflowTab(QWidget): w = getattr(self, attr, None) if w is not None: w.setVisible(not is_ace) - # Tab / strip label - step6_label = "6 Scripts" if is_ace else "6 Plugins.js" - self._step_tabs.setTabText(6, step6_label) - if hasattr(self, "_step_labels") and len(self._step_labels) > 6: - self._step_labels[6] = step6_label - if hasattr(self, "_step_buttons") and len(self._step_buttons) > 6: - self._step_buttons[6].setToolTip(step6_label) - # Step 6 section header + # Tab / strip label (Plugins step is index 5 after Setup merge) + step6_label = "5 Scripts" if is_ace else "5 Plugins.js" + self._step_tabs.setTabText(5, step6_label) + if hasattr(self, "_step_labels") and len(self._step_labels) > 5: + self._step_labels[5] = step6_label + if hasattr(self, "_step_buttons") and len(self._step_buttons) > 5: + self._step_buttons[5].setToolTip(step6_label) + # Step 5 section header lbl = getattr(self, "_step6_section_label", None) if lbl is not None: - lbl.setText("Step 6 — Translate Ace Scripts (.rb)" if is_ace - else "Step 6 — Translate plugins.js") + lbl.setText("Step 5 — Translate Ace Scripts (.rb)" if is_ace + else "Step 5 — Translate plugins.js") # Hint text hint = getattr(self, "_step6_hint", None) if hint is not None: @@ -3352,18 +3125,18 @@ class WorkflowTab(QWidget): "Copy a prompt that instructs Copilot/Cursor to translate only " "visible player-facing strings in plugins.js, using vocab.txt as a glossary." ) - # Step 8 — TL Inspector (MV/MZ only; hidden for Ace) + # Step 7 — TL Inspector (MV/MZ only; hidden for Ace) show_playtest = not is_ace - if hasattr(self, "_step_tabs") and self._step_tabs.count() > 8: + if hasattr(self, "_step_tabs") and self._step_tabs.count() > 7: if hasattr(self._step_tabs, "setTabVisible"): - self._step_tabs.setTabVisible(8, show_playtest) + self._step_tabs.setTabVisible(7, show_playtest) else: - self._step_tabs.setTabEnabled(8, show_playtest) - if is_ace and self._step_tabs.currentIndex() == 8: - self._goto_step(7) - if hasattr(self, "_step_buttons") and len(self._step_buttons) > 8: - self._step_buttons[8].setVisible(show_playtest) - self._step_buttons[8].setEnabled(show_playtest) + self._step_tabs.setTabEnabled(7, show_playtest) + if is_ace and self._step_tabs.currentIndex() == 7: + self._goto_step(6) + if hasattr(self, "_step_buttons") and len(self._step_buttons) > 7: + self._step_buttons[7].setVisible(show_playtest) + self._step_buttons[7].setEnabled(show_playtest) self._refresh_step_strip() box = getattr(self, "_step8_playtest_box", None) install_both_btn = getattr(self, "_install_both_btn", None) @@ -3718,24 +3491,81 @@ class WorkflowTab(QWidget): _BASE_SEPARATOR = _SHARED_BASE_SEPARATOR - def _copy_glossary_prompt(self): - """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 = ( - "\n" - "These character names were extracted from the game files by the Parse Speakers tool.\n" - "For the '# Game Characters' section, output entries for ONLY these names.\n" - "Skip any unnamed NPCs, generic enemies, or narration-only entries.\n" - "\n" - + speaker_lines - + "\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 _copy_project_setup_prompt(self): + """Copy the Project Setup skill, optionally prepending known speakers.""" + try: + speakers = self._read_vocab_speakers() + prepend = "" + if speakers: + speaker_lines = "\n".join(f" {orig} ({tl})" for orig, tl in speakers) + prepend = ( + "\n" + "These character names were extracted from the game files by the Parse Speakers tool.\n" + "For the glossary block '# Game Characters', prefer entries for these names, " + "then cross-check Actors.json for other major named actors.\n" + "\n" + + speaker_lines + + "\n\n" + ) + prompt = load_project_setup("rpgmaker", prepend=prepend) + self._copy_to_clipboard(prompt, "Project Setup skill copied.") + except Exception as exc: + self._log(f"❌ Could not load Project Setup skill: {exc}") + + def _game_root_or_warn(self) -> str | None: + root = self.folder_edit.text().strip() + if not root: + self._log("⚠ Select a game folder in Step 0 first.") + return None + return root + + def _reload_quirks(self): + root = self.folder_edit.text().strip() + path = quirks_path_for_game(root) + if not path or not path.is_file(): + if hasattr(self, "quirks_editor"): + self.quirks_editor.setPlainText("") + return + try: + self.quirks_editor.setPlainText(path.read_text(encoding="utf-8")) + except Exception as exc: + self._log(f"❌ Could not load skills/quirks.md: {exc}") + + def _save_quirks(self): + root = self._game_root_or_warn() + if not root: + return + path = quirks_path_for_game(root) + try: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(self.quirks_editor.toPlainText().rstrip() + "\n", encoding="utf-8") + self._log(f"✅ Saved {path}") + except Exception as exc: + self._log(f"❌ Could not save skills/quirks.md: {exc}") + + def _reload_game_skill(self): + root = self.folder_edit.text().strip() + path = game_skill_path_for_game(root) + if not path or not path.is_file(): + if hasattr(self, "game_skill_editor"): + self.game_skill_editor.setPlainText("") + return + try: + self.game_skill_editor.setPlainText(path.read_text(encoding="utf-8")) + except Exception as exc: + self._log(f"❌ Could not load game skill: {exc}") + + def _save_game_skill(self): + root = self._game_root_or_warn() + if not root: + return + path = game_skill_path_for_game(root) + try: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(self.game_skill_editor.toPlainText().rstrip() + "\n", encoding="utf-8") + self._log(f"✅ Saved {path}") + except Exception as exc: + self._log(f"❌ Could not save game skill: {exc}") def _read_vocab_speakers(self) -> list[tuple[str, str]]: """Parse the '# Speakers' section from vocab.txt and return (orig, tl) pairs.""" @@ -3790,8 +3620,8 @@ class WorkflowTab(QWidget): # ───────────────────────────────────────────────────────────────────────── def _copy_speaker_prompt(self): - QApplication.clipboard().setText(self._SPEAKER_PROMPT) - self._log("Speaker format prompt copied to clipboard.") + # Legacy alias — Project Setup covers speakers analysis. + self._copy_project_setup_prompt() def _copy_wrap_prompt(self): QApplication.clipboard().setText(self._WRAP_PROMPT) diff --git a/modules/rpgmakermvmz.py b/modules/rpgmakermvmz.py index bcaf162..a65dd10 100644 --- a/modules/rpgmakermvmz.py +++ b/modules/rpgmakermvmz.py @@ -15,14 +15,15 @@ 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, convert_corner_brackets from util.speakers import SPEAKER_BRACKET_INNER, strip_speaker_prefix +from util.skills import ctx, load_system_prompt +from util.paths import VOCAB_PATH # Globals MODEL = os.getenv("model") TIMEOUT = int(os.getenv("timeout")) LANGUAGE = os.getenv("language").capitalize() -from util.paths import PROMPT_PATH, VOCAB_PATH -PROMPT = PROMPT_PATH.read_text(encoding="utf-8") +PROMPT = load_system_prompt() VOCAB = VOCAB_PATH.read_text(encoding="utf-8") LOCK = threading.Lock() THREAD_CTX = threading.local() @@ -240,7 +241,7 @@ def _pat355655_captured_text(match): def handleMVMZ(filename, estimate): - global ESTIMATE, TOKENS, FILENAME, MISMATCH + global ESTIMATE, TOKENS, FILENAME, MISMATCH, PROMPT ESTIMATE = estimate FILENAME = filename MISMATCH = [] # Reset per-file; prevents cross-file contamination in CLI mode @@ -250,6 +251,10 @@ def handleMVMZ(filename, estimate): except Exception: pass + # Reload base prompt + optional per-game quirks (DAZED_GAME_ROOT). + PROMPT = load_system_prompt() + TRANSLATION_CONFIG.prompt = PROMPT + # Translate start = time.time() translatedData = openFiles(filename) @@ -979,7 +984,7 @@ def parseMap(data, filename): if "Map" in filename: response = translateAI( data["displayName"], - "Reply with only the " + LANGUAGE + " translation of the RPG location name", + ctx("names.location"), False, ) totalTokens[0] += response[1][0] @@ -1097,7 +1102,7 @@ def _normalize_sg_desc(text: str) -> str: # Translate response = translateAI( modifiedJAString, - "Reply with only the " + LANGUAGE + " translation.", + ctx("database.generic"), False, ) translatedText = response[0] @@ -1132,7 +1137,7 @@ def translateNoteOmitSpace(event, regex): # Translate response = translateAI( jaString, - "Reply with the " + LANGUAGE + " translation of the location name.", + ctx("names.location_short"), False, ) # Defend against unexpected response shapes @@ -1181,7 +1186,7 @@ def translateLBNames(events): names_to_translate = [item[1] for item in lb_events] response = translateAI( names_to_translate, - "Reply with only the " + LANGUAGE + " translation of the name.", + ctx("names.generic"), True, ) translated_names = response[0] if isinstance(response[0], list) else [response[0]] @@ -1670,21 +1675,21 @@ def searchNames(data, pbar, context, filename): # Set the context of what we are translating if "Actors" in context: - newContext = "Reply with only the " + LANGUAGE + " translation of the NPC name" + newContext = ctx("names.npc_only") if "Armors" in context: - newContext = "Reply with only the " + LANGUAGE + " translation of the RPG equipment name" + newContext = ctx("database.armor") if "Classes" in context: - newContext = "Reply with only the " + LANGUAGE + " translation of the RPG class name" + newContext = ctx("database.class") if "MapInfos" in context: - newContext = "Reply with only the " + LANGUAGE + " translation of the location name" + newContext = ctx("names.location") if "Enemies" in context: - newContext = "Reply with only the " + LANGUAGE + " translation of the enemy NPC name" + newContext = ctx("names.enemy") if "Weapons" in context: - newContext = "Reply with only the " + LANGUAGE + " translation of the RPG weapon name" + newContext = ctx("database.weapon") if "Items" in context: - newContext = "Reply with only the " + LANGUAGE + " translation of the RPG item name" + newContext = ctx("database.item") if "Skills" in context: - newContext = "Reply with only the " + LANGUAGE + " translation of the RPG skill name" + newContext = ctx("database.skill") # Names with open("log/translations.txt", "a", encoding="utf-8") as file: @@ -1753,7 +1758,7 @@ def searchNames(data, pbar, context, filename): # --- Batch translate all notes --- translatedNotesBatch = [] if notesBatch: - response = translateAI(notesBatch, f"Reply with only the {LANGUAGE} translation of the note text.") + response = translateAI(notesBatch, ctx("database.note")) translatedNotesBatch = response[0] totalTokens[0] += response[1][0] totalTokens[1] += response[1][1] @@ -2002,7 +2007,7 @@ def searchNames(data, pbar, context, filename): if descriptionList: response = translateAI( descriptionList, - f"Reply with only the {LANGUAGE} translation of the text.", + ctx("events.generic_text"), True, ) translatedDescriptionBatch = response[0] @@ -2954,7 +2959,7 @@ def searchCodes(page, pbar, jobList, filename): question = codeList[i]["parameters"][3]["messageText"] response = translateAI( matchList, - f"Previous text for context: {question}\n", + ctx("events.plugin_with_context", context=question), True, ) totalTokens[0] += response[1][0] @@ -3716,7 +3721,7 @@ def searchCodes(page, pbar, jobList, filename): # Translate response = translateAI( matchList[0], - "Reply with the " + LANGUAGE + " translation of the NPC name.", + ctx("names.npc"), False, ) translatedText = response[0] @@ -3812,7 +3817,7 @@ def searchCodes(page, pbar, jobList, filename): if len(matchList) > 0: # Translate text = matchList[0] - response = translateAI(text, "Reply with the " + LANGUAGE + " Translation") + response = translateAI(text, ctx("events.generic")) translatedText = response[0] totalTokens[0] += response[1][0] totalTokens[1] += response[1][1] @@ -3851,7 +3856,7 @@ def searchCodes(page, pbar, jobList, filename): if len(matchList) > 0: # Translate text = matchList[0] - response = translateAI(text, "Reply with the " + LANGUAGE + " Translation") + response = translateAI(text, ctx("events.generic")) translatedText = response[0] totalTokens[0] += response[1][0] totalTokens[1] += response[1][1] @@ -3894,7 +3899,7 @@ def searchCodes(page, pbar, jobList, filename): question = translatedText response = translateAI( choiceList, - f"Previous text for context: {question}\n", + ctx("events.plugin_with_context", context=question), True, ) totalTokens[0] += response[1][0] @@ -3949,11 +3954,11 @@ def searchCodes(page, pbar, jobList, filename): if len(textHistory) > 0: response = translateAI( choiceList, - f"Reply with the English translation of the dialogue choice.\n\nPrevious text for context: {str(textHistory)}\n", + ctx("events.choice_with_context", context=str(textHistory)), True, ) else: - response = translateAI(choiceList, "Reply with the English translation of the dialogue choice.") + response = translateAI(choiceList, ctx("events.choice")) translatedTextList = response[0] totalTokens[0] += response[1][0] @@ -4143,7 +4148,7 @@ def searchCodes(page, pbar, jobList, filename): # 122 if len(list122) > 0: - response = translateAI(list122, "Keep your translation as brief as possible") + response = translateAI(list122, ctx("events.code122_brief")) list122TL = response[0] totalTokens[0] += response[1][0] totalTokens[1] += response[1][1] @@ -4168,7 +4173,7 @@ def searchCodes(page, pbar, jobList, filename): # 108 if len(list108) > 0: - response = translateAI(list108, "This text is a label. Use title capitalization and keep it brief.") + response = translateAI(list108, ctx("events.label_108")) list108TL = response[0] totalTokens[0] += response[1][0] totalTokens[1] += response[1][1] @@ -4224,7 +4229,7 @@ def searchCodes(page, pbar, jobList, filename): # 324 if len(list324) > 0: # Generic short-text translation for parameter index 1 - response = translateAI(list324, "Reply with only the " + LANGUAGE + " translation of the text.") + response = translateAI(list324, ctx("events.generic_text")) list324TL = response[0] totalTokens[0] += response[1][0] totalTokens[1] += response[1][1] @@ -4236,7 +4241,7 @@ def searchCodes(page, pbar, jobList, filename): # 325 if len(list325) > 0: # Use same short-text speaker-style translation as other name fields - response = translateAI(list325, "Reply with the " + LANGUAGE + " translation of the NPC name.") + response = translateAI(list325, ctx("names.npc")) list325TL = response[0] totalTokens[0] += response[1][0] totalTokens[1] += response[1][1] @@ -4388,7 +4393,7 @@ def searchSS(state, pbar): # --- Batch translate all notes --- translatedNotesBatch = [] if notesBatch: - response = translateAI(notesBatch, f"Reply with only the {LANGUAGE} translation of the note text.") + response = translateAI(notesBatch, ctx("database.note")) translatedNotesBatch = response[0] totalTokens[0] += response[1][0] totalTokens[1] += response[1][1] @@ -4444,7 +4449,7 @@ def searchSS(state, pbar): def searchSystem(data, pbar): totalTokens = [0, 0] - context = "Reply with only the " + LANGUAGE + ' translation of the UI textbox."' + context = ctx("database.ui_term") # Title - batch as a single-item list title_src = _system_scalar_source(data, "gameTitle") @@ -4452,7 +4457,7 @@ def searchSystem(data, pbar): if not (IGNORETLTEXT and not re.search(LANGREGEX, title_src)): response = translateAI( [title_src], - " Reply with the " + LANGUAGE + " translation of the game title name", + ctx("database.game_title"), False, ) totalTokens[0] += response[1][0] @@ -4507,7 +4512,7 @@ def searchSystem(data, pbar): if armor_values: response = translateAI( armor_values, - "Reply with only the " + LANGUAGE + " translation of the armor type", + ctx("database.type_armor"), False, ) totalTokens[0] += response[1][0] @@ -4534,7 +4539,7 @@ def searchSystem(data, pbar): if skill_values: response = translateAI( skill_values, - "Reply with only the " + LANGUAGE + " translation", + ctx("database.generic"), False, ) totalTokens[0] += response[1][0] @@ -4561,7 +4566,7 @@ def searchSystem(data, pbar): if equip_values: response = translateAI( equip_values, - "Reply with only the " + LANGUAGE + " translation of the equipment type. No disclaimers.", + ctx("database.type_equipment"), False, ) totalTokens[0] += response[1][0] @@ -4591,7 +4596,7 @@ def searchSystem(data, pbar): if element_values: response = translateAI( element_values, - "Reply with only the " + LANGUAGE + " translation of the element type", + ctx("database.element"), False, ) totalTokens[0] += response[1][0] @@ -4621,7 +4626,7 @@ def searchSystem(data, pbar): if weapon_values: response = translateAI( weapon_values, - "Reply with only the " + LANGUAGE + " translation of the weapon type", + ctx("database.type_weapon"), False, ) totalTokens[0] += response[1][0] @@ -4650,7 +4655,7 @@ def searchSystem(data, pbar): if var_values: response = translateAI( var_values, - 'Reply with only the ' + LANGUAGE + ' translation of the title', + ctx("database.actor_title"), True, ) totalTokens[0] += response[1][0] @@ -4680,7 +4685,7 @@ def searchSystem(data, pbar): if switch_values: response = translateAI( switch_values, - 'Reply with only the ' + LANGUAGE + ' translation of the switch name', + ctx("database.switch"), True, ) totalTokens[0] += response[1][0] @@ -4712,9 +4717,7 @@ def searchSystem(data, pbar): if msg_values: response = translateAI( msg_values, - "Reply with only the " - + LANGUAGE - + ' translation of the battle text.\nTranslate "常時ダッシュ" as "Always Dash"\nTranslate "次の%1まで" as Next %1.', + ctx("database.battle_messages"), False, ) totalTokens[0] += response[1][0] @@ -4805,7 +4808,7 @@ def getSpeaker(speaker: str): pass response = translateAI( speaker, - "Reply with the " + LANGUAGE + " translation of the NPC name.", + ctx("names.npc"), False, ) try: @@ -4822,7 +4825,7 @@ def getSpeaker(speaker: str): pass response = translateAI( speaker, - "Reply with the " + LANGUAGE + " translation of the NPC name.", + ctx("names.npc"), False, ) try: @@ -5033,7 +5036,7 @@ def finalizeSpeakerParse(): pass resp = translateAI( to_translate, - "Reply with the " + LANGUAGE + " translation of the NPC name.", + ctx("names.npc"), True, ) try: diff --git a/modules/wolfdawn.py b/modules/wolfdawn.py index efb2879..224d030 100644 --- a/modules/wolfdawn.py +++ b/modules/wolfdawn.py @@ -58,7 +58,8 @@ import traceback from colorama import Fore from tqdm import tqdm -from util.paths import PROMPT_PATH, VOCAB_PATH +from util.paths import VOCAB_PATH +from util.skills import ctx, load_system_prompt from util.translation import ( TranslationConfig, translateAI as sharedtranslateAI, @@ -78,7 +79,7 @@ MODEL = os.getenv("model") TIMEOUT = int(os.getenv("timeout")) LANGUAGE = os.getenv("language").capitalize() -PROMPT = PROMPT_PATH.read_text(encoding="utf-8") +PROMPT = load_system_prompt() VOCAB = VOCAB_PATH.read_text(encoding="utf-8") LOCK = threading.Lock() MAXHISTORY = 10 @@ -143,7 +144,7 @@ def _batch_phase() -> str: def handleWolfDawn(filename, estimate): """Entry point used by the CLI/GUI dispatchers. Returns a summary string or 'Fail'.""" - global ESTIMATE, TOKENS, FILENAME, SPEAKER_CONFIG, VOCAB + global ESTIMATE, TOKENS, FILENAME, SPEAKER_CONFIG, VOCAB, PROMPT ESTIMATE = estimate FILENAME = filename # Re-read workflow-configured settings so edits made this session take effect @@ -153,6 +154,9 @@ def handleWolfDawn(filename, estimate): # that an earlier Phase 0 (names) harvested into vocab.txt. VOCAB = VOCAB_PATH.read_text(encoding="utf-8") TRANSLATION_CONFIG.vocab = VOCAB + # Reload base prompt + optional per-game quirks (DAZED_GAME_ROOT). + PROMPT = load_system_prompt() + TRANSLATION_CONFIG.prompt = PROMPT start = time.time() translatedData = openFiles(filename) @@ -361,7 +365,7 @@ def getSpeaker(speaker: str): response = translateAI( speaker, - "Reply with the " + LANGUAGE + " translation of the NPC name.", + ctx("names.npc"), ) translated = _normalize_speaker_name( response[0] if isinstance(response[0], str) else str(response[0]) @@ -371,7 +375,7 @@ def getSpeaker(speaker: str): if re.search(r"([a-zA-Z??])", translated) is None: response = translateAI( speaker, - "Reply with the " + LANGUAGE + " translation of the NPC name.", + ctx("names.npc"), ) translated = _normalize_speaker_name( response[0] if isinstance(response[0], str) else str(response[0]) diff --git a/util/paths.py b/util/paths.py index 20d38c8..5c1c140 100644 --- a/util/paths.py +++ b/util/paths.py @@ -8,11 +8,19 @@ PROJECT_ROOT = Path(__file__).resolve().parent.parent DATA_DIR = PROJECT_ROOT / "data" VOCAB_PATH = DATA_DIR / "vocab.txt" VOCAB_BASE_PATH = DATA_DIR / "vocab_base.txt" -PROMPT_PATH = DATA_DIR / "prompt.txt" +SKILLS_DIR = DATA_DIR / "skills" +# Runtime translation system skill (formerly data/prompt.txt). +PROMPT_PATH = SKILLS_DIR / "system.md" +LEGACY_PROMPT_PATH = DATA_DIR / "prompt.txt" LAST_UPDATE_SHA_PATH = DATA_DIR / "last_update_sha.txt" ENV_PATH = PROJECT_ROOT / ".env" ICON_PATH = PROJECT_ROOT / "assets" / "icon.png" ENGINE_ICONS_DIR = PROJECT_ROOT / "assets" / "engine_icons" +TRANSLATION_CONTEXTS_PATH = DATA_DIR / "translation_contexts.json" +# Per-game quirks skill (API overlay). Legacy flat file still migrated on load. +GAME_QUIRKS_RELATIVE = Path("skills") / "quirks.md" +LEGACY_QUIRKS_FILENAME = "translation_quirks.txt" +GAME_SKILL_RELATIVE = Path("skills") / "translation.md" _ROOT_DATA_FILES = ( "vocab.txt", @@ -32,7 +40,19 @@ def migrate_root_data_files() -> None: src.rename(dst) +def migrate_prompt_to_skills() -> None: + """Move legacy ``data/prompt.txt`` to ``data/skills/system.md`` when needed.""" + SKILLS_DIR.mkdir(parents=True, exist_ok=True) + if PROMPT_PATH.is_file(): + return + for legacy in (LEGACY_PROMPT_PATH, PROJECT_ROOT / "prompt.txt"): + if legacy.is_file(): + legacy.rename(PROMPT_PATH) + return + + migrate_root_data_files() +migrate_prompt_to_skills() def ensure_vocab_file() -> None: diff --git a/util/skills/__init__.py b/util/skills/__init__.py new file mode 100644 index 0000000..e1c22a5 --- /dev/null +++ b/util/skills/__init__.py @@ -0,0 +1,21 @@ +"""Skill and prompt loaders (editable files under ``data/skills/``).""" + +from __future__ import annotations + +from util.skills.contexts import ctx, reload_contexts +from util.skills.setup import load_project_setup, skills_dir +from util.skills.system import ( + game_skill_path_for_game, + load_system_prompt, + quirks_path_for_game, +) + +__all__ = [ + "ctx", + "game_skill_path_for_game", + "load_project_setup", + "load_system_prompt", + "quirks_path_for_game", + "reload_contexts", + "skills_dir", +] diff --git a/util/skills/contexts.py b/util/skills/contexts.py new file mode 100644 index 0000000..e567f4b --- /dev/null +++ b/util/skills/contexts.py @@ -0,0 +1,63 @@ +"""Load per-call translation history templates from data/translation_contexts.json.""" + +from __future__ import annotations + +import json +import os +from typing import Any + +from util.paths import TRANSLATION_CONTEXTS_PATH + +_cache: dict[str, Any] | None = None +_cache_mtime: float | None = None + + +def _load() -> dict[str, Any]: + global _cache, _cache_mtime + path = TRANSLATION_CONTEXTS_PATH + if not path.is_file(): + raise FileNotFoundError(f"Translation contexts file missing: {path}") + mtime = path.stat().st_mtime + if _cache is not None and _cache_mtime == mtime: + return _cache + data = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(data, dict): + raise ValueError(f"translation_contexts.json must be an object: {path}") + _cache = data + _cache_mtime = mtime + return data + + +def ctx(path: str, *, language: str | None = None, **kwargs: Any) -> str: + """Return a formatted history string for ``section.key``. + + ``{language}`` defaults to the ``language`` env / argument (capitalized). + Extra kwargs fill other ``{placeholders}`` (e.g. ``context``). + """ + if not path or "." not in path: + raise KeyError(f"Context path must be 'section.key', got {path!r}") + section_name, key = path.split(".", 1) + data = _load() + section = data.get(section_name) + if not isinstance(section, dict): + raise KeyError(f"Unknown translation context section: {section_name!r}") + template = section.get(key) + if not isinstance(template, str) or not template.strip(): + raise KeyError(f"Unknown translation context key: {path!r}") + + lang = language or os.getenv("language", "English") + lang = str(lang).capitalize() + values = {"language": lang, **kwargs} + try: + return template.format(**values) + except KeyError as exc: + raise KeyError( + f"Missing placeholder {exc.args[0]!r} for context {path!r}" + ) from exc + + +def reload_contexts() -> None: + """Clear the in-memory cache so the next ctx() re-reads from disk.""" + global _cache, _cache_mtime + _cache = None + _cache_mtime = None diff --git a/util/skills/setup.py b/util/skills/setup.py new file mode 100644 index 0000000..225e591 --- /dev/null +++ b/util/skills/setup.py @@ -0,0 +1,62 @@ +"""Load editable clipboard skills from data/skills/.""" + +from __future__ import annotations + +from pathlib import Path + +from util.paths import SKILLS_DIR + +_ENGINE_MARKERS = { + "rpgmaker": ("", ""), + "wolf": ("", ""), +} + + +def _read_skill_file(name: str) -> str: + path = SKILLS_DIR / name + if not path.is_file(): + raise FileNotFoundError(f"Skill file missing: {path}") + return path.read_text(encoding="utf-8") + + +def _extract_engine_section(text: str, engine: str) -> str: + """Keep shared body + the requested engine block; drop other engine blocks.""" + start_tag, end_tag = _ENGINE_MARKERS.get(engine, (None, None)) + if not start_tag: + return text + + result_parts: list[str] = [] + pos = 0 + while pos < len(text): + next_starts = [ + (text.find(marker[0], pos), eng, marker) + for eng, marker in _ENGINE_MARKERS.items() + if text.find(marker[0], pos) != -1 + ] + if not next_starts: + result_parts.append(text[pos:]) + break + next_starts.sort(key=lambda x: x[0]) + idx, eng, (s_tag, e_tag) = next_starts[0] + result_parts.append(text[pos:idx]) + end_idx = text.find(e_tag, idx) + if end_idx == -1: + raise ValueError(f"Unclosed engine block {s_tag} in skill file") + block_body = text[idx + len(s_tag) : end_idx] + if eng == engine: + result_parts.append(block_body.strip("\n") + "\n") + pos = end_idx + len(e_tag) + return "".join(result_parts).strip() + "\n" + + +def load_project_setup(engine: str = "rpgmaker", *, prepend: str = "") -> str: + """Load the Project Setup clipboard skill for *engine*.""" + raw = _read_skill_file("project_setup.md") + body = _extract_engine_section(raw, engine) + if prepend: + return prepend.rstrip() + "\n\n" + body + return body + + +def skills_dir() -> Path: + return SKILLS_DIR diff --git a/util/skills/system.py b/util/skills/system.py new file mode 100644 index 0000000..d145d8b --- /dev/null +++ b/util/skills/system.py @@ -0,0 +1,66 @@ +"""Load the static system prompt and optional per-game quirks overlay.""" + +from __future__ import annotations + +import os +from pathlib import Path + +from util.paths import ( + GAME_QUIRKS_RELATIVE, + GAME_SKILL_RELATIVE, + LEGACY_QUIRKS_FILENAME, + PROMPT_PATH, +) + + +def quirks_path_for_game(game_root: str | Path | None) -> Path | None: + """Return ``/skills/quirks.md`` when *game_root* is set. + + Also migrates a legacy ``translation_quirks.txt`` in the game root if present. + """ + if not game_root: + return None + root = Path(game_root) + preferred = root / GAME_QUIRKS_RELATIVE + legacy = root / LEGACY_QUIRKS_FILENAME + if not preferred.is_file() and legacy.is_file(): + preferred.parent.mkdir(parents=True, exist_ok=True) + try: + legacy.rename(preferred) + except OSError: + # Fall back to reading the legacy path if rename fails. + return legacy + return preferred + + +def game_skill_path_for_game(game_root: str | Path | None) -> Path | None: + """Return ``/skills/translation.md`` when *game_root* is set.""" + if not game_root: + return None + return Path(game_root) / GAME_SKILL_RELATIVE + + +def load_system_prompt(game_root: str | Path | None = None) -> str: + """Load ``data/skills/system.md`` and optionally append per-game quirks. + + Quirks path resolution order: + 1. Explicit *game_root* argument + 2. ``DAZED_GAME_ROOT`` environment variable + """ + base = "" + if PROMPT_PATH.is_file(): + base = PROMPT_PATH.read_text(encoding="utf-8") + + root = game_root or os.getenv("DAZED_GAME_ROOT") or "" + root = str(root).strip() + quirks_file = quirks_path_for_game(root) if root else None + if quirks_file and quirks_file.is_file(): + quirks = quirks_file.read_text(encoding="utf-8").strip() + if quirks: + return ( + base.rstrip() + + "\n\n## Game-Specific Translation Quirks\n\n" + + quirks + + "\n" + ) + return base diff --git a/util/translation.py b/util/translation.py index 5cc8834..201fed6 100644 --- a/util/translation.py +++ b/util/translation.py @@ -1477,8 +1477,8 @@ class TranslationConfig: # Load prompt and vocab files if not provided if prompt is None: try: - from util.paths import PROMPT_PATH, VOCAB_PATH - self.prompt = PROMPT_PATH.read_text(encoding="utf-8") + from util.skills import load_system_prompt + self.prompt = load_system_prompt() except FileNotFoundError: self.prompt = "" else: @@ -1945,7 +1945,7 @@ def createContext(config, subbedText, formatType, history=None): cache invalidation caused by changing vocabulary matches. Cached in static_system: - - prompt.txt content + - data/skills/system.md content (plus optional game quirks overlay) Dynamic in vocab_text: - only vocab terms found in the current batch text @@ -2061,7 +2061,7 @@ def buildClaudeRequest(system, user, history, formatType, model, numLines=None, def translateText(system, user, history, penalty, formatType, model, numLines=None, vocab_text=""): """Send translation request to the selected API. - system: Static system prompt (prompt.txt). Cached by Claude. + system: Static system prompt (data/skills/system.md). Cached by Claude. vocab_text: Per-batch vocabulary (dynamic, never cached to avoid cache busting). """ # Ensure system content is not empty @@ -2830,7 +2830,7 @@ def translateAI(text, history, config, filename=None, pbar=None, lock=None, mism continue - # Create context — static_system is the stable prompt.txt content; + # Create context — static_system is the stable system.md content; # vocab_text is the per-batch matched vocabulary (dynamic). static_system, vocab_text, user = createContext(config, subbedT, formatType, history)