patruff's picture
Upload tool
8147e43 verified
from smolagents.tools import Tool
import string
import pronouncing
import json
class ParodyWordSuggestionTool(Tool):
name = "parody_word_suggester"
description = """Suggests rhyming funny words using CMU dictionary and custom pronunciations.
Returns similar-sounding words that rhyme, especially focusing on common vowel sounds."""
inputs = {'target': {'type': 'string', 'description': 'The word you want to find rhyming alternatives for'}, 'word_list_str': {'type': 'string', 'description': 'JSON string of word list (e.g. \'["word1", "word2"]\')'}, 'min_similarity': {'type': 'string', 'description': 'Minimum similarity threshold (0.0-1.0)', 'nullable': True, 'default': '0.6'}, 'custom_phones': {'type': 'object', 'description': 'Optional dictionary of custom word pronunciations', 'nullable': True, 'default': None}}
output_type = "string"
VOWEL_REF = "AH,UH,AX|AE,EH|IY,IH|AO,AA|UW,UH|AY,EY|OW,AO|AW,AO|OY,OW|ER,AXR"
def _get_vowel_groups(self):
groups = []
group_strs = self.VOWEL_REF.split("|")
group_str = ""
for group_str in group_strs:
groups.append(group_str.split(","))
return groups
def _get_word_phones(self, word, custom_phones=None):
"""Get phones for a word, checking custom dictionary first."""
if custom_phones and word in custom_phones:
return custom_phones[word]["primary_phones"]
import pronouncing
phones = pronouncing.phones_for_word(word)
return phones[0] if phones else None
def _get_last_syllable(self, phones: list) -> tuple:
"""Extract the last syllable (vowel + remaining consonants)."""
last_vowel_idx = -1
last_vowel = None
vowel_groups = self._get_vowel_groups()
# Initialize loop variables
i = 0
phone = ""
base_phone = ""
group = []
vowel_char = ""
# First, find the primary stressed vowel if it exists
for i, phone in enumerate(phones):
# Check for primary stress (1)
if '1' in phone:
# Check if it's a vowel
base_phone = phone.rstrip('012')
for vowel_char in 'AEIOU':
if vowel_char in base_phone:
last_vowel_idx = i
last_vowel = base_phone
break
if last_vowel is not None:
break
# If no primary stress, just use the last vowel
if last_vowel_idx == -1:
for i, phone in enumerate(phones):
base_phone = phone.rstrip('012')
for vowel_char in 'AEIOU':
if vowel_char in base_phone:
last_vowel_idx = i
last_vowel = base_phone
if last_vowel_idx == -1:
return None, []
remaining = phones[last_vowel_idx + 1:]
return last_vowel, remaining
def _strip_stress(self, phones: list) -> list:
"""Remove stress markers from phones."""
result = []
# Initialize loop variable
phone = ""
for phone in phones:
result.append(phone.rstrip('012'))
return result
def _vowels_match(self, v1: str, v2: str) -> bool:
"""Check if vowels belong to the same sound group."""
v1 = v1.rstrip('012')
v2 = v2.rstrip('012')
if v1 == v2:
return True
# Initialize loop variables
vowel_groups = self._get_vowel_groups()
group = []
for group in vowel_groups:
if v1 in group and v2 in group:
return True
return False
def _consonants_are_similar(self, c1, c2):
"""Check if two consonants belong to similar phonetic groups."""
# Group consonants by articulation manner
nasals = ['M', 'N', 'NG']
stops = ['P', 'B', 'T', 'D', 'K', 'G']
fricatives = ['F', 'V', 'TH', 'DH', 'S', 'Z', 'SH', 'ZH']
liquids = ['L', 'R']
glides = ['W', 'Y']
# Check if consonants are in the same group
if c1 in nasals and c2 in nasals:
return True
if c1 in stops and c2 in stops:
return True
if c1 in fricatives and c2 in fricatives:
return True
if c1 in liquids and c2 in liquids:
return True
if c1 in glides and c2 in glides:
return True
return False
def _words_have_similar_structure(self, word1, word2, phones1, phones2):
"""Check if words have similar structure beyond just ending."""
# Similar word length
if abs(len(word1) - len(word2)) > 2:
return False
# Similar syllable count
import pronouncing
syllables1 = len(pronouncing.stresses(phones1))
syllables2 = len(pronouncing.stresses(phones2))
if syllables1 != syllables2:
return False
# For -ing words, check if consonants before -ing have similar patterns
if word1.endswith('ing') and word2.endswith('ing'):
# Get consonant patterns (c-v-c structure)
phone_list1 = phones1.split()
phone_list2 = phones2.split()
# Initialize variables for list comprehension
p = ""
v = ""
# Get consonants
consonants1 = [p for p in self._strip_stress(phone_list1) if not any(v in p for v in 'AEIOU')]
consonants2 = [p for p in self._strip_stress(phone_list2) if not any(v in p for v in 'AEIOU')]
# Same consonant count is promising
if len(consonants1) == len(consonants2):
return True
# For words like 'running' and 'cumming', check pre-final consonant similarity
if len(consonants1) >= 2 and len(consonants2) >= 2:
pre_final1 = consonants1[-2]
pre_final2 = consonants2[-2]
if pre_final1 == pre_final2 or self._consonants_are_similar(pre_final1, pre_final2):
return True
return False
def _calculate_similarity(self, word1, phones1, word2, phones2):
"""Calculate similarity score using refined metrics for parody."""
# Initialize all variables
phone_list1 = phones1.split()
phone_list2 = phones2.split()
# Variables for rhyme scoring
rhyme_score = 0.0
word_vowel = None
word_end = []
target_vowel = None
target_end = []
word_end_clean = []
target_end_clean = []
common_length = 0
matched = 0
i = 0
# Variables for whole-word matching
primary_stress_vowel1 = None
primary_stress_vowel2 = None
primary_stress_idx1 = -1
primary_stress_idx2 = -1
front_consonants1 = []
front_consonants2 = []
# Variables for special pattern matching
special_pattern_score = 0.0
stem1 = ""
stem2 = ""
consonant1 = ""
consonant2 = ""
nasals = ['m', 'n']
stops = ['p', 'b', 't', 'd', 'k', 'g']
fricatives = ['f', 'v', 'th', 's', 'z', 'sh']
base1 = ""
base2 = ""
# Variables for list comprehensions
p = ""
v = ""
group = []
# Find primary stressed vowels
for i, phone in enumerate(phone_list1):
if '1' in phone and any(v in phone for v in 'AEIOU'):
primary_stress_vowel1 = phone.rstrip('012')
primary_stress_idx1 = i
break
for i, phone in enumerate(phone_list2):
if '1' in phone and any(v in phone for v in 'AEIOU'):
primary_stress_vowel2 = phone.rstrip('012')
primary_stress_idx2 = i
break
# Get consonants before the primary stress
if primary_stress_idx1 > 0:
front_consonants1 = [p for p in self._strip_stress(phone_list1[:primary_stress_idx1])
if not any(v in p for v in 'AEIOU')]
if primary_stress_idx2 > 0:
front_consonants2 = [p for p in self._strip_stress(phone_list2[:primary_stress_idx2])
if not any(v in p for v in 'AEIOU')]
# Calculate front consonant similarity (important for parody)
front_consonant_score = 0.0
if front_consonants1 and front_consonants2:
min_length = min(len(front_consonants1), len(front_consonants2))
if min_length > 0:
matches = 0
for i in range(min_length):
if front_consonants1[i] == front_consonants2[i]:
matches += 1
front_consonant_score = matches / min_length
# Get last syllable components for rhyming
result1 = self._get_last_syllable(phone_list1)
result2 = self._get_last_syllable(phone_list2)
word_vowel, word_end = result1
target_vowel, target_end = result2
# Perfect rhyme check (40% of score)
if word_vowel and target_vowel:
if self._vowels_match(word_vowel, target_vowel):
word_end_clean = self._strip_stress(word_end)
target_end_clean = self._strip_stress(target_end)
if word_end_clean == target_end_clean:
rhyme_score = 1.0
else:
# Partial rhyme based on ending similarity
common_length = min(len(word_end_clean), len(target_end_clean))
matched = 0
for i in range(common_length):
if word_end_clean[i] == target_end_clean[i]:
matched += 1
if max(len(word_end_clean), len(target_end_clean)) > 0:
rhyme_score = 0.6 * (matched / max(1, max(len(word_end_clean), len(target_end_clean))))
else:
rhyme_score = 0.6 # Still somewhat rhymes even without ending consonants
# Primary stressed vowel match (15% of score)
primary_vowel_score = 0.0
if primary_stress_vowel1 and primary_stress_vowel2:
if primary_stress_vowel1 == primary_stress_vowel2:
primary_vowel_score = 1.0
else:
# Check if they're in the same vowel group
for group in self._get_vowel_groups():
if primary_stress_vowel1 in group and primary_stress_vowel2 in group:
primary_vowel_score = 0.7
break
# Near rhyme check - 15% of score
near_rhyme_score = 0.0
# Check for specific endings
if len(phone_list1) >= 2 and len(phone_list2) >= 2:
# Check for -ing endings
if (self._strip_stress(phone_list1[-2:]) == ['IH', 'NG'] and
self._strip_stress(phone_list2[-2:]) == ['IH', 'NG']):
# Base score for -ing endings
near_rhyme_score = 0.6
# Additional checks for consonant before -ing
if len(phone_list1) >= 3 and len(phone_list2) >= 3:
consonant1_list = self._strip_stress(phone_list1[-3:-2])
consonant2_list = self._strip_stress(phone_list2[-3:-2])
if consonant1_list and consonant2_list:
consonant1 = consonant1_list[0]
consonant2 = consonant2_list[0]
# Same consonant gets highest score (like running/gunning)
if consonant1 == consonant2:
near_rhyme_score = 0.9
# Similar consonants (nasal: 'N'/'M') get high score (running/cumming)
elif self._consonants_are_similar(consonant1, consonant2):
near_rhyme_score = 0.8
# Check for -y endings (like happy/sappy)
elif (self._strip_stress(phone_list1[-1:]) == ['IY'] and
self._strip_stress(phone_list2[-1:]) == ['IY']):
near_rhyme_score = 0.7
# Special pattern matching for running/cumming type pairs (15% of score)
if word1.endswith('ing') and word2.endswith('ing'):
# Get the stem (without -ing)
stem1 = word1[:-3]
stem2 = word2[:-3]
# Same stem length is good for parody
if len(stem1) == len(stem2):
special_pattern_score += 0.4
# If both stems end with same consonant (like 'n' in run-ning, 'm' in cum-ming)
# this makes them rhyme much better
if stem1 and stem2 and stem1[-1] == stem2[-1]:
special_pattern_score += 0.3
elif stem1 and stem2:
# Check if the final consonants are in the same phonetic group
# This helps pair words like running/humming (nasal consonants)
consonant1 = stem1[-1]
consonant2 = stem2[-1]
# Check if they're in the same group
if (consonant1 in nasals and consonant2 in nasals) or \
(consonant1 in stops and consonant2 in stops) or \
(consonant1 in fricatives and consonant2 in fricatives):
special_pattern_score += 0.2
# Check for double consonants (like nn in running, mm in cumming)
if len(stem1) >= 2 and stem1[-1] == stem1[-2] and \
len(stem2) >= 2 and stem2[-1] == stem2[-2]:
special_pattern_score += 0.3
# Length and stress similarity (5% each)
phone_diff = abs(len(phone_list1) - len(phone_list2))
max_phones = max(len(phone_list1), len(phone_list2))
length_score = 1.0 if phone_diff == 0 else 1.0 - (phone_diff / max_phones)
# Check stress pattern similarity
import pronouncing
stress1 = pronouncing.stresses(phones1)
stress2 = pronouncing.stresses(phones2)
stress_score = 1.0 if stress1 == stress2 else 0.5
# Front consonant match (5% of score)
front_score = front_consonant_score * 0.05
# Weighted combination
similarity = (
(rhyme_score * 0.40) + # End rhyme (40%)
(primary_vowel_score * 0.15) + # Primary vowel (15%)
(near_rhyme_score * 0.15) + # Near rhyme features (15%)
(special_pattern_score * 0.15) + # Special pattern match (15%)
(length_score * 0.05) + # Length similarity (5%)
(stress_score * 0.05) + # Stress pattern (5%)
(front_score) # Front consonants (5%)
)
# Additional boost for specific word patterns that make great parody matches
# This specifically addresses running/cumming type pairs
if word1.endswith('ing') and word2.endswith('ing'):
base1 = word1[:-3]
base2 = word2[:-3]
# Specific pattern for words like running/cunning/cumming
if (len(base1) == 3 and len(base2) == 3 and
base1[0] != base2[0] and # Different first consonant (good for parody)
len(base1) >= 2 and len(base2) >= 2 and
base1[-1] == base1[-2] and # Double consonant in first word (nn in running)
base2[-1] == base2[-2]): # Double consonant in second word (mm in cumming)
similarity = max(similarity, 0.9) # These are excellent parody matches
# Cap at 1.0
similarity = min(1.0, similarity)
return {
"similarity": round(similarity, 3),
"rhyme_score": round(rhyme_score, 3),
"primary_vowel_score": round(primary_vowel_score, 3),
"near_rhyme_score": round(near_rhyme_score, 3),
"special_pattern_score": round(special_pattern_score, 3),
"length_score": round(length_score, 3),
"stress_score": round(stress_score, 3),
"front_consonant_score": round(front_consonant_score, 3),
"phone_length_difference": phone_diff
}
def forward(self, target: str, word_list_str: str, min_similarity: str = "0.6", custom_phones: dict = None) -> str:
import pronouncing
import string
import json
# Initialize all variables
target = target.lower().strip(string.punctuation)
min_similarity = float(min_similarity)
suggestions = []
valid_words = []
invalid_words = []
words = []
target_phones = ""
target_phone_list = []
target_vowel = None
target_end = []
word = ""
word_phones = ""
word_phone_list = []
word_vowel = None
word_end = []
similarity_result = {}
# Parse JSON string to list
try:
words = json.loads(word_list_str)
except json.JSONDecodeError:
return json.dumps({
"error": "Invalid JSON string for word_list_str",
"suggestions": []
}, indent=2)
# Get target pronunciation
target_phones = self._get_word_phones(target, custom_phones)
if not target_phones:
return json.dumps({
"error": f"Target word '{target}' not found in dictionary or custom phones",
"suggestions": []
}, indent=2)
# Parse target phones
target_phone_list = target_phones.split()
target_vowel, target_end = self._get_last_syllable(target_phone_list)
# Filter word list
for word in words:
word = word.lower().strip(string.punctuation)
if self._get_word_phones(word, custom_phones):
valid_words.append(word)
else:
invalid_words.append(word)
if not valid_words:
return json.dumps({
"error": "No valid words found in dictionary or custom phones",
"invalid_words": invalid_words,
"suggestions": []
}, indent=2)
# Check each word
for word in valid_words:
word_phones = self._get_word_phones(word, custom_phones)
if word_phones:
similarity_result = self._calculate_similarity(word, word_phones, target, target_phones)
if similarity_result["similarity"] >= min_similarity:
word_phone_list = word_phones.split()
word_vowel, word_end = self._get_last_syllable(word_phone_list)
suggestions.append({
"word": word,
"similarity": similarity_result["similarity"],
"rhyme_score": similarity_result["rhyme_score"],
"primary_vowel_score": similarity_result["primary_vowel_score"],
"near_rhyme_score": similarity_result["near_rhyme_score"],
"special_pattern_score": similarity_result.get("special_pattern_score", 0),
"length_score": similarity_result["length_score"],
"stress_score": similarity_result["stress_score"],
"front_consonant_score": similarity_result["front_consonant_score"],
"phones": word_phones,
"last_vowel": word_vowel,
"ending": " ".join(word_end) if word_end else "",
"is_custom": word in custom_phones if custom_phones else False
})
# Sort by similarity score descending
suggestions.sort(key=lambda x: x["similarity"], reverse=True)
result = {
"target": target,
"target_phones": target_phones,
"target_last_vowel": target_vowel,
"target_ending": " ".join(target_end) if target_end else "",
"invalid_words": invalid_words,
"suggestions": suggestions
}
return json.dumps(result, indent=2)
def __init__(self, *args, **kwargs):
self.is_initialized = False