Compare commits

...

3 commits

Author SHA1 Message Date
601c64fe9f Updating stuff 2026-07-11 11:54:15 -05:00
2e17604718 Cleanup 2026-07-11 11:44:24 -05:00
fe31c71130 Reworking prompts/skills 2026-07-11 11:33:14 -05:00
17 changed files with 1635 additions and 641 deletions

2
.gitignore vendored
View file

@ -18,7 +18,7 @@ __pycache__
!assets/*.png
!.pre-commit-config.yaml
!data/prompt.txt
!data/skills/**
!data/vocab_base.txt
!requirements.txt
!util/tl_inspector/TLInspector.js

View file

@ -176,8 +176,8 @@ Format: Japanese name, English name in parentheses, then gender.
> **Note:** A very large vocab file can increase API costs and potentially reduce quality. Focus on the most important characters and terms.
### data/prompt.txt
This is the system prompt sent to the AI. A default `data/prompt.txt` is included and works well for most games. You generally don't need to edit it unless you want to customize the translation style.
### data/skills/system.md
This is the system prompt skill sent to the AI on every translation call. A default `data/skills/system.md` is included and works well for most games. You generally don't need to edit it unless you want to customize the translation style. Per-game voice rules go in `<game>/skills/quirks.md` instead.
---
@ -230,7 +230,7 @@ How it works (all engine modules are supported automatically):
1. **Pass 1 (collect)** — files are processed normally, but instead of calling the API each
request is queued to `log/batch_requests.json`. Requests are byte-identical to live ones:
the static `prompt.txt` block is cached with a 1h TTL, matched vocab and translation
the static `data/skills/system.md` block is cached with a 1h TTL, matched vocab and translation
history ride along per request, and structured output enforces the exact line count.
Speaker/variable names still translate live during this pass (they get embedded into the
dialogue payloads, so both passes must resolve them identically) — they're a tiny share

10
TODO.md
View file

@ -1,19 +1,13 @@
# TODO
## Custom game prompt
- Keep the default prompt (`prompt.txt`) as the base for every game.
- Add an optional **per-game custom prompt** in the UI that merges with the default (custom rules layered on top of shared instructions, not a full replacement unless explicitly chosen).
- Surface this in settings / project setup so each game folder can carry its own tone, terminology notes, or content warnings without forking the global prompt.
## Local translation cache (game retranslate)
- Persist every successful translation locally so a full retranslate from scratch can reuse prior results instead of paying again.
- **Cache key:** hash the translation **payload** (source Japanese text sent to the model) plus target language — same approach as the existing global cache in `util/translation.py` (`get_cache_key`, `log/translation_cache.json`).
- **Cache key:** hash the translation **payload** (source Japanese text sent to the model) plus target language - same approach as the existing global cache in `util.translation` (`get_cache_key`, `log/translation_cache.json`).
Payload keys naturally dedupe identical lines across files and survive re-extract / re-import as long as the source string is unchanged.
- **Scope:** consider a per-game cache file under the game project (e.g. `<game_root>/translation_cache.json` or alongside `wolf_json/`) in addition to (or merged with) the tool-wide `log/translation_cache.json`, so caches travel with the game folder and are easy to back up.
- **Write path:** on every successful translate (live and batch consume), store `{cache_key: translation}` before or as results land in `translated/`.
- **Read path:** before calling the API, check cache; on hit, skip the request and apply the cached translation (respecting file-specific re-expansion where the payload is Japanese-only).
- **Retranslate workflow:** when the user re-imports fresh JSON and runs Translate again, cache hits should fill `translated/` automatically for unchanged source lines; only new or edited source text should incur API cost.
- **UI (later):** cache stats (hits / misses / estimated savings), export/import, clear cache for one game, optional “prefer cache” toggle.
- **UI (later):** cache stats (hits / misses / estimated savings), export/import, clear cache for one game, optional "prefer cache" toggle.
- **Invalidation:** optional metadata (model id, prompt version) in the key or entry if we need to avoid reusing stale translations after prompt or model changes.

View file

@ -0,0 +1,192 @@
# DazedMTLTool — Project Setup
You are analysing a Japanese game project to produce configuration artifacts for DazedMTLTool.
Work in the game repository. Scan files; do not invent content you did not see.
---
## Task
In **one pass**, discover everything needed for translation setup and emit the labeled output blocks below.
Do **not** translate the game. Do **not** rewrite formatting codes, wrap widths, or tool pipeline settings.
Default: emit **all four** blocks.
Regenerate mode: if the user asks for only one block (`glossary`, `speakers`, `translation_quirks`, or `game_skill`), emit **only** that block using the same schema and exclusivity rules.
---
## Ownership (no duplication)
| Block | Owns | Must NOT include |
|-------|------|------------------|
| `glossary` | Named characters (gender, role, **per-character** speech register) + worldbuilding terms | Global dialect / person rules; honorific policy; speaker-format flags; formatting |
| `speakers` | Tool flag ENABLE/SKIP + short evidence | Character bios; quirks; full glossary |
| `translation_quirks` | Cross-cutting voice rules (battle-log person, global dialect, item-description style, **unusual** honorific habits) | Per-character register; "always keep -san" (tool base prompt already does); codes/wrap/line counts; speaker flags |
| `game_skill` | Game identity, which files matter, pointer to `skills/quirks.md`, what the tool owns | Verbatim quirks list; verbatim glossary; restating formatting/honorific base policy |
Hard rules:
1. Per-character voice → `glossary` only.
2. Category-wide / cross-cutting voice → `translation_quirks` only.
3. Default honorifics policy is owned by the tool base prompt - only note **unusual** honorific habits in quirks.
4. `game_skill` **references** `skills/quirks.md` only; do not reprint those rules or cite legacy names like `translation_quirks.txt`.
5. `speakers` is config, not lore.
---
## Shared scan strategy
Map / event files can be huge. Do **not** read them sequentially end-to-end.
1. Read small DB files in full first (richest, always small).
2. For large event/map files: **search/grep**; sample early story maps; stop when patterns stabilize.
3. One scan feeds all four blocks - do not rescan from scratch per block.
<!-- engine:rpgmaker -->
### RPG Maker file strategy
**DB (read in full):**
- `Actors.json` (mandatory for major characters, actor IDs, `\\N[n]` mappings)
- `Classes.json`, `Troops.json`, `Skills.json`, `Items.json`, `Armors.json`, `Weapons.json`, `States.json`, `System.json`
**Events (grep / sample):**
- `CommonEvents.json`, `Troops.json`, `Map001.json``Map010.json` (early maps first)
- Prefer code `401` dialogue + nearby `101` speaker/name params; `405` scrolling text when present
- Speaker markup evidence: `【Name】`, `[Name]`, `Name`, `\\n<Name>`, `\\k<Name>`, colour-wrapped name lines
--- attach your game data files / open the game repo before continuing ---
### Speakers analysis (for `speakers` block)
Code `101` opens the text window. Code `401` is a dialogue line. Multiple `401`s form one message box.
**Always-on formats (NO FLAG needed):**
- `101` param[4] name
- `\\n<Name>` or `\\k<Name>` (angle brackets)
- `【Name】` alone or with dialogue
- `[Name]` alone or with dialogue
- `\\c[N]Name\\c[0]` colour-wrapped name on its own `401` line
- `Name` full-width colon name line
**Critical:** `\\N[X]` / `\\n[X]` (square brackets + number) are actor variable codes, NOT speaker formats. Do not count them as always-on speaker hits.
**Flags (only when some speakers lack an always-on format):**
- `INLINE401SPEAKERS` — name immediately before `「` on a `401` line (e.g. `エレナ「今日は…`)
- `FIRSTLINESPEAKERS` — first `401` is a short plain name (< 40 chars, has Japanese); next line starts with `「` `"`` `` `*` `[`; often empty face on `101`
- `FACENAME101` — last resort only when no always-on and neither flag above; face filename in `101` param[0], empty param[4]. If ENABLE, list every unique face filename from `101` param[0].
### Glossary rules (for `glossary` block)
- Separator: plain hyphen-minus `-` only (never em/en dash).
- Descriptions entirely in English; refer to other characters by English name.
- Commit to one spelling - never `Sylfia / Sylphia`.
- Characters: gender, role, speech register, personality, player-chosen name (Actors.json ID 1).
- Real named actors get full `# Game Characters` entries, not only `\\N[n]` placeholders.
- Worldbuilding: factions, lore locations, unique systems/titles - exclude skill/item/weapon/armour names and generic RPG words.
### Quirks rules (for `translation_quirks` block)
Find translation-only quirks, for example:
- Battle log / system messages consistently 3rd person (or other fixed person)
- Global dialect (old-timer speech, archaic narration)
- Recurring item/skill description style
- Unusual honorific habits (who uses what with whom)
Exclude: formatting codes, wrap, line counts, speaker flags, character name lists.
Output as short imperative bullets suitable to paste into `skills/quirks.md`.
### Game skill rules (for `game_skill` block)
Produce a durable IDE skill for this title saved at `skills/translation.md`:
- Short game identity (tone/genre/content notes)
- Which files matter for dialogue/DB in this repo
- A **Voice rules** section that points only at ``skills/quirks.md`` (authoritative)
- What not to change (codes, formatting - tool-owned via base prompt)
- Do **not** reprint quirks or the glossary
**Path names (required):**
- Voice quirks file: ``skills/quirks.md`` (never ``translation_quirks.txt`` or any other legacy name)
- This IDE skill file: ``skills/translation.md``
- Optional user overlays (if mentioned): other ``skills/*.md`` files except ``quirks.md`` / ``translation.md``
<!-- /engine:rpgmaker -->
<!-- engine:wolf -->
### Wolf file strategy (stub)
When analysing a WolfDawn / WOLF project later: use `wolf_json/` extracts (`names.json`, DB sheets, map/common scenes). Prefer foundation DB + narrative sheets; grep large map dumps. Same four-block ownership rules apply.
<!-- /engine:wolf -->
---
## Output format
Emit **only** labeled fenced code blocks (plus a one-line heading before each block for navigation).
No essay outside the blocks.
### Block `glossary`
Label the fence language as `glossary`. Inside:
```
# Game Characters
Japanese (English) - description
# Worldbuilding Terms
Japanese (English) - description
```
### Block `speakers`
Label the fence language as `speakers`. Inside:
```
Patterns detected:
- ...
Flag decisions:
INLINE401SPEAKERS : ENABLE|SKIP — <one-line reason>
FIRSTLINESPEAKERS : ENABLE|SKIP — <one-line reason>
FACENAME101 : ENABLE|SKIP — <one-line reason>
Examples:
- ...
Face filenames (only if FACENAME101 ENABLE):
- ...
```
### Block `translation_quirks`
Label the fence language as `translation_quirks`. Inside: imperative bullet list only (no preamble).
### Block `game_skill`
Label the fence language as `game_skill`. Inside: full markdown for `skills/translation.md`.
Required skeleton (fill in the blanks; keep the Voice rules path exactly):
```markdown
# <Game title> - Translation skill
## Identity
<1-3 sentences: tone, genre, content notes>
## Files that matter
- <dialogue / events paths for this repo>
- <database paths>
## Voice rules
Follow `skills/quirks.md` as the authoritative cross-cutting voice guide for this title.
Do not invent alternate quirk filenames.
## Tool boundaries
- DazedMTLTool owns formatting codes, wrap, line counts, and honorifics defaults via its base system skill.
- Per-character register lives in the glossary (`vocab.txt`), not here.
- Do not reprint quirks or glossary entries in this file.
```
If known speakers were prepended in a `<known_speakers>` block above this skill, prefer those names in the glossary, then cross-check Actors.json for other major named actors.

View file

@ -30,6 +30,7 @@ You will be translating erotic and sexual content. You will receive lines of dia
- **Speech register:** If the entry describes how a character speaks (flustered, blunt, formal, childlike, crude, etc.), mirror that register in their English dialogue. A character described as speaking in a "cute, flustered register" should sound different from one described as "cold and terse".
- **Role & context:** Use the role/personality notes to inform tone — a villain's lines should feel threatening, a comic-relief NPC's lines should feel goofy, etc.
- Japanese omits pronouns constantly. Infer the correct subject and pronoun from context, translation history, and the character list.
- **Person / perspective consistency (1st / 2nd / 3rd):** Keep the grammatical person consistent with the source and with surrounding lines. Before writing English, verify what perspective the Japanese line actually uses - do not assume first-person dialogue when the line is third-person narration, second-person address (お前 / 君 / あなた), or impersonal UI text. Match "I / you / he / she / they" (and possessives) to that verified perspective; do not flip person mid-scene unless the Japanese does.
- **Preserve third-person self-reference.** Some characters refer to themselves by name instead of using "I" (e.g. ワタシ used as a name, or a character saying their own name). When a character is clearly speaking about themselves in the third person as a stylistic trait, maintain that in English (e.g. "Feris doesn't know" rather than "I don't know").
- Third-person pronouns (彼, 彼女, あいつ, こいつ, そいつ, コイツ) should match the known gender of the person being referenced.
- Translate **コイツ** as "this bastard" (male) or "this bitch" (female) depending on the referenced character's gender.
@ -38,7 +39,9 @@ You will be translating erotic and sexual content. You will receive lines of dia
## Honorifics and Names
- Preserve Japanese honorifics in the translation: -san, -kun, -chan, -senpai, -sensei, -sama, -dono, etc.
- **Always preserve and enforce Japanese honorifics** in the English translation: -san, -kun, -chan, -senpai, -sensei, -sama, -dono, etc.
- Do **not** drop honorifics to make the English sound more "natural" unless the source Japanese also lacks them.
- If `# Game Characters` (or `<game>/skills/quirks.md`) specifies how a character addresses someone, follow that address pattern.
- The `=` or `` character in a Japanese name marks a foreign or nickname component. Wrap that part in parentheses.
- Example: `バンカー=ベット``Bunker (Bet)`
- Always translate speaker tags to English: `[クロネ]:``[Kurone]:`

View file

@ -595,6 +595,7 @@ from gui.config_tab import ConfigTab
from gui.translation_tab import TranslationTab
from gui.workflow_tab import WorkflowTab
from gui.wolf_workflow_tab import WolfWorkflowTab
from gui.skills_tab import SkillsTab
from gui.batch_tab import BatchTab
class DazedMTLGUI(QMainWindow):
@ -838,9 +839,16 @@ class DazedMTLGUI(QMainWindow):
sidebar_layout.addWidget(btn_batches)
self.nav_buttons.append(btn_batches)
# Configuration button (fourth)
# Skills button (fourth)
btn_skills = self.create_nav_button("📚", "Skills")
btn_skills.setToolTip("Skills — edit system prompt, Project Setup, and translation contexts")
btn_skills.clicked.connect(lambda: self.switch_page(3))
sidebar_layout.addWidget(btn_skills)
self.nav_buttons.append(btn_skills)
# Configuration button (fifth)
btn_config = self.create_nav_button("⚙️", "Configuration")
btn_config.clicked.connect(lambda: self.switch_page(3))
btn_config.clicked.connect(lambda: self.switch_page(4))
sidebar_layout.addWidget(btn_config)
self.nav_buttons.append(btn_config)
@ -906,7 +914,11 @@ class DazedMTLGUI(QMainWindow):
self.batch_tab = BatchTab(self)
self.content_stack.addWidget(self.batch_tab)
# Configuration Tab (index 3)
# Skills Tab (index 3)
self.skills_tab = SkillsTab(self)
self.content_stack.addWidget(self.skills_tab)
# Configuration Tab (index 4)
self.config_tab = ConfigTab()
self.config_tab.config_changed.connect(self.on_config_changed)
self.content_stack.addWidget(self.config_tab)

247
gui/skills_tab.py Normal file
View file

@ -0,0 +1,247 @@
"""Skills Tab - edit tool-level skills and translation contexts under data/."""
from __future__ import annotations
import json
from pathlib import Path
from PyQt5.QtCore import Qt
from PyQt5.QtGui import QFont
from PyQt5.QtWidgets import (
QHBoxLayout,
QLabel,
QMessageBox,
QPushButton,
QTabWidget,
QTextEdit,
QVBoxLayout,
QWidget,
)
from util.paths import PROMPT_PATH, SKILLS_DIR, TRANSLATION_CONTEXTS_PATH
from util.skills.contexts import reload_contexts
class _PlainPasteTextEdit(QTextEdit):
"""QTextEdit that always pastes as plain text."""
def insertFromMimeData(self, source): # noqa: N802
self.insertPlainText(source.text())
_EDITOR_STYLE = (
"QTextEdit{background-color:#1e1e1e;color:#d4d4d4;"
"border:1px solid #3c3c3c;border-radius:4px;padding:10px;"
"selection-background-color:#264f78;}"
)
_HINT_STYLE = "color:#9d9d9d;font-size:13px;"
_PATH_STYLE = "color:#569cd6;font-size:12px;font-family:Consolas,monospace;"
class SkillsTab(QWidget):
"""Edit ``data/skills/*.md`` and ``data/translation_contexts.json``."""
def __init__(self, parent=None):
super().__init__(parent)
self._pages: dict[str, dict] = {}
self._build_ui()
self.reload_all()
def _build_ui(self):
root = QVBoxLayout(self)
root.setContentsMargins(20, 16, 20, 16)
root.setSpacing(10)
title = QLabel("Skills")
title.setStyleSheet("color:#ffffff;font-size:18px;font-weight:bold;")
root.addWidget(title)
intro = QLabel(
"Tool-level AI instructions live here. "
"Per-game quirks, the game IDE skill, and optional custom skills are edited "
"in Workflow Step 2 (they travel with the game folder)."
)
intro.setWordWrap(True)
intro.setStyleSheet(_HINT_STYLE)
root.addWidget(intro)
self.tabs = QTabWidget()
self.tabs.setStyleSheet(
"QTabWidget::pane{border:1px solid #3c3c3c;border-radius:4px;top:-1px;}"
"QTabBar::tab{background:#2d2d30;color:#cccccc;padding:8px 14px;"
"border:1px solid #3c3c3c;border-bottom:none;margin-right:2px;}"
"QTabBar::tab:selected{background:#1e1e1e;color:#ffffff;"
"border-top:2px solid #007acc;}"
)
root.addWidget(self.tabs, 1)
self._add_file_page(
key="system",
tab_title="System",
path=PROMPT_PATH,
hint=(
"Runtime system prompt for every translation API call. "
"Honorifics, formatting, and universal quality rules live here. "
"Do not put game-specific voice quirks here - use <game>/skills/quirks.md."
),
is_json=False,
)
self._add_file_page(
key="project_setup",
tab_title="Project Setup",
path=SKILLS_DIR / "project_setup.md",
hint=(
"Clipboard skill copied from Workflow. "
"Instructs the IDE to emit glossary, speakers, quirks, and game_skill blocks."
),
is_json=False,
)
self._add_file_page(
key="contexts",
tab_title="Contexts",
path=TRANSLATION_CONTEXTS_PATH,
hint=(
"Per-call history templates used by RPG Maker / WolfDawn "
"(names.npc, database.item, events.label_108, …). "
"Edit carefully - keys must match what the engine modules look up."
),
is_json=True,
)
btn_row = QHBoxLayout()
reload_btn = QPushButton("↺ Reload All")
reload_btn.setFixedWidth(140)
reload_btn.clicked.connect(self.reload_all)
btn_row.addWidget(reload_btn)
btn_row.addStretch()
open_btn = QPushButton("Open skills folder")
open_btn.setFixedWidth(160)
open_btn.clicked.connect(self._open_skills_folder)
btn_row.addWidget(open_btn)
root.addLayout(btn_row)
def _add_file_page(
self,
*,
key: str,
tab_title: str,
path: Path,
hint: str,
is_json: bool,
):
page = QWidget()
layout = QVBoxLayout(page)
layout.setContentsMargins(12, 12, 12, 12)
layout.setSpacing(8)
path_lbl = QLabel(str(path))
path_lbl.setStyleSheet(_PATH_STYLE)
path_lbl.setTextInteractionFlags(Qt.TextSelectableByMouse)
layout.addWidget(path_lbl)
hint_lbl = QLabel(hint)
hint_lbl.setWordWrap(True)
hint_lbl.setStyleSheet(_HINT_STYLE)
layout.addWidget(hint_lbl)
editor = _PlainPasteTextEdit()
editor.setFont(QFont("Consolas", 10))
editor.setStyleSheet(_EDITOR_STYLE)
layout.addWidget(editor, 1)
row = QHBoxLayout()
save_btn = QPushButton("💾 Save")
save_btn.setFixedWidth(120)
save_btn.clicked.connect(lambda _=False, k=key: self._save_page(k))
row.addWidget(save_btn)
reload_btn = QPushButton("↺ Reload")
reload_btn.setFixedWidth(120)
reload_btn.clicked.connect(lambda _=False, k=key: self._reload_page(k))
row.addWidget(reload_btn)
status = QLabel("")
status.setStyleSheet("color:#6a9955;font-size:12px;")
row.addWidget(status)
row.addStretch()
layout.addLayout(row)
self.tabs.addTab(page, tab_title)
self._pages[key] = {
"path": path,
"editor": editor,
"status": status,
"is_json": is_json,
}
def reload_all(self):
for key in self._pages:
self._reload_page(key)
def _reload_page(self, key: str):
page = self._pages[key]
path: Path = page["path"]
editor: QTextEdit = page["editor"]
status: QLabel = page["status"]
try:
if not path.is_file():
editor.setPlainText("")
status.setText(f"Missing: {path.name}")
status.setStyleSheet("color:#f0ad4e;font-size:12px;")
return
editor.setPlainText(path.read_text(encoding="utf-8"))
status.setText("Loaded")
status.setStyleSheet("color:#6a9955;font-size:12px;")
except Exception as exc:
status.setText(f"Load failed: {exc}")
status.setStyleSheet("color:#f44747;font-size:12px;")
def _save_page(self, key: str):
page = self._pages[key]
path: Path = page["path"]
editor: QTextEdit = page["editor"]
status: QLabel = page["status"]
text = editor.toPlainText()
if page["is_json"]:
try:
parsed = json.loads(text)
text = json.dumps(parsed, ensure_ascii=False, indent=2) + "\n"
editor.setPlainText(text.rstrip("\n") + "\n")
except json.JSONDecodeError as exc:
QMessageBox.warning(
self,
"Invalid JSON",
f"Cannot save {path.name}:\n{exc}",
)
status.setText("Invalid JSON")
status.setStyleSheet("color:#f44747;font-size:12px;")
return
try:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(text if text.endswith("\n") else text + "\n", encoding="utf-8")
if key == "contexts":
reload_contexts()
status.setText(f"Saved {path.name}")
status.setStyleSheet("color:#6a9955;font-size:12px;")
except Exception as exc:
QMessageBox.warning(self, "Save failed", str(exc))
status.setText(f"Save failed: {exc}")
status.setStyleSheet("color:#f44747;font-size:12px;")
def _open_skills_folder(self):
folder = SKILLS_DIR
folder.mkdir(parents=True, exist_ok=True)
import os
import subprocess
import sys
try:
if sys.platform.startswith("win"):
os.startfile(str(folder)) # type: ignore[attr-defined]
elif sys.platform == "darwin":
subprocess.run(["open", str(folder)], check=False)
else:
subprocess.run(["xdg-open", str(folder)], check=False)
except Exception as exc:
QMessageBox.information(self, "Skills folder", f"{folder}\n\n{exc}")

View file

@ -3018,6 +3018,20 @@ class TranslationTab(QWidget):
except Exception:
pass
# Per-game quirks overlay: Workflow Step 0 folder → DAZED_GAME_ROOT
try:
game_root = ""
if self.settings:
game_root = str(
self.settings.value("last_game_folder", "") or ""
).strip()
if game_root and Path(game_root).is_dir():
os.environ["DAZED_GAME_ROOT"] = game_root
else:
os.environ.pop("DAZED_GAME_ROOT", None)
except Exception:
pass
# Try to create a hard link at legacy location so modules that
# still write to log/translationHistory.txt end up in this file.
# This will be created when the run_log_path file is first written to

File diff suppressed because it is too large Load diff

View file

@ -15,14 +15,15 @@ from retry import retry
from tqdm import tqdm
from util.translation import TranslationConfig, translateAI as sharedtranslateAI, getPricingConfig, calculateCost, getPricingConfig, calculateCost, get_var_translation, set_var_translations_batch, convert_corner_brackets
from util.speakers import SPEAKER_BRACKET_INNER, strip_speaker_prefix
from util.skills import ctx, load_system_prompt
from util.paths import VOCAB_PATH
# Globals
MODEL = os.getenv("model")
TIMEOUT = int(os.getenv("timeout"))
LANGUAGE = os.getenv("language").capitalize()
from util.paths import PROMPT_PATH, VOCAB_PATH
PROMPT = PROMPT_PATH.read_text(encoding="utf-8")
PROMPT = load_system_prompt()
VOCAB = VOCAB_PATH.read_text(encoding="utf-8")
LOCK = threading.Lock()
THREAD_CTX = threading.local()
@ -240,7 +241,7 @@ def _pat355655_captured_text(match):
def handleMVMZ(filename, estimate):
global ESTIMATE, TOKENS, FILENAME, MISMATCH
global ESTIMATE, TOKENS, FILENAME, MISMATCH, PROMPT
ESTIMATE = estimate
FILENAME = filename
MISMATCH = [] # Reset per-file; prevents cross-file contamination in CLI mode
@ -250,6 +251,10 @@ def handleMVMZ(filename, estimate):
except Exception:
pass
# Reload base prompt + optional per-game quirks (DAZED_GAME_ROOT).
PROMPT = load_system_prompt()
TRANSLATION_CONFIG.prompt = PROMPT
# Translate
start = time.time()
translatedData = openFiles(filename)
@ -979,7 +984,7 @@ def parseMap(data, filename):
if "Map" in filename:
response = translateAI(
data["displayName"],
"Reply with only the " + LANGUAGE + " translation of the RPG location name",
ctx("names.location"),
False,
)
totalTokens[0] += response[1][0]
@ -1097,7 +1102,7 @@ def _normalize_sg_desc(text: str) -> str:
# Translate
response = translateAI(
modifiedJAString,
"Reply with only the " + LANGUAGE + " translation.",
ctx("database.generic"),
False,
)
translatedText = response[0]
@ -1132,7 +1137,7 @@ def translateNoteOmitSpace(event, regex):
# Translate
response = translateAI(
jaString,
"Reply with the " + LANGUAGE + " translation of the location name.",
ctx("names.location_short"),
False,
)
# Defend against unexpected response shapes
@ -1181,7 +1186,7 @@ def translateLBNames(events):
names_to_translate = [item[1] for item in lb_events]
response = translateAI(
names_to_translate,
"Reply with only the " + LANGUAGE + " translation of the name.",
ctx("names.generic"),
True,
)
translated_names = response[0] if isinstance(response[0], list) else [response[0]]
@ -1670,21 +1675,21 @@ def searchNames(data, pbar, context, filename):
# Set the context of what we are translating
if "Actors" in context:
newContext = "Reply with only the " + LANGUAGE + " translation of the NPC name"
newContext = ctx("names.npc_only")
if "Armors" in context:
newContext = "Reply with only the " + LANGUAGE + " translation of the RPG equipment name"
newContext = ctx("database.armor")
if "Classes" in context:
newContext = "Reply with only the " + LANGUAGE + " translation of the RPG class name"
newContext = ctx("database.class")
if "MapInfos" in context:
newContext = "Reply with only the " + LANGUAGE + " translation of the location name"
newContext = ctx("names.location")
if "Enemies" in context:
newContext = "Reply with only the " + LANGUAGE + " translation of the enemy NPC name"
newContext = ctx("names.enemy")
if "Weapons" in context:
newContext = "Reply with only the " + LANGUAGE + " translation of the RPG weapon name"
newContext = ctx("database.weapon")
if "Items" in context:
newContext = "Reply with only the " + LANGUAGE + " translation of the RPG item name"
newContext = ctx("database.item")
if "Skills" in context:
newContext = "Reply with only the " + LANGUAGE + " translation of the RPG skill name"
newContext = ctx("database.skill")
# Names
with open("log/translations.txt", "a", encoding="utf-8") as file:
@ -1753,7 +1758,7 @@ def searchNames(data, pbar, context, filename):
# --- Batch translate all notes ---
translatedNotesBatch = []
if notesBatch:
response = translateAI(notesBatch, f"Reply with only the {LANGUAGE} translation of the note text.")
response = translateAI(notesBatch, ctx("database.note"))
translatedNotesBatch = response[0]
totalTokens[0] += response[1][0]
totalTokens[1] += response[1][1]
@ -2002,7 +2007,7 @@ def searchNames(data, pbar, context, filename):
if descriptionList:
response = translateAI(
descriptionList,
f"Reply with only the {LANGUAGE} translation of the text.",
ctx("events.generic_text"),
True,
)
translatedDescriptionBatch = response[0]
@ -2954,7 +2959,7 @@ def searchCodes(page, pbar, jobList, filename):
question = codeList[i]["parameters"][3]["messageText"]
response = translateAI(
matchList,
f"Previous text for context: {question}\n",
ctx("events.plugin_with_context", context=question),
True,
)
totalTokens[0] += response[1][0]
@ -3716,7 +3721,7 @@ def searchCodes(page, pbar, jobList, filename):
# Translate
response = translateAI(
matchList[0],
"Reply with the " + LANGUAGE + " translation of the NPC name.",
ctx("names.npc"),
False,
)
translatedText = response[0]
@ -3812,7 +3817,7 @@ def searchCodes(page, pbar, jobList, filename):
if len(matchList) > 0:
# Translate
text = matchList[0]
response = translateAI(text, "Reply with the " + LANGUAGE + " Translation")
response = translateAI(text, ctx("events.generic"))
translatedText = response[0]
totalTokens[0] += response[1][0]
totalTokens[1] += response[1][1]
@ -3851,7 +3856,7 @@ def searchCodes(page, pbar, jobList, filename):
if len(matchList) > 0:
# Translate
text = matchList[0]
response = translateAI(text, "Reply with the " + LANGUAGE + " Translation")
response = translateAI(text, ctx("events.generic"))
translatedText = response[0]
totalTokens[0] += response[1][0]
totalTokens[1] += response[1][1]
@ -3894,7 +3899,7 @@ def searchCodes(page, pbar, jobList, filename):
question = translatedText
response = translateAI(
choiceList,
f"Previous text for context: {question}\n",
ctx("events.plugin_with_context", context=question),
True,
)
totalTokens[0] += response[1][0]
@ -3949,11 +3954,11 @@ def searchCodes(page, pbar, jobList, filename):
if len(textHistory) > 0:
response = translateAI(
choiceList,
f"Reply with the English translation of the dialogue choice.\n\nPrevious text for context: {str(textHistory)}\n",
ctx("events.choice_with_context", context=str(textHistory)),
True,
)
else:
response = translateAI(choiceList, "Reply with the English translation of the dialogue choice.")
response = translateAI(choiceList, ctx("events.choice"))
translatedTextList = response[0]
totalTokens[0] += response[1][0]
@ -4143,7 +4148,7 @@ def searchCodes(page, pbar, jobList, filename):
# 122
if len(list122) > 0:
response = translateAI(list122, "Keep your translation as brief as possible")
response = translateAI(list122, ctx("events.code122_brief"))
list122TL = response[0]
totalTokens[0] += response[1][0]
totalTokens[1] += response[1][1]
@ -4168,7 +4173,7 @@ def searchCodes(page, pbar, jobList, filename):
# 108
if len(list108) > 0:
response = translateAI(list108, "This text is a label. Use title capitalization and keep it brief.")
response = translateAI(list108, ctx("events.label_108"))
list108TL = response[0]
totalTokens[0] += response[1][0]
totalTokens[1] += response[1][1]
@ -4224,7 +4229,7 @@ def searchCodes(page, pbar, jobList, filename):
# 324
if len(list324) > 0:
# Generic short-text translation for parameter index 1
response = translateAI(list324, "Reply with only the " + LANGUAGE + " translation of the text.")
response = translateAI(list324, ctx("events.generic_text"))
list324TL = response[0]
totalTokens[0] += response[1][0]
totalTokens[1] += response[1][1]
@ -4236,7 +4241,7 @@ def searchCodes(page, pbar, jobList, filename):
# 325
if len(list325) > 0:
# Use same short-text speaker-style translation as other name fields
response = translateAI(list325, "Reply with the " + LANGUAGE + " translation of the NPC name.")
response = translateAI(list325, ctx("names.npc"))
list325TL = response[0]
totalTokens[0] += response[1][0]
totalTokens[1] += response[1][1]
@ -4388,7 +4393,7 @@ def searchSS(state, pbar):
# --- Batch translate all notes ---
translatedNotesBatch = []
if notesBatch:
response = translateAI(notesBatch, f"Reply with only the {LANGUAGE} translation of the note text.")
response = translateAI(notesBatch, ctx("database.note"))
translatedNotesBatch = response[0]
totalTokens[0] += response[1][0]
totalTokens[1] += response[1][1]
@ -4444,7 +4449,7 @@ def searchSS(state, pbar):
def searchSystem(data, pbar):
totalTokens = [0, 0]
context = "Reply with only the " + LANGUAGE + ' translation of the UI textbox."'
context = ctx("database.ui_term")
# Title - batch as a single-item list
title_src = _system_scalar_source(data, "gameTitle")
@ -4452,7 +4457,7 @@ def searchSystem(data, pbar):
if not (IGNORETLTEXT and not re.search(LANGREGEX, title_src)):
response = translateAI(
[title_src],
" Reply with the " + LANGUAGE + " translation of the game title name",
ctx("database.game_title"),
False,
)
totalTokens[0] += response[1][0]
@ -4507,7 +4512,7 @@ def searchSystem(data, pbar):
if armor_values:
response = translateAI(
armor_values,
"Reply with only the " + LANGUAGE + " translation of the armor type",
ctx("database.type_armor"),
False,
)
totalTokens[0] += response[1][0]
@ -4534,7 +4539,7 @@ def searchSystem(data, pbar):
if skill_values:
response = translateAI(
skill_values,
"Reply with only the " + LANGUAGE + " translation",
ctx("database.generic"),
False,
)
totalTokens[0] += response[1][0]
@ -4561,7 +4566,7 @@ def searchSystem(data, pbar):
if equip_values:
response = translateAI(
equip_values,
"Reply with only the " + LANGUAGE + " translation of the equipment type. No disclaimers.",
ctx("database.type_equipment"),
False,
)
totalTokens[0] += response[1][0]
@ -4591,7 +4596,7 @@ def searchSystem(data, pbar):
if element_values:
response = translateAI(
element_values,
"Reply with only the " + LANGUAGE + " translation of the element type",
ctx("database.element"),
False,
)
totalTokens[0] += response[1][0]
@ -4621,7 +4626,7 @@ def searchSystem(data, pbar):
if weapon_values:
response = translateAI(
weapon_values,
"Reply with only the " + LANGUAGE + " translation of the weapon type",
ctx("database.type_weapon"),
False,
)
totalTokens[0] += response[1][0]
@ -4650,7 +4655,7 @@ def searchSystem(data, pbar):
if var_values:
response = translateAI(
var_values,
'Reply with only the ' + LANGUAGE + ' translation of the title',
ctx("database.actor_title"),
True,
)
totalTokens[0] += response[1][0]
@ -4680,7 +4685,7 @@ def searchSystem(data, pbar):
if switch_values:
response = translateAI(
switch_values,
'Reply with only the ' + LANGUAGE + ' translation of the switch name',
ctx("database.switch"),
True,
)
totalTokens[0] += response[1][0]
@ -4712,9 +4717,7 @@ def searchSystem(data, pbar):
if msg_values:
response = translateAI(
msg_values,
"Reply with only the "
+ LANGUAGE
+ ' translation of the battle text.\nTranslate "常時ダッシュ" as "Always Dash"\nTranslate "次の%1まで" as Next %1.',
ctx("database.battle_messages"),
False,
)
totalTokens[0] += response[1][0]
@ -4805,7 +4808,7 @@ def getSpeaker(speaker: str):
pass
response = translateAI(
speaker,
"Reply with the " + LANGUAGE + " translation of the NPC name.",
ctx("names.npc"),
False,
)
try:
@ -4822,7 +4825,7 @@ def getSpeaker(speaker: str):
pass
response = translateAI(
speaker,
"Reply with the " + LANGUAGE + " translation of the NPC name.",
ctx("names.npc"),
False,
)
try:
@ -5033,7 +5036,7 @@ def finalizeSpeakerParse():
pass
resp = translateAI(
to_translate,
"Reply with the " + LANGUAGE + " translation of the NPC name.",
ctx("names.npc"),
True,
)
try:

View file

@ -58,7 +58,8 @@ import traceback
from colorama import Fore
from tqdm import tqdm
from util.paths import PROMPT_PATH, VOCAB_PATH
from util.paths import VOCAB_PATH
from util.skills import ctx, load_system_prompt
from util.translation import (
TranslationConfig,
translateAI as sharedtranslateAI,
@ -78,7 +79,7 @@ MODEL = os.getenv("model")
TIMEOUT = int(os.getenv("timeout"))
LANGUAGE = os.getenv("language").capitalize()
PROMPT = PROMPT_PATH.read_text(encoding="utf-8")
PROMPT = load_system_prompt()
VOCAB = VOCAB_PATH.read_text(encoding="utf-8")
LOCK = threading.Lock()
MAXHISTORY = 10
@ -143,7 +144,7 @@ def _batch_phase() -> str:
def handleWolfDawn(filename, estimate):
"""Entry point used by the CLI/GUI dispatchers. Returns a summary string or 'Fail'."""
global ESTIMATE, TOKENS, FILENAME, SPEAKER_CONFIG, VOCAB
global ESTIMATE, TOKENS, FILENAME, SPEAKER_CONFIG, VOCAB, PROMPT
ESTIMATE = estimate
FILENAME = filename
# Re-read workflow-configured settings so edits made this session take effect
@ -153,6 +154,9 @@ def handleWolfDawn(filename, estimate):
# that an earlier Phase 0 (names) harvested into vocab.txt.
VOCAB = VOCAB_PATH.read_text(encoding="utf-8")
TRANSLATION_CONFIG.vocab = VOCAB
# Reload base prompt + optional per-game quirks (DAZED_GAME_ROOT).
PROMPT = load_system_prompt()
TRANSLATION_CONFIG.prompt = PROMPT
start = time.time()
translatedData = openFiles(filename)
@ -361,7 +365,7 @@ def getSpeaker(speaker: str):
response = translateAI(
speaker,
"Reply with the " + LANGUAGE + " translation of the NPC name.",
ctx("names.npc"),
)
translated = _normalize_speaker_name(
response[0] if isinstance(response[0], str) else str(response[0])
@ -371,7 +375,7 @@ def getSpeaker(speaker: str):
if re.search(r"([a-zA-Z?])", translated) is None:
response = translateAI(
speaker,
"Reply with the " + LANGUAGE + " translation of the NPC name.",
ctx("names.npc"),
)
translated = _normalize_speaker_name(
response[0] if isinstance(response[0], str) else str(response[0])

View file

@ -8,11 +8,21 @@ PROJECT_ROOT = Path(__file__).resolve().parent.parent
DATA_DIR = PROJECT_ROOT / "data"
VOCAB_PATH = DATA_DIR / "vocab.txt"
VOCAB_BASE_PATH = DATA_DIR / "vocab_base.txt"
PROMPT_PATH = DATA_DIR / "prompt.txt"
SKILLS_DIR = DATA_DIR / "skills"
# Runtime translation system skill (formerly data/prompt.txt).
PROMPT_PATH = SKILLS_DIR / "system.md"
LEGACY_PROMPT_PATH = DATA_DIR / "prompt.txt"
LAST_UPDATE_SHA_PATH = DATA_DIR / "last_update_sha.txt"
ENV_PATH = PROJECT_ROOT / ".env"
ICON_PATH = PROJECT_ROOT / "assets" / "icon.png"
ENGINE_ICONS_DIR = PROJECT_ROOT / "assets" / "engine_icons"
TRANSLATION_CONTEXTS_PATH = DATA_DIR / "translation_contexts.json"
# Per-game quirks skill (API overlay). Legacy flat file still migrated on load.
GAME_QUIRKS_RELATIVE = Path("skills") / "quirks.md"
LEGACY_QUIRKS_FILENAME = "translation_quirks.txt"
GAME_SKILL_RELATIVE = Path("skills") / "translation.md"
# Built-in skill filenames under <game>/skills/ (not user-custom overlays).
GAME_SKILL_RESERVED_NAMES = frozenset({"quirks.md", "translation.md"})
_ROOT_DATA_FILES = (
"vocab.txt",
@ -32,7 +42,19 @@ def migrate_root_data_files() -> None:
src.rename(dst)
def migrate_prompt_to_skills() -> None:
"""Move legacy ``data/prompt.txt`` to ``data/skills/system.md`` when needed."""
SKILLS_DIR.mkdir(parents=True, exist_ok=True)
if PROMPT_PATH.is_file():
return
for legacy in (LEGACY_PROMPT_PATH, PROJECT_ROOT / "prompt.txt"):
if legacy.is_file():
legacy.rename(PROMPT_PATH)
return
migrate_root_data_files()
migrate_prompt_to_skills()
def ensure_vocab_file() -> None:

29
util/skills/__init__.py Normal file
View file

@ -0,0 +1,29 @@
"""Skill and prompt loaders (editable files under ``data/skills/``)."""
from __future__ import annotations
from util.skills.contexts import ctx, reload_contexts
from util.skills.setup import load_project_setup, skills_dir
from util.skills.system import (
custom_skill_path_for_game,
game_skill_path_for_game,
list_custom_skill_paths,
load_system_prompt,
migrate_game_skill_text,
quirks_path_for_game,
sanitize_custom_skill_stem,
)
__all__ = [
"ctx",
"custom_skill_path_for_game",
"game_skill_path_for_game",
"list_custom_skill_paths",
"load_project_setup",
"load_system_prompt",
"migrate_game_skill_text",
"quirks_path_for_game",
"reload_contexts",
"sanitize_custom_skill_stem",
"skills_dir",
]

63
util/skills/contexts.py Normal file
View file

@ -0,0 +1,63 @@
"""Load per-call translation history templates from data/translation_contexts.json."""
from __future__ import annotations
import json
import os
from typing import Any
from util.paths import TRANSLATION_CONTEXTS_PATH
_cache: dict[str, Any] | None = None
_cache_mtime: float | None = None
def _load() -> dict[str, Any]:
global _cache, _cache_mtime
path = TRANSLATION_CONTEXTS_PATH
if not path.is_file():
raise FileNotFoundError(f"Translation contexts file missing: {path}")
mtime = path.stat().st_mtime
if _cache is not None and _cache_mtime == mtime:
return _cache
data = json.loads(path.read_text(encoding="utf-8"))
if not isinstance(data, dict):
raise ValueError(f"translation_contexts.json must be an object: {path}")
_cache = data
_cache_mtime = mtime
return data
def ctx(path: str, *, language: str | None = None, **kwargs: Any) -> str:
"""Return a formatted history string for ``section.key``.
``{language}`` defaults to the ``language`` env / argument (capitalized).
Extra kwargs fill other ``{placeholders}`` (e.g. ``context``).
"""
if not path or "." not in path:
raise KeyError(f"Context path must be 'section.key', got {path!r}")
section_name, key = path.split(".", 1)
data = _load()
section = data.get(section_name)
if not isinstance(section, dict):
raise KeyError(f"Unknown translation context section: {section_name!r}")
template = section.get(key)
if not isinstance(template, str) or not template.strip():
raise KeyError(f"Unknown translation context key: {path!r}")
lang = language or os.getenv("language", "English")
lang = str(lang).capitalize()
values = {"language": lang, **kwargs}
try:
return template.format(**values)
except KeyError as exc:
raise KeyError(
f"Missing placeholder {exc.args[0]!r} for context {path!r}"
) from exc
def reload_contexts() -> None:
"""Clear the in-memory cache so the next ctx() re-reads from disk."""
global _cache, _cache_mtime
_cache = None
_cache_mtime = None

62
util/skills/setup.py Normal file
View file

@ -0,0 +1,62 @@
"""Load editable clipboard skills from data/skills/."""
from __future__ import annotations
from pathlib import Path
from util.paths import SKILLS_DIR
_ENGINE_MARKERS = {
"rpgmaker": ("<!-- engine:rpgmaker -->", "<!-- /engine:rpgmaker -->"),
"wolf": ("<!-- engine:wolf -->", "<!-- /engine:wolf -->"),
}
def _read_skill_file(name: str) -> str:
path = SKILLS_DIR / name
if not path.is_file():
raise FileNotFoundError(f"Skill file missing: {path}")
return path.read_text(encoding="utf-8")
def _extract_engine_section(text: str, engine: str) -> str:
"""Keep shared body + the requested engine block; drop other engine blocks."""
start_tag, end_tag = _ENGINE_MARKERS.get(engine, (None, None))
if not start_tag:
return text
result_parts: list[str] = []
pos = 0
while pos < len(text):
next_starts = [
(text.find(marker[0], pos), eng, marker)
for eng, marker in _ENGINE_MARKERS.items()
if text.find(marker[0], pos) != -1
]
if not next_starts:
result_parts.append(text[pos:])
break
next_starts.sort(key=lambda x: x[0])
idx, eng, (s_tag, e_tag) = next_starts[0]
result_parts.append(text[pos:idx])
end_idx = text.find(e_tag, idx)
if end_idx == -1:
raise ValueError(f"Unclosed engine block {s_tag} in skill file")
block_body = text[idx + len(s_tag) : end_idx]
if eng == engine:
result_parts.append(block_body.strip("\n") + "\n")
pos = end_idx + len(e_tag)
return "".join(result_parts).strip() + "\n"
def load_project_setup(engine: str = "rpgmaker", *, prepend: str = "") -> str:
"""Load the Project Setup clipboard skill for *engine*."""
raw = _read_skill_file("project_setup.md")
body = _extract_engine_section(raw, engine)
if prepend:
return prepend.rstrip() + "\n\n" + body
return body
def skills_dir() -> Path:
return SKILLS_DIR

153
util/skills/system.py Normal file
View file

@ -0,0 +1,153 @@
"""Load the static system prompt and optional per-game quirks overlay."""
from __future__ import annotations
import os
import re
from pathlib import Path
from util.paths import (
GAME_QUIRKS_RELATIVE,
GAME_SKILL_RELATIVE,
GAME_SKILL_RESERVED_NAMES,
LEGACY_QUIRKS_FILENAME,
PROMPT_PATH,
)
_SKILL_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$")
def quirks_path_for_game(game_root: str | Path | None) -> Path | None:
"""Return ``<game_root>/skills/quirks.md`` when *game_root* is set.
Also migrates a legacy ``translation_quirks.txt`` in the game root if present.
"""
if not game_root:
return None
root = Path(game_root)
preferred = root / GAME_QUIRKS_RELATIVE
legacy = root / LEGACY_QUIRKS_FILENAME
if not preferred.is_file() and legacy.is_file():
preferred.parent.mkdir(parents=True, exist_ok=True)
try:
legacy.rename(preferred)
except OSError:
# Fall back to reading the legacy path if rename fails.
return legacy
return preferred
def game_skill_path_for_game(game_root: str | Path | None) -> Path | None:
"""Return ``<game_root>/skills/translation.md`` when *game_root* is set."""
if not game_root:
return None
return Path(game_root) / GAME_SKILL_RELATIVE
def migrate_game_skill_text(text: str) -> str:
"""Rewrite legacy quirk path names inside a game skill markdown body."""
if not text:
return text
# Common stale pointers from older Project Setup output.
updated = text.replace("translation_quirks.txt", "skills/quirks.md")
updated = updated.replace("`translation_quirks.md`", "`skills/quirks.md`")
# Bare relative quirks.md without skills/ prefix (keep skills/quirks.md intact).
updated = re.sub(
r"(?<!skills/)(?<![\w./-])quirks\.md(?![\w.-])",
"skills/quirks.md",
updated,
)
# Avoid double skills/skills/
updated = updated.replace("skills/skills/quirks.md", "skills/quirks.md")
return updated
def game_skills_dir(game_root: str | Path | None) -> Path | None:
"""Return ``<game_root>/skills`` when *game_root* is set."""
if not game_root:
return None
return Path(game_root) / "skills"
def sanitize_custom_skill_stem(name: str) -> str | None:
"""Return a safe skill stem (no ``.md``) or None if invalid/reserved."""
raw = (name or "").strip()
if not raw:
return None
if raw.lower().endswith(".md"):
raw = raw[:-3].strip()
if not _SKILL_NAME_RE.match(raw):
return None
filename = f"{raw}.md"
if filename.lower() in {n.lower() for n in GAME_SKILL_RESERVED_NAMES}:
return None
return raw
def custom_skill_path_for_game(
game_root: str | Path | None, name: str
) -> Path | None:
"""Return ``<game_root>/skills/<name>.md`` for a valid custom skill name."""
stem = sanitize_custom_skill_stem(name)
skills = game_skills_dir(game_root)
if not stem or skills is None:
return None
return skills / f"{stem}.md"
def list_custom_skill_paths(game_root: str | Path | None) -> list[Path]:
"""List user custom skill markdown files under ``<game>/skills/``.
Excludes built-in ``quirks.md`` and ``translation.md``.
"""
skills = game_skills_dir(game_root)
if skills is None or not skills.is_dir():
return []
reserved = {n.lower() for n in GAME_SKILL_RESERVED_NAMES}
out: list[Path] = []
for path in sorted(skills.glob("*.md"), key=lambda p: p.name.lower()):
if path.name.lower() in reserved:
continue
if path.is_file():
out.append(path)
return out
def load_system_prompt(game_root: str | Path | None = None) -> str:
"""Load ``data/skills/system.md`` plus optional per-game overlays.
Overlay order:
1. ``skills/quirks.md`` (voice quirks)
2. Any other ``skills/*.md`` except ``translation.md`` (custom skills)
Path resolution order for the game root:
1. Explicit *game_root* argument
2. ``DAZED_GAME_ROOT`` environment variable
"""
base = ""
if PROMPT_PATH.is_file():
base = PROMPT_PATH.read_text(encoding="utf-8")
root = game_root or os.getenv("DAZED_GAME_ROOT") or ""
root = str(root).strip()
if not root:
return base
parts = [base.rstrip()] if base.strip() else []
quirks_file = quirks_path_for_game(root)
if quirks_file and quirks_file.is_file():
quirks = quirks_file.read_text(encoding="utf-8").strip()
if quirks:
parts.append("## Game-Specific Translation Quirks\n\n" + quirks)
for path in list_custom_skill_paths(root):
body = path.read_text(encoding="utf-8").strip()
if not body:
continue
title = path.stem.replace("_", " ").replace("-", " ").strip() or path.stem
parts.append(f"## Custom Game Skill: {title}\n\n{body}")
if not parts:
return base
return "\n\n".join(parts) + "\n"

View file

@ -1477,8 +1477,8 @@ class TranslationConfig:
# Load prompt and vocab files if not provided
if prompt is None:
try:
from util.paths import PROMPT_PATH, VOCAB_PATH
self.prompt = PROMPT_PATH.read_text(encoding="utf-8")
from util.skills import load_system_prompt
self.prompt = load_system_prompt()
except FileNotFoundError:
self.prompt = ""
else:
@ -1945,7 +1945,7 @@ def createContext(config, subbedText, formatType, history=None):
cache invalidation caused by changing vocabulary matches.
Cached in static_system:
- prompt.txt content
- data/skills/system.md content (plus optional game quirks overlay)
Dynamic in vocab_text:
- only vocab terms found in the current batch text
@ -2061,7 +2061,7 @@ def buildClaudeRequest(system, user, history, formatType, model, numLines=None,
def translateText(system, user, history, penalty, formatType, model, numLines=None, vocab_text=""):
"""Send translation request to the selected API.
system: Static system prompt (prompt.txt). Cached by Claude.
system: Static system prompt (data/skills/system.md). Cached by Claude.
vocab_text: Per-batch vocabulary (dynamic, never cached to avoid cache busting).
"""
# Ensure system content is not empty
@ -2830,7 +2830,7 @@ def translateAI(text, history, config, filename=None, pbar=None, lock=None, mism
continue
# Create context — static_system is the stable prompt.txt content;
# Create context — static_system is the stable system.md content;
# vocab_text is the per-batch matched vocabulary (dynamic).
static_system, vocab_text, user = createContext(config, subbedT, formatType, history)