Compare commits
3 commits
e991f27cbf
...
601c64fe9f
| Author | SHA1 | Date | |
|---|---|---|---|
| 601c64fe9f | |||
| 2e17604718 | |||
| fe31c71130 |
17 changed files with 1635 additions and 641 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
10
TODO.md
|
|
@ -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.
|
||||
|
|
|
|||
192
data/skills/project_setup.md
Normal file
192
data/skills/project_setup.md
Normal 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.
|
||||
|
|
@ -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]:`
|
||||
18
gui/main.py
18
gui/main.py
|
|
@ -595,6 +595,7 @@ from gui.config_tab import ConfigTab
|
|||
from gui.translation_tab import TranslationTab
|
||||
from gui.workflow_tab import WorkflowTab
|
||||
from gui.wolf_workflow_tab import WolfWorkflowTab
|
||||
from gui.skills_tab import SkillsTab
|
||||
from gui.batch_tab import BatchTab
|
||||
|
||||
class DazedMTLGUI(QMainWindow):
|
||||
|
|
@ -838,9 +839,16 @@ class DazedMTLGUI(QMainWindow):
|
|||
sidebar_layout.addWidget(btn_batches)
|
||||
self.nav_buttons.append(btn_batches)
|
||||
|
||||
# Configuration button (fourth)
|
||||
# Skills button (fourth)
|
||||
btn_skills = self.create_nav_button("📚", "Skills")
|
||||
btn_skills.setToolTip("Skills — edit system prompt, Project Setup, and translation contexts")
|
||||
btn_skills.clicked.connect(lambda: self.switch_page(3))
|
||||
sidebar_layout.addWidget(btn_skills)
|
||||
self.nav_buttons.append(btn_skills)
|
||||
|
||||
# Configuration button (fifth)
|
||||
btn_config = self.create_nav_button("⚙️", "Configuration")
|
||||
btn_config.clicked.connect(lambda: self.switch_page(3))
|
||||
btn_config.clicked.connect(lambda: self.switch_page(4))
|
||||
sidebar_layout.addWidget(btn_config)
|
||||
self.nav_buttons.append(btn_config)
|
||||
|
||||
|
|
@ -906,7 +914,11 @@ class DazedMTLGUI(QMainWindow):
|
|||
self.batch_tab = BatchTab(self)
|
||||
self.content_stack.addWidget(self.batch_tab)
|
||||
|
||||
# Configuration Tab (index 3)
|
||||
# Skills Tab (index 3)
|
||||
self.skills_tab = SkillsTab(self)
|
||||
self.content_stack.addWidget(self.skills_tab)
|
||||
|
||||
# Configuration Tab (index 4)
|
||||
self.config_tab = ConfigTab()
|
||||
self.config_tab.config_changed.connect(self.on_config_changed)
|
||||
self.content_stack.addWidget(self.config_tab)
|
||||
|
|
|
|||
247
gui/skills_tab.py
Normal file
247
gui/skills_tab.py
Normal 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}")
|
||||
|
|
@ -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
|
||||
|
|
|
|||
1336
gui/workflow_tab.py
1336
gui/workflow_tab.py
File diff suppressed because it is too large
Load diff
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
|
|
|
|||
|
|
@ -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
29
util/skills/__init__.py
Normal 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
63
util/skills/contexts.py
Normal 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
62
util/skills/setup.py
Normal 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
153
util/skills/system.py
Normal 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"
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue