Skip to content

Commit e349387

Browse files
authored
feat: Add script to run experiment on generate-referrals pipeline (#31)
1 parent 016d896 commit e349387

8 files changed

Lines changed: 990 additions & 12 deletions

File tree

app/.dockleconfig

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
# https://github.com/goodwithtech/dockle#accept-suspicious-environment-variables--files--file-extensions
55

66
# Hayhooks has an out of the box example and it uses a settings file, we don't need or use it but need to ignore it
7-
DOCKLE_ACCEPT_FILES=app/.venv/lib/python3.12/site-packages/hayhooks/settings.py,app/.venv/lib/python3.12/site-packages/phoenix/otel/settings.py
7+
DOCKLE_ACCEPT_FILES=app/.venv/lib/python3.12/site-packages/hayhooks/settings.py,app/.venv/lib/python3.12/site-packages/phoenix/otel/settings.py,app/.venv/lib/python3.12/site-packages/phoenix/settings.py,app/.venv/lib/python3.12/site-packages/scipy/_lib/cobyqa/settings.py
88

99
# python:3.12-slim doesn't clear package caches after apt-get install; we do so in our own Dockerfile
1010
DOCKLE_IGNORES=DKL-DI-0005

app/.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,3 +35,6 @@ poetry-installer-error-*.log
3535
# file to the container, for secrets. It should not be committed
3636
# to the repo because tests and CI/CD will not have an .env file.
3737
docker-compose.override.yml
38+
39+
# Files related to experiments
40+
dataset.csv

app/Makefile

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,3 +230,6 @@ extract-supports:
230230

231231
copy-prompts:
232232
$(PY_RUN_CMD) copy-prompts
233+
234+
run-experiment:
235+
$(PY_RUN_CMD) run-experiment "$(DATASET)" "$(ACTION)"

app/poetry.lock

Lines changed: 767 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

app/pyproject.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ presidio-anonymizer = "^2.2.359"
3434
spacy = "^3.8.7"
3535
en-core-web-lg = {url = "https://github.com/explosion/spacy-models/releases/download/en_core_web_lg-3.8.0/en_core_web_lg-3.8.0-py3-none-any.whl"}
3636
pypdf = "^6.0.0"
37+
arize-phoenix = "^11.37.0"
3738

3839
[tool.poetry.group.dev.dependencies]
3940
certifi = "^2025.8.3"
@@ -53,6 +54,7 @@ types-pyyaml = "^6.0.12.11"
5354
setuptools = "^78.1.1"
5455
debugpy = "^1.8.1"
5556
ruff = "^0.4.9"
57+
types-requests = "^2.32.4.20250913"
5658

5759
[build-system]
5860
requires = ["poetry-core>=1.0.0"]
@@ -64,6 +66,7 @@ db-migrate-down = "src.db.migrations.run:down"
6466
db-migrate-down-all = "src.db.migrations.run:downall"
6567
extract-supports= "src.ingestion.extract_supports:main"
6668
copy-prompts= "src.common.phoenix_utils:copy_deployed_prompts"
69+
run-experiment= "src.experiments:main"
6770

6871
[tool.black]
6972
line-length = 100

app/src/common/phoenix_utils.py

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,10 @@ def get_prompt_template(prompt_name: str) -> PromptVersion:
8282
client = _create_client()
8383
prompt = client.prompts.get(**prompt_params)
8484
logger.info(
85-
"Retrieved prompt with %r: id='%s'\n%s", prompt_params, prompt.id, pformat(prompt._dumps())
85+
"Retrieved prompt with %r: id='%s'\n%s",
86+
prompt_params,
87+
prompt.id,
88+
pformat(prompt._dumps(), width=160),
8689
)
8790
return prompt
8891

@@ -96,16 +99,19 @@ def which_prompt_version(prompt_name: str) -> dict:
9699
return {"prompt_version_id": config.PROMPT_VERSIONS[prompt_name]}
97100

98101

99-
def copy_deployed_prompts() -> None:
100-
logging.basicConfig(format="%(levelname)s - %(name)s - %(message)s", level=logging.INFO)
101-
102+
def client_to_deployed_phoenix() -> Client:
102103
url = os.environ.get("DEPLOYED_PHOENIX_URL")
103104
api_key = os.environ.get("DEPLOYED_PHOENIX_API_KEY")
104-
logger.info("Copying prompts from %s with API key: %r", url, api_key)
105+
logger.info("Creating client to deployed Phoenix at %s with API key: %r", url, api_key)
105106
assert url, "DEPLOYED_PHOENIX_URL is not set -- add it to override.env"
106107
assert api_key, "DEPLOYED_PHOENIX_API_KEY is not set -- add it to override.env"
108+
return _create_client(url, api_key=api_key)
107109

108-
src_client = _create_client(url, api_key=api_key)
110+
111+
def copy_deployed_prompts() -> None:
112+
logging.basicConfig(format="%(levelname)s - %(name)s - %(message)s", level=logging.INFO)
113+
114+
src_client = client_to_deployed_phoenix()
109115
local_client = _create_client()
110116
for prompt in list_prompts(src_client):
111117
# The prompt id is base64 encoding of 'Prompt:N' where N is simply a counter
@@ -126,7 +132,9 @@ def copy_prompt(src_client: Client, local_client: Client, prompt_name: str) -> N
126132
return
127133

128134
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()))
135+
logger.info(
136+
"Retrieved prompt with id='%s'\n%s", prompt_ver.id, pformat(prompt_ver._dumps(), width=160)
137+
)
130138

131139
logger.info("Creating prompt %r in %r", prompt_name, local_client._client.base_url)
132140
# If prompt_name already exists, a new prompt version will be created

app/src/experiments.py

Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
import argparse
2+
import functools
3+
import json
4+
import logging
5+
import os
6+
from pprint import pformat
7+
from typing import Any, Dict
8+
9+
import requests
10+
from phoenix.client import Client
11+
from phoenix.client.experiments import run_experiment
12+
from phoenix.client.resources.datasets import Dataset
13+
from phoenix.client.resources.experiments.types import TaskOutput
14+
15+
from src.common import phoenix_utils
16+
from src.pipelines.generate_referrals.pipeline_wrapper import PipelineWrapper
17+
18+
logger = logging.getLogger(__name__)
19+
20+
21+
def get_sets(output: TaskOutput, expected: Dict[str, Any]) -> tuple[set[str], set[str]]:
22+
assert isinstance(output, list), f"Expected list of content, but got {type(output)}"
23+
assert len(output) == 1, f"Expected exactly one output, but got {len(output)}"
24+
output_obj = json.loads(output[0]["text"])
25+
# Usually the output is {"resources": [...]}, sometimes it's just [...]
26+
resources = output_obj["resources"] if "resources" in output_obj else output_obj
27+
# Extract only the names of the resources from the output
28+
output_set = set([resource["name"] for resource in resources])
29+
30+
expected_json_str = expected[OUTPUT_COLUMN_NAME]
31+
expected_obj = json.loads(expected_json_str)
32+
expectation_set = set(expected_obj["expected_referrals"])
33+
34+
return output_set, expectation_set
35+
36+
37+
def recall(output: TaskOutput, expected: Dict[str, Any]) -> float:
38+
output_set, expectation_set = get_sets(output, expected)
39+
if len(expectation_set) == 0:
40+
raise ValueError("No expected referrals to compute recall.")
41+
return len(output_set.intersection(expectation_set)) / len(expectation_set)
42+
43+
44+
def precision(output: TaskOutput, expected: Dict[str, Any]) -> float:
45+
output_set, expectation_set = get_sets(output, expected)
46+
if len(output_set) == 0:
47+
return 0.0
48+
return len(output_set.intersection(expectation_set)) / len(output_set)
49+
50+
51+
url_base = os.environ.get("DEPLOYED_API_URL")
52+
if url_base:
53+
logger.info("Using deployed API at %s", url_base)
54+
55+
56+
@functools.lru_cache
57+
def create_pipeline() -> PipelineWrapper:
58+
logger.info("Creating local Haystack pipeline")
59+
pipeline_wrapper = PipelineWrapper()
60+
pipeline_wrapper.setup()
61+
return pipeline_wrapper
62+
63+
64+
def get_question(example: dict) -> str:
65+
question_json_str = example["input"][INPUT_COLUMN_NAME]
66+
question_obj = json.loads(question_json_str)
67+
question = question_obj["caseworker_input"]
68+
return question
69+
70+
71+
def query_pipeline(example: dict) -> TaskOutput:
72+
question = get_question(example)
73+
logger.info("Getting answer for: %r", question)
74+
response = create_pipeline().run_api(query=question)
75+
replies = response["llm"]["replies"]
76+
assert len(replies) == 1, f"Expected exactly one reply but got {len(replies)}"
77+
return replies[0].to_dict()["content"]
78+
79+
80+
def query_api(example: dict) -> TaskOutput:
81+
question = get_question(example)
82+
logger.info("Getting answer for: %r", question)
83+
84+
assert url_base, "DEPLOYED_API_URL is not set -- add it to override.env"
85+
response = requests.post(
86+
f"{url_base}/generate_referrals/run",
87+
headers={
88+
"accept": "application/json",
89+
"Content-Type": "application/json",
90+
},
91+
json={"query": question},
92+
timeout=60,
93+
)
94+
resp_obj = response.json()
95+
logger.info("Response: %s", pformat(resp_obj, width=160))
96+
assert len(resp_obj["result"]["llm"]["replies"]) == 1, "Expected exactly one reply"
97+
return resp_obj["result"]["llm"]["replies"][0]["_content"]
98+
99+
100+
def run(dataset: Dataset, client: Client) -> None:
101+
task = query_api if url_base else query_pipeline
102+
logger.info("Running experiment using %r", task.__name__)
103+
run_experiment(
104+
dataset=dataset,
105+
task=task,
106+
evaluators=[recall, precision],
107+
client=client,
108+
)
109+
110+
111+
INPUT_COLUMN_NAME = "Input"
112+
OUTPUT_COLUMN_NAME = "Output"
113+
114+
115+
def export_dataset(dataset_name: str, filename: str, client: Client) -> None:
116+
"""Retrieve a dataset by name and save it as a CSV file. Useful for exporting a dataset from the deployed Phoenix."""
117+
try:
118+
dataset = client.datasets.get_dataset(dataset=dataset_name)
119+
# Convert the dataset to a pandas DataFrame to save as CSV
120+
df = dataset.to_dataframe()
121+
122+
# Remove the extraneous 'input' and 'output' keys and just keep the values
123+
# This ensures that when the CSV file is imported, the input and output columns are the same as in the deployed Phoenix
124+
df[INPUT_COLUMN_NAME] = df["input"].apply(lambda x: x[INPUT_COLUMN_NAME])
125+
df[OUTPUT_COLUMN_NAME] = df["output"].apply(lambda x: x[OUTPUT_COLUMN_NAME])
126+
# Convert metadata to JSON string so it can be correctly parsed when running experiments if needed
127+
df["metadata"] = df["metadata"].apply(lambda x: json.dumps(x))
128+
df = df[[INPUT_COLUMN_NAME, OUTPUT_COLUMN_NAME, "metadata"]]
129+
df.to_csv(filename, index=False)
130+
except ValueError as e:
131+
logger.error("Error retrieving dataset: %s", e)
132+
print_datasets(client)
133+
134+
135+
def print_datasets(client: Client) -> list:
136+
all_datasets = client.datasets.list()
137+
logger.info("%d available datasets:\n%s", len(all_datasets), pformat(all_datasets))
138+
return all_datasets
139+
140+
141+
def import_dataset(client: Client, filename: str, dataset_name: str) -> Any:
142+
"""
143+
Import a dataset from a CSV file into Phoenix.
144+
Useful for loading dataset locally to test experiments.
145+
Reminder to reduce the number of rows in the CSV file for development.
146+
"""
147+
return client.datasets.create_dataset(
148+
name=dataset_name,
149+
csv_file_path=filename,
150+
input_keys=[INPUT_COLUMN_NAME],
151+
output_keys=[OUTPUT_COLUMN_NAME],
152+
# Caution: Note that the metadata column is different than on the deployed Phoenix
153+
# In the Phoenix UI, click on each example to compare -- don't compare in the table view,
154+
# which shows multiple examples and hides the "Input" and "Output" keys
155+
metadata_keys=["metadata"],
156+
)
157+
158+
159+
def main() -> None:
160+
logging.basicConfig(format="%(levelname)s - %(name)s - %(message)s", level=logging.INFO)
161+
162+
parser = argparse.ArgumentParser()
163+
parser.add_argument("dataset", type=str, default="brandon2")
164+
parser.add_argument(
165+
"action",
166+
type=str,
167+
choices=["export", "import", "run", "run_on_deployed"],
168+
default="run",
169+
)
170+
args = parser.parse_args()
171+
172+
logger.info("Action=%s Dataset=%r", args.action, args.dataset)
173+
174+
if args.action == "export":
175+
client_to_deployed_phx = phoenix_utils.client_to_deployed_phoenix()
176+
export_dataset(args.dataset, "dataset.csv", client_to_deployed_phx)
177+
logger.info("Export complete. Check dataset.csv file.")
178+
return
179+
180+
client = phoenix_utils._create_client()
181+
if args.action == "import":
182+
import_dataset(client, "dataset.csv", args.dataset)
183+
logger.info("Import complete. Check Phoenix UI.")
184+
return
185+
186+
if args.action == "run_on_deployed":
187+
logger.info("Running experiment on dataset in deployed Phoenix")
188+
client = phoenix_utils.client_to_deployed_phoenix()
189+
190+
try:
191+
# Get the dataset, run the experiment, and post the results
192+
dataset = client.datasets.get_dataset(dataset=args.dataset)
193+
run(dataset, client)
194+
logger.info("Check results in the Phoenix UI.")
195+
except ValueError as e:
196+
logger.error("Error retrieving dataset: %s", e)
197+
print_datasets(client)

app/src/pipelines/generate_referrals/pipeline_wrapper.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ def run_api(self, query: str) -> dict:
6262
},
6363
}
6464
)
65-
logger.info("Results: %s", pformat(response))
65+
logger.info("Results: %s", pformat(response, width=160))
6666
return response
6767

6868
# https://docs.haystack.deepset.ai/docs/hayhooks#openai-compatibility

0 commit comments

Comments
 (0)