Updating stuff
This commit is contained in:
parent
2e17604718
commit
601c64fe9f
9 changed files with 449 additions and 34 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -18,7 +18,7 @@ __pycache__
|
||||||
|
|
||||||
!assets/*.png
|
!assets/*.png
|
||||||
!.pre-commit-config.yaml
|
!.pre-commit-config.yaml
|
||||||
!data/prompt.txt
|
!data/skills/**
|
||||||
!data/vocab_base.txt
|
!data/vocab_base.txt
|
||||||
!requirements.txt
|
!requirements.txt
|
||||||
!util/tl_inspector/TLInspector.js
|
!util/tl_inspector/TLInspector.js
|
||||||
|
|
|
||||||
10
TODO.md
10
TODO.md
|
|
@ -1,19 +1,13 @@
|
||||||
# TODO
|
# TODO
|
||||||
|
|
||||||
## Custom game prompt
|
|
||||||
|
|
||||||
- Keep the default prompt (`data/skills/system.md`) 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)
|
## Local translation cache (game retranslate)
|
||||||
|
|
||||||
- Persist every successful translation locally so a full retranslate from scratch can reuse prior results instead of paying again.
|
- 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.
|
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.
|
- **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/`.
|
- **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).
|
- **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.
|
- **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.
|
- **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.
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ Hard rules:
|
||||||
1. Per-character voice → `glossary` only.
|
1. Per-character voice → `glossary` only.
|
||||||
2. Category-wide / cross-cutting voice → `translation_quirks` 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.
|
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`; do not reprint those rules.
|
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.
|
5. `speakers` is config, not lore.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -101,10 +101,15 @@ Output as short imperative bullets suitable to paste into `skills/quirks.md`.
|
||||||
Produce a durable IDE skill for this title saved at `skills/translation.md`:
|
Produce a durable IDE skill for this title saved at `skills/translation.md`:
|
||||||
- Short game identity (tone/genre/content notes)
|
- Short game identity (tone/genre/content notes)
|
||||||
- Which files matter for dialogue/DB in this repo
|
- Which files matter for dialogue/DB in this repo
|
||||||
- Explicit pointer: follow `skills/quirks.md` for voice rules (authoritative)
|
- A **Voice rules** section that points only at ``skills/quirks.md`` (authoritative)
|
||||||
- What not to change (codes, formatting - tool-owned via base prompt)
|
- What not to change (codes, formatting - tool-owned via base prompt)
|
||||||
- Do **not** reprint quirks or the glossary
|
- 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:rpgmaker -->
|
||||||
|
|
||||||
<!-- engine:wolf -->
|
<!-- engine:wolf -->
|
||||||
|
|
@ -160,6 +165,28 @@ Label the fence language as `translation_quirks`. Inside: imperative bullet list
|
||||||
|
|
||||||
### Block `game_skill`
|
### Block `game_skill`
|
||||||
|
|
||||||
Label the fence language as `game_skill`. Inside: full markdown for `skills/translation.md` (identity, file map, quirks pointer, tool boundaries).
|
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.
|
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.
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,7 @@ You will be translating erotic and sexual content. You will receive lines of dia
|
||||||
|
|
||||||
- **Always preserve and enforce Japanese honorifics** in the English 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.
|
- Do **not** drop honorifics to make the English sound more "natural" unless the source Japanese also lacks them.
|
||||||
- If `# Game Characters` (or game quirks) specifies how a character addresses someone, follow that address pattern.
|
- 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.
|
- The `=` or `=` character in a Japanese name marks a foreign or nickname component. Wrap that part in parentheses.
|
||||||
- Example: `バンカー=ベット` → `Bunker (Bet)`
|
- Example: `バンカー=ベット` → `Bunker (Bet)`
|
||||||
- Always translate speaker tags to English: `[クロネ]:` → `[Kurone]:`
|
- Always translate speaker tags to English: `[クロネ]:` → `[Kurone]:`
|
||||||
|
|
|
||||||
|
|
@ -59,8 +59,8 @@ class SkillsTab(QWidget):
|
||||||
|
|
||||||
intro = QLabel(
|
intro = QLabel(
|
||||||
"Tool-level AI instructions live here. "
|
"Tool-level AI instructions live here. "
|
||||||
"Per-game quirks and the game IDE skill are edited in Workflow Step 3 "
|
"Per-game quirks, the game IDE skill, and optional custom skills are edited "
|
||||||
"(they travel with the game folder)."
|
"in Workflow Step 2 (they travel with the game folder)."
|
||||||
)
|
)
|
||||||
intro.setWordWrap(True)
|
intro.setWordWrap(True)
|
||||||
intro.setStyleSheet(_HINT_STYLE)
|
intro.setStyleSheet(_HINT_STYLE)
|
||||||
|
|
|
||||||
|
|
@ -22,9 +22,13 @@ from pathlib import Path
|
||||||
|
|
||||||
from util.paths import VOCAB_PATH
|
from util.paths import VOCAB_PATH
|
||||||
from util.skills import (
|
from util.skills import (
|
||||||
|
custom_skill_path_for_game,
|
||||||
game_skill_path_for_game,
|
game_skill_path_for_game,
|
||||||
|
list_custom_skill_paths,
|
||||||
load_project_setup,
|
load_project_setup,
|
||||||
|
migrate_game_skill_text,
|
||||||
quirks_path_for_game,
|
quirks_path_for_game,
|
||||||
|
sanitize_custom_skill_stem,
|
||||||
)
|
)
|
||||||
from util.vocab import BASE_SEPARATOR as _SHARED_BASE_SEPARATOR
|
from util.vocab import BASE_SEPARATOR as _SHARED_BASE_SEPARATOR
|
||||||
from util.vocab import read_game_vocab, write_game_vocab
|
from util.vocab import read_game_vocab, write_game_vocab
|
||||||
|
|
@ -42,6 +46,7 @@ from PyQt5.QtWidgets import (
|
||||||
QGridLayout,
|
QGridLayout,
|
||||||
QGroupBox,
|
QGroupBox,
|
||||||
QHBoxLayout,
|
QHBoxLayout,
|
||||||
|
QInputDialog,
|
||||||
QLabel,
|
QLabel,
|
||||||
QLineEdit,
|
QLineEdit,
|
||||||
QListWidget,
|
QListWidget,
|
||||||
|
|
@ -52,7 +57,9 @@ from PyQt5.QtWidgets import (
|
||||||
QSizePolicy,
|
QSizePolicy,
|
||||||
QSpinBox,
|
QSpinBox,
|
||||||
QSplitter,
|
QSplitter,
|
||||||
|
QStackedWidget,
|
||||||
QStyle,
|
QStyle,
|
||||||
|
QTabBar,
|
||||||
QTabWidget,
|
QTabWidget,
|
||||||
QTextEdit,
|
QTextEdit,
|
||||||
QToolButton,
|
QToolButton,
|
||||||
|
|
@ -437,8 +444,10 @@ _STEP_HELP: dict[int, str] = {
|
||||||
"• <code>speakers</code> → apply flags above<br>"
|
"• <code>speakers</code> → apply flags above<br>"
|
||||||
"• <code>translation_quirks</code> → Quirks tab "
|
"• <code>translation_quirks</code> → Quirks tab "
|
||||||
"(<code>skills/quirks.md</code>)<br>"
|
"(<code>skills/quirks.md</code>)<br>"
|
||||||
"• <code>game_skill</code> → Game skill tab "
|
"• <code>game_skill</code> → Game skills → translation tab "
|
||||||
"(<code>skills/translation.md</code>)<br><br>"
|
"(<code>skills/translation.md</code>)<br><br>"
|
||||||
|
"Optional: <b>+</b> on Game skills adds custom <code>skills/*.md</code> "
|
||||||
|
"overlays (merged into the API prompt - can hurt quality; use sparingly).<br><br>"
|
||||||
"Use one game folder per translation run so quirks stay in the prompt cache."
|
"Use one game folder per translation run so quirks stay in the prompt cache."
|
||||||
),
|
),
|
||||||
3: (
|
3: (
|
||||||
|
|
@ -1858,21 +1867,77 @@ class WorkflowTab(QWidget):
|
||||||
),
|
),
|
||||||
"Quirks",
|
"Quirks",
|
||||||
)
|
)
|
||||||
editors.addTab(
|
|
||||||
|
# Game skills: built-in translation.md + optional custom overlays
|
||||||
|
game_skills_page = QWidget()
|
||||||
|
gs_layout = QVBoxLayout(game_skills_page)
|
||||||
|
gs_layout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
gs_layout.setSpacing(0)
|
||||||
|
|
||||||
|
self._custom_skill_editors: dict[str, QTextEdit] = {}
|
||||||
|
self._game_skills_bar = QTabBar()
|
||||||
|
self._game_skills_bar.setDocumentMode(True)
|
||||||
|
self._game_skills_bar.setExpanding(False)
|
||||||
|
self._game_skills_bar.setDrawBase(False)
|
||||||
|
self._game_skills_bar.setMinimumHeight(30)
|
||||||
|
self._game_skills_bar.setStyleSheet(
|
||||||
|
"QTabBar::tab{background:#2d2d30;color:#9d9d9d;padding:6px 12px;"
|
||||||
|
"border:1px solid #3c3c3c;border-bottom:none;margin-right:2px;}"
|
||||||
|
"QTabBar::tab:selected{background:#1e1e1e;color:#e0e0e0;}"
|
||||||
|
"QTabBar::tab:hover{color:#ffffff;}"
|
||||||
|
)
|
||||||
|
self._game_skills_stack = QStackedWidget()
|
||||||
|
self._game_skills_stack.setStyleSheet("background:#1e1e1e;")
|
||||||
|
self._game_skills_bar.currentChanged.connect(self._game_skills_stack.setCurrentIndex)
|
||||||
|
|
||||||
|
strip = QWidget()
|
||||||
|
strip.setStyleSheet("background-color:#252526;")
|
||||||
|
strip_l = QHBoxLayout(strip)
|
||||||
|
strip_l.setContentsMargins(8, 6, 10, 0)
|
||||||
|
strip_l.setSpacing(6)
|
||||||
|
strip_l.addWidget(self._game_skills_bar, 0, Qt.AlignBottom)
|
||||||
|
|
||||||
|
add_btn = QPushButton("+ Add custom")
|
||||||
|
add_btn.setCursor(Qt.PointingHandCursor)
|
||||||
|
add_btn.setFixedHeight(30)
|
||||||
|
add_btn.setToolTip(
|
||||||
|
"Create a custom skills/*.md overlay.\n"
|
||||||
|
"Merged into the translation system prompt - can hurt quality."
|
||||||
|
)
|
||||||
|
add_btn.setStyleSheet(
|
||||||
|
"QPushButton{background-color:#2d2d30;color:#4ec9b0;"
|
||||||
|
"border:1px solid #3c3c3c;border-bottom:none;"
|
||||||
|
"border-top-left-radius:3px;border-top-right-radius:3px;"
|
||||||
|
"padding:0 12px;font-size:12px;font-weight:bold;}"
|
||||||
|
"QPushButton:hover{background-color:#3a3a3a;color:#7ee0c8;"
|
||||||
|
"border-color:#4ec9b0;}"
|
||||||
|
"QPushButton:pressed{background-color:#1e1e1e;}"
|
||||||
|
)
|
||||||
|
add_btn.clicked.connect(self._add_custom_skill)
|
||||||
|
strip_l.addWidget(add_btn, 0, Qt.AlignBottom)
|
||||||
|
strip_l.addStretch(1)
|
||||||
|
gs_layout.addWidget(strip)
|
||||||
|
gs_layout.addWidget(self._game_skills_stack, 1)
|
||||||
|
|
||||||
|
self._add_game_skill_page(
|
||||||
|
"translation",
|
||||||
_editor_page(
|
_editor_page(
|
||||||
"<game>/skills/translation.md",
|
"<game>/skills/translation.md",
|
||||||
"Paste the game_skill block. IDE companion skill for this game repo.",
|
"Paste the game_skill block. IDE companion skill for this game repo "
|
||||||
|
"(not injected into the API prompt).",
|
||||||
self._save_game_skill,
|
self._save_game_skill,
|
||||||
self._reload_game_skill,
|
self._reload_game_skill,
|
||||||
"game_skill_editor",
|
"game_skill_editor",
|
||||||
),
|
),
|
||||||
"Game skill",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
editors.addTab(game_skills_page, "Game skills")
|
||||||
layout.addWidget(editors, 1)
|
layout.addWidget(editors, 1)
|
||||||
|
|
||||||
self._reload_vocab()
|
self._reload_vocab()
|
||||||
self._reload_quirks()
|
self._reload_quirks()
|
||||||
self._reload_game_skill()
|
self._reload_game_skill()
|
||||||
|
self._reload_custom_skills()
|
||||||
|
|
||||||
|
|
||||||
def _run_parse_speakers(self):
|
def _run_parse_speakers(self):
|
||||||
|
|
@ -3122,8 +3187,6 @@ class WorkflowTab(QWidget):
|
||||||
self._detected_on_show = True # new folder chosen — treat as already-shown
|
self._detected_on_show = True # new folder chosen — treat as already-shown
|
||||||
self._ask_clear_old_files()
|
self._ask_clear_old_files()
|
||||||
self._detect_folder()
|
self._detect_folder()
|
||||||
self._reload_quirks()
|
|
||||||
self._reload_game_skill()
|
|
||||||
|
|
||||||
def _ask_clear_old_files(self):
|
def _ask_clear_old_files(self):
|
||||||
"""Prompt the user to clear /files and /translated to avoid stale data conflicts."""
|
"""Prompt the user to clear /files and /translated to avoid stale data conflicts."""
|
||||||
|
|
@ -3224,6 +3287,9 @@ class WorkflowTab(QWidget):
|
||||||
return
|
return
|
||||||
|
|
||||||
self._save_setting("last_game_folder", folder)
|
self._save_setting("last_game_folder", folder)
|
||||||
|
self._reload_quirks()
|
||||||
|
self._reload_game_skill()
|
||||||
|
self._reload_custom_skills()
|
||||||
self.detected_label.setText("Scanning…")
|
self.detected_label.setText("Scanning…")
|
||||||
self.detected_label.setStyleSheet(
|
self.detected_label.setStyleSheet(
|
||||||
"color:#9d9d9d;font-size:13px;padding:4px 8px;"
|
"color:#9d9d9d;font-size:13px;padding:4px 8px;"
|
||||||
|
|
@ -3620,7 +3686,14 @@ class WorkflowTab(QWidget):
|
||||||
self.game_skill_editor.setPlainText("")
|
self.game_skill_editor.setPlainText("")
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
self.game_skill_editor.setPlainText(path.read_text(encoding="utf-8"))
|
raw = path.read_text(encoding="utf-8")
|
||||||
|
fixed = migrate_game_skill_text(raw)
|
||||||
|
self.game_skill_editor.setPlainText(fixed)
|
||||||
|
if fixed != raw:
|
||||||
|
path.write_text(fixed.rstrip() + "\n", encoding="utf-8")
|
||||||
|
self._log(
|
||||||
|
f"↺ Updated legacy quirk path in {path.name} → skills/quirks.md"
|
||||||
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
self._log(f"❌ Could not load game skill: {exc}")
|
self._log(f"❌ Could not load game skill: {exc}")
|
||||||
|
|
||||||
|
|
@ -3631,11 +3704,235 @@ class WorkflowTab(QWidget):
|
||||||
path = game_skill_path_for_game(root)
|
path = game_skill_path_for_game(root)
|
||||||
try:
|
try:
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
path.write_text(self.game_skill_editor.toPlainText().rstrip() + "\n", encoding="utf-8")
|
text = migrate_game_skill_text(self.game_skill_editor.toPlainText())
|
||||||
|
self.game_skill_editor.setPlainText(text)
|
||||||
|
path.write_text(text.rstrip() + "\n", encoding="utf-8")
|
||||||
self._log(f"✅ Saved {path}")
|
self._log(f"✅ Saved {path}")
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
self._log(f"❌ Could not save game skill: {exc}")
|
self._log(f"❌ Could not save game skill: {exc}")
|
||||||
|
|
||||||
|
def _add_game_skill_page(self, title: str, page: QWidget) -> int:
|
||||||
|
"""Add a page to the Game skills bar + stack. Returns the new index."""
|
||||||
|
idx = self._game_skills_stack.addWidget(page)
|
||||||
|
self._game_skills_bar.addTab(title)
|
||||||
|
return idx
|
||||||
|
|
||||||
|
def _select_game_skill_tab(self, title: str) -> None:
|
||||||
|
for i in range(self._game_skills_bar.count()):
|
||||||
|
if self._game_skills_bar.tabText(i) == title:
|
||||||
|
self._game_skills_bar.setCurrentIndex(i)
|
||||||
|
return
|
||||||
|
|
||||||
|
def _custom_skill_editor_page(self, stem: str) -> QWidget:
|
||||||
|
"""Editor page for one custom skill file under <game>/skills/<stem>.md."""
|
||||||
|
page = QWidget()
|
||||||
|
vl = QVBoxLayout(page)
|
||||||
|
vl.setContentsMargins(8, 8, 8, 8)
|
||||||
|
vl.setSpacing(6)
|
||||||
|
|
||||||
|
tip = QLabel(
|
||||||
|
"Custom skill - merged into the translation system prompt. "
|
||||||
|
"Extra or conflicting rules can hurt quality."
|
||||||
|
)
|
||||||
|
tip.setWordWrap(True)
|
||||||
|
tip.setStyleSheet("color:#c9a227;font-size:12px;")
|
||||||
|
vl.addWidget(tip)
|
||||||
|
|
||||||
|
path_lbl = QLabel(f"<game>/skills/{stem}.md")
|
||||||
|
path_lbl.setStyleSheet(
|
||||||
|
"color:#569cd6;font-size:11px;font-family:Consolas,monospace;"
|
||||||
|
)
|
||||||
|
vl.addWidget(path_lbl)
|
||||||
|
|
||||||
|
ed = _PlainPasteTextEdit()
|
||||||
|
ed.setMinimumHeight(160)
|
||||||
|
ed.setFont(QFont("Consolas", 9))
|
||||||
|
ed.setStyleSheet(
|
||||||
|
"QTextEdit{background-color:#1e1e1e;color:#d4d4d4;"
|
||||||
|
"border:none;padding:8px;"
|
||||||
|
"selection-background-color:#264f78;}"
|
||||||
|
)
|
||||||
|
self._custom_skill_editors[stem] = ed
|
||||||
|
vl.addWidget(ed, 1)
|
||||||
|
|
||||||
|
row = QHBoxLayout()
|
||||||
|
row.setSpacing(8)
|
||||||
|
save_btn = _make_btn("💾 Save", "#3a7a3a")
|
||||||
|
save_btn.setFixedWidth(110)
|
||||||
|
save_btn.clicked.connect(lambda _=False, s=stem: self._save_custom_skill(s))
|
||||||
|
row.addWidget(save_btn)
|
||||||
|
|
||||||
|
reload_btn = _make_btn("↺ Reload", "#555")
|
||||||
|
reload_btn.setFixedWidth(110)
|
||||||
|
reload_btn.clicked.connect(lambda _=False, s=stem: self._reload_one_custom_skill(s))
|
||||||
|
row.addWidget(reload_btn)
|
||||||
|
|
||||||
|
delete_btn = _make_btn("🗑 Delete", "#8b0000")
|
||||||
|
delete_btn.setFixedWidth(110)
|
||||||
|
delete_btn.setToolTip("Delete this custom skill file from the game folder")
|
||||||
|
delete_btn.clicked.connect(lambda _=False, s=stem: self._delete_custom_skill(s))
|
||||||
|
row.addWidget(delete_btn)
|
||||||
|
row.addStretch()
|
||||||
|
vl.addLayout(row)
|
||||||
|
return page
|
||||||
|
|
||||||
|
def _reload_custom_skills(self):
|
||||||
|
"""Rebuild custom skill tabs from <game>/skills/*.md (excluding built-ins)."""
|
||||||
|
bar = getattr(self, "_game_skills_bar", None)
|
||||||
|
stack = getattr(self, "_game_skills_stack", None)
|
||||||
|
if bar is None or stack is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Remove existing custom tabs (index 0 is always translation).
|
||||||
|
while bar.count() > 1:
|
||||||
|
bar.removeTab(1)
|
||||||
|
while stack.count() > 1:
|
||||||
|
w = stack.widget(1)
|
||||||
|
stack.removeWidget(w)
|
||||||
|
if w is not None:
|
||||||
|
w.deleteLater()
|
||||||
|
self._custom_skill_editors = {}
|
||||||
|
|
||||||
|
root = self.folder_edit.text().strip()
|
||||||
|
for path in list_custom_skill_paths(root):
|
||||||
|
stem = path.stem
|
||||||
|
page = self._custom_skill_editor_page(stem)
|
||||||
|
self._add_game_skill_page(stem, page)
|
||||||
|
try:
|
||||||
|
self._custom_skill_editors[stem].setPlainText(
|
||||||
|
path.read_text(encoding="utf-8")
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
self._log(f"❌ Could not load skills/{stem}.md: {exc}")
|
||||||
|
|
||||||
|
def _reload_one_custom_skill(self, stem: str):
|
||||||
|
root = self.folder_edit.text().strip()
|
||||||
|
path = custom_skill_path_for_game(root, stem)
|
||||||
|
ed = self._custom_skill_editors.get(stem)
|
||||||
|
if ed is None:
|
||||||
|
return
|
||||||
|
if not path or not path.is_file():
|
||||||
|
ed.setPlainText("")
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
ed.setPlainText(path.read_text(encoding="utf-8"))
|
||||||
|
except Exception as exc:
|
||||||
|
self._log(f"❌ Could not load skills/{stem}.md: {exc}")
|
||||||
|
|
||||||
|
def _save_custom_skill(self, stem: str):
|
||||||
|
root = self._game_root_or_warn()
|
||||||
|
if not root:
|
||||||
|
return
|
||||||
|
path = custom_skill_path_for_game(root, stem)
|
||||||
|
ed = self._custom_skill_editors.get(stem)
|
||||||
|
if path is None or ed is None:
|
||||||
|
self._log("❌ Invalid custom skill.")
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_text(ed.toPlainText().rstrip() + "\n", encoding="utf-8")
|
||||||
|
self._log(f"✅ Saved {path}")
|
||||||
|
except Exception as exc:
|
||||||
|
self._log(f"❌ Could not save skills/{stem}.md: {exc}")
|
||||||
|
|
||||||
|
def _add_custom_skill(self):
|
||||||
|
root = self._game_root_or_warn()
|
||||||
|
if not root:
|
||||||
|
return
|
||||||
|
|
||||||
|
warn = QMessageBox(self)
|
||||||
|
warn.setIcon(QMessageBox.Warning)
|
||||||
|
warn.setWindowTitle("Custom skill - quality warning")
|
||||||
|
warn.setTextFormat(Qt.RichText)
|
||||||
|
warn.setText(
|
||||||
|
"<b>Custom skills are merged into the translation system prompt.</b><br><br>"
|
||||||
|
"Extra or poorly written skills can <b>distract the model and hurt "
|
||||||
|
"translation quality</b> (conflicting rules, prompt bloat, diluted quirks).<br><br>"
|
||||||
|
"Prefer <code>quirks.md</code> for voice rules and <code>translation.md</code> "
|
||||||
|
"for IDE guidance. Add a custom skill only if you need a rare, tightly scoped "
|
||||||
|
"overlay - <b>at your own risk</b>."
|
||||||
|
)
|
||||||
|
warn.setStandardButtons(QMessageBox.Cancel | QMessageBox.Ok)
|
||||||
|
warn.button(QMessageBox.Ok).setText("I understand - continue")
|
||||||
|
warn.setStyleSheet(
|
||||||
|
"QMessageBox{background-color:#252526;}"
|
||||||
|
"QLabel{color:#d4d4d4;font-size:13px;min-width:420px;}"
|
||||||
|
)
|
||||||
|
if warn.exec_() != QMessageBox.Ok:
|
||||||
|
return
|
||||||
|
|
||||||
|
name, ok = QInputDialog.getText(
|
||||||
|
self,
|
||||||
|
"New custom skill",
|
||||||
|
"Skill file name (letters, numbers, ._- ; saved as <game>/skills/<name>.md):",
|
||||||
|
)
|
||||||
|
if not ok:
|
||||||
|
return
|
||||||
|
stem = sanitize_custom_skill_stem(name)
|
||||||
|
if not stem:
|
||||||
|
QMessageBox.warning(
|
||||||
|
self,
|
||||||
|
"Invalid name",
|
||||||
|
"Use a short name like battle-log or honorifics_extra.\n"
|
||||||
|
"Reserved: quirks, translation.",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
if stem in self._custom_skill_editors:
|
||||||
|
QMessageBox.information(
|
||||||
|
self, "Already exists", f"Custom skill '{stem}' is already open."
|
||||||
|
)
|
||||||
|
self._select_game_skill_tab(stem)
|
||||||
|
return
|
||||||
|
|
||||||
|
path = custom_skill_path_for_game(root, stem)
|
||||||
|
if path is None:
|
||||||
|
return
|
||||||
|
if path.is_file():
|
||||||
|
# File exists on disk but tab missing - just reload tabs.
|
||||||
|
self._reload_custom_skills()
|
||||||
|
self._select_game_skill_tab(stem)
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_text(
|
||||||
|
f"# {stem}\n\n"
|
||||||
|
"# Keep this short and specific. Conflicting rules hurt quality.\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
self._log(f"❌ Could not create skills/{stem}.md: {exc}")
|
||||||
|
return
|
||||||
|
|
||||||
|
page = self._custom_skill_editor_page(stem)
|
||||||
|
idx = self._add_game_skill_page(stem, page)
|
||||||
|
self._game_skills_bar.setCurrentIndex(idx)
|
||||||
|
self._reload_one_custom_skill(stem)
|
||||||
|
self._log(f"✅ Created custom skill {path}")
|
||||||
|
|
||||||
|
def _delete_custom_skill(self, stem: str):
|
||||||
|
root = self._game_root_or_warn()
|
||||||
|
if not root:
|
||||||
|
return
|
||||||
|
path = custom_skill_path_for_game(root, stem)
|
||||||
|
reply = QMessageBox.question(
|
||||||
|
self,
|
||||||
|
"Delete custom skill",
|
||||||
|
f"Delete skills/{stem}.md from the game folder?",
|
||||||
|
QMessageBox.Yes | QMessageBox.No,
|
||||||
|
QMessageBox.No,
|
||||||
|
)
|
||||||
|
if reply != QMessageBox.Yes:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
if path and path.is_file():
|
||||||
|
path.unlink()
|
||||||
|
self._log(f"🗑 Deleted {path}")
|
||||||
|
except Exception as exc:
|
||||||
|
self._log(f"❌ Could not delete skills/{stem}.md: {exc}")
|
||||||
|
return
|
||||||
|
self._reload_custom_skills()
|
||||||
|
|
||||||
def _read_vocab_speakers(self) -> list[tuple[str, str]]:
|
def _read_vocab_speakers(self) -> list[tuple[str, str]]:
|
||||||
"""Parse the '# Speakers' section from vocab.txt and return (orig, tl) pairs."""
|
"""Parse the '# Speakers' section from vocab.txt and return (orig, tl) pairs."""
|
||||||
vocab_path = VOCAB_PATH
|
vocab_path = VOCAB_PATH
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,8 @@ TRANSLATION_CONTEXTS_PATH = DATA_DIR / "translation_contexts.json"
|
||||||
GAME_QUIRKS_RELATIVE = Path("skills") / "quirks.md"
|
GAME_QUIRKS_RELATIVE = Path("skills") / "quirks.md"
|
||||||
LEGACY_QUIRKS_FILENAME = "translation_quirks.txt"
|
LEGACY_QUIRKS_FILENAME = "translation_quirks.txt"
|
||||||
GAME_SKILL_RELATIVE = Path("skills") / "translation.md"
|
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 = (
|
_ROOT_DATA_FILES = (
|
||||||
"vocab.txt",
|
"vocab.txt",
|
||||||
|
|
|
||||||
|
|
@ -5,17 +5,25 @@ from __future__ import annotations
|
||||||
from util.skills.contexts import ctx, reload_contexts
|
from util.skills.contexts import ctx, reload_contexts
|
||||||
from util.skills.setup import load_project_setup, skills_dir
|
from util.skills.setup import load_project_setup, skills_dir
|
||||||
from util.skills.system import (
|
from util.skills.system import (
|
||||||
|
custom_skill_path_for_game,
|
||||||
game_skill_path_for_game,
|
game_skill_path_for_game,
|
||||||
|
list_custom_skill_paths,
|
||||||
load_system_prompt,
|
load_system_prompt,
|
||||||
|
migrate_game_skill_text,
|
||||||
quirks_path_for_game,
|
quirks_path_for_game,
|
||||||
|
sanitize_custom_skill_stem,
|
||||||
)
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"ctx",
|
"ctx",
|
||||||
|
"custom_skill_path_for_game",
|
||||||
"game_skill_path_for_game",
|
"game_skill_path_for_game",
|
||||||
|
"list_custom_skill_paths",
|
||||||
"load_project_setup",
|
"load_project_setup",
|
||||||
"load_system_prompt",
|
"load_system_prompt",
|
||||||
|
"migrate_game_skill_text",
|
||||||
"quirks_path_for_game",
|
"quirks_path_for_game",
|
||||||
"reload_contexts",
|
"reload_contexts",
|
||||||
|
"sanitize_custom_skill_stem",
|
||||||
"skills_dir",
|
"skills_dir",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -3,15 +3,19 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from util.paths import (
|
from util.paths import (
|
||||||
GAME_QUIRKS_RELATIVE,
|
GAME_QUIRKS_RELATIVE,
|
||||||
GAME_SKILL_RELATIVE,
|
GAME_SKILL_RELATIVE,
|
||||||
|
GAME_SKILL_RESERVED_NAMES,
|
||||||
LEGACY_QUIRKS_FILENAME,
|
LEGACY_QUIRKS_FILENAME,
|
||||||
PROMPT_PATH,
|
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:
|
def quirks_path_for_game(game_root: str | Path | None) -> Path | None:
|
||||||
"""Return ``<game_root>/skills/quirks.md`` when *game_root* is set.
|
"""Return ``<game_root>/skills/quirks.md`` when *game_root* is set.
|
||||||
|
|
@ -40,10 +44,83 @@ def game_skill_path_for_game(game_root: str | Path | None) -> Path | None:
|
||||||
return Path(game_root) / GAME_SKILL_RELATIVE
|
return Path(game_root) / GAME_SKILL_RELATIVE
|
||||||
|
|
||||||
|
|
||||||
def load_system_prompt(game_root: str | Path | None = None) -> str:
|
def migrate_game_skill_text(text: str) -> str:
|
||||||
"""Load ``data/skills/system.md`` and optionally append per-game quirks.
|
"""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
|
||||||
|
|
||||||
Quirks path resolution order:
|
|
||||||
|
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
|
1. Explicit *game_root* argument
|
||||||
2. ``DAZED_GAME_ROOT`` environment variable
|
2. ``DAZED_GAME_ROOT`` environment variable
|
||||||
"""
|
"""
|
||||||
|
|
@ -53,14 +130,24 @@ def load_system_prompt(game_root: str | Path | None = None) -> str:
|
||||||
|
|
||||||
root = game_root or os.getenv("DAZED_GAME_ROOT") or ""
|
root = game_root or os.getenv("DAZED_GAME_ROOT") or ""
|
||||||
root = str(root).strip()
|
root = str(root).strip()
|
||||||
quirks_file = quirks_path_for_game(root) if root else None
|
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():
|
if quirks_file and quirks_file.is_file():
|
||||||
quirks = quirks_file.read_text(encoding="utf-8").strip()
|
quirks = quirks_file.read_text(encoding="utf-8").strip()
|
||||||
if quirks:
|
if quirks:
|
||||||
return (
|
parts.append("## Game-Specific Translation Quirks\n\n" + quirks)
|
||||||
base.rstrip()
|
|
||||||
+ "\n\n## Game-Specific Translation Quirks\n\n"
|
for path in list_custom_skill_paths(root):
|
||||||
+ quirks
|
body = path.read_text(encoding="utf-8").strip()
|
||||||
+ "\n"
|
if not body:
|
||||||
)
|
continue
|
||||||
return base
|
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"
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue