Skip to content

Commit 540481a

Browse files
authored
doc: Add more app documentation; add LLM temperature setting (#185)
1 parent 75efb18 commit 540481a

11 files changed

Lines changed: 199 additions & 131 deletions

File tree

app/src/app.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
1+
"""
2+
This application uses Hayhooks to create an API service.
3+
API requests are handled by Hayhooks and triggers specific Haystack pipelines (defined in pipeline_wrapper.py files).
4+
Phoenix is used for prompt templates and OpenTelemetry-based tracing of API requests.
5+
"""
6+
17
import logging
28
from typing import Dict
39

app/src/app_config.py

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,11 @@
1010

1111

1212
class AppConfig(PydanticBaseEnvConfig):
13+
"""
14+
Application configuration settings, overridable by environment variables of the same name.
15+
See docker-compose.yml, local.env, and override.env for environment variable being set.
16+
"""
17+
1318
environment: str = "local"
1419
# Preview environment bucket names look like 'p-###-labs-referral-pilot-app-dev'
1520
bucket_name: str = "local"
@@ -23,6 +28,9 @@ class AppConfig(PydanticBaseEnvConfig):
2328
phoenix_collector_endpoint: str = "https://phoenix:6006"
2429
batch_otel: bool = True
2530

31+
# For sending emails, AWS SES region configuration where email address is verified.
32+
aws_ses_region: str = "us-east-1"
33+
# From field of the email sent via AWS SES. This email address needs to be verified in SES.
2634
aws_ses_from_email: str = "no-reply@test.com"
2735

2836
@cached_property
@@ -33,7 +41,7 @@ def db_session(self) -> db.Session:
3341
return self.db_client.get_session()
3442

3543
# These versions should only be used for the deployed Phoenix instance.
36-
# Version ids are base64 encodings of 'PromptVersion:N' where N is simply a counter,
44+
# Be aware: Version ids are base64 encodings of 'PromptVersion:N' where N is simply a counter,
3745
# so they are not unique across different Phoenix instances.
3846
PROMPT_VERSIONS: dict = {
3947
"generate_referrals_centraltx": "UHJvbXB0VmVyc2lvbjoxNDM=",
@@ -65,12 +73,11 @@ def db_session(self) -> db.Session:
6573

6674
generate_referrals_rag_model_version: str = "gpt-5.1"
6775
generate_referrals_rag_reasoning_level: str = "none"
76+
generate_referrals_rag_temperature: float = 0.9
6877

6978
generate_action_plan_model_version: str = "gpt-5.1"
7079
generate_action_plan_reasoning_level: str = "none"
71-
72-
generate_referrals_from_doc_model_version: str = "gpt-5.1"
73-
generate_referrals_from_doc_reasoning_level: str = "none"
80+
generate_action_plan_temperature: float = 0.9
7481

7582
def chroma_client(self) -> ClientAPI:
7683
return chromadb.HttpClient(host=self.rag_db_host, port=self.rag_db_port)

app/src/common/components.py

Lines changed: 107 additions & 103 deletions
Original file line numberDiff line numberDiff line change
@@ -39,42 +39,6 @@
3939
logger = 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
7943
class 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

469473
BaseModelT = 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
474478
class LlmOutputValidator:
475479
def __init__(self, pydantic_model: type[BaseModelT]):

0 commit comments

Comments
 (0)