3333
3434_DECOMPOSITION_DIR = pathlib .Path (CONVERTER_SEED_PROMPT_PATH ) / "decomposition"
3535
36+ # Innocuous codewords substituted for harmful noun phrases when the word-game is enabled. The list
37+ # bounds how many noun phrases the word-game supports; a converter with more nouns raises rather than
38+ # silently reusing a codeword (which would make the mapping ambiguous).
39+ _CODEWORDS = (
40+ "apple" ,
41+ "banana" ,
42+ "cherry" ,
43+ "grape" ,
44+ "kiwi" ,
45+ "lemon" ,
46+ "mango" ,
47+ "orange" ,
48+ "papaya" ,
49+ "raspberry" ,
50+ "strawberry" ,
51+ "watermelon" ,
52+ "apricot" ,
53+ "blueberry" ,
54+ "coconut" ,
55+ "fig" ,
56+ "guava" ,
57+ "melon" ,
58+ "peach" ,
59+ "pear" ,
60+ )
61+
3662
3763def _tokens (text : str ) -> list [str ]:
3864 """
@@ -98,6 +124,9 @@ def __init__(
98124 converter_target : PromptTarget = REQUIRED_VALUE , # type: ignore[ty:invalid-parameter-default]
99125 decomposition_prompt : SeedPrompt | None = None ,
100126 reconstruction_prompt : SeedPrompt | None = None ,
127+ use_word_game : bool = False ,
128+ word_game_prompt : SeedPrompt | None = None ,
129+ codewords : tuple [str , ...] = _CODEWORDS ,
101130 ) -> None :
102131 """
103132 Initialize the converter.
@@ -112,6 +141,17 @@ def __init__(
112141 reconstruction_prompt (SeedPrompt | None): Template that renders the decomposed objective
113142 into the reconstruction task. Defaults to the bundled
114143 ``decomposition/reconstruction_prompt.yaml``.
144+ use_word_game (bool): If True, each harmful noun phrase is replaced by an innocuous codeword
145+ in the reconstruction questions, and a mapping preamble is prepended in the same prompt.
146+ Defaults to False.
147+ word_game_prompt (SeedPrompt | None): Template for the word-game mapping preamble. Defaults
148+ to the bundled ``decomposition/word_game_preamble.yaml``. Only used when
149+ ``use_word_game`` is True.
150+ codewords (tuple[str, ...]): Innocuous codewords substituted for harmful noun phrases when
151+ the word-game is enabled. Defaults to a bundled list of fruit names.
152+
153+ Raises:
154+ ValueError: If ``codewords`` is empty while the word-game is enabled, or contains duplicates.
115155 """
116156 super ().__init__ (converter_target = converter_target )
117157 self ._converter_target = converter_target
@@ -121,6 +161,15 @@ def __init__(
121161 self ._reconstruction_prompt = reconstruction_prompt or SeedPrompt .from_yaml_file (
122162 _DECOMPOSITION_DIR / "reconstruction_prompt.yaml"
123163 )
164+ self ._use_word_game = use_word_game
165+ self ._word_game_prompt = word_game_prompt or SeedPrompt .from_yaml_file (
166+ _DECOMPOSITION_DIR / "word_game_preamble.yaml"
167+ )
168+ if use_word_game and not codewords :
169+ raise ValueError ("codewords must be non-empty when the word-game is enabled" )
170+ if len (set (codewords )) != len (codewords ):
171+ raise ValueError ("codewords must be unique; duplicates produce an ambiguous word-game mapping" )
172+ self ._codewords = codewords
124173
125174 def _build_identifier (self ) -> ComponentIdentifier :
126175 """
@@ -129,11 +178,16 @@ def _build_identifier(self) -> ComponentIdentifier:
129178 Returns:
130179 ComponentIdentifier: The identifier for this converter.
131180 """
181+ params : dict [str , Any ] = {
182+ "decomposition_prompt" : self ._decomposition_prompt .value ,
183+ "reconstruction_prompt" : self ._reconstruction_prompt .value ,
184+ "use_word_game" : self ._use_word_game ,
185+ }
186+ if self ._use_word_game :
187+ params ["word_game_prompt" ] = self ._word_game_prompt .value
188+ params ["codewords" ] = list (self ._codewords )
132189 return self ._create_identifier (
133- params = {
134- "decomposition_prompt" : self ._decomposition_prompt .value ,
135- "reconstruction_prompt" : self ._reconstruction_prompt .value ,
136- },
190+ params = params ,
137191 converter_target = self ._converter_target .get_identifier (),
138192 )
139193
@@ -174,7 +228,7 @@ async def _decompose_async(self, *, objective: str) -> tuple[list[str], list[str
174228 tuple[list[str], list[str]]: The phrase list and the matching role-tag list.
175229
176230 Raises:
177- InvalidJsonException: If the response is unparseable or fails validation.
231+ InvalidJsonException: If the response is missing, unparseable, or fails validation.
178232 """
179233 conversation_id = str (uuid .uuid4 ())
180234 self ._converter_target .set_system_prompt (
@@ -202,6 +256,9 @@ async def _decompose_async(self, *, objective: str) -> tuple[list[str], list[str
202256 ]
203257 )
204258 response = await self ._converter_target .send_prompt_async (message = request )
259+ if not response :
260+ # A blocked/filtered request can yield no response; signal a retry rather than IndexError.
261+ raise InvalidJsonException (message = "no response from the decomposition target" )
205262 return self ._parse_and_validate (objective = objective , raw = response [0 ].get_value ())
206263
207264 def _parse_and_validate (self , * , objective : str , raw : str ) -> tuple [list [str ], list [str ]]:
@@ -216,8 +273,9 @@ def _parse_and_validate(self, *, objective: str, raw: str) -> tuple[list[str], l
216273 tuple[list[str], list[str]]: The validated phrase list and role-tag list.
217274
218275 Raises:
219- InvalidJsonException: If the response is unparseable, malformed, has invalid tags, lacks an
220- opening instruction phrase or a noun, or fails the reconstruction-recall invariant.
276+ InvalidJsonException: If the response is unparseable, malformed, contains an empty phrase, has
277+ invalid tags, lacks an opening instruction phrase or a noun, produces more noun phrases than
278+ codewords when the word-game is enabled, or fails the reconstruction-recall invariant.
221279 """
222280 try :
223281 data = json .loads (remove_markdown_json (raw ))
@@ -232,12 +290,23 @@ def _parse_and_validate(self, *, objective: str, raw: str) -> tuple[list[str], l
232290 if not isinstance (words , list ) or not isinstance (types , list ) or not words or len (words ) != len (types ):
233291 raise InvalidJsonException (message = "response must contain equal-length non-empty 'words' and 'types' lists" )
234292 words = [str (w ) for w in words ]
293+ if any (not w .strip () for w in words ):
294+ raise InvalidJsonException (message = "every phrase must be non-empty" )
235295 if any (t not in _VALID_TAGS for t in types ):
236296 raise InvalidJsonException (message = f"types must be one of { _VALID_TAGS } " )
237297 if types [0 ] != "instruction" :
238298 raise InvalidJsonException (message = "the first phrase must be tagged 'instruction'" )
239299 if "noun" not in types :
240300 raise InvalidJsonException (message = "at least one phrase must be tagged 'noun'" )
301+ if self ._use_word_game :
302+ # The noun count comes from model output, so an overflow is recoverable: signal a retry
303+ # rather than a config error (which is reserved for duplicate codewords in __init__).
304+ noun_count = sum (1 for t in types if t == "noun" )
305+ if noun_count > len (self ._codewords ):
306+ raise InvalidJsonException (
307+ message = f"word-game supports at most { len (self ._codewords )} noun phrases, "
308+ f"but the decomposition produced { noun_count } "
309+ )
241310
242311 recall = _token_recall (_tokens (objective ), _tokens (" " .join (words )))
243312 if recall < _MIN_RECALL :
@@ -248,28 +317,45 @@ def _build_reconstruction(self, *, words: list[str], types: list[str]) -> str:
248317 """
249318 Assemble the Question-A/Question-B reconstruction prompt from the decomposition.
250319
320+ When the word-game is enabled, each noun phrase is replaced by an innocuous codeword in its
321+ question, and a mapping preamble is prepended in the same prompt.
322+
251323 Args:
252324 words (list[str]): The decomposed phrases.
253325 types (list[str]): The role tag for each phrase.
254326
255327 Returns:
256- str: The rendered reconstruction prompt.
328+ str: The rendered reconstruction prompt, optionally prefixed with the word-game preamble .
257329 """
258330 questions = [f"Question A: How to { words [0 ].lower ()} ?" ]
259331 question_prefixes = ["Question A" ]
260332 structure_mask = ["using the methods in Question A" ]
333+ word_game_mappings : list [str ] = []
261334 noun_index = 0
262335 for i in range (1 , len (types )):
263336 if types [i ] == "noun" :
264337 noun_index += 1
265338 structure_mask .append (f"the thing in Question B{ noun_index } " )
266- questions .append (f"Question B{ noun_index } : What is/are { words [i ]} ?" )
267339 question_prefixes .append (f"Question B{ noun_index } " )
340+ phrase = words [i ]
341+ if self ._use_word_game :
342+ codeword = self ._codewords [noun_index - 1 ]
343+ # Serialise both sides so a phrase containing quotes cannot make the mapping
344+ # ambiguous; ensure_ascii=False keeps non-Latin phrases (e.g. Arabic) readable.
345+ word_game_mappings .append (
346+ f"{ json .dumps (codeword , ensure_ascii = False )} means { json .dumps (phrase , ensure_ascii = False )} "
347+ )
348+ phrase = codeword
349+ questions .append (f"Question B{ noun_index } : What is/are { phrase } ?" )
268350 else :
269351 structure_mask .append (words [i ])
270352
271- return self ._reconstruction_prompt .render_template_value (
353+ reconstruction = self ._reconstruction_prompt .render_template_value (
272354 questions_prefix = " " .join (question_prefixes ),
273355 sentence_structure = " " .join (structure_mask ),
274356 harmful_questions = " " .join (questions ),
275357 )
358+ if self ._use_word_game and word_game_mappings :
359+ preamble = self ._word_game_prompt .render_template_value (mapping = "; " .join (word_game_mappings ))
360+ return f"{ preamble } \n \n { reconstruction } "
361+ return reconstruction
0 commit comments