Skip to content

Commit e0c3541

Browse files
michelle-hadfield-navaCopilotyoomlam
authored
FEAT: add prompt suffix handling, restore ActionPlan streaming (#145)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Yoom Lam <yoom@navapbc.com>
1 parent 16db068 commit e0c3541

6 files changed

Lines changed: 185 additions & 58 deletions

File tree

app/src/app_config.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,9 @@ def db_session(self) -> db.Session:
3939
# so they are not unique across different Phoenix instances.
4040
PROMPT_VERSIONS: dict = {
4141
"extract_supports": "UHJvbXB0VmVyc2lvbjo0Ng==",
42-
"generate_referrals": "UHJvbXB0VmVyc2lvbjo3NA==",
43-
"generate_action_plan": "UHJvbXB0VmVyc2lvbjo1Mg==",
42+
"generate_referrals": "UHJvbXB0VmVyc2lvbjo3NA==", # if no suffix, the default Austin area prompt will be used
43+
"generate_referrals_keystone": "UHJvbXB0VmVyc2lvbjo3Mg==",
44+
"generate_action_plan": "UHJvbXB0VmVyc2lvbjo3NQ==",
4445
"crawl_gcta": "UHJvbXB0VmVyc2lvbjozNg==",
4546
"crawl_indeed": "UHJvbXB0VmVyc2lvbjozNA==",
4647
}

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: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,19 @@
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
1111

1212
from src.common import phoenix_utils
1313

1414

15-
def get_phoenix_prompt(prompt_name: str, prompt_version_id: str = "") -> list[ChatMessage]:
16-
prompt_ver = phoenix_utils.get_prompt_template(prompt_name, prompt_version_id)
15+
def get_phoenix_prompt(
16+
prompt_name: str, prompt_version_id: str = "", suffix: str = ""
17+
) -> list[ChatMessage]:
18+
full_prompt_name = f"{prompt_name}_{suffix}" if suffix else prompt_name
19+
prompt_ver = phoenix_utils.get_prompt_template(full_prompt_name, prompt_version_id)
1720
return to_chat_messages(prompt_ver._template["messages"])
1821

1922

@@ -74,12 +77,13 @@ def stream_response(
7477
self,
7578
pipeline_run_args: dict,
7679
*,
80+
user_id: str,
7781
metadata: dict[str, Any],
7882
input_: Any | None = None,
7983
shorten_output: Callable[[str], str] = lambda resp: resp,
8084
) -> Generator:
81-
with using_metadata(metadata):
82-
# Must set using_metadata context before calling tracer.start_as_current_span()
85+
# Must set using attributes and metadata tracer context before calling tracer.start_as_current_span()
86+
with using_attributes(user_id=user_id, metadata=metadata):
8387
with phoenix_utils.tracer().start_as_current_span( # pylint: disable=not-context-manager,unexpected-keyword-arg
8488
self.parent_span_name, openinference_span_kind="chain"
8589
) as span:
@@ -120,13 +124,14 @@ def return_response(
120124
self,
121125
pipeline_run_args: dict,
122126
*,
127+
user_id: str,
123128
metadata: dict[str, Any],
124129
input_: Any | None = None,
125130
include_outputs_from: set[str] | None = None,
126131
extract_output: Callable[[Any], Any] = lambda resp: resp,
127132
) -> dict:
128133
# Must set using_metadata context before calling tracer.start_as_current_span()
129-
with using_metadata(metadata):
134+
with using_attributes(user_id=user_id, metadata=metadata):
130135
with phoenix_utils.tracer().start_as_current_span( # pylint: disable=not-context-manager,unexpected-keyword-arg
131136
self.parent_span_name, openinference_span_kind="chain"
132137
) as span:

app/src/pipelines/generate_action_plan/pipeline_wrapper.py

Lines changed: 83 additions & 39 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):
@@ -48,11 +46,9 @@ def setup(self) -> None:
4846
pipeline = Pipeline()
4947
pipeline.add_component("llm", create_websearch())
5048

51-
prompt_template = haystack_utils.get_phoenix_prompt("generate_action_plan")
5249
pipeline.add_component(
5350
instance=ChatPromptBuilder(
54-
template=prompt_template,
55-
required_variables=["resources", "action_plan_json", "user_query"],
51+
variables=["resources", "action_plan_json", "user_query"],
5652
),
5753
name="prompt_builder",
5854
)
@@ -67,51 +63,99 @@ def setup(self) -> None:
6763
pipeline.connect("llm", "logger")
6864

6965
self.pipeline = pipeline
66+
self.runner = haystack_utils.TracedPipelineRunner(self.name, self.pipeline)
7067

7168
# Called for the `generate-action-plan/run` endpoint
7269
def run_api(
73-
self, resources: list[Resource] | list[dict], user_email: str, user_query: str
70+
self,
71+
resources: list[Resource] | list[dict],
72+
user_email: str,
73+
user_query: str,
7474
) -> dict:
7575
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-
},
76+
pipeline_run_args = self.create_pipeline_args(
77+
user_email,
78+
resource_objects,
79+
user_query,
80+
)
81+
response = self.runner.return_response(
82+
pipeline_run_args,
83+
user_id=user_email,
84+
metadata={"user_id": user_email},
10785
include_outputs_from={"llm", "save_result"},
86+
input_=[r.name for r in resource_objects],
87+
extract_output=lambda response: response["llm"]["replies"][0]._content[0].text,
10888
)
10989
logger.debug("Results: %s", pformat(response, width=160))
90+
11091
return {
11192
"response": response["llm"]["replies"][0]._content[0].text,
11293
"save_result": response["save_result"],
11394
}
11495

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

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

app/src/pipelines/generate_referrals/pipeline_wrapper.py

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -100,14 +100,16 @@ def setup(self) -> None:
100100
self.pipeline = pipeline
101101

102102
# Called for the `generate-referrals/run` endpoint
103-
def run_api(self, query: str, user_email: str, prompt_version_id: str = "") -> dict:
103+
def run_api(
104+
self, query: str, user_email: str, prompt_version_id: str = "", suffix: str = ""
105+
) -> dict:
104106
with using_attributes(user_id=user_email), using_metadata({"user_id": user_email}):
105107
# Must set using_metadata context before calling tracer.start_as_current_span()
106108
assert isinstance(tracer, _tracers.OITracer), f"Got unexpected {type(tracer)}"
107109
with tracer.start_as_current_span( # pylint: disable=not-context-manager,unexpected-keyword-arg
108110
self.name, openinference_span_kind="chain"
109111
) as span:
110-
result = self._run(query, user_email, prompt_version_id)
112+
result = self._run(query, user_email, prompt_version_id, suffix)
111113
span.set_input(query)
112114
try:
113115
resp_obj = json.loads(result["llm"]["replies"][-1].text)
@@ -117,16 +119,18 @@ def run_api(self, query: str, user_email: str, prompt_version_id: str = "") -> d
117119
span.set_status(Status(StatusCode.OK))
118120
return result
119121

120-
def _run(self, query: str, user_email: str, prompt_version_id: str = "") -> dict:
121-
# Retrieve the requested prompt_version_id and error if requested prompt version is not found
122+
def _run(
123+
self, query: str, user_email: str, prompt_version_id: str = "", suffix: str = ""
124+
) -> dict:
125+
# Retrieve the requested prompt (with optional prompt_version_id and/or suffix)
122126
try:
123127
prompt_template = haystack_utils.get_phoenix_prompt(
124-
"generate_referrals", prompt_version_id
128+
"generate_referrals", prompt_version_id=prompt_version_id, suffix=suffix
125129
)
126130
except httpx.HTTPStatusError as he:
127131
raise HTTPException(
128132
status_code=422,
129-
detail=f"The requested prompt version '{prompt_version_id}' could not be retrieved due to HTTP status {he.response.status_code}",
133+
detail=f"The requested prompt version '{prompt_version_id}' with suffix '{suffix}' could not be retrieved due to HTTP status {he.response.status_code}",
130134
) from he
131135

132136
try:

0 commit comments

Comments
 (0)