From 32e6f7d33be19e9d1586657269588b8ca8b62c2a Mon Sep 17 00:00:00 2001 From: DazedAnon Date: Sat, 16 May 2026 14:21:09 -0500 Subject: [PATCH] Rewrite tab --- gui/main.py | 12 + gui/rewrite_tab.py | 906 ++++++++++++++++++++++++++++++++++++++++ scan_violations.py | 68 +++ skills/REWRITE_SKILL.md | 171 ++++++++ 4 files changed, 1157 insertions(+) create mode 100644 gui/rewrite_tab.py create mode 100644 scan_violations.py create mode 100644 skills/REWRITE_SKILL.md diff --git a/gui/main.py b/gui/main.py index c8a69cf..211752d 100644 --- a/gui/main.py +++ b/gui/main.py @@ -211,6 +211,7 @@ class UpdateDialog(QDialog): from gui.config_tab import ConfigTab from gui.translation_tab import TranslationTab from gui.workflow_tab import WorkflowTab +from gui.rewrite_tab import RewriteTab class DazedMTLGUI(QMainWindow): """Main GUI window for the DazedMTLTool.""" @@ -411,6 +412,13 @@ class DazedMTLGUI(QMainWindow): btn_config.clicked.connect(lambda: self.switch_page(2)) sidebar_layout.addWidget(btn_config) self.nav_buttons.append(btn_config) + + # Rewrite button (fourth) + btn_rewrite = self.create_nav_button("✏️", "Rewrite") + btn_rewrite.setToolTip("Rewrite — fix over-limit dialogue messages") + btn_rewrite.clicked.connect(lambda: self.switch_page(3)) + sidebar_layout.addWidget(btn_rewrite) + self.nav_buttons.append(btn_rewrite) sidebar_layout.addStretch() @@ -488,6 +496,10 @@ class DazedMTLGUI(QMainWindow): self.config_tab = ConfigTab() self.config_tab.config_changed.connect(self.on_config_changed) self.content_stack.addWidget(self.config_tab) + + # Rewrite Tab (index 3) + self.rewrite_tab = RewriteTab(self) + self.content_stack.addWidget(self.rewrite_tab) def show_update_dialog(self): """Open the update dialog and check for a newer version.""" diff --git a/gui/rewrite_tab.py b/gui/rewrite_tab.py new file mode 100644 index 0000000..1baa4a6 --- /dev/null +++ b/gui/rewrite_tab.py @@ -0,0 +1,906 @@ +#!/usr/bin/env python3 +""" +Rewrite Tab for DazedMTLTool GUI + +Fixes over-limit game dialogue via LLM API. +Scans translated/*.json for messages exceeding 3 lines / 60 chars per line, +then rewrites them via the configured LLM while preserving all meaning. +""" + +import os +import json +import time +import traceback +from pathlib import Path +from dotenv import load_dotenv + +# Optional fast token counter — falls back to char/4 heuristic if unavailable +try: + import tiktoken as _tiktoken + _TIKTOKEN_OK = True +except ImportError: + _TIKTOKEN_OK = False + +from PyQt5.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QGroupBox, + QTextEdit, QMessageBox, QListWidget, QListWidgetItem, + QSplitter, QFileDialog, QProgressBar, QFrame, QSpinBox, QLineEdit, + QSizePolicy, +) +from PyQt5.QtCore import Qt, pyqtSignal, QThread, QMutex +from PyQt5.QtGui import QFont, QColor + + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + +MAX_LINES = 3 +MAX_LINE_LEN = 60 +NEWLINE = "\r\n" + +SYSTEM_PROMPT_TEMPLATE = """\ +You are an editor for a visual novel translation. Rewrite dialogue so it fits +the game's text box. + +Hard limits (non-negotiable): +- Maximum 3 lines +- Maximum 60 characters per line (every character counts) +- Break lines on spaces only — never mid-word +- Put each line on its own line (use a newline character to separate lines) + +Rewriting rules: +1. Preserve ALL meaning — every fact and clause must survive in condensed form +2. NEVER truncate — do not drop the last sentence or clause to make it fit; + rephrase earlier parts more tightly instead +3. If a line ends a sentence, the next line begins with a capital letter +4. Preserve these terms exactly: {terms} +5. Use only plain ASCII punctuation — NO em dashes (—), en dashes (–), + ellipsis (…), or curly quotes. Use a hyphen (-) instead of a dash. + +Reply with ONLY the rewritten text, each line on its own line. +No explanation, no quotes, no markdown.""" + +DEFAULT_TERMS = ( + "Settler, Assimilation, Synchronization, Those Things, We, Inspector, " + "Suppressant, Brain Dive, Mutant Form, Mimicry, Resource War, " + "Resource Crisis, Quarantine, Debris Field" +) + +USER_PROMPT_TEMPLATE = """\ +Rewrite this to fit in 3 lines of max 60 characters each. +Keep all meaning. Do not drop the ending. + +Original: +{original_flat}""" + +RETRY_PROMPT_TEMPLATE = """\ +VALIDATION FAILED: {issues} + +Rewrite more tightly. Every character counts — including spaces and punctuation. +Do NOT drop any meaning from the original. Rephrase earlier parts to make room.""" + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def is_violation(text: str) -> bool: + lines = text.split(NEWLINE) + if len(lines) > MAX_LINES: + return True + return any(len(line) > MAX_LINE_LEN for line in lines) + + +def is_valid(text: str) -> list: + issues = [] + lines = text.split("\r\n") + if len(lines) > 3: + issues.append(f"{len(lines)} lines (max 3)") + for i, line in enumerate(lines, 1): + if len(line) > 60: + issues.append(f"line {i} is {len(line)} chars (max 60)") + return issues + + +def scan_folder(folder: Path) -> list: + violations = [] + for f in sorted(folder.glob("*.json")): + try: + data = json.loads(f.read_text(encoding="utf-8")) + except Exception: + continue + if not isinstance(data, list): + continue + for i, entry in enumerate(data): + if not isinstance(entry, dict): + continue + m = entry.get("message") + if isinstance(m, str) and is_violation(m): + violations.append({"file": f.name, "index": i, "original": m}) + return violations + + +def load_vocab_terms(project_root: Path) -> str: + """Load preserved terms from vocab.txt if present, else use defaults.""" + vocab_path = project_root / "vocab.txt" + if vocab_path.exists(): + try: + lines = [ln.strip() for ln in vocab_path.read_text(encoding="utf-8").splitlines() if ln.strip()] + if lines: + return ", ".join(lines) + except Exception: + pass + return DEFAULT_TERMS + + +# --------------------------------------------------------------------------- +# Cost estimation helpers +# --------------------------------------------------------------------------- + +# Approximate output tokens per rewrite (used for pre-run estimate) +_EST_OUTPUT_TOKENS = 150 + + +def _count_tokens(text: str, model: str) -> int: + """Fast token count using tiktoken; falls back to char/4 heuristic.""" + if _TIKTOKEN_OK: + try: + enc = _tiktoken.encoding_for_model(model) + except Exception: + try: + enc = _tiktoken.get_encoding("cl100k_base") + except Exception: + return len(text) // 4 + return len(enc.encode(text)) + return len(text) // 4 + + +def estimate_run_cost(violations: list, project_root: Path) -> str: + """Return a human-readable cost estimate for a full rewrite run.""" + try: + load_dotenv() + model = os.getenv("model", "gpt-4.1").strip() + from util.translation import getPricingConfig + pricing = getPricingConfig(model) + in_rate = pricing["inputAPICost"] # $ per million tokens + out_rate = pricing["outputAPICost"] + + terms = load_vocab_terms(project_root) + system_prompt = SYSTEM_PROMPT_TEMPLATE.format(terms=terms) + sys_tokens = _count_tokens(system_prompt, model) + + total_in = 0 + total_out = 0 + for v in violations: + original_flat = v["original"].replace(NEWLINE, "\n") + user_msg = USER_PROMPT_TEMPLATE.format(original_flat=original_flat) + total_in += sys_tokens + _count_tokens(user_msg, model) + total_out += _EST_OUTPUT_TOKENS + + cost = (total_in / 1_000_000) * in_rate + (total_out / 1_000_000) * out_rate + return ( + f"~${cost:.4f} " + f"({total_in:,} in / ~{total_out:,} out est. | " + f"${in_rate:.2f}/${out_rate:.2f} per M)" + ) + except Exception as exc: + return f"(estimate unavailable: {exc})" + + +# --------------------------------------------------------------------------- +# API caller (OpenAI-compat + Anthropic) +# --------------------------------------------------------------------------- + +def _normalise_line_endings(text: str) -> str: + """Normalise any line-ending variant the LLM might emit to actual \\r\\n.""" + # Handle escaped sequences the model may output literally (e.g. backslash-r-backslash-n) + text = text.replace("\\r\\n", "\n").replace("\\n", "\n") + # Now normalise all real CR/LF variants + text = text.replace("\r\n", "\n").replace("\r", "\n") + return text.replace("\n", "\r\n") + + +# Characters the game engine cannot render — map to ASCII-safe equivalents +_CHAR_SUBSTITUTIONS = [ + ("\u2014", " - "), # em dash — → hyphen + ("\u2013", " - "), # en dash – → hyphen + ("\u2026", "..."), # ellipsis … → three dots + ("\u201C", '"'), # left double quote " + ("\u201D", '"'), # right double quote " + ("\u2018", "'"), # left single quote ' + ("\u2019", "'"), # right single quote ' +] + + +def _sanitise_output(text: str) -> str: + """Replace characters the game engine can't render with ASCII equivalents.""" + for src, dst in _CHAR_SUBSTITUTIONS: + text = text.replace(src, dst) + return text + + +def _call_llm(messages: list, model: str, api_url: str, api_key: str, + is_claude: bool, timeout: int) -> tuple: + """Call the LLM. Returns (text, input_tokens, output_tokens).""" + if is_claude: + import anthropic + client = anthropic.Anthropic(api_key=api_key) + system_text = next((m["content"] for m in messages if m["role"] == "system"), "") + user_msgs = [m for m in messages if m["role"] != "system"] + response = client.messages.create( + model=model, + max_tokens=512, + system=system_text, + messages=user_msgs, + timeout=timeout, + ) + text = response.content[0].text.strip() + in_tok = getattr(response.usage, "input_tokens", 0) or 0 + out_tok = getattr(response.usage, "output_tokens", 0) or 0 + return text, in_tok, out_tok + else: + import openai as _openai + client_kwargs = {"api_key": api_key} + if api_url: + client_kwargs["base_url"] = api_url + client = _openai.OpenAI(**client_kwargs) + response = client.chat.completions.create( + model=model, + messages=messages, + max_tokens=512, + temperature=0, + timeout=timeout, + ) + text = response.choices[0].message.content.strip() + in_tok = getattr(response.usage, "prompt_tokens", 0) or 0 + out_tok = getattr(response.usage, "completion_tokens", 0) or 0 + return text, in_tok, out_tok + + +# --------------------------------------------------------------------------- +# Worker thread +# --------------------------------------------------------------------------- + +class RewriteWorker(QThread): + log_signal = pyqtSignal(str) + file_status_signal = pyqtSignal(str, str) # filename, status + progress_signal = pyqtSignal(int, int) # current, total + cost_signal = pyqtSignal(float, int, int) # running_cost, total_in_tok, total_out_tok + finished_signal = pyqtSignal(bool, str) + + def __init__(self, violations: list, output_folder: Path, max_retries: int, + project_root: Path): + super().__init__() + self.violations = violations + self.output_folder = output_folder + self.max_retries = max_retries + self.project_root = project_root + self.should_stop = False + self._mutex = QMutex() + + def stop(self): + self._mutex.lock() + self.should_stop = True + self._mutex.unlock() + + def _is_stopped(self): + self._mutex.lock() + v = self.should_stop + self._mutex.unlock() + return v + + def run(self): + try: + self._run() + except Exception as exc: + self.log_signal.emit(f"[ERROR] Unexpected error: {exc}") + self.log_signal.emit(traceback.format_exc()) + self.finished_signal.emit(False, str(exc)) + + def _run(self): + load_dotenv() + + api_key = os.getenv("key", "").strip() + api_url = os.getenv("api", "").strip() + model = os.getenv("model", "gpt-4.1").strip() + provider = os.getenv("API_PROVIDER", "openai").strip().lower() + timeout = int(os.getenv("timeout", "120")) + + if provider == "gemini": + api_url = api_url or "https://generativelanguage.googleapis.com/v1beta/openai/" + + is_claude = ( + any(x in model.lower() for x in ("claude", "sonnet", "haiku", "opus")) + and (not api_url or "anthropic" in api_url.lower()) + ) + + if not api_key: + self.log_signal.emit("[ERROR] No API key configured. Set 'key' in .env") + self.finished_signal.emit(False, "No API key") + return + + from util.translation import getPricingConfig + pricing = getPricingConfig(model) + input_cost_per_m = pricing["inputAPICost"] + output_cost_per_m = pricing["outputAPICost"] + + terms = load_vocab_terms(self.project_root) + system_prompt = SYSTEM_PROMPT_TEMPLATE.format(terms=terms) + + self.log_signal.emit(f"Model: {model} | Provider: {'Claude' if is_claude else provider}") + self.log_signal.emit( + f"Pricing: ${input_cost_per_m:.4f}/M in ${output_cost_per_m:.4f}/M out" + ) + self.log_signal.emit(f"Output folder: {self.output_folder}") + self.log_signal.emit(f"Total violations to process: {len(self.violations)}\n") + + total_in_tok = 0 + total_out_tok = 0 + running_cost = 0.0 + + # Group violations by file (preserving order) + from collections import defaultdict + file_groups: dict = defaultdict(list) + for idx, v in enumerate(self.violations): + file_groups[v["file"]].append((idx, v)) + + done = 0 + failed = 0 + total = len(self.violations) + + for filename, items in file_groups.items(): + if self._is_stopped(): + break + + self.file_status_signal.emit(filename, "processing") + file_done = 0 + file_failed = 0 + + json_path = self.output_folder / filename + if not json_path.exists(): + self.log_signal.emit(f"\n[SKIP] {filename} — not found in output folder") + self.file_status_signal.emit(filename, "skipped") + failed += len(items) + self.progress_signal.emit(done + failed, total) + continue + + try: + file_data = json.loads(json_path.read_text(encoding="utf-8")) + except Exception as exc: + self.log_signal.emit(f"\n[ERROR] Could not read {filename}: {exc}") + self.file_status_signal.emit(filename, "failed") + failed += len(items) + self.progress_signal.emit(done + failed, total) + continue + + self.log_signal.emit(f"\n{'─'*50}") + self.log_signal.emit(f"File: {filename} ({len(items)} violation{'s' if len(items) != 1 else ''})") + + for idx, violation in items: + if self._is_stopped(): + break + + original = violation["original"] + original_flat = original.replace(NEWLINE, "\n") + entry_index = violation["index"] + + self.log_signal.emit(f"\n [{idx + 1}/{total}] entry {entry_index}") + self.log_signal.emit(f" Original: {repr(original_flat)}") + + messages = [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": USER_PROMPT_TEMPLATE.format(original_flat=original_flat)}, + ] + + rewrite = None + for attempt in range(1, self.max_retries + 1): + if self._is_stopped(): + break + try: + result, in_tok, out_tok = _call_llm( + messages, model, api_url, api_key, is_claude, timeout + ) + total_in_tok += in_tok + total_out_tok += out_tok + running_cost = ( + (total_in_tok / 1_000_000) * input_cost_per_m + + (total_out_tok / 1_000_000) * output_cost_per_m + ) + self.cost_signal.emit(running_cost, total_in_tok, total_out_tok) + + result = _normalise_line_endings(result) + result = _sanitise_output(result) + issues = is_valid(result) + if not issues: + rewrite = result + self.log_signal.emit(f" -> OK (attempt {attempt}): {repr(result)}") + break + else: + self.log_signal.emit(f" -> Attempt {attempt} invalid: {issues}") + messages.append({"role": "assistant", "content": result}) + messages.append({"role": "user", "content": RETRY_PROMPT_TEMPLATE.format(issues="; ".join(issues))}) + except Exception as exc: + self.log_signal.emit(f" -> Attempt {attempt} API error: {exc}") + if attempt < self.max_retries: + time.sleep(2) + + if rewrite is not None and not self._is_stopped(): + entry = file_data[entry_index] + if isinstance(entry, dict): + entry["message"] = rewrite + # Save immediately after every successful rewrite + try: + json_path.write_text( + json.dumps(file_data, ensure_ascii=False, indent=4) + "\n", + encoding="utf-8", + ) + except Exception as exc: + self.log_signal.emit(f" [ERROR] Could not save {filename}: {exc}") + done += 1 + file_done += 1 + else: + failed += 1 + file_failed += 1 + if rewrite is None: + self.log_signal.emit(f" -> FAILED after {self.max_retries} attempts") + + self.progress_signal.emit(done + failed, total) + + # Per-file summary and final status colour + if not self._is_stopped(): + status = "done" if file_failed == 0 else ("partial" if file_done > 0 else "failed") + self.file_status_signal.emit(filename, status) + self.log_signal.emit( + f" [FILE DONE] {filename} ✓ {file_done} ✗ {file_failed}" + ) + + stopped = self._is_stopped() + summary = f"Done: {done} Failed: {failed} Total: {total}" + if stopped: + summary += " (stopped early)" + self.log_signal.emit(f"\n{'='*50}\n{summary}") + self.finished_signal.emit(not stopped and failed == 0, summary) + + +# --------------------------------------------------------------------------- +# UI helpers +# --------------------------------------------------------------------------- + +def _make_header(title: str) -> QLabel: + lbl = QLabel(title) + lbl.setStyleSheet(""" + QLabel { + font-size: 13px; + font-weight: bold; + color: #007acc; + padding: 8px 0px 5px 0px; + background-color: transparent; + } + """) + return lbl + + +def _make_hline() -> QFrame: + line = QFrame() + line.setFrameShape(QFrame.HLine) + line.setFrameShadow(QFrame.Sunken) + line.setStyleSheet("QFrame { color: #555555; margin: 5px 0px; }") + return line + + +# --------------------------------------------------------------------------- +# Main tab widget +# --------------------------------------------------------------------------- + +class RewriteTab(QWidget): + """Tab for scanning and fixing over-limit dialogue messages.""" + + def __init__(self, parent=None): + super().__init__(parent) + self._project_root = Path(__file__).resolve().parent.parent + self._violations: list = [] + self._worker: RewriteWorker | None = None + # filename → row index in _file_list for fast status updates + self._file_row: dict = {} + self._setup_ui() + + # ------------------------------------------------------------------ + # UI construction + # ------------------------------------------------------------------ + + def _setup_ui(self): + root_layout = QHBoxLayout(self) + root_layout.setContentsMargins(0, 0, 0, 0) + root_layout.setSpacing(0) + + splitter = QSplitter(Qt.Horizontal) + splitter.setHandleWidth(4) + + splitter.addWidget(self._build_left_panel()) + splitter.addWidget(self._build_right_panel()) + splitter.setSizes([380, 800]) + splitter.setStretchFactor(0, 0) + splitter.setStretchFactor(1, 1) + + root_layout.addWidget(splitter) + + def _build_left_panel(self) -> QWidget: + panel = QWidget() + panel.setMinimumWidth(300) + panel.setMaximumWidth(460) + layout = QVBoxLayout(panel) + layout.setContentsMargins(10, 10, 10, 10) + layout.setSpacing(6) + + # ---- Folder settings ---- + layout.addWidget(_make_header("Output Folder")) + + folder_row = QHBoxLayout() + self._folder_edit = QLineEdit() + self._folder_edit.setPlaceholderText("translated/") + self._folder_edit.setText(str(self._project_root / "translated")) + self._folder_edit.setReadOnly(False) + folder_row.addWidget(self._folder_edit) + btn_browse_folder = QPushButton("Browse") + btn_browse_folder.setFixedWidth(72) + btn_browse_folder.clicked.connect(self._browse_folder) + folder_row.addWidget(btn_browse_folder) + layout.addLayout(folder_row) + + layout.addWidget(_make_hline()) + + # ---- Violations ---- + layout.addWidget(_make_header("Violations")) + + self._violations_label = QLabel("No violations loaded.") + self._violations_label.setWordWrap(True) + self._violations_label.setStyleSheet("color: #aaaaaa; font-size: 12px;") + layout.addWidget(self._violations_label) + + self._estimate_label = QLabel("") + self._estimate_label.setWordWrap(True) + self._estimate_label.setStyleSheet("color: #9cdcfe; font-size: 11px;") + layout.addWidget(self._estimate_label) + + scan_row = QHBoxLayout() + self._btn_scan = QPushButton("Scan Folder") + self._btn_scan.setToolTip("Scan the output folder and load violations") + self._btn_scan.clicked.connect(self._scan_folder) + scan_row.addWidget(self._btn_scan) + + self._btn_load_violations = QPushButton("Load JSON") + self._btn_load_violations.setToolTip("Load an existing violations.json file") + self._btn_load_violations.clicked.connect(self._load_violations_file) + scan_row.addWidget(self._btn_load_violations) + layout.addLayout(scan_row) + + layout.addWidget(_make_hline()) + + # ---- Rewrite settings ---- + layout.addWidget(_make_header("Settings")) + + retry_row = QHBoxLayout() + retry_row.addWidget(QLabel("Max retries per message:")) + self._retry_spin = QSpinBox() + self._retry_spin.setRange(1, 10) + self._retry_spin.setValue(3) + self._retry_spin.setFixedWidth(60) + retry_row.addWidget(self._retry_spin) + retry_row.addStretch() + layout.addLayout(retry_row) + + layout.addWidget(_make_hline()) + + # ---- Progress ---- + layout.addWidget(_make_header("Progress")) + + self._progress_bar = QProgressBar() + self._progress_bar.setRange(0, 100) + self._progress_bar.setValue(0) + self._progress_bar.setTextVisible(True) + layout.addWidget(self._progress_bar) + + self._progress_label = QLabel("Idle") + self._progress_label.setStyleSheet("color: #aaaaaa; font-size: 11px;") + layout.addWidget(self._progress_label) + + self._cost_label = QLabel("Cost: $0.0000") + self._cost_label.setStyleSheet("color: #aaaaaa; font-size: 11px;") + layout.addWidget(self._cost_label) + + layout.addWidget(_make_hline()) + + # ---- Action buttons ---- + btn_row = QHBoxLayout() + self._btn_start = QPushButton("Start Rewrite") + self._btn_start.setEnabled(False) + self._btn_start.setStyleSheet(""" + QPushButton { background-color: #2ea043; } + QPushButton:hover { background-color: #3fb950; } + QPushButton:disabled { background-color: #404040; color: #888888; } + """) + self._btn_start.clicked.connect(self._start_rewrite) + btn_row.addWidget(self._btn_start) + + self._btn_stop = QPushButton("Stop") + self._btn_stop.setEnabled(False) + self._btn_stop.setStyleSheet(""" + QPushButton { background-color: #da3633; } + QPushButton:hover { background-color: #f85149; } + QPushButton:disabled { background-color: #404040; color: #888888; } + """) + self._btn_stop.clicked.connect(self._stop_rewrite) + btn_row.addWidget(self._btn_stop) + layout.addLayout(btn_row) + + layout.addStretch() + return panel + + def _build_right_panel(self) -> QWidget: + panel = QWidget() + layout = QVBoxLayout(panel) + layout.setContentsMargins(6, 10, 10, 10) + layout.setSpacing(6) + + splitter = QSplitter(Qt.Vertical) + splitter.setHandleWidth(4) + + # File checklist + file_container = QWidget() + file_layout = QVBoxLayout(file_container) + file_layout.setContentsMargins(0, 0, 0, 0) + file_layout.setSpacing(4) + + file_header_row = QHBoxLayout() + file_header_row.addWidget(_make_header("Files")) + file_header_row.addStretch() + btn_all = QPushButton("All") + btn_all.setToolTip("Select all files") + btn_all.clicked.connect(lambda: self._set_all_checked(True)) + btn_none = QPushButton("None") + btn_none.setToolTip("Deselect all files") + btn_none.clicked.connect(lambda: self._set_all_checked(False)) + file_header_row.addWidget(btn_all) + file_header_row.addWidget(btn_none) + file_layout.addLayout(file_header_row) + + self._file_list = QListWidget() + self._file_list.setFont(QFont("Consolas", 9)) + file_layout.addWidget(self._file_list) + splitter.addWidget(file_container) + + # Log area + log_container = QWidget() + log_layout = QVBoxLayout(log_container) + log_layout.setContentsMargins(0, 0, 0, 0) + log_layout.addWidget(_make_header("Log")) + self._log_text = QTextEdit() + self._log_text.setReadOnly(True) + self._log_text.setFont(QFont("Consolas", 9)) + log_layout.addWidget(self._log_text) + splitter.addWidget(log_container) + + splitter.setSizes([300, 400]) + layout.addWidget(splitter) + return panel + + # ------------------------------------------------------------------ + # Actions + # ------------------------------------------------------------------ + + def _browse_folder(self): + folder = QFileDialog.getExistingDirectory( + self, "Select Output Folder", self._folder_edit.text() + ) + if folder: + self._folder_edit.setText(folder) + + def _scan_folder(self): + folder = Path(self._folder_edit.text().strip()) + if not folder.exists(): + QMessageBox.warning(self, "Folder Not Found", f"Folder does not exist:\n{folder}") + return + + self._log("Scanning folder for violations...") + violations = scan_folder(folder) + + # Save violations.json next to the project root + out_path = self._project_root / "violations.json" + try: + out_path.write_text( + json.dumps(violations, ensure_ascii=False, indent=2), encoding="utf-8" + ) + self._log(f"Saved {len(violations)} violations → {out_path}") + except Exception as exc: + self._log(f"[WARNING] Could not save violations.json: {exc}") + + self._load_violations(violations) + + def _load_violations_file(self): + path, _ = QFileDialog.getOpenFileName( + self, "Load Violations File", + str(self._project_root), + "JSON Files (*.json);;All Files (*)" + ) + if not path: + return + try: + data = json.loads(Path(path).read_text(encoding="utf-8")) + if not isinstance(data, list): + raise ValueError("Expected a JSON array") + self._load_violations(data) + self._log(f"Loaded {len(data)} violations from {path}") + except Exception as exc: + QMessageBox.critical(self, "Load Error", f"Failed to load violations:\n{exc}") + + def _load_violations(self, violations: list): + self._violations = violations + self._file_list.clear() + self._file_row.clear() + + # Group by file, preserving order + from collections import defaultdict, OrderedDict + file_groups: dict = OrderedDict() + for v in violations: + file_groups.setdefault(v["file"], []).append(v) + + for row, (filename, items) in enumerate(file_groups.items()): + n = len(items) + label = f"{filename} ({n} violation{'s' if n != 1 else ''})" + item = QListWidgetItem(label) + item.setFlags(item.flags() | Qt.ItemIsUserCheckable) + item.setCheckState(Qt.Checked) + item.setForeground(QColor("#cccccc")) + item.setData(Qt.UserRole, filename) + self._file_list.addItem(item) + self._file_row[filename] = row + + n_files = len(file_groups) + count = len(violations) + self._violations_label.setText( + f"{count} violation{'s' if count != 1 else ''} across {n_files} file{'s' if n_files != 1 else ''}." + ) + self._estimate_label.setText("Estimating cost...") + self._btn_start.setEnabled(count > 0) + + if count > 0: + self._log(f"Loaded {count} violations across {n_files} files.") + self._run_cost_estimate(violations) + else: + self._log("No violations found.") + self._estimate_label.setText("") + + def _set_all_checked(self, checked: bool): + state = Qt.Checked if checked else Qt.Unchecked + for i in range(self._file_list.count()): + self._file_list.item(i).setCheckState(state) + + def _checked_violations(self) -> list: + """Return only violations belonging to checked files.""" + checked_files = set() + for i in range(self._file_list.count()): + item = self._file_list.item(i) + if item.checkState() == Qt.Checked: + checked_files.add(item.data(Qt.UserRole)) + return [v for v in self._violations if v["file"] in checked_files] + + def _run_cost_estimate(self, violations: list): + """Compute cost estimate in a background thread and update the label.""" + project_root = self._project_root + + class _EstimateThread(QThread): + done = pyqtSignal(str) + def run(self_t): + self_t.done.emit(estimate_run_cost(violations, project_root)) + + self._estimate_thread = _EstimateThread(self) + self._estimate_thread.done.connect( + lambda text: self._estimate_label.setText(f"Est. cost: {text}") + ) + self._estimate_thread.done.connect( + lambda text: self._log(f"Cost estimate: {text}") + ) + self._estimate_thread.start() + + def _start_rewrite(self): + if not self._violations: + QMessageBox.information(self, "No Violations", "Scan or load violations first.") + return + + violations = self._checked_violations() + if not violations: + QMessageBox.information(self, "No Files Selected", + "Check at least one file in the file list.") + return + + folder = Path(self._folder_edit.text().strip()) + if not folder.exists(): + QMessageBox.warning(self, "Folder Not Found", f"Output folder not found:\n{folder}") + return + + self._btn_start.setEnabled(False) + self._btn_stop.setEnabled(True) + self._btn_scan.setEnabled(False) + self._btn_load_violations.setEnabled(False) + self._progress_bar.setValue(0) + self._progress_label.setText("Starting...") + self._cost_label.setText("Cost: $0.0000") + self._log("\n" + "="*50) + n_files = len({v["file"] for v in violations}) + self._log(f"Starting rewrite: {len(violations)} violations across {n_files} files...") + + # Reset file list colours for checked items only + for i in range(self._file_list.count()): + item = self._file_list.item(i) + if item.checkState() == Qt.Checked: + item.setForeground(QColor("#cccccc")) + + self._worker = RewriteWorker( + violations=violations, + output_folder=folder, + max_retries=self._retry_spin.value(), + project_root=self._project_root, + ) + self._worker.log_signal.connect(self._log) + self._worker.file_status_signal.connect(self._on_file_status) + self._worker.progress_signal.connect(self._on_progress) + self._worker.cost_signal.connect(self._on_cost) + self._worker.finished_signal.connect(self._on_finished) + self._worker.start() + + def _stop_rewrite(self): + if self._worker and self._worker.isRunning(): + self._worker.stop() + self._btn_stop.setEnabled(False) + self._progress_label.setText("Stopping...") + self._log("Stop requested...") + + # ------------------------------------------------------------------ + # Signals + # ------------------------------------------------------------------ + + def _on_file_status(self, filename: str, status: str): + row = self._file_row.get(filename) + if row is None: + return + item = self._file_list.item(row) + if item is None: + return + colors = { + "processing": "#007acc", + "done": "#2ea043", + "partial": "#e3b341", # some succeeded, some failed + "failed": "#da3633", + "skipped": "#888888", + } + item.setForeground(QColor(colors.get(status, "#cccccc"))) + + def _on_progress(self, current: int, total: int): + if total > 0: + pct = int(current / total * 100) + self._progress_bar.setValue(pct) + self._progress_label.setText(f"{current} / {total} ({pct}%)") + + def _on_cost(self, cost: float, in_tok: int, out_tok: int): + self._cost_label.setText( + f"Cost: ${cost:.4f} ({in_tok:,} in / {out_tok:,} out tokens)" + ) + + def _on_finished(self, success: bool, message: str): + self._btn_start.setEnabled(bool(self._violations)) + self._btn_stop.setEnabled(False) + self._btn_scan.setEnabled(True) + self._btn_load_violations.setEnabled(True) + self._progress_label.setText("Finished") + self._worker = None + + # ------------------------------------------------------------------ + # Log helper + # ------------------------------------------------------------------ + + def _log(self, text: str): + self._log_text.append(text) + # Auto-scroll + sb = self._log_text.verticalScrollBar() + sb.setValue(sb.maximum()) diff --git a/scan_violations.py b/scan_violations.py new file mode 100644 index 0000000..6d8b9c9 --- /dev/null +++ b/scan_violations.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python3 +"""Scan a folder of JSON files and write violations.json listing every over-limit message. + +Usage: + python scan_violations.py [--folder FOLDER] [--output OUTPUT] + +Defaults: + --folder translated + --output violations.json +""" + +import json +import sys +import argparse +from pathlib import Path + +MAX_LINES = 3 +MAX_LINE_LEN = 60 +NEWLINE = "\r\n" + + +def is_violation(text: str) -> bool: + lines = text.split(NEWLINE) + if len(lines) > MAX_LINES: + return True + return any(len(line) > MAX_LINE_LEN for line in lines) + + +def scan_folder(folder: Path) -> list[dict]: + violations = [] + for f in sorted(folder.glob("*.json")): + try: + data = json.loads(f.read_text(encoding="utf-8")) + except Exception as e: + print(f"Warning: could not read {f.name}: {e}", file=sys.stderr) + continue + if not isinstance(data, list): + continue + for i, entry in enumerate(data): + if not isinstance(entry, dict): + continue + m = entry.get("message") + if isinstance(m, str) and is_violation(m): + violations.append({"file": f.name, "index": i, "original": m}) + return violations + + +def main(): + parser = argparse.ArgumentParser(description="Scan JSON files for over-limit messages") + parser.add_argument("--folder", default="translated", help="Folder to scan (default: translated)") + parser.add_argument("--output", default="violations.json", help="Output file (default: violations.json)") + args = parser.parse_args() + + folder = Path(args.folder) + if not folder.exists(): + print(f"Error: folder '{folder}' does not exist", file=sys.stderr) + sys.exit(1) + + violations = scan_folder(folder) + + Path(args.output).write_text( + json.dumps(violations, ensure_ascii=False, indent=2), encoding="utf-8" + ) + print(f"Found {len(violations)} violations → {args.output}") + + +if __name__ == "__main__": + main() diff --git a/skills/REWRITE_SKILL.md b/skills/REWRITE_SKILL.md new file mode 100644 index 0000000..b14a21c --- /dev/null +++ b/skills/REWRITE_SKILL.md @@ -0,0 +1,171 @@ +# Skill: Fix over-limit game dialogue messages via LLM API + +## Files needed + +| File | Purpose | +|------|---------| +| `scan_violations.py` | Run this first to generate violations.json | +| `vocab.txt` | Glossary — terms the LLM must preserve exactly | +| `output/` folder | The tool reads and writes JSON files here directly | + +--- + +## scan_violations.py + +Run this to produce `violations.json` before starting the rewrite tool. + +```python +#!/usr/bin/env python3 +"""Scan output/*.json and write violations.json listing every over-limit message.""" +import json +from pathlib import Path + +OUTPUT = Path("output") # adjust if running from a different working directory +MAX_LINES = 3 +MAX_LINE_LEN = 60 +NEWLINE = "\r\n" + +def is_violation(text: str) -> bool: + lines = text.split(NEWLINE) + if len(lines) > MAX_LINES: + return True + return any(len(line) > MAX_LINE_LEN for line in lines) + +violations = [] +for f in sorted(OUTPUT.glob("*.json")): + data = json.loads(f.read_text(encoding="utf-8")) + for i, entry in enumerate(data): + if not isinstance(entry, dict): + continue + m = entry.get("message") + if isinstance(m, str) and is_violation(m): + violations.append({"file": f.name, "index": i, "original": m}) + +Path("violations.json").write_text( + json.dumps(violations, ensure_ascii=False, indent=2), encoding="utf-8" +) +print(f"Found {len(violations)} violations → violations.json") +``` + +--- + +## violations.json entry format + +```json +{ + "file": "yst00183.json", + "index": 2, + "original": "Though significantly behind schedule, we completed the\r\nsurvey without losing a single crew member..." +} +``` + +--- + +## Inline validation + +```python +def is_valid(text: str) -> list[str]: + """Return list of issues, empty if valid.""" + issues = [] + lines = text.split("\r\n") + if len(lines) > 3: + issues.append(f"{len(lines)} lines (max 3)") + for i, line in enumerate(lines, 1): + if len(line) > 60: + issues.append(f"line {i} is {len(line)} chars (max 60)") + return issues +``` + +--- + +## System prompt + +``` +You are an editor for a visual novel translation. Rewrite dialogue so it fits +the game's text box. + +Hard limits (non-negotiable): +- Maximum 3 lines +- Maximum 60 characters per line (every character counts) +- Break lines on spaces only — never mid-word +- Use \r\n between lines (not \n) + +Rewriting rules: +1. Preserve ALL meaning — every fact and clause must survive in condensed form +2. NEVER truncate — do not drop the last sentence or clause to make it fit; + rephrase earlier parts more tightly instead +3. If a line ends a sentence, the next line begins with a capital letter +4. Preserve these terms exactly: Settler, Assimilation, Synchronization, + Those Things, We, Inspector, Suppressant, Brain Dive, Mutant Form, + Mimicry, Resource War, Resource Crisis, Quarantine, Debris Field + +Reply with ONLY the rewritten text. Use literal \r\n between lines. +No explanation, no quotes, no markdown. +``` + +--- + +## User prompt per message + +``` +Rewrite this to fit in 3 lines of max 60 characters each. +Keep all meaning. Do not drop the ending. + +Original: +{original_flat} +``` + +`{original_flat}` = `original` with `\r\n` replaced by `\n` for readability. + +--- + +## Retry prompt (on validation failure) + +``` +VALIDATION FAILED: {issues} + +Rewrite more tightly. Every character counts — including spaces and punctuation. +Do NOT drop any meaning from the original. Rephrase earlier parts to make room. +``` + +--- + +## Good vs bad example + +**Original (5 lines — over limit):** +``` +Though significantly behind schedule, we completed the +survey without losing a single crew member, and should +arrive at Earth within two weeks by Earth time. Securing a +modest amount of resources during the voyage to Jupiter is +our primary achievement. +``` + +**Correct rewrite — all meaning kept, 3 lines:** +``` +We finished behind schedule but without losing any crew.\r\nWe should reach Earth in two weeks. Securing some\r\nresources en route to Jupiter is our main achievement. +``` + +**Wrong rewrite — truncated (NEVER do this):** +``` +We finished behind schedule but without losing any crew.\r\nWe should reach Earth in two weeks. +``` + +--- + +## Apply logic + +```python +import json +from pathlib import Path + +OUTPUT = Path("path/to/INKAN_RE_DL/output") + +def apply(file: str, index: int, rewrite: str): + path = OUTPUT / file + data = json.loads(path.read_text(encoding="utf-8")) + data[index]["message"] = rewrite + path.write_text(json.dumps(data, ensure_ascii=False, indent=4) + "\n", encoding="utf-8") +``` + +Write per-file after all rewrites for that file are collected to minimise disk writes.