From ec5d5956a7395bc081611d67d00b97832fce5ba5 Mon Sep 17 00:00:00 2001 From: DazedAnon Date: Sat, 13 Jun 2026 18:29:07 -0500 Subject: [PATCH] Remove Rewrite Tab --- gui/main.py | 12 - gui/platform_glyph.py | 1 - gui/rewrite_tab.py | 1008 --------------------------------------- scan_violations.py | 68 --- skills/REWRITE_SKILL.md | 171 ------- 5 files changed, 1260 deletions(-) delete mode 100644 gui/rewrite_tab.py delete mode 100644 scan_violations.py delete mode 100644 skills/REWRITE_SKILL.md diff --git a/gui/main.py b/gui/main.py index f638c65..7b6dc18 100644 --- a/gui/main.py +++ b/gui/main.py @@ -223,7 +223,6 @@ 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.""" @@ -424,13 +423,6 @@ 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() @@ -492,10 +484,6 @@ 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/platform_glyph.py b/gui/platform_glyph.py index 59e8d5d..693ea81 100644 --- a/gui/platform_glyph.py +++ b/gui/platform_glyph.py @@ -40,7 +40,6 @@ _LINUX_NAV_FALLBACKS: dict[str, str] = { "\U00002694": "\u2694", "\U0001F504": "\u21bb", # 🔄 Update → ↻ "\U00002699\uFE0F": "\u2699", # ⚙️ Config → ⚙ - "\U0000270F\uFE0F": "\u270E", # ✏️ Rewrite → ✎ "\U000026A1": "\u26a1", # ⚡ Workflow } diff --git a/gui/rewrite_tab.py b/gui/rewrite_tab.py deleted file mode 100644 index fbb91b5..0000000 --- a/gui/rewrite_tab.py +++ /dev/null @@ -1,1008 +0,0 @@ -#!/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 -import threading -from concurrent.futures import ThreadPoolExecutor, as_completed -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 _extract_claude_text(response) -> str: - """Collect text from all text blocks; skip thinking/tool blocks.""" - parts = [] - for block in getattr(response, "content", None) or []: - if getattr(block, "type", None) == "text": - t = getattr(block, "text", None) - if t: - parts.append(t) - elif hasattr(block, "text") and block.text: - # Older SDK blocks without explicit type - parts.append(block.text) - text = "".join(parts).strip() - if not text: - stop = getattr(response, "stop_reason", None) - raise ValueError( - f"Empty response from API (stop_reason={stop!r}). " - "Try again or use a different model." - ) - return text - - -def _extract_openai_text(response) -> str: - """Extract assistant message text from an OpenAI-compatible completion.""" - choices = getattr(response, "choices", None) or [] - if not choices: - raise ValueError("Empty response from API (no choices returned).") - message = choices[0].message - text = getattr(message, "content", None) - if text is None and hasattr(message, "refusal") and message.refusal: - raise ValueError(f"Model refused: {message.refusal}") - if not text or not str(text).strip(): - finish = getattr(choices[0], "finish_reason", None) - raise ValueError( - f"Empty response from API (finish_reason={finish!r}). " - "Try again or use a different model." - ) - return str(text).strip() - - -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 = _extract_claude_text(response) - 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: - from util.translation import isMistralAPI, callMistral - if isMistralAPI(): - # Mistral budgets requests AND tokens per minute — go through the - # shared adaptive limiter (same one the translation runs use). - response = callMistral({ - "model": model, - "messages": messages, - "max_tokens": 512, - "temperature": 0, - "timeout": timeout, - }) - 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 = _extract_openai_text(response) - 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, - file_threads: int, project_root: Path): - super().__init__() - self.violations = violations - self.output_folder = output_folder - self.max_retries = max_retries - self.file_threads = file_threads - 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/" - elif provider == "mistral": - api_url = api_url or "https://api.mistral.ai/v1/" - - 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: {len(self.violations)} | Parallel files: {self.file_threads}\n" - ) - - # Shared counters — all accesses protected by _counter_lock - _counter_lock = threading.Lock() - _totals = {"done": 0, "failed": 0, "in_tok": 0, "out_tok": 0} - total = len(self.violations) - - # Group violations by file (preserving order) - from collections import OrderedDict - file_groups: dict = OrderedDict() - for idx, v in enumerate(self.violations): - file_groups.setdefault(v["file"], []).append((idx, v)) - - # ------------------------------------------------------------------ # - # Per-file worker — runs in a thread-pool thread - # ------------------------------------------------------------------ # - def process_file(filename: str, items: list): - if self._is_stopped(): - return - - 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") - with _counter_lock: - _totals["failed"] += len(items) - self.progress_signal.emit(_totals["done"] + _totals["failed"], total) - return - - 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") - with _counter_lock: - _totals["failed"] += len(items) - self.progress_signal.emit(_totals["done"] + _totals["failed"], total) - return - - self.log_signal.emit( - f"\n{'─'*50}\nFile: {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"] - - with _counter_lock: - pos = _totals["done"] + _totals["failed"] + 1 - self.log_signal.emit(f"\n [{pos}/{total}] {filename} 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 - ) - with _counter_lock: - _totals["in_tok"] += in_tok - _totals["out_tok"] += out_tok - running_cost = ( - (_totals["in_tok"] / 1_000_000) * input_cost_per_m + - (_totals["out_tok"] / 1_000_000) * output_cost_per_m - ) - self.cost_signal.emit(running_cost, _totals["in_tok"], _totals["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 - 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}") - file_done += 1 - with _counter_lock: - _totals["done"] += 1 - self.progress_signal.emit(_totals["done"] + _totals["failed"], total) - else: - file_failed += 1 - with _counter_lock: - _totals["failed"] += 1 - self.progress_signal.emit(_totals["done"] + _totals["failed"], total) - if rewrite is None: - self.log_signal.emit(f" -> FAILED after {self.max_retries} attempts") - - 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}" - ) - - # ------------------------------------------------------------------ # - # Dispatch files to thread pool - # ------------------------------------------------------------------ # - with ThreadPoolExecutor(max_workers=self.file_threads) as executor: - futures = { - executor.submit(process_file, fn, items): fn - for fn, items in file_groups.items() - } - for future in as_completed(futures): - if self._is_stopped(): - # Cancel queued (not yet started) futures - for f in futures: - f.cancel() - break - try: - future.result() - except Exception as exc: - fn = futures[future] - self.log_signal.emit(f"[ERROR] {fn}: {exc}") - - stopped = self._is_stopped() - summary = ( - f"Done: {_totals['done']} Failed: {_totals['failed']} Total: {total}" - + (" (stopped early)" if stopped else "") - ) - self.log_signal.emit(f"\n{'='*50}\n{summary}") - self.finished_signal.emit(not stopped and _totals["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 - load_dotenv() - 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) - - threads_row = QHBoxLayout() - threads_row.addWidget(QLabel("Parallel files:")) - self._threads_spin = QSpinBox() - self._threads_spin.setRange(1, 20) - default_threads = int(os.getenv("fileThreads", "1")) - self._threads_spin.setValue(max(1, default_threads)) - self._threads_spin.setFixedWidth(60) - self._threads_spin.setToolTip( - "Number of files to process simultaneously.\n" - "Defaults to fileThreads from .env. Keep low for free-tier APIs." - ) - threads_row.addWidget(self._threads_spin) - threads_row.addStretch() - layout.addLayout(threads_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(), - file_threads=self._threads_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 deleted file mode 100644 index 6d8b9c9..0000000 --- a/scan_violations.py +++ /dev/null @@ -1,68 +0,0 @@ -#!/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 deleted file mode 100644 index b14a21c..0000000 --- a/skills/REWRITE_SKILL.md +++ /dev/null @@ -1,171 +0,0 @@ -# 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.