Skip to content

Commit 5115462

Browse files
JarbasAlclaude
andcommitted
refactor: use JSON-based euphony rules for word list joining
Extracted word joining logic into ovos_workshop/skills/util.py with generic, language-configurable euphony rules loaded from JSON: - join_word_list() now supports per-language euphony.json config files - Italian and Spanish euphony rules defined in locale/{lang}/euphony.json - Removed hardcoded _join_word_list_it/es() special case handlers - Rules engine supports: starts_with_vowel, starts_with_letter, starts_with_any_except conditions with accent normalization - Moved simple_trace() to util.py for cleaner ovos.py module This enables language teams to contribute euphony rules without modifying Python code, improving i18n scalability for ask_selection() formatting. All existing tests pass; word joining behavior is identical. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 931f247 commit 5115462

4 files changed

Lines changed: 242 additions & 132 deletions

File tree

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
{
2+
"normalize": {
3+
"strip_leading_h": true,
4+
"replace_accents": {
5+
"ó": "o",
6+
"í": "i",
7+
"á": "a"
8+
}
9+
},
10+
"rules": [
11+
{
12+
"connector": "y",
13+
"condition": "starts_with_any_except",
14+
"letters": ["i"],
15+
"excluded_patterns": ["io", "ia", "ie"],
16+
"replace_with": "e"
17+
},
18+
{
19+
"connector": "o",
20+
"condition": "starts_with_vowel",
21+
"vowels": ["o"],
22+
"replace_with": "u"
23+
}
24+
]
25+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
{
2+
"normalize": {},
3+
"rules": [
4+
{
5+
"connector": "e",
6+
"condition": "starts_with_vowel",
7+
"vowels": ["e"],
8+
"replace_with": "ed"
9+
},
10+
{
11+
"connector": "o",
12+
"condition": "starts_with_vowel",
13+
"vowels": ["o"],
14+
"replace_with": "od"
15+
}
16+
]
17+
}

ovos_workshop/skills/ovos.py

Lines changed: 1 addition & 132 deletions
Original file line numberDiff line numberDiff line change
@@ -62,20 +62,7 @@
6262
from ovos_workshop.intents import IntentBuilder, Intent, munge_regex, munge_intent_parser, IntentServiceInterface
6363
from ovos_workshop.resource_files import ResourceFile, CoreResources, find_resource, SkillResources
6464
from ovos_workshop.settings import PrivateSettings
65-
66-
67-
def simple_trace(stack_trace: List[str]) -> str:
68-
"""
69-
Generate a simplified traceback.
70-
@param stack_trace: Formatted stack trace (each string ends with \n)
71-
@return: Stack trace with any empty lines removed and last line removed
72-
"""
73-
stack_trace = stack_trace[:-1]
74-
tb = 'Traceback:\n'
75-
for line in stack_trace:
76-
if line.strip():
77-
tb += line
78-
return tb
65+
from ovos_workshop.skills.util import join_word_list, simple_trace
7966

8067

8168
class OVOSSkill:
@@ -2413,123 +2400,5 @@ def __init__(self, skill: OVOSSkill):
24132400
ui_directories=ui_directories)
24142401

24152402

2416-
def _get_word(lang, connector):
2417-
""" Helper to get word translations
2418-
2419-
Args:
2420-
lang (str, optional): an optional BCP-47 language code, if omitted
2421-
the default language will be used.
2422-
2423-
Returns:
2424-
str: translated version of resource name
2425-
"""
2426-
data = CoreResources(lang).load_json_file("word_connectors")
2427-
if connector in data:
2428-
return data[connector]
2429-
LOG.warning(f"untranslated word connector '{connector}' for lang: {lang}")
2430-
return ", "
2431-
2432-
2433-
def join_word_list(items: List[str], connector: str, sep: str, lang: str) -> str:
2434-
""" Join a list into a phrase using the given connector word
2435-
2436-
Examples:
2437-
join_word_list([1,2,3], "or") -> "1, 2 or 3"
2438-
join_word_list([1,2,3], "and") -> "1, 2 and 3"
2439-
join_word_list([1,2,3], "and", ";") -> "1; 2 and 3"
2440-
2441-
Args:
2442-
items (array): items to be joined
2443-
connector (str): connecting word (resource name), like "and" or "or"
2444-
sep (str, optional): separator character, default = ","
2445-
lang (str, optional): an optional BCP-47 language code, if omitted
2446-
the default language will be used.
2447-
Returns:
2448-
str: the connected list phrase
2449-
"""
2450-
if lang.startswith("it"):
2451-
return _join_word_list_it(items, connector, sep)
2452-
elif lang.startswith("es"):
2453-
return _join_word_list_es(items, connector, sep)
2454-
2455-
cons = {
2456-
"and": _get_word(lang, "and"),
2457-
"or": _get_word(lang, "or")
2458-
}
2459-
if not items:
2460-
return ""
2461-
if len(items) == 1:
2462-
return str(items[0])
2463-
2464-
if not sep:
2465-
sep = ", "
2466-
else:
2467-
sep += " "
2468-
return (sep.join(str(item) for item in items[:-1]) +
2469-
" " + cons[connector] +
2470-
" " + items[-1])
2471-
2472-
2473-
def _join_word_list_it(items: List[str], connector: str, sep: str = ",") -> str:
2474-
cons = {
2475-
"and": _get_word("it", "and"),
2476-
"or": _get_word("it", "or")
2477-
}
2478-
if not items:
2479-
return ""
2480-
if len(items) == 1:
2481-
return str(items[0])
2482-
2483-
if not sep:
2484-
sep = ", "
2485-
else:
2486-
sep += " "
2487-
2488-
final_connector = cons[connector]
2489-
if len(items) > 2:
2490-
joined_string = sep.join(item for item in items[:-1])
2491-
else:
2492-
joined_string = items[0]
2493-
2494-
# Check for euphonic transformation cases for "e" and "o"
2495-
if cons[connector] == "e" and items[-1][0].lower() == "e":
2496-
final_connector = "ed"
2497-
elif cons[connector] == "o" and items[-1][0].lower() == "o":
2498-
final_connector = "od"
2499-
return f"{joined_string} {final_connector} {items[-1]}"
2500-
2501-
2502-
def _join_word_list_es(items: List[str], connector: str, sep: str = ",") -> str:
2503-
cons = {
2504-
"and": _get_word("es", "and"),
2505-
"or": _get_word("es", "or")
2506-
}
2507-
if not items:
2508-
return ""
2509-
if len(items) == 1:
2510-
return str(items[0])
2511-
2512-
if not sep:
2513-
sep = ", "
2514-
else:
2515-
sep += " "
2516-
2517-
final_connector = cons[connector]
2518-
if len(items) > 2:
2519-
joined_string = sep.join(item for item in items[:-1])
2520-
else:
2521-
joined_string = items[0]
2522-
2523-
# Check for euphonic transformation cases for "y"
2524-
w = items[-1].lower().lstrip("h").replace("ó", "o").replace("í", "i").replace("á", "a")
2525-
if not any([w.startswith("io"), w.startswith("ia"), w.startswith("ie")]):
2526-
# When following word starts by (H)IA, (H)IE or (H)IO, then usual Y preposition is used
2527-
if cons[connector] == "y" and w[0] == "i":
2528-
final_connector = "e"
2529-
# Check for euphonic transformation cases for "o"
2530-
if cons[connector] == "o" and w[0] == "o":
2531-
final_connector = "u"
2532-
2533-
return f"{joined_string} {final_connector} {items[-1]}"
25342403

25352404

ovos_workshop/skills/util.py

Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
# Copyright 2026 OpenVoiceOS
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
"""
15+
Utility functions for skills, including word list joining with language-specific euphony
16+
and error traceback formatting.
17+
"""
18+
19+
from typing import Dict, List, Optional
20+
21+
from ovos_utils.log import LOG
22+
from ovos_workshop.resource_files import CoreResources
23+
24+
25+
def simple_trace(stack_trace: List[str]) -> str:
26+
"""Generate a simplified traceback.
27+
28+
Args:
29+
stack_trace: Formatted stack trace (each string ends with \\n)
30+
31+
Returns:
32+
Stack trace with any empty lines removed and last line removed
33+
"""
34+
stack_trace = stack_trace[:-1]
35+
tb = 'Traceback:\n'
36+
for line in stack_trace:
37+
if line.strip():
38+
tb += line
39+
return tb
40+
41+
42+
def get_word(lang: str, connector: str) -> str:
43+
"""Get connector word translation for a language.
44+
45+
Args:
46+
lang: BCP-47 language code
47+
connector: Connector key ("and" or "or")
48+
49+
Returns:
50+
Translated connector word, or ", " as fallback
51+
"""
52+
data = CoreResources(lang).load_json_file("word_connectors")
53+
if connector in data:
54+
return data[connector]
55+
LOG.warning(f"untranslated word connector '{connector}' for lang: {lang}")
56+
return ", "
57+
58+
59+
def load_euphony_rules(lang: str) -> Optional[Dict]:
60+
"""Load euphony.json for a language if it exists.
61+
62+
Args:
63+
lang: BCP-47 language tag
64+
65+
Returns:
66+
Dict with euphony rules or None if not found
67+
"""
68+
try:
69+
return CoreResources(lang).load_json_file("euphony")
70+
except Exception:
71+
return None
72+
73+
74+
def normalize_word(word: str, rules: dict) -> str:
75+
"""Normalize a word for euphony comparison per language rules.
76+
77+
Args:
78+
word: The word to normalize
79+
rules: The euphony rules dict (may contain normalize section)
80+
81+
Returns:
82+
Normalized word string
83+
"""
84+
if not word:
85+
return word
86+
87+
normalize = rules.get("normalize", {})
88+
89+
# Strip leading h if language specifies it
90+
if normalize.get("strip_leading_h"):
91+
word = word.lstrip("h")
92+
93+
# Apply character replacements if language specifies them
94+
replacements = normalize.get("replace_accents", {})
95+
for old, new in replacements.items():
96+
word = word.replace(old, new)
97+
98+
return word
99+
100+
101+
def apply_euphony(connector: str, next_word: str, rules: dict) -> str:
102+
"""Apply euphony transformation to connector based on rules.
103+
104+
Args:
105+
connector: The connector word (e.g., "e", "y", "o")
106+
next_word: The word following the connector
107+
rules: The euphony rules dict
108+
109+
Returns:
110+
Potentially transformed connector word
111+
"""
112+
if not next_word or not rules:
113+
return connector
114+
115+
# Check each rule to see if it applies
116+
for rule in rules.get("rules", []):
117+
rule_connector = rule.get("connector")
118+
if rule_connector != connector:
119+
continue
120+
121+
# Check the condition type
122+
condition = rule.get("condition")
123+
normalized_next = normalize_word(next_word.lower(), rules)
124+
first_char = normalized_next[0] if normalized_next else ""
125+
126+
if condition == "starts_with_vowel":
127+
vowels = rule.get("vowels", [])
128+
if first_char in vowels:
129+
return rule.get("replace_with", connector)
130+
131+
elif condition == "starts_with_letter":
132+
letters = rule.get("letters", [])
133+
if first_char in letters:
134+
return rule.get("replace_with", connector)
135+
136+
elif condition == "starts_with_any_except":
137+
# Apply transformation if word does NOT start with any of the excluded patterns
138+
excluded = rule.get("excluded_patterns", [])
139+
excluded_match = any(normalized_next.startswith(p) for p in excluded)
140+
if not excluded_match:
141+
letters = rule.get("letters", [])
142+
if first_char in letters:
143+
return rule.get("replace_with", connector)
144+
145+
return connector
146+
147+
148+
def join_word_list(items: List[str], connector: str, sep: str, lang: str) -> str:
149+
"""Join a list into a phrase using language-specific connector and euphony rules.
150+
151+
Supports language-specific euphony transformations via euphony.json config files.
152+
153+
Examples:
154+
join_word_list(["a", "b", "c"], "and", ",", "en-US")
155+
-> "a, b and c"
156+
157+
join_word_list(["inverno", "estate"], "and", ",", "it-IT")
158+
-> "inverno ed estate" (euphony: e + vowel e -> ed)
159+
160+
join_word_list(["Juan", "Irene"], "and", ",", "es-ES")
161+
-> "Juan e Irene" (euphony: y + i -> e)
162+
163+
Args:
164+
items: List of items to join (converted to strings)
165+
connector: Connector word key ("and" or "or")
166+
sep: Separator character between items (default ",")
167+
lang: BCP-47 language tag (default "en-US")
168+
169+
Returns:
170+
Joined phrase with language-appropriate formatting
171+
"""
172+
if not items:
173+
return ""
174+
if len(items) == 1:
175+
return str(items[0])
176+
177+
# Load connector word
178+
connector_word = get_word(lang, connector)
179+
180+
# Load and apply euphony rules if available
181+
euphony_rules = load_euphony_rules(lang)
182+
if euphony_rules:
183+
connector_word = apply_euphony(connector_word, str(items[-1]), euphony_rules)
184+
185+
# Format separator
186+
if not sep:
187+
sep = ", "
188+
else:
189+
sep += " "
190+
191+
# Join: items[:-1] with sep, then connector, then final item
192+
if len(items) == 2:
193+
# Two items: no separator before connector
194+
return f"{items[0]} {connector_word} {items[1]}"
195+
else:
196+
# Three or more items: use separator
197+
return (sep.join(str(item) for item in items[:-1]) +
198+
" " + connector_word +
199+
" " + items[-1])

0 commit comments

Comments
 (0)