3939logger = logging .getLogger (__name__ )
4040
4141
42- def format_resources (resources : list [dict ]) -> str :
43- return "\n \n " .join ([format_resource (resource ) for resource in resources ])
44-
45-
46- def format_resource (resource : dict ) -> str :
47- return "\n " .join (
48- [
49- f"### { resource .get ('name' , 'Unnamed Resource' )} " ,
50- f"- Referral Type: { resource .get ('referral_type' , 'None' )} " ,
51- f"- Description: { resource .get ('description' , 'None' )} " ,
52- f"- Website: { resource .get ('website' , 'None' )} " ,
53- f"- Phone: { ', ' .join (resource .get ('phones' , ['None' ]))} " ,
54- f"- Email: { ', ' .join (resource .get ('emails' , ['None' ]))} " ,
55- f"- Addresses: { ', ' .join (resource .get ('addresses' , ['None' ]))} " ,
56- ]
57- )
58-
59-
60- def format_action_plan (action_plan : dict ) -> str :
61- """Format the action plan for email display. Returns empty string if no action plan."""
62- if not action_plan :
63- return ""
64-
65- title = action_plan .get ("title" , "Your Action Plan" )
66- summary = action_plan .get ("summary" , "" )
67- content = action_plan .get ("content" , "" )
68-
69- parts = [f"## { title } " ]
70- if summary :
71- parts .append (f"\n { summary } " )
72- if content :
73- parts .append (f"\n { content } " )
74-
75- return "\n " .join (parts )
76-
77-
7842@component
7943class EchoNode :
8044 """
@@ -213,6 +177,7 @@ def __init__(self) -> None:
213177 Initialize the OpenAI web search generator.
214178 """
215179
180+ self .client = OpenAI ()
216181 # Declare this attribute so it can be set when streaming_generator() is called
217182 self .streaming_callback : Callable | None = None
218183
@@ -224,13 +189,18 @@ def run(
224189 model : str = config .default_openai_model_version ,
225190 reasoning_effort : str = config .default_openai_reasoning_level ,
226191 streaming : bool = False ,
192+ temperature : float = 1.0 ,
227193 ) -> dict :
228194 """
229195 Run the OpenAI web search generator.
230196
231197 Args:
232198 messages: List of ChatMessage objects to send to the API
233199 domain: Domain to restrict web search to
200+ model: LLM model to use
201+ reasoning_effort: Reasoning effort level
202+ streaming: Whether to use streaming response
203+ temperature: temperature for the LLM
234204
235205 Returns:
236206 Dictionary with response key containing string of response
@@ -253,15 +223,12 @@ def run(
253223 "input" : prompt ,
254224 "reasoning" : {"effort" : reasoning_effort },
255225 "tools" : [{"type" : "web_search" }],
256- # Add other parameters, like temperature
226+ "temperature" : temperature ,
257227 }
258228
259229 if domain :
260230 api_params ["tools" ][0 ]["filters" ] = {"allowed_domains" : [domain ]}
261231
262- client = OpenAI ()
263-
264- # Use streaming if callback is provided
265232 if streaming :
266233 logger .info (
267234 "Starting OpenAI streaming request (model=%s, reasoning_effort=%s)" ,
@@ -271,70 +238,15 @@ def run(
271238 api_params ["stream" ] = True
272239
273240 try :
274- response = client .responses .create (** api_params )
275- except Exception as e :
276- logger .error ("Failed to create OpenAI stream: %s" , e , exc_info = True )
277- raise
278-
279- # Collect full response while streaming
280- full_text = ""
281- chunk_count = 0
282-
283- try :
284- for openai_chunk in response :
285- chunk_count += 1
286- chunk_text = ""
287-
288- # Extract text from OpenAI Responses API events
289- if hasattr (openai_chunk , "type" ):
290- # Check delta attribute (for text delta events)
291- if not chunk_text and hasattr (openai_chunk , "delta" ):
292- delta = openai_chunk .delta
293- if isinstance (delta , str ):
294- chunk_text = delta
295- elif isinstance (delta , list ):
296- chunk_text = "" .join (str (item ) for item in delta )
297- elif hasattr (delta , "content" ):
298- chunk_text = delta .content or ""
299- elif hasattr (delta , "text" ):
300- chunk_text = delta .text or ""
301-
302- # Fallback for non-Responses API format
303- if not chunk_text and hasattr (openai_chunk , "output_text" ):
304- chunk_text = openai_chunk .output_text or ""
305-
306- if chunk_text :
307- full_text += chunk_text
308- # Convert to Haystack StreamingChunk and call the callback
309- streaming_chunk = StreamingChunk (content = chunk_text )
310- assert (
311- self .streaming_callback is not None
312- ), "Expected streaming_callback to be set by Hayhooks"
313- self .streaming_callback (streaming_chunk )
314-
315- # Capture metadata from OpenAI chunk; handle each type of Response*Event
316- if isinstance (openai_chunk , ResponseOutputItemDoneEvent ):
317- if isinstance (openai_chunk .item , ResponseFunctionWebSearch ):
318- self ._add_child_spans ([openai_chunk .item ])
319- elif isinstance (openai_chunk , ResponseCreatedEvent ):
320- resp = openai_chunk .response
321- span = trace .get_current_span ()
322- span .set_attribute ("model" , str (resp .model ))
323- span .set_attribute ("reasoning_effort" , str (resp .reasoning ))
324- span .set_attribute ("temperature" , str (resp .temperature ))
325-
241+ response = self .client .responses .create (** api_params )
242+ full_text = self ._stream_response (response )
243+ return {"replies" : [ChatMessage .from_assistant (full_text )]}
326244 except Exception as e :
327- logger .error ("Error during streaming : %s" , e , exc_info = True )
245+ logger .error ("Failed to stream response : %s" , e , exc_info = True )
328246 raise
329-
330- logger .info ("Streaming complete: %d chunks, %d characters" , chunk_count , len (full_text ))
331- if not full_text :
332- logger .warning ("No text collected during streaming" )
333-
334- return {"replies" : [ChatMessage .from_assistant (full_text )]}
335247 else :
336248 # Non-streaming response
337- response = client .responses .create (** api_params )
249+ response = self . client .responses .create (** api_params )
338250
339251 web_search_responses = [
340252 item for item in response .output if isinstance (item , ResponseFunctionWebSearch )
@@ -348,6 +260,58 @@ def run(
348260 "web_search" : [str (result ) for result in web_search_responses ],
349261 }
350262
263+ def _stream_response (self , response : Any ) -> str :
264+ # Collect full response while streaming
265+ full_text = ""
266+ chunk_count = 0
267+
268+ for openai_chunk in response :
269+ chunk_count += 1
270+ chunk_text = ""
271+
272+ # Extract text from OpenAI Responses API events
273+ if hasattr (openai_chunk , "type" ):
274+ # Check delta attribute (for text delta events)
275+ if not chunk_text and hasattr (openai_chunk , "delta" ):
276+ delta = openai_chunk .delta
277+ if isinstance (delta , str ):
278+ chunk_text = delta
279+ elif isinstance (delta , list ):
280+ chunk_text = "" .join (str (item ) for item in delta )
281+ elif hasattr (delta , "content" ):
282+ chunk_text = delta .content or ""
283+ elif hasattr (delta , "text" ):
284+ chunk_text = delta .text or ""
285+
286+ # Fallback for non-Responses API format
287+ if not chunk_text and hasattr (openai_chunk , "output_text" ):
288+ chunk_text = openai_chunk .output_text or ""
289+
290+ if chunk_text :
291+ full_text += chunk_text
292+ # Convert to Haystack StreamingChunk and call the callback
293+ streaming_chunk = StreamingChunk (content = chunk_text )
294+ assert (
295+ self .streaming_callback is not None
296+ ), "Expected streaming_callback to be set by Hayhooks"
297+ self .streaming_callback (streaming_chunk )
298+
299+ # Capture metadata from OpenAI chunk; handle each type of Response*Event
300+ if isinstance (openai_chunk , ResponseOutputItemDoneEvent ):
301+ if isinstance (openai_chunk .item , ResponseFunctionWebSearch ):
302+ self ._add_child_spans ([openai_chunk .item ])
303+ elif isinstance (openai_chunk , ResponseCreatedEvent ):
304+ resp = openai_chunk .response
305+ span = trace .get_current_span ()
306+ span .set_attribute ("model" , str (resp .model ))
307+ span .set_attribute ("reasoning_effort" , str (resp .reasoning ))
308+ span .set_attribute ("temperature" , str (resp .temperature ))
309+
310+ logger .info ("Streaming complete: %d chunks, %d characters" , chunk_count , len (full_text ))
311+ if not full_text :
312+ logger .warning ("No text collected during streaming" )
313+ return full_text
314+
351315 def _add_child_spans (self , web_search_responses : list [ResponseFunctionWebSearch ]) -> None :
352316 for tool_call in web_search_responses :
353317 with phoenix_utils .tracer ().start_as_current_span ( # pylint: disable=not-context-manager,unexpected-keyword-arg
@@ -422,6 +386,11 @@ def run(self, email: str, resources_dict: dict, action_plan_dict: dict) -> dict:
422386
423387 # Validate that at least one type of content is provided
424388 if not has_resources and not has_action_plan :
389+ logger .error (
390+ "EmailResponses: No content to email resources_dict=%r, action_plan_dict=%r" ,
391+ resources_dict ,
392+ action_plan_dict ,
393+ )
425394 raise ValueError (
426395 "At least one of resources_dict or action_plan_dict must contain valid data. "
427396 f"Received resources_dict={ bool (resources_dict )} , action_plan_dict={ bool (action_plan_dict )} "
@@ -441,11 +410,16 @@ def run(self, email: str, resources_dict: dict, action_plan_dict: dict) -> dict:
441410 message_parts = [EMAIL_INTRO ]
442411
443412 if has_resources :
444- formatted_resources = format_resources (resources_dict .get ("resources" , []))
413+ formatted_resources = "\n \n " .join (
414+ [
415+ self ._format_resource (resource )
416+ for resource in resources_dict .get ("resources" , [])
417+ ]
418+ )
445419 message_parts .append (formatted_resources )
446420
447421 if has_action_plan :
448- formatted_action_plan = format_action_plan (action_plan_dict )
422+ formatted_action_plan = self . _format_action_plan (action_plan_dict )
449423 message_parts .append (formatted_action_plan )
450424
451425 message = "\n \n " .join (message_parts )
@@ -465,11 +439,41 @@ def run(self, email: str, resources_dict: dict, action_plan_dict: dict) -> dict:
465439 logger .info ("Email send status: %s" , status )
466440 return {"status" : status , "email" : email , "message" : message }
467441
442+ def _format_resource (self , resource : dict ) -> str :
443+ return "\n " .join (
444+ [
445+ f"### { resource .get ('name' , 'Unnamed Resource' )} " ,
446+ f"- Referral Type: { resource .get ('referral_type' , 'None' )} " ,
447+ f"- Description: { resource .get ('description' , 'None' )} " ,
448+ f"- Website: { resource .get ('website' , 'None' )} " ,
449+ f"- Phone: { ', ' .join (resource .get ('phones' , ['None' ]))} " ,
450+ f"- Email: { ', ' .join (resource .get ('emails' , ['None' ]))} " ,
451+ f"- Addresses: { ', ' .join (resource .get ('addresses' , ['None' ]))} " ,
452+ ]
453+ )
454+
455+ def _format_action_plan (self , action_plan : dict ) -> str :
456+ """Format the action plan for email display. Returns empty string if no action plan."""
457+ if not action_plan :
458+ return ""
459+
460+ title = action_plan .get ("title" , "Your Action Plan" )
461+ summary = action_plan .get ("summary" , "" )
462+ content = action_plan .get ("content" , "" )
463+
464+ parts = [f"## { title } " ]
465+ if summary :
466+ parts .append (f"\n { summary } " )
467+ if content :
468+ parts .append (f"\n { content } " )
469+
470+ return "\n " .join (parts )
471+
468472
469473BaseModelT = TypeVar ("BaseModelT" , bound = BaseModel )
470474
471475
472- # TODO: Replace with https://docs.haystack.deepset.ai/docs/jsonschemavalidator
476+ # Consider replacing this with https://docs.haystack.deepset.ai/docs/jsonschemavalidator
473477@component
474478class LlmOutputValidator :
475479 def __init__ (self , pydantic_model : type [BaseModelT ]):
0 commit comments