Skip to content

Commit 829c840

Browse files
authored
feat: Lookup prompt template from Arize Phoenix (#23)
1 parent 7b0042a commit 829c840

12 files changed

Lines changed: 200 additions & 67 deletions

File tree

app/Makefile

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,3 +227,6 @@ help: ## Prints the help documentation and info about each command
227227
# Usage: make extract-supports NAME="SupportName" FILE="path/to/file"
228228
extract-supports:
229229
$(PY_RUN_CMD) extract-supports "$(NAME)" "$(FILE)"
230+
231+
copy-prompts:
232+
$(PY_RUN_CMD) copy-prompts

app/README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,3 +81,11 @@ Based on [documentation](https://arize.com/docs/phoenix/self-hosting/features/au
8181
```
8282
- PHOENIX_API_KEY=<paste API key>
8383
```
84+
85+
### Copying prompts from the deployed Phoenix
86+
87+
For local development, you'll likely want to replicate the prompts in the deployed Phoenix instance onto your local Phoenix instance.
88+
To do so, add the `DEPLOYED_PHOENIX_URL` and `DEPLOYED_PHOENIX_API_KEY` environment variables to `override.env`.
89+
Create a system API key at `$DEPLOYED_PHOENIX_URL/settings/general`.
90+
Then run `make copy-prompts`. This will copy the prompt versions specified in `app_config.py`, which are the ones used in the deployed app.
91+
Remember to do this every time the prompt version is updated in `app_config.py`. When running locally, the latest version of the prompt is used.

app/docker-compose.yml

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,6 @@ services:
3838
- PHOENIX_TLS_KEY_FILE_PASSWORD=
3939
# For verifying client certificates
4040
- PHOENIX_TLS_VERIFY_CLIENT=False
41-
# In case we want to disable PII Redaction
42-
- REDACT_PII=${REDACT_PII:-True}
4341

4442
# See README.md to enable authentication locally
4543
# - PHOENIX_ENABLE_AUTH=True

app/local.env

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,3 +100,6 @@ BATCH_OTEL=false
100100
# local Phoenix instance within the Docker compose network
101101
PHOENIX_COLLECTOR_ENDPOINT=https://phoenix:6006
102102
PHOENIX_PROJECT_NAME=local-docker-project
103+
104+
# Disable PII redaction for local development
105+
# REDACT_PII=False

app/pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ db-migrate = "src.db.migrations.run:up"
6363
db-migrate-down = "src.db.migrations.run:down"
6464
db-migrate-down-all = "src.db.migrations.run:downall"
6565
extract-supports= "src.ingestion.extract_supports:main"
66+
copy-prompts= "src.common.phoenix_utils:copy_deployed_prompts"
6667

6768
[tool.black]
6869
line-length = 100

app/src/app_config.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66

77
class AppConfig(PydanticBaseEnvConfig):
8+
environment: str = "local"
89
# Set HOST to 127.0.0.1 by default to avoid other machines on the network
910
# from accessing the application. This is especially important if you are
1011
# running the application locally on a public network. This needs to be
@@ -24,5 +25,14 @@ def db_client(self) -> db.PostgresDBClient:
2425
def db_session(self) -> db.Session:
2526
return self.db_client.get_session()
2627

28+
# These versions should only be used for the deployed Phoenix instance.
29+
# Version ids are base64 encodings of 'PromptVersion:N' where N is simply a counter,
30+
# so they are not unique across different Phoenix instances.
31+
PROMPT_VERSIONS: dict = {
32+
"sample_rag": "UHJvbXB0VmVyc2lvbjox",
33+
"extract_supports": "UHJvbXB0VmVyc2lvbjoz",
34+
"generate_referrals": "UHJvbXB0VmVyc2lvbjo0",
35+
}
36+
2737

2838
config = AppConfig()

app/src/common/haystack_utils.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
from typing import Sequence
2+
3+
from haystack.dataclasses.chat_message import ChatMessage
4+
from phoenix.client.__generated__ import v1
5+
6+
from src.common import phoenix_utils
7+
8+
9+
def get_phoenix_prompt(prompt_name: str) -> list[ChatMessage]:
10+
prompt_ver = phoenix_utils.get_prompt_template(prompt_name)
11+
return to_chat_messages(prompt_ver._template["messages"])
12+
13+
14+
def to_chat_messages(
15+
msg_list: Sequence[dict | v1.PromptMessage | ChatMessage],
16+
) -> list[ChatMessage]:
17+
"""Convert a list of dicts or Phoenix PromptMessage to a list of Haystack ChatMessage."""
18+
messages = []
19+
for msg in msg_list:
20+
if isinstance(msg, ChatMessage):
21+
messages.append(msg)
22+
continue
23+
elif not isinstance(msg, dict): # PromptMessage is a TypedDict
24+
raise ValueError(f"Expected dict or ChatMessage, got {type(msg)}")
25+
26+
role = msg["role"]
27+
content = msg["content"]
28+
29+
assert isinstance(content, list), f"Expected list content, got {type(content)}: {content}"
30+
assert len(content) == 1, f"Expected single content, got {len(content)} items: {content}"
31+
assert content[0]["type"] == "text", f"Expected text content, got {content[0]['type']}"
32+
assert "text" in content[0], f"Expected 'text' in content[0], got {content[0]}"
33+
text = content[0]["text"]
34+
35+
if role == "system":
36+
assert isinstance(text, str), f"Expected string, got {type(text)}"
37+
chat_msg = ChatMessage.from_system(text)
38+
elif role == "user":
39+
assert isinstance(text, str), f"Expected string, got {type(text)}"
40+
chat_msg = ChatMessage.from_user(text)
41+
elif role == "assistant":
42+
assert isinstance(text, str), f"Expected string, got {type(text)}"
43+
chat_msg = ChatMessage.from_assistant(text)
44+
else:
45+
raise ValueError(f"Unexpected role: {role} for message {msg}")
46+
messages.append(chat_msg)
47+
48+
return messages

app/src/common/phoenix_utils.py

Lines changed: 80 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,28 @@
11
import logging
22
import os
3+
from pprint import pformat
34

45
import httpx
56
import opentelemetry.exporter.otlp.proto.http.trace_exporter as otel_trace_exporter
7+
import phoenix.otel
8+
from opentelemetry.sdk.trace.export import BatchSpanProcessor
69

710
# https://docs.arize.com/phoenix/tracing/integrations-tracing/haystack
811
# Arize's Phoenix observability platform
9-
import phoenix.client
10-
import phoenix.otel
11-
from opentelemetry.sdk.trace.export import BatchSpanProcessor
12+
from phoenix.client import Client
13+
from phoenix.client.types import PromptVersion
1214

1315
from src.app_config import config
1416
from src.logging.presidio_pii_filter import PresidioRedactionSpanProcessor
1517

1618
logger = logging.getLogger(__name__)
1719

1820

19-
def _create_client() -> phoenix.client.Client:
20-
logger.info("Creating Phoenix client to %s", config.phoenix_collector_endpoint)
21-
# If base_url is None, then phoenix.client.Client defaults to PHOENIX_COLLECTOR_ENDPOINT
22-
# env variable value or "http://localhost:6006"
23-
return phoenix.client.Client(base_url=config.phoenix_collector_endpoint)
21+
def _create_client(
22+
url: str = config.phoenix_collector_endpoint, api_key: str | None = None
23+
) -> Client:
24+
logger.info("Creating Phoenix client to %s", url)
25+
return Client(base_url=url, api_key=api_key)
2426

2527

2628
def service_alive() -> bool:
@@ -70,3 +72,73 @@ def configure_phoenix(only_if_alive: bool = True) -> None:
7072
if config.batch_otel:
7173
tracer_provider.add_span_processor(BatchSpanProcessor(span_exporter))
7274
tracer_provider.add_span_processor(pii_processor)
75+
76+
77+
def get_prompt_template(prompt_name: str) -> PromptVersion:
78+
"""Retrieve a prompt template from Phoenix by name.
79+
https://arize.com/docs/phoenix/sdk-api-reference/python/overview#prompt-management
80+
"""
81+
prompt_params = which_prompt_version(prompt_name)
82+
client = _create_client()
83+
prompt = client.prompts.get(**prompt_params)
84+
logger.info(
85+
"Retrieved prompt with %r: id='%s'\n%s", prompt_params, prompt.id, pformat(prompt._dumps())
86+
)
87+
return prompt
88+
89+
90+
def which_prompt_version(prompt_name: str) -> dict:
91+
if config.environment == "local":
92+
# Get the latest version regardless of tags
93+
return {"prompt_identifier": prompt_name}
94+
95+
# Use the hardcoded version ids
96+
return {"prompt_version_id": config.PROMPT_VERSIONS[prompt_name]}
97+
98+
99+
def copy_deployed_prompts() -> None:
100+
logging.basicConfig(format="%(levelname)s - %(name)s - %(message)s", level=logging.INFO)
101+
102+
url = os.environ.get("DEPLOYED_PHOENIX_URL")
103+
api_key = os.environ.get("DEPLOYED_PHOENIX_API_KEY")
104+
logger.info("Copying prompts from %s with API key: %r", url, api_key)
105+
assert url, "DEPLOYED_PHOENIX_URL is not set -- add it to override.env"
106+
assert api_key, "DEPLOYED_PHOENIX_API_KEY is not set -- add it to override.env"
107+
108+
src_client = _create_client(url, api_key=api_key)
109+
local_client = _create_client()
110+
for prompt in list_prompts(src_client):
111+
# The prompt id is base64 encoding of 'Prompt:N' where N is simply a counter
112+
logger.info("Copying prompt: %r with id=%r)", prompt["name"], prompt["id"])
113+
copy_prompt(src_client, local_client, prompt["name"])
114+
115+
116+
def list_prompts(client: Client) -> list[dict]:
117+
"client.prompts doesn't have a list() method, so use the underlying httpx client."
118+
response = client._client.get("/v1/prompts")
119+
return response.json()["data"]
120+
121+
122+
def copy_prompt(src_client: Client, local_client: Client, prompt_name: str) -> None:
123+
"Copy a prompt from src_client to local_client"
124+
if prompt_name not in config.PROMPT_VERSIONS:
125+
logger.warning("No version id found for prompt %r -- skipping", prompt_name)
126+
return
127+
128+
prompt_ver = src_client.prompts.get(prompt_version_id=config.PROMPT_VERSIONS[prompt_name])
129+
logger.info("Retrieved prompt with id='%s'\n%s", prompt_ver.id, pformat(prompt_ver._dumps()))
130+
131+
logger.info("Creating prompt %r in %r", prompt_name, local_client._client.base_url)
132+
# If prompt_name already exists, a new prompt version will be created
133+
local_client.prompts.create(
134+
version=prompt_ver, name=prompt_name, prompt_description=prompt_ver._description
135+
)
136+
137+
138+
def list_prompt_version_ids(prompt_name: str, client: Client) -> list[str]:
139+
"List all version ids for a given prompt name. client.prompts doesn't have a list_versions() method."
140+
response = client._client.get(f"/v1/prompts/{prompt_name}/versions")
141+
resp_data = response.json()["data"]
142+
# version tags are not in the response
143+
return [ver["id"] for ver in resp_data]
144+
# To get tags for the version: client.prompts.tags.list(prompt_version_id=version_id)

app/src/ingestion/extract_supports.py

Lines changed: 6 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,13 @@
1515
from haystack.components.converters import PyPDFToDocument
1616
from haystack.components.preprocessors import DocumentSplitter
1717
from haystack.dataclasses import Document
18-
from haystack.dataclasses.chat_message import ChatMessage
1918
from haystack_integrations.components.generators.amazon_bedrock import AmazonBedrockChatGenerator
2019
from pydantic import BaseModel, Field
2120
from smart_open import open as smart_open
2221

2322
from src.adapters import db
2423
from src.app_config import config
24+
from src.common import haystack_utils
2525
from src.db.models.support_listing import Support, SupportListing
2626

2727
logger = logging.getLogger(__name__)
@@ -76,21 +76,7 @@ class SupportEntry(BaseModel):
7676
description: str | None = Field(description="2-sentence summary, including offerings")
7777

7878

79-
SYSTEM_PROMPT_TEMPLATE = f"""Using only the document content provided by the user, return a JSON list of objects.
80-
Each object must match exactly this schema:
81-
```
82-
{json.dumps(SupportEntry.model_json_schema(), indent=2)}
83-
```
84-
85-
Rules:
86-
- Output ONLY raw JSON (no markdown fences, no commentary).
87-
- If a field is missing in the PDF, use null or [] as appropriate.
88-
- Keep strings concise; avoid line breaks inside values.
89-
"""
90-
91-
USER_TEMPLATE = """Document content:
92-
{{ doc.content }}
93-
"""
79+
OUTPUT_SCHEMA = json.dumps(SupportEntry.model_json_schema(), indent=2)
9480

9581

9682
def create_llm() -> AmazonBedrockChatGenerator: # pragma: no cover
@@ -105,12 +91,9 @@ def create_llm() -> AmazonBedrockChatGenerator: # pragma: no cover
10591
def build_pipeline() -> AsyncPipeline:
10692
pipe = AsyncPipeline()
10793

108-
messages = [
109-
ChatMessage.from_system(SYSTEM_PROMPT_TEMPLATE),
110-
ChatMessage.from_user(USER_TEMPLATE),
111-
]
94+
chat_template = haystack_utils.get_phoenix_prompt("extract_supports")
11295
pipe.add_component(
113-
"prompt_builder", ChatPromptBuilder(template=messages, required_variables="*")
96+
"prompt_builder", ChatPromptBuilder(template=chat_template, required_variables="*")
11497
)
11598
pipe.add_component("llm", create_llm())
11699
# If needed, add OutputValidator to retry the LLM call -- https://haystack.deepset.ai/tutorials/28_structured_output_with_loop
@@ -124,7 +107,7 @@ async def run_pipeline(pipeline: AsyncPipeline, doc: Document) -> list[dict]:
124107
assert doc.content
125108
logger.info("Running pipeline with subdoc content length: %d", len(doc.content))
126109

127-
_result = await pipeline.run_async({"prompt_builder": {"doc": doc}})
110+
_result = await pipeline.run_async({"prompt_builder": {"schema": OUTPUT_SCHEMA, "doc": doc}})
128111
assert len(_result["llm"]["replies"]) == 1
129112
reply = _result["llm"]["replies"][0]
130113
# Useful info for checking if tokens have reached the limit for the LLM
@@ -186,8 +169,8 @@ def save_to_db(
186169

187170
support_listing_id = existing_listing.id if existing_listing else support_listing.id
188171
assert support_listing_id
189-
# Populate support records
190172
for support in support_entries:
173+
logger.info("Adding Support record: %r", support.name)
191174
support_record = Support(
192175
support_listing_id=support_listing_id,
193176
name=support.name,
@@ -216,9 +199,6 @@ def main() -> None: # pragma: no cover
216199
logger.info("Extracted content length: %d", len(doc.content))
217200
extracted_supports = extract_support_entries(args.name, doc)
218201

219-
for support in extracted_supports.values():
220-
logger.info("Support: %r", support.name)
221-
222202
with config.db_session() as db_session, db_session.begin():
223203
support_listing = SupportListing(name=args.name, uri=args.filepath)
224204
save_to_db(db_session, support_listing, extracted_supports.values())

app/src/pipelines/generate_referrals/pipeline_wrapper.py

Lines changed: 3 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from sqlalchemy.inspection import inspect
1313

1414
from src.app_config import config
15+
from src.common import haystack_utils
1516
from src.db.models.support_listing import Support
1617

1718
logger = logging.getLogger(__name__)
@@ -26,46 +27,17 @@ class Resource(BaseModel):
2627

2728

2829
resource_as_json = json.dumps(Resource.model_json_schema(), indent=2)
29-
30-
system_prompt = """
31-
You are a supporting API for Goodwill Central Texas Referral. You are designed to help career case managers provide high-quality, local resource referrals to client's in Central Texas.
32-
Your role is to support Goodwill Central Texas career case managers working with low-income job seekers and learners in Austin and surrounding counties (Bastrop, Blanco, Burnet, Caldwell, DeWitt, Fayette, Gillespie, Gonzales, Hays, Lavaca, Lee, Llano, Mason, Travis, Williamson).
33-
34-
## Task Checklist
35-
- Evaluate the client needs and determine their eligibility (Factors to consider: age, income, disability, immigration/veteran status, number of dependents)
36-
- Prioritize Goodwill resources first (Basic Needs Resource packet, Goodwill websites)
37-
- Rank recommendations by proximity, eligibility fit, and other relevant factors
38-
39-
## Core Instructions
40-
- Use only trusted and up-to-date sources: Goodwill, government, vetted nonprofits, trusted news outlets (Findhelp, 211, Connect ATX permitted). Never use unreliable websites (e.g., shelterlistings.org, needhelppayingbills.com).
41-
- Never invent or fabricate resources. If none are available, state this clearly and suggest actionable, specific next steps
42-
43-
List of resources to choose from:
44-
{% for s in supports %}
45-
- {{ s.content }}
46-
{% endfor %}
47-
48-
## Response Constraints
49-
- Your response should ONLY include resources.
50-
- Do not summarize your assessment of the clients needs.
51-
- Limit the description for a resource to be less than 255 words.
52-
- Return a JSON list of resources in the following format:
53-
'''{{ resource_json }}'''
54-
"""
5530
model = "us.anthropic.claude-3-5-sonnet-20241022-v2:0"
5631

57-
prompt_template = [
58-
ChatMessage.from_system(system_prompt),
59-
ChatMessage.from_user("""User query: {{ query }}"""),
60-
]
61-
6232

6333
class PipelineWrapper(BasePipelineWrapper):
6434
name = "generate_referrals"
6535

6636
def setup(self) -> None:
6737
pipeline = Pipeline()
6838
pipeline.add_component("llm", AmazonBedrockChatGenerator(model=model))
39+
40+
prompt_template = haystack_utils.get_phoenix_prompt("generate_referrals")
6941
pipeline.add_component(
7042
instance=ChatPromptBuilder(
7143
template=prompt_template, required_variables=["query", "supports", "resource_json"]

0 commit comments

Comments
 (0)