Skip to content

Commit 0ab6b0b

Browse files
feat: capture RAG documents and pass as context to action plan (#198)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent ed9098c commit 0ab6b0b

6 files changed

Lines changed: 223 additions & 14 deletions

File tree

app/src/common/components.py

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,10 @@
1111

1212
import json
1313
import logging
14+
import threading
1415
from json import JSONDecodeError
1516
from pprint import pformat
16-
from typing import Any, Callable, List, Optional, TypeVar
17+
from typing import Any, Callable, ClassVar, List, Optional, TypeVar
1718
from uuid import UUID
1819

1920
from fastapi import UploadFile
@@ -675,6 +676,35 @@ def parse_json_if_possible(self, content: Any) -> Any:
675676
return content
676677

677678

679+
@component
680+
class DocumentCapture:
681+
"""Passes Haystack Documents through unchanged while capturing their content as strings.
682+
683+
Acts as a side-effect component: documents flow to the next component as normal,
684+
but their text content is stored in a class-level dict keyed by result_id so that
685+
the content can be retrieved later (e.g. in a streaming generator hook).
686+
687+
Thread-safe: multiple concurrent pipeline runs are isolated by result_id.
688+
"""
689+
690+
_storage: ClassVar[dict[str, list[str]]] = {}
691+
_lock: ClassVar[threading.Lock] = threading.Lock()
692+
693+
@component.output_types(documents=list[Document])
694+
def run(self, documents: list[Document], result_id: str = "") -> dict:
695+
if result_id:
696+
contents = [d.content for d in documents if d.content]
697+
with self.__class__._lock:
698+
self.__class__._storage[result_id] = contents
699+
return {"documents": documents}
700+
701+
@classmethod
702+
def pop(cls, result_id: str) -> list[str]:
703+
"""Retrieve and remove stored document content for the given result_id."""
704+
with cls._lock:
705+
return cls._storage.pop(result_id, [])
706+
707+
678708
@component
679709
class DocumentMetadataAdder:
680710
def __init__(self, metadata: dict[str, Any]) -> None:

app/src/common/haystack_utils.py

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import json
12
import logging
23
from typing import Any, Callable, Generator, Sequence
34

@@ -67,7 +68,11 @@ def to_chat_messages(
6768
return messages
6869

6970

70-
def create_result_id_hook(pipeline: Pipeline, result_id: str) -> Callable[[dict], Generator]:
71+
def create_result_id_hook(
72+
pipeline: Pipeline,
73+
result_id: str,
74+
pop_documents_fn: Callable[[], dict] | None = None,
75+
) -> Callable[[dict], Generator]:
7176
"""Creates a generator hook that yields the result_id as the last chunk in a streaming response,
7277
i.e., TracedPipelineRunner.stream_response() calls a generator_hook() after pipeline.run() completes.
7378
@@ -77,6 +82,8 @@ def create_result_id_hook(pipeline: Pipeline, result_id: str) -> Callable[[dict]
7782
Args:
7883
pipeline: The Haystack pipeline to check for SaveResult component
7984
result_id: The result_id that will be used by SaveResult and yielded to frontend
85+
pop_documents_fn: Optional callable that retrieves and clears captured documents,
86+
returning them as a dict to merge into the yielded JSON
8087
8188
Raises:
8289
ValueError: If the pipeline does not have a SaveResult component
@@ -96,8 +103,10 @@ def create_result_id_hook(pipeline: Pipeline, result_id: str) -> Callable[[dict]
96103
)
97104

98105
def hook(pipeline_run_args: dict) -> Generator:
99-
# Yield result_id as last chunk for frontend
100-
yield StreamingChunk(content=f'{{"result_id": "{result_id}"}}\n')
106+
data: dict = {"result_id": result_id}
107+
if pop_documents_fn:
108+
data.update(pop_documents_fn())
109+
yield StreamingChunk(content=json.dumps(data) + "\n")
101110

102111
return hook
103112

@@ -132,6 +141,7 @@ def stream_response(
132141
shorten_output: Callable[[str], str] = lambda resp: resp,
133142
parent_span_name_suffix: str | None = None,
134143
generator_hook: Callable[[dict], Generator] | None = None,
144+
cleanup_fn: Callable[[], Any] | None = None,
135145
) -> Generator:
136146
"""
137147
Run the pipeline with tracing and return a streaming response using hayhooks.streaming_generator().
@@ -140,6 +150,9 @@ def stream_response(
140150
The parent_span_name_suffix is appended to the parent span name for easier region identification in Phoenix.
141151
The generator_hook can be used to yield additional chunks after the main pipeline.run() completes,
142152
such as yielding the result_id for reference by the frontend.
153+
The cleanup_fn is called in a finally block, guaranteeing execution even when the pipeline raises
154+
before the generator_hook runs (e.g., to release class-level component state like DocumentCapture entries).
155+
Its return value is ignored.
143156
"""
144157
# Must set using attributes and metadata tracer context before calling tracer.start_as_current_span()
145158
with using_attributes(user_id=user_id, metadata=metadata):
@@ -191,6 +204,9 @@ def stream_response(
191204
span.set_status(Status(StatusCode.ERROR, str(e)))
192205
span.record_exception(e)
193206
raise HTTPException(status_code=500, detail=str(e)) from e
207+
finally:
208+
if cleanup_fn:
209+
cleanup_fn()
194210

195211
def return_response(
196212
self,

app/src/pipelines/generate_action_plan/pipeline_wrapper.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ def setup(self) -> None:
4545

4646
pipeline.add_component(
4747
instance=ChatPromptBuilder(
48-
variables=["resources", "action_plan_json", "user_query"],
48+
variables=["resources", "action_plan_json", "user_query", "context_documents"],
4949
),
5050
name="prompt_builder",
5151
)
@@ -68,16 +68,20 @@ def run_api(
6868
resources: list[Resource] | list[dict],
6969
user_email: str,
7070
user_query: str,
71+
context_documents: list[str] | None = None,
7172
) -> dict:
7273
"""
7374
Generate an action plan based on the given resources.
7475
The user query provides more context to the generation process.
76+
The optional context_documents are RAG source documents from the referrals pipeline,
77+
passed as additional context to the LLM prompt.
7578
"""
7679
resource_objects = get_resources(resources)
7780
pipeline_run_args = self.create_pipeline_args(
7881
user_email,
7982
resource_objects,
8083
user_query,
84+
context_documents=context_documents,
8185
)
8286
response = self.runner.return_response(
8387
pipeline_run_args,
@@ -100,6 +104,7 @@ def create_pipeline_args(
100104
resource_objects: list[Resource],
101105
user_query: str,
102106
*,
107+
context_documents: list[str] | None = None,
103108
llm_model: str | None = None,
104109
reasoning_effort: str | None = None,
105110
streaming: bool = False,
@@ -116,6 +121,7 @@ def create_pipeline_args(
116121
"resources": format_resources(resource_objects),
117122
"action_plan_json": action_plan_as_json,
118123
"user_query": user_query,
124+
"context_documents": format_context_documents(context_documents),
119125
},
120126
"llm": {
121127
"model": llm_model or config.generate_action_plan_model_version,
@@ -135,6 +141,7 @@ def run_chat_completion(self, model: str, messages: list, body: dict) -> Generat
135141
resources = body.get("resources", [])
136142
user_email = body.get("user_email", "")
137143
user_query = body.get("user_query", "")
144+
context_documents = body.get("context_documents", None)
138145

139146
if not resources:
140147
raise ValueError("resources parameter is required")
@@ -146,6 +153,7 @@ def run_chat_completion(self, model: str, messages: list, body: dict) -> Generat
146153
user_email,
147154
resource_objects,
148155
user_query,
156+
context_documents=context_documents,
149157
llm_model=body.get("llm_model", None),
150158
reasoning_effort=body.get("reasoning_effort", None),
151159
streaming=True,
@@ -174,6 +182,14 @@ def get_resources(resources: list[Resource] | list[dict]) -> list[Resource]:
174182
return [Resource(**res) for res in resources] # type: ignore[arg-type]
175183

176184

185+
def format_context_documents(documents: list[str] | None) -> str:
186+
"""Format a list of retrieved RAG documents into a readable string for the LLM prompt."""
187+
if not documents:
188+
return ""
189+
doc_lines = "\n\n".join(f"- {doc}" for doc in documents)
190+
return f"Source Documents:\n{doc_lines}"
191+
192+
177193
def format_resources(resources: list[Resource]) -> str:
178194
"""Format a list of Resource objects into a readable string."""
179195
formatted_resources = []

app/src/pipelines/generate_referrals_rag/pipeline_wrapper.py

Lines changed: 23 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -105,8 +105,11 @@ def _create_pipeline(self) -> Pipeline:
105105
output_type=list,
106106
),
107107
)
108+
pipeline.add_component("document_capture", components.DocumentCapture())
109+
108110
pipeline.connect("query_embedder.embedding", "retriever.query_embedding")
109-
pipeline.connect("retriever.documents", "output_adapter")
111+
pipeline.connect("retriever.documents", "document_capture.documents")
112+
pipeline.connect("document_capture.documents", "output_adapter")
110113

111114
pipeline.add_component(
112115
"prompt_builder",
@@ -173,7 +176,7 @@ def run_api(
173176
pipeline_run_args,
174177
user_id=user_email,
175178
metadata={"user_id": user_email},
176-
include_outputs_from={"llm", "save_result"},
179+
include_outputs_from={"llm", "save_result", "retriever"},
177180
input_=query,
178181
extract_output=extract_output,
179182
parent_span_name_suffix=suffix,
@@ -192,6 +195,7 @@ def create_pipeline_args(
192195
llm_model: str | None = None,
193196
reasoning_effort: str | None = None,
194197
streaming: bool = False,
198+
result_id: str | None = None,
195199
) -> dict:
196200
"""Create pipeline run arguments with optional overrides for model, reasoning effort, and streaming."""
197201
assert suffix, "suffix is required"
@@ -207,7 +211,7 @@ def create_pipeline_args(
207211
detail=f"The requested prompt version '{prompt_version_id}' with suffix '{suffix}' could not be retrieved",
208212
) from e
209213

210-
return {
214+
args = {
211215
"logger": {
212216
"messages_list": [{"query": query, "user_email": user_email}],
213217
},
@@ -230,6 +234,10 @@ def create_pipeline_args(
230234
"filters": {"field": "region", "operator": "==", "value": region},
231235
},
232236
}
237+
if result_id:
238+
args["save_result"] = {"result_id": result_id}
239+
args["document_capture"] = {"result_id": result_id}
240+
return args
233241

234242
# https://docs.haystack.deepset.ai/docs/hayhooks#openai-compatibility
235243
# This function is called for the `{pipeline_name}/chat`, `/chat/completions`, or `/v1/chat/completions` streaming endpoint using Server-Sent Events (SSE)
@@ -249,6 +257,8 @@ def run_chat_completion(self, model: str, messages: list, body: dict) -> Generat
249257
if not user_email:
250258
raise ValueError("user_email parameter is required")
251259

260+
# Generate result_id upfront to pass to both SaveResult and DocumentCapture via pipeline args
261+
result_id = str(uuid.uuid4())
252262
pipeline_run_args = self.create_pipeline_args(
253263
query,
254264
user_email,
@@ -258,12 +268,9 @@ def run_chat_completion(self, model: str, messages: list, body: dict) -> Generat
258268
llm_model=body.get("llm_model", None),
259269
reasoning_effort=body.get("reasoning_effort", None),
260270
streaming=True,
271+
result_id=result_id,
261272
)
262273

263-
# Generate result_id upfront to pass to both SaveResult and the hook
264-
result_id = str(uuid.uuid4())
265-
pipeline_run_args["save_result"] = {"result_id": result_id}
266-
267274
logger.info("Streaming referrals: %s", pipeline_run_args)
268275
return self.runner.stream_response(
269276
pipeline_run_args,
@@ -272,5 +279,13 @@ def run_chat_completion(self, model: str, messages: list, body: dict) -> Generat
272279
input_=query,
273280
shorten_output=shorten_output,
274281
parent_span_name_suffix=suffix,
275-
generator_hook=haystack_utils.create_result_id_hook(self.pipeline, result_id),
282+
generator_hook=haystack_utils.create_result_id_hook(
283+
self.pipeline,
284+
result_id,
285+
pop_documents_fn=lambda: {"documents": components.DocumentCapture.pop(result_id)},
286+
),
287+
# No-op if the hook already ran (pop returns [] for missing keys).
288+
# Ensures the DocumentCapture entry is always removed even when the pipeline
289+
# raises before the hook executes.
290+
cleanup_fn=lambda: components.DocumentCapture.pop(result_id),
276291
)

app/tests/src/common/test_components.py

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
from src.adapters import db
99
from src.common.components import (
10+
DocumentCapture,
1011
EmailResponses,
1112
LlmOutputValidator,
1213
LoadResultOptional,
@@ -607,6 +608,109 @@ def test_RemoveResourcesForEmail_preserves_other_fields():
607608
assert resources_dict["resources"][0]["name"] == "Resource A"
608609

609610

611+
def test_DocumentCapture_stores_and_pops_content():
612+
"""DocumentCapture stores document content and pop() retrieves and removes it."""
613+
from haystack import Document
614+
615+
component = DocumentCapture()
616+
docs = [Document(content="First doc"), Document(content="Second doc")]
617+
618+
output = component.run(documents=docs, result_id="test-id-1")
619+
620+
# Documents pass through unchanged
621+
assert output["documents"] == docs
622+
623+
# Content was stored
624+
retrieved = DocumentCapture.pop("test-id-1")
625+
assert retrieved == ["First doc", "Second doc"]
626+
627+
# Second pop returns empty (entry removed)
628+
assert DocumentCapture.pop("test-id-1") == []
629+
630+
631+
def test_DocumentCapture_no_op_without_result_id():
632+
"""DocumentCapture does not store anything when result_id is empty."""
633+
from haystack import Document
634+
635+
component = DocumentCapture()
636+
docs = [Document(content="Some content")]
637+
638+
output = component.run(documents=docs, result_id="")
639+
640+
# Documents still pass through
641+
assert output["documents"] == docs
642+
643+
# Nothing stored (pop of any key returns [])
644+
assert DocumentCapture.pop("") == []
645+
646+
647+
def test_DocumentCapture_pop_nonexistent_key():
648+
"""pop() returns an empty list for an unknown result_id."""
649+
assert DocumentCapture.pop("nonexistent-key") == []
650+
651+
652+
def test_DocumentCapture_skips_documents_without_content():
653+
"""DocumentCapture only stores documents that have non-empty content."""
654+
from haystack import Document
655+
656+
component = DocumentCapture()
657+
docs = [Document(content="Has content"), Document(content=None), Document(content="")]
658+
659+
component.run(documents=docs, result_id="test-id-2")
660+
661+
retrieved = DocumentCapture.pop("test-id-2")
662+
assert retrieved == ["Has content"]
663+
664+
665+
def test_DocumentCapture_concurrent_isolation():
666+
"""Multiple concurrent pipeline runs store and retrieve documents without cross-contamination.
667+
668+
Verifies the thread-safety contract stated in components.py:1-10: pipeline components
669+
are shared across threads and must be safe for concurrent use.
670+
"""
671+
import concurrent.futures
672+
import time
673+
674+
from haystack import Document
675+
676+
component = DocumentCapture()
677+
n_runs = 20
678+
679+
def run_and_pop(run_id: str) -> list[str]:
680+
docs = [Document(content=f"{run_id}_doc_{i}") for i in range(5)]
681+
component.run(documents=docs, result_id=run_id)
682+
# Brief sleep to increase thread interleaving
683+
time.sleep(0.01)
684+
return DocumentCapture.pop(run_id)
685+
686+
errors: list[Exception] = []
687+
results: dict[str, list[str]] = {}
688+
689+
with concurrent.futures.ThreadPoolExecutor(max_workers=n_runs) as executor:
690+
future_to_run_id = {
691+
executor.submit(run_and_pop, f"concurrent-run-{i}"): f"concurrent-run-{i}"
692+
for i in range(n_runs)
693+
}
694+
for future in concurrent.futures.as_completed(future_to_run_id):
695+
run_id = future_to_run_id[future]
696+
try:
697+
results[run_id] = future.result()
698+
except Exception as e:
699+
errors.append(e)
700+
701+
assert not errors, f"Concurrent runs raised errors: {errors}"
702+
assert len(results) == n_runs
703+
704+
for i in range(n_runs):
705+
run_id = f"concurrent-run-{i}"
706+
expected = [f"{run_id}_doc_{j}" for j in range(5)]
707+
assert results[run_id] == expected, f"{run_id} got wrong docs: {results[run_id]}"
708+
709+
# All entries should have been popped — storage is clean
710+
for i in range(n_runs):
711+
assert DocumentCapture.pop(f"concurrent-run-{i}") == []
712+
713+
610714
def test_RemoveResourcesForEmail_mixed_valid_invalid_exclusions(caplog):
611715
"""Test RemoveResourcesForEmail with mix of valid and invalid exclusion names."""
612716
import logging

0 commit comments

Comments
 (0)