Vernacular / pipeline /rules.py
bhardwaj08sarthak's picture
Upload folder using huggingface_hub
2619b20 verified
Raw
History Blame Contribute Delete
5.12 kB
"""Translatability rules: which strings in a language-pack JSON file get translated.
The public API is `iter_translatable(data, rel_path)`, which yields `Item`s with a
key path addressing the string inside the file, the source string, and a `kind`:
dialogue - a line spoken by the file's character (eligible for the tone pass)
player - a line spoken by the phone owner / player (plain translation)
other - UI copy, web text, notes, etc. (plain translation)
`get_at` / `set_at` address values by the same key paths and are used by the
exporter to substitute translations without touching structure.
"""
import copy
import re
from collections.abc import Iterator
from typing import Any, NamedTuple
# Field names that hold structural data, never prose.
SKIP_FIELDS = {"id", "audio", "next", "options_next", "time", "type"}
# Speaker codes in Filler Chats ("Schema B").
TYPE_PLAYER = "1"
TYPE_SYSTEM = "-1"
_LETTER_RE = re.compile(r"[A-Za-z]")
_URL_OR_FILE_RE = re.compile(r"^[\w.-]+\.[a-z][a-z0-9]{1,5}(/\S*)?$", re.IGNORECASE)
class Item(NamedTuple):
path: tuple # key path into the file's JSON structure
source: str
kind: str # "dialogue" | "player" | "other"
def is_translatable_value(value: Any) -> bool:
"""Value-level filter applied to every candidate string."""
if not isinstance(value, str):
return False
text = value.strip()
if not text or text == "-":
return False
if text.startswith("system::"):
return False
if text.startswith(("http://", "https://")) or _URL_OR_FILE_RE.match(text):
return False
if not _LETTER_RE.search(text): # numbers, emoji, punctuation only
return False
return True
def detect_schema(data: Any, rel_path: str) -> str:
"""Classify a file as 'conversation', 'chat_log', or 'generic'."""
parts = rel_path.replace("\\", "/").split("/")
if "Conversations" in parts and isinstance(data, dict):
if any(isinstance(v, dict) and "message" in v for v in data.values()):
return "conversation"
if "Filler Chats" in parts:
rows = data if isinstance(data, list) else list(data.values()) if isinstance(data, dict) else []
if any(isinstance(r, dict) and "text" in r for r in rows):
return "chat_log"
return "generic"
def iter_translatable(data: Any, rel_path: str) -> Iterator[Item]:
schema = detect_schema(data, rel_path)
if schema == "conversation":
yield from _iter_conversation(data)
elif schema == "chat_log":
yield from _iter_chat_log(data)
else:
yield from _iter_generic(data, ())
def _iter_conversation(data: dict) -> Iterator[Item]:
for msg_id, node in data.items():
if not isinstance(node, dict):
continue
message = node.get("message")
if is_translatable_value(message):
yield Item((msg_id, "message"), message, "dialogue")
options = node.get("options")
if isinstance(options, str):
if is_translatable_value(options):
yield Item((msg_id, "options"), options, "player")
elif isinstance(options, list):
for i, opt in enumerate(options):
if is_translatable_value(opt):
yield Item((msg_id, "options", i), opt, "player")
def _iter_chat_log(data: Any) -> Iterator[Item]:
rows = enumerate(data) if isinstance(data, list) else data.items()
for key, row in rows:
if not isinstance(row, dict):
continue
text = row.get("text")
if not is_translatable_value(text):
continue
row_type = str(row.get("type", ""))
if row_type == TYPE_PLAYER:
kind = "player"
elif row_type == TYPE_SYSTEM:
kind = "other"
else: # "0" and any other code = a non-player chat participant
kind = "dialogue"
yield Item((key, "text"), text, kind)
def _iter_generic(node: Any, path: tuple) -> Iterator[Item]:
if isinstance(node, dict):
for key, value in node.items():
if key in SKIP_FIELDS:
continue
yield from _iter_generic(value, path + (key,))
elif isinstance(node, list):
for i, value in enumerate(node):
yield from _iter_generic(value, path + (i,))
elif is_translatable_value(node):
yield Item(path, node, "other")
def get_at(data: Any, path: tuple) -> Any:
node = data
for key in path:
node = node[key]
return node
def set_at(data: Any, path: tuple, value: Any) -> Any:
"""Return a deep copy of `data` with the value at `path` replaced."""
return set_many(data, [(path, value)])
def set_many(data: Any, updates: list[tuple[tuple, Any]]) -> Any:
"""Return a deep copy of `data` with every (path, value) update applied."""
result = copy.deepcopy(data)
for path, value in updates:
node = result
for key in path[:-1]:
node = node[key]
node[path[-1]] = value
return result
def key_to_str(path: tuple) -> str:
return ".".join(str(p) for p in path)