Rewrite tab
This commit is contained in:
parent
77efab8dc2
commit
32e6f7d33b
4 changed files with 1157 additions and 0 deletions
12
gui/main.py
12
gui/main.py
|
|
@ -211,6 +211,7 @@ class UpdateDialog(QDialog):
|
||||||
from gui.config_tab import ConfigTab
|
from gui.config_tab import ConfigTab
|
||||||
from gui.translation_tab import TranslationTab
|
from gui.translation_tab import TranslationTab
|
||||||
from gui.workflow_tab import WorkflowTab
|
from gui.workflow_tab import WorkflowTab
|
||||||
|
from gui.rewrite_tab import RewriteTab
|
||||||
|
|
||||||
class DazedMTLGUI(QMainWindow):
|
class DazedMTLGUI(QMainWindow):
|
||||||
"""Main GUI window for the DazedMTLTool."""
|
"""Main GUI window for the DazedMTLTool."""
|
||||||
|
|
@ -411,6 +412,13 @@ class DazedMTLGUI(QMainWindow):
|
||||||
btn_config.clicked.connect(lambda: self.switch_page(2))
|
btn_config.clicked.connect(lambda: self.switch_page(2))
|
||||||
sidebar_layout.addWidget(btn_config)
|
sidebar_layout.addWidget(btn_config)
|
||||||
self.nav_buttons.append(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()
|
sidebar_layout.addStretch()
|
||||||
|
|
||||||
|
|
@ -488,6 +496,10 @@ class DazedMTLGUI(QMainWindow):
|
||||||
self.config_tab = ConfigTab()
|
self.config_tab = ConfigTab()
|
||||||
self.config_tab.config_changed.connect(self.on_config_changed)
|
self.config_tab.config_changed.connect(self.on_config_changed)
|
||||||
self.content_stack.addWidget(self.config_tab)
|
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):
|
def show_update_dialog(self):
|
||||||
"""Open the update dialog and check for a newer version."""
|
"""Open the update dialog and check for a newer version."""
|
||||||
|
|
|
||||||
906
gui/rewrite_tab.py
Normal file
906
gui/rewrite_tab.py
Normal file
|
|
@ -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())
|
||||||
68
scan_violations.py
Normal file
68
scan_violations.py
Normal file
|
|
@ -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()
|
||||||
171
skills/REWRITE_SKILL.md
Normal file
171
skills/REWRITE_SKILL.md
Normal file
|
|
@ -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.
|
||||||
Loading…
Reference in a new issue