Skip to content

Commit 358337c

Browse files
FEAT: added streaming to generate referrals pipelines (#153)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1 parent e9cbcfd commit 358337c

5 files changed

Lines changed: 143 additions & 52 deletions

File tree

app/src/common/haystack_utils.py

Lines changed: 11 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import logging
2-
import uuid
32
from typing import Any, Callable, Generator, Sequence
43

54
import hayhooks
@@ -68,14 +67,15 @@ def to_chat_messages(
6867
return messages
6968

7069

71-
def create_result_id_hook(pipeline: Pipeline) -> Callable[[dict], Generator]:
72-
"""Creates a pregenerator hook that generates result_id and yields it as first chunk.
70+
def create_result_id_hook(pipeline: Pipeline, result_id: str) -> Callable[[dict], Generator]:
71+
"""Creates a generator hook that yields the result_id as the last chunk.
7372
7473
This hook is specific to pipelines that use the SaveResult component and need to
7574
return the result_id to the frontend for caching/reference.
7675
7776
Args:
7877
pipeline: The Haystack pipeline to check for SaveResult component
78+
result_id: The result_id that will be used by SaveResult and yielded to frontend
7979
8080
Raises:
8181
ValueError: If the pipeline does not have a SaveResult component
@@ -95,14 +95,7 @@ def create_result_id_hook(pipeline: Pipeline) -> Callable[[dict], Generator]:
9595
)
9696

9797
def hook(pipeline_run_args: dict) -> Generator:
98-
result_id = str(uuid.uuid4())
99-
100-
# Add to pipeline args for SaveResult component
101-
if save_result_component_name not in pipeline_run_args: # checking to prevent KeyError
102-
pipeline_run_args[save_result_component_name] = {}
103-
pipeline_run_args[save_result_component_name]["result_id"] = result_id
104-
105-
# Yield as first chunk for frontend
98+
# Yield result_id as last chunk for frontend
10699
yield StreamingChunk(content=f'{{"result_id": "{result_id}"}}\n')
107100

108101
return hook
@@ -127,7 +120,7 @@ def stream_response(
127120
input_: Any | None = None,
128121
shorten_output: Callable[[str], str] = lambda resp: resp,
129122
parent_span_name_suffix: str | None = None,
130-
pregenerator_hook: Callable[[dict], Generator] | None = None,
123+
generator_hook: Callable[[dict], Generator] | None = None,
131124
) -> Generator:
132125
# Must set using attributes and metadata tracer context before calling tracer.start_as_current_span()
133126
with using_attributes(user_id=user_id, metadata=metadata):
@@ -141,11 +134,6 @@ def stream_response(
141134
try:
142135
span.set_input(input_)
143136

144-
# Call pregenerator_hook if provided (inside span, before streaming)
145-
if pregenerator_hook:
146-
for chunk in pregenerator_hook(pipeline_run_args):
147-
yield chunk
148-
149137
# hayhooks.streaming_generator() creates a thread that now inherits OpenTelemetry context
150138
# thanks to ThreadingInstrumentor enabled in phoenix_utils.py
151139
# streaming_generator() must be run inside the span context to inherit correctly
@@ -165,6 +153,12 @@ def stream_response(
165153
# Must yield in the tracer span context to have spans linked as child spans
166154
yield chunk
167155

156+
# Call generator_hook if provided (inside span, after streaming)
157+
# We do this after streaming all chunks so that the parent-child span relationship is established
158+
if generator_hook:
159+
for chunk in generator_hook(pipeline_run_args):
160+
yield chunk
161+
168162
logger.info("Successfully streamed %d chunks", chunk_count)
169163
response_text = "".join(full_response) if full_response else ""
170164
span.set_output(shorten_output(response_text))

app/src/pipelines/generate_action_plan/pipeline_wrapper.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import logging
2+
import uuid
23
from pprint import pformat
34
from typing import Generator
45

@@ -148,13 +149,18 @@ def run_chat_completion(self, model: str, messages: list, body: dict) -> Generat
148149
reasoning_effort=body.get("reasoning_effort", None),
149150
streaming=True,
150151
)
152+
153+
# Generate result_id upfront to pass to both SaveResult and the hook
154+
result_id = str(uuid.uuid4())
155+
pipeline_run_args["save_result"] = {"result_id": result_id}
156+
151157
logger.info("Streaming action plan: %s", pipeline_run_args)
152158
return self.runner.stream_response(
153159
pipeline_run_args,
154160
user_id=user_email,
155161
metadata={"user_id": user_email},
156162
input_=[r.name for r in resource_objects],
157-
pregenerator_hook=haystack_utils.create_result_id_hook(self.pipeline),
163+
generator_hook=haystack_utils.create_result_id_hook(self.pipeline, result_id),
158164
)
159165

160166

app/src/pipelines/generate_referrals/pipeline_wrapper.py

Lines changed: 75 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,15 @@
11
import json
22
import logging
3+
import uuid
34
from enum import Enum
45
from pprint import pformat
5-
from typing import Optional
6+
from typing import Generator, Optional
67

78
import httpx
89
from fastapi import HTTPException
910
from hayhooks import BasePipelineWrapper
1011
from haystack import Pipeline
1112
from haystack.components.builders import ChatPromptBuilder
12-
from haystack.dataclasses.chat_message import ChatMessage
1313
from pydantic import BaseModel
1414

1515
from src.app_config import config
@@ -103,17 +103,13 @@ def run_api(
103103
self, query: str, user_email: str, prompt_version_id: str = "", suffix: str = ""
104104
) -> dict:
105105
# Retrieve the requested prompt (with optional prompt_version_id and/or suffix)
106-
try:
107-
prompt_template = haystack_utils.get_phoenix_prompt(
108-
"generate_referrals", prompt_version_id=prompt_version_id, suffix=suffix
109-
)
110-
except httpx.HTTPStatusError as he:
111-
raise HTTPException(
112-
status_code=422,
113-
detail=f"The requested prompt version '{prompt_version_id}' with suffix '{suffix}' could not be retrieved due to HTTP status {he.response.status_code}",
114-
) from he
115-
pipeline_run_args = self._run_arg_data(
116-
query, user_email, prompt_template, region=suffix or "centraltx"
106+
107+
pipeline_run_args = self.create_pipeline_args(
108+
query,
109+
user_email,
110+
prompt_version_id=prompt_version_id,
111+
suffix=suffix,
112+
region=suffix or "centraltx",
117113
)
118114

119115
def extract_output(result: dict) -> list | str:
@@ -135,9 +131,29 @@ def extract_output(result: dict) -> list | str:
135131
logger.debug("Results: %s", pformat(response, width=160))
136132
return response
137133

138-
def _run_arg_data(
139-
self, query: str, user_email: str, prompt_template: list[ChatMessage], *, region: str
134+
def create_pipeline_args(
135+
self,
136+
query: str,
137+
user_email: str,
138+
*,
139+
region: str,
140+
prompt_version_id: str = "",
141+
suffix: str = "",
142+
llm_model: str | None = None,
143+
reasoning_effort: str | None = None,
144+
streaming: bool = False,
140145
) -> dict:
146+
"""Create pipeline run arguments with optional overrides for model, reasoning effort, and streaming."""
147+
try:
148+
prompt_template = haystack_utils.get_phoenix_prompt(
149+
"generate_referrals", prompt_version_id=prompt_version_id, suffix=suffix
150+
)
151+
except httpx.HTTPStatusError as e:
152+
raise HTTPException(
153+
status_code=422,
154+
detail=f"The requested prompt version '{prompt_version_id}' with suffix '{suffix}' could not be retrieved",
155+
) from e
156+
141157
return {
142158
"logger": {
143159
"messages_list": [{"query": query, "user_email": user_email}],
@@ -148,7 +164,49 @@ def _run_arg_data(
148164
"response_json": response_schema,
149165
},
150166
"llm": {
151-
"model": config.generate_referrals_model_version,
152-
"reasoning_effort": config.generate_referrals_reasoning_level,
167+
"model": llm_model or config.generate_referrals_model_version,
168+
"reasoning_effort": reasoning_effort or config.generate_referrals_reasoning_level,
169+
"streaming": streaming,
153170
},
154171
}
172+
173+
# https://docs.haystack.deepset.ai/docs/hayhooks#openai-compatibility
174+
# Called for the `{pipeline_name}/chat`, `/chat/completions`, or `/v1/chat/completions` streaming endpoint using Server-Sent Events (SSE)
175+
def run_chat_completion(self, model: str, messages: list, body: dict) -> Generator:
176+
# Note: 'model' parameter is the pipeline name, not the LLM model
177+
assert model == self.name, f"Unexpected model/pipeline name: {model}"
178+
179+
# Extract custom parameters from the body
180+
query = body.get("query", "")
181+
user_email = body.get("user_email", "")
182+
suffix = body.get("suffix", "")
183+
184+
if not query:
185+
raise ValueError("query parameter is required")
186+
187+
if not user_email:
188+
raise ValueError("user_email parameter is required")
189+
190+
pipeline_run_args = self.create_pipeline_args(
191+
query,
192+
user_email,
193+
prompt_version_id=body.get("prompt_version_id", ""),
194+
suffix=body.get("suffix", ""),
195+
region=suffix or "centraltx",
196+
llm_model=body.get("llm_model", None),
197+
reasoning_effort=body.get("reasoning_effort", None),
198+
streaming=True,
199+
)
200+
201+
# Generate result_id upfront to pass to both SaveResult and the hook
202+
result_id = str(uuid.uuid4())
203+
pipeline_run_args["save_result"] = {"result_id": result_id}
204+
205+
logger.info("Streaming referrals: %s", pipeline_run_args)
206+
return self.runner.stream_response(
207+
pipeline_run_args,
208+
user_id=user_email,
209+
metadata={"user_id": user_email},
210+
input_=[query],
211+
generator_hook=haystack_utils.create_result_id_hook(self.pipeline, result_id),
212+
)

app/src/pipelines/generate_referrals_rag/pipeline_wrapper.py

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44
from haystack.components.builders import ChatPromptBuilder
55
from haystack.components.converters import OutputAdapter
66
from haystack.components.embedders import SentenceTransformersTextEmbedder
7-
from haystack.dataclasses.chat_message import ChatMessage
87
from haystack_integrations.components.retrievers.chroma import ChromaEmbeddingRetriever
98

109
from src.app_config import config
@@ -76,10 +75,33 @@ def _create_pipeline(self) -> Pipeline:
7675
# pipeline.draw(path="generate_referrals_rag.png")
7776
return pipeline
7877

79-
def _run_arg_data(
80-
self, query: str, user_email: str, prompt_template: list[ChatMessage], *, region: str
78+
def create_pipeline_args(
79+
self,
80+
query: str,
81+
user_email: str,
82+
*,
83+
prompt_version_id: str = "",
84+
suffix: str = "",
85+
region: str,
86+
llm_model: str | None = None,
87+
reasoning_effort: str | None = None,
88+
streaming: bool = False,
8189
) -> dict:
82-
return super()._run_arg_data(query, user_email, prompt_template, region=region) | {
90+
"""Create pipeline run arguments with optional overrides for model, reasoning effort, and streaming."""
91+
# Get base args from parent class
92+
base_args = super().create_pipeline_args(
93+
query,
94+
user_email,
95+
prompt_version_id=prompt_version_id,
96+
suffix=suffix,
97+
region=region,
98+
llm_model=llm_model,
99+
reasoning_effort=reasoning_effort,
100+
streaming=streaming,
101+
)
102+
103+
# Override/add RAG-specific args
104+
return base_args | {
83105
# For querying RAG DB
84106
"query_embedder": {"text": query},
85107
"retriever": {
@@ -88,7 +110,9 @@ def _run_arg_data(
88110
},
89111
# Override LLM config for RAG pipeline
90112
"llm": {
91-
"model": config.generate_referrals_rag_model_version,
92-
"reasoning_effort": config.generate_referrals_rag_reasoning_level,
113+
"model": llm_model or config.generate_referrals_rag_model_version,
114+
"reasoning_effort": reasoning_effort
115+
or config.generate_referrals_rag_reasoning_level,
116+
"streaming": streaming,
93117
},
94118
}

frontend/src/util/fetchActionPlan.ts

Lines changed: 20 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -285,6 +285,7 @@ export async function fetchActionPlanStreaming(
285285

286286
let buffer = "";
287287
let accumulatedJSON = ""; // Accumulate the full JSON response
288+
let lastChunkContent = ""; // Track the last chunk to extract result_id
288289

289290
while (true) {
290291
const { done, value } = await reader.read();
@@ -340,17 +341,8 @@ export async function fetchActionPlanStreaming(
340341
// Handle hayhooks response format: choices[0].delta.content
341342
const content = parsed.choices?.[0]?.delta?.content;
342343
if (content) {
343-
// Check if this is the first message containing result_id
344-
if (!resultId && content.includes("result_id")) {
345-
try {
346-
const resultIdData = JSON.parse(content);
347-
if (resultIdData.result_id) {
348-
resultId = resultIdData.result_id;
349-
}
350-
} catch (e) {
351-
// Not a JSON object, continue processing as regular content
352-
}
353-
}
344+
// Store the last chunk content for result_id extraction
345+
lastChunkContent = content;
354346

355347
// Only accumulate content that's not the result_id metadata
356348
if (!content.includes("result_id")) {
@@ -365,6 +357,23 @@ export async function fetchActionPlanStreaming(
365357

366358
// Check for finish_reason to detect completion
367359
if (parsed.choices?.[0]?.finish_reason === "stop") {
360+
// Extract result_id from the last chunk (the chunk before this stop message)
361+
if (!resultId && lastChunkContent.includes("result_id")) {
362+
try {
363+
const resultIdData = JSON.parse(lastChunkContent) as {
364+
result_id?: string;
365+
};
366+
if (resultIdData.result_id) {
367+
resultId = resultIdData.result_id;
368+
}
369+
} catch (e) {
370+
console.error(
371+
"Failed to extract result_id from last chunk:",
372+
e,
373+
);
374+
}
375+
}
376+
368377
clearTimeout(timer);
369378
onComplete();
370379
break;

0 commit comments

Comments
 (0)