Skip to content

Commit d92a681

Browse files
FEAT: stream action plan response from backend (#124)
Co-authored-by: Yoom Lam <yoom@navapbc.com>
1 parent 2e0cc86 commit d92a681

4 files changed

Lines changed: 160 additions & 44 deletions

File tree

app/src/common/components.py

Lines changed: 76 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
from haystack.core.component.types import Variadic
2121
from haystack.dataclasses.byte_stream import ByteStream
2222
from haystack.dataclasses.chat_message import ChatMessage
23+
from haystack.dataclasses.streaming_chunk import StreamingChunk
2324
from openai import OpenAI
2425
from pydantic import BaseModel, ValidationError
2526

@@ -187,13 +188,22 @@ def run(self, result_id: str) -> dict:
187188
class OpenAIWebSearchGenerator:
188189
"""Searches the web using OpenAI's web search capabilities and generates a response."""
189190

191+
def __init__(self) -> None:
192+
"""
193+
Initialize the OpenAI web search generator.
194+
"""
195+
196+
# Declare this attribute so it can be set when streaming_generator() is called
197+
self.streaming_callback: Callable | None = None
198+
190199
@component.output_types(replies=List[ChatMessage])
191200
def run(
192201
self,
193202
messages: list[ChatMessage],
194203
domain: str | None = None,
195204
model: str = config.default_openai_model_version,
196205
reasoning_effort: str = config.default_openai_reasoning_level,
206+
streaming: bool = False,
197207
) -> dict:
198208
"""
199209
Run the OpenAI web search generator.
@@ -207,10 +217,11 @@ def run(
207217
"""
208218

209219
logger.info(
210-
"Calling OpenAI API with web_search, model=%s, domain=%s, reasoning_effort=%s",
220+
"Calling OpenAI API with web_search, model=%s, domain=%s, reasoning_effort=%s, streaming=%s",
211221
model,
212222
domain,
213223
reasoning_effort,
224+
streaming,
214225
)
215226

216227
assert len(messages) == 1
@@ -228,11 +239,72 @@ def run(
228239
api_params["tools"][0]["filters"] = {"allowed_domains": [domain]}
229240

230241
client = OpenAI()
231-
response = client.responses.create(**api_params)
232242

233-
logger.debug("Response: %s", pformat(response.output_text, width=160))
243+
# Use streaming if callback is provided
244+
if streaming:
245+
logger.info(
246+
"Starting OpenAI streaming request (model=%s, reasoning_effort=%s)",
247+
model,
248+
reasoning_effort,
249+
)
250+
api_params["stream"] = True
234251

235-
return {"replies": [ChatMessage.from_assistant(response.output_text)]}
252+
try:
253+
response = client.responses.create(**api_params)
254+
except Exception as e:
255+
logger.error("Failed to create OpenAI stream: %s", e, exc_info=True)
256+
raise
257+
258+
# Collect full response while streaming
259+
full_text = ""
260+
chunk_count = 0
261+
262+
try:
263+
for openai_chunk in response:
264+
chunk_count += 1
265+
chunk_text = ""
266+
267+
# Extract text from OpenAI Responses API events
268+
if hasattr(openai_chunk, "type"):
269+
# Check delta attribute (for text delta events)
270+
if not chunk_text and hasattr(openai_chunk, "delta"):
271+
delta = openai_chunk.delta
272+
if isinstance(delta, str):
273+
chunk_text = delta
274+
elif isinstance(delta, list):
275+
chunk_text = "".join(str(item) for item in delta)
276+
elif hasattr(delta, "content"):
277+
chunk_text = delta.content or ""
278+
elif hasattr(delta, "text"):
279+
chunk_text = delta.text or ""
280+
281+
# Fallback for non-Responses API format
282+
if not chunk_text and hasattr(openai_chunk, "output_text"):
283+
chunk_text = openai_chunk.output_text or ""
284+
285+
if chunk_text:
286+
full_text += chunk_text
287+
# Convert to Haystack StreamingChunk and call the callback
288+
streaming_chunk = StreamingChunk(content=chunk_text)
289+
assert (
290+
self.streaming_callback is not None
291+
), "Expected streaming_callback to be set by Hayhooks"
292+
self.streaming_callback(streaming_chunk)
293+
294+
except Exception as e:
295+
logger.error("Error during streaming: %s", e, exc_info=True)
296+
raise
297+
298+
logger.info("Streaming complete: %d chunks, %d characters", chunk_count, len(full_text))
299+
if not full_text:
300+
logger.warning("No text collected during streaming")
301+
302+
return {"replies": [ChatMessage.from_assistant(full_text)]}
303+
else:
304+
# Non-streaming response
305+
response = client.responses.create(**api_params)
306+
logger.debug("Response: %s", pformat(response.output_text, width=160))
307+
return {"replies": [ChatMessage.from_assistant(response.output_text)]}
236308

237309

238310
EMAIL_INTRO = """\

app/src/common/haystack_utils.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
import hayhooks
55
from haystack import Pipeline
66
from haystack.dataclasses.chat_message import ChatMessage
7-
from openinference.instrumentation import using_metadata
7+
from openinference.instrumentation import using_attributes
88
from opentelemetry.trace import Span
99
from opentelemetry.trace.status import Status, StatusCode
1010
from phoenix.client.__generated__ import v1
@@ -74,12 +74,13 @@ def stream_response(
7474
self,
7575
pipeline_run_args: dict,
7676
*,
77+
user_id: str,
7778
metadata: dict[str, Any],
7879
input_: Any | None = None,
7980
shorten_output: Callable[[str], str] = lambda resp: resp,
8081
) -> Generator:
81-
with using_metadata(metadata):
82-
# Must set using_metadata context before calling tracer.start_as_current_span()
82+
# Must set using attributes and metadata tracer context before calling tracer.start_as_current_span()
83+
with using_attributes(user_id=user_id, metadata=metadata):
8384
with phoenix_utils.tracer().start_as_current_span( # pylint: disable=not-context-manager,unexpected-keyword-arg
8485
self.parent_span_name, openinference_span_kind="chain"
8586
) as span:
@@ -120,13 +121,14 @@ def return_response(
120121
self,
121122
pipeline_run_args: dict,
122123
*,
124+
user_id: str,
123125
metadata: dict[str, Any],
124126
input_: Any | None = None,
125127
include_outputs_from: set[str] | None = None,
126128
extract_output: Callable[[Any], Any] = lambda resp: resp,
127129
) -> dict:
128130
# Must set using_metadata context before calling tracer.start_as_current_span()
129-
with using_metadata(metadata):
131+
with using_attributes(user_id=user_id, metadata=metadata):
130132
with phoenix_utils.tracer().start_as_current_span( # pylint: disable=not-context-manager,unexpected-keyword-arg
131133
self.parent_span_name, openinference_span_kind="chain"
132134
) as span:

app/src/pipelines/generate_action_plan/pipeline_wrapper.py

Lines changed: 76 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,14 @@
11
import logging
22
from pprint import pformat
3+
from typing import Generator
34

45
from hayhooks import BasePipelineWrapper
56
from haystack import Pipeline
67
from haystack.components.builders import ChatPromptBuilder
7-
from openinference.instrumentation import _tracers, using_attributes, using_metadata
8-
from opentelemetry.trace.status import Status, StatusCode
98
from pydantic import BaseModel
109

1110
from src.app_config import config
12-
from src.common import haystack_utils, phoenix_utils
11+
from src.common import haystack_utils
1312
from src.common.components import (
1413
LlmOutputValidator,
1514
OpenAIWebSearchGenerator,
@@ -19,7 +18,6 @@
1918
from src.pipelines.generate_referrals.pipeline_wrapper import Resource
2019

2120
logger = logging.getLogger(__name__)
22-
tracer = phoenix_utils.tracer_provider.get_tracer(__name__)
2321

2422

2523
class ActionPlan(BaseModel):
@@ -67,51 +65,94 @@ def setup(self) -> None:
6765
pipeline.connect("llm", "logger")
6866

6967
self.pipeline = pipeline
68+
self.runner = haystack_utils.TracedPipelineRunner(self.name, self.pipeline)
7069

7170
# Called for the `generate-action-plan/run` endpoint
7271
def run_api(
7372
self, resources: list[Resource] | list[dict], user_email: str, user_query: str
7473
) -> dict:
7574
resource_objects = get_resources(resources)
76-
77-
with using_attributes(user_id=user_email), using_metadata({"user_id": user_email}):
78-
# Must set using_metadata context before calling tracer.start_as_current_span()
79-
assert isinstance(tracer, _tracers.OITracer), f"Got unexpected {type(tracer)}"
80-
with tracer.start_as_current_span( # pylint: disable=not-context-manager,unexpected-keyword-arg
81-
self.name, openinference_span_kind="chain"
82-
) as span:
83-
result = self._run(resource_objects, user_email, user_query)
84-
span.set_input([r.name for r in resource_objects])
85-
span.set_output(result["response"])
86-
span.set_status(Status(StatusCode.OK))
87-
return result
88-
89-
def _run(self, resource_objects: list[Resource], user_email: str, user_query: str) -> dict:
90-
response = self.pipeline.run(
91-
{
92-
"logger": {
93-
"messages_list": [
94-
{"resource_count": len(resource_objects), "user_email": user_email}
95-
],
96-
},
97-
"prompt_builder": {
98-
"resources": format_resources(resource_objects),
99-
"action_plan_json": action_plan_as_json,
100-
"user_query": user_query,
101-
},
102-
"llm": {
103-
"model": config.generate_action_plan_model_version,
104-
"reasoning_effort": config.generate_action_plan_reasoning_level,
105-
},
106-
},
75+
pipeline_run_args = self.create_pipeline_args(
76+
user_email,
77+
resource_objects,
78+
user_query,
79+
)
80+
response = self.runner.return_response(
81+
pipeline_run_args,
82+
user_id=user_email,
83+
metadata={"user_id": user_email},
10784
include_outputs_from={"llm", "save_result"},
85+
input_=[r.name for r in resource_objects],
86+
extract_output=lambda response: response["llm"]["replies"][0]._content[0].text,
10887
)
10988
logger.debug("Results: %s", pformat(response, width=160))
89+
11090
return {
11191
"response": response["llm"]["replies"][0]._content[0].text,
11292
"save_result": response["save_result"],
11393
}
11494

95+
def create_pipeline_args(
96+
self,
97+
user_email: str,
98+
resource_objects: list[Resource],
99+
user_query: str,
100+
*,
101+
llm_model: str | None = None,
102+
reasoning_effort: str | None = None,
103+
streaming: bool = False,
104+
) -> dict:
105+
return {
106+
"logger": {
107+
"messages_list": [
108+
{"resource_count": len(resource_objects), "user_email": user_email}
109+
],
110+
},
111+
"prompt_builder": {
112+
"resources": format_resources(resource_objects),
113+
"action_plan_json": action_plan_as_json,
114+
"user_query": user_query,
115+
},
116+
"llm": {
117+
"model": llm_model or config.generate_action_plan_model_version,
118+
"reasoning_effort": reasoning_effort or config.generate_action_plan_reasoning_level,
119+
"streaming": streaming,
120+
},
121+
}
122+
123+
# https://docs.haystack.deepset.ai/docs/hayhooks#openai-compatibility
124+
# Called for the `{pipeline_name}/chat`, `/chat/completions`, or `/v1/chat/completions` streaming endpoint using Server-Sent Events (SSE)
125+
def run_chat_completion(self, model: str, messages: list, body: dict) -> Generator:
126+
# Note: 'model' parameter is the pipeline name, not the LLM model
127+
assert model == self.name, f"Unexpected model/pipeline name: {model}"
128+
129+
# Extract custom parameters from the body
130+
resources = body.get("resources", [])
131+
user_email = body.get("user_email", "")
132+
user_query = body.get("user_query", "")
133+
134+
if not resources:
135+
raise ValueError("resources parameter is required")
136+
if not user_email:
137+
raise ValueError("user_email parameter is required")
138+
139+
resource_objects = get_resources(resources)
140+
pipeline_run_args = self.create_pipeline_args(
141+
user_email,
142+
resource_objects,
143+
user_query,
144+
llm_model=body.get("llm_model", None),
145+
reasoning_effort=body.get("reasoning_effort", None),
146+
streaming=True,
147+
)
148+
logger.info("Streaming action plan: %s", pipeline_run_args)
149+
return self.runner.stream_response(
150+
pipeline_run_args,
151+
user_id=user_email,
152+
metadata={"user_id": user_email},
153+
input_=[r.name for r in resource_objects],
154+
)
155+
115156

116157
def get_resources(resources: list[Resource] | list[dict]) -> list[Resource]:
117158
"""Ensure we have a list of Resource objects."""

app/src/pipelines/sample_pipeline/pipeline_wrapper.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ def run_api(self, question: str) -> dict:
5656
}
5757
response = self.runner.return_response(
5858
pipeline_run_args,
59+
user_id=user_id,
5960
metadata={"user_id": user_id},
6061
include_outputs_from={"echo_component", "echo_component2"},
6162
input_=question,
@@ -87,7 +88,7 @@ def run_chat_completion(self, model: str, messages: list, body: dict) -> Generat
8788

8889
user_id = "someone@example.com"
8990
return self.runner.stream_response(
90-
pipeline_run_args, metadata={"user_id": user_id}, input_=question
91+
pipeline_run_args, user_id=user_id, metadata={"user_id": user_id}, input_=question
9192
)
9293

9394
def create_pipeline_args(self, location: str, messages: list[ChatMessage]) -> dict:

0 commit comments

Comments
 (0)