Skip to content

Commit bc5fc8a

Browse files
author
Andrei Bratu
committed
refactoring
1 parent bf6196f commit bc5fc8a

File tree

11 files changed

+146
-184
lines changed

11 files changed

+146
-184
lines changed

src/humanloop/client.py

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@
88
from opentelemetry.sdk.trace import TracerProvider
99
from opentelemetry.trace import Tracer
1010

11-
from humanloop.context import PromptContext, reset_prompt_context, set_prompt_context
1211
from humanloop.core.client_wrapper import SyncClientWrapper
1312

1413
from humanloop.eval_utils import run_eval
@@ -17,8 +16,8 @@
1716
from humanloop.base_client import AsyncBaseHumanloop, BaseHumanloop
1817
from humanloop.overload import overload_call, overload_log
1918
from humanloop.utilities.flow import flow as flow_decorator_factory
20-
from humanloop.utilities.prompt import prompt
21-
from humanloop.utilities.tool import tool as tool_decorator_factory
19+
from humanloop.utilities.prompt import prompt_decorator_factory
20+
from humanloop.utilities.tool import tool_decorator_factory as tool_decorator_factory
2221
from humanloop.environment import HumanloopEnvironment
2322
from humanloop.evaluations.client import EvaluationsClient
2423
from humanloop.otel import instrument_provider
@@ -223,9 +222,7 @@ def call_llm(messages):
223222
224223
:param prompt_kernel: Attributes that define the Prompt. See `class:DecoratorPromptKernelRequestParams`
225224
"""
226-
227-
with prompt(path=path, template=template):
228-
yield
225+
return prompt_decorator_factory(path=path, template=template)
229226

230227
def tool(
231228
self,

src/humanloop/eval_utils/run.py

Lines changed: 22 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,10 @@ def run_eval(
104104
:param workers: the number of threads to process datapoints using your function concurrently.
105105
:return: per Evaluator checks.
106106
"""
107+
if workers > 32:
108+
logger.warning("Too many workers requested, capping the number to 32.")
109+
workers = min(workers, 32)
110+
107111
evaluators_worker_pool = ThreadPoolExecutor(max_workers=workers)
108112

109113
file_ = _file_or_file_inside_hl_utility(file)
@@ -183,7 +187,7 @@ def upload_callback(log_id: str):
183187
start_time = datetime.now()
184188
try:
185189
output = _call_function(function_, hl_file.type, dp)
186-
if not _callable_is_hl_utility(file):
190+
if not _callable_is_decorated(file):
187191
# function_ is a plain callable so we need to create a Log
188192
log_func(
189193
inputs=dp.inputs,
@@ -277,7 +281,7 @@ class _LocalEvaluator:
277281
function: Callable
278282

279283

280-
def _callable_is_hl_utility(file: File) -> bool:
284+
def _callable_is_decorated(file: File) -> bool:
281285
"""Check if a File is a decorated function."""
282286
return hasattr(file["callable"], "file")
283287

@@ -348,34 +352,29 @@ def _get_checks(
348352

349353

350354
def _file_or_file_inside_hl_utility(file: File) -> File:
351-
if _callable_is_hl_utility(file):
355+
if _callable_is_decorated(file):
352356
# When the decorator inside `file` is a decorated function,
353357
# we need to validate that the other parameters of `file`
354358
# match the attributes of the decorator
359+
decorated_fn_name = file["callable"].__name__
355360
inner_file: File = file["callable"].file
356-
if "path" in file and inner_file["path"] != file["path"]:
357-
raise ValueError(
358-
"`path` attribute specified in the `file` does not match the File path of the decorated function."
359-
)
360-
if "version" in file and inner_file["version"] != file["version"]:
361-
raise ValueError(
362-
"`version` attribute in the `file` does not match the File version of the decorated function."
363-
)
364-
if "type" in file and inner_file["type"] != file["type"]:
365-
raise ValueError(
366-
"`type` attribute of `file` argument does not match the File type of the decorated function."
367-
)
368-
if "id" in file:
369-
raise ValueError("Do not specify an `id` attribute in `file` argument when using a decorated function.")
370-
# file on decorated function holds at least
371-
# or more information than the `file` argument
361+
for argument in ["version", "path", "type", "id"]:
362+
if argument in file:
363+
logger.warning(
364+
f"Argument `file.{argument}` will be ignored: "
365+
f"callable `{decorated_fn_name}` is managed by "
366+
"the @{inner_file['type']} decorator."
367+
)
368+
369+
# Use the file manifest in the decorated function
372370
file_ = copy.deepcopy(inner_file)
371+
373372
else:
373+
# Simple function
374+
# Raise error if one of path or id not provided
374375
file_ = file
375-
376-
# Raise error if one of path or id not provided
377-
if not file_.get("path") and not file_.get("id"):
378-
raise ValueError("You must provide a path or id in your `file`.")
376+
if not file_.get("path") and not file_.get("id"):
377+
raise ValueError("You must provide a path or id in your `file`.")
379378

380379
return file_
381380

src/humanloop/otel/exporter.py

Lines changed: 15 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -26,29 +26,6 @@
2626

2727

2828
class HumanloopSpanExporter(SpanExporter):
29-
"""Upload Spans created by SDK decorators to Humanloop.
30-
31-
Spans not created by Humanloop SDK decorators will be dropped.
32-
33-
Each Humanloop Span contains information about the File to log against and
34-
the Log to create. We are using the .log actions that pass the kernel in the
35-
request. This allows us to create new Versions if the decorated function
36-
is changed.
37-
38-
The exporter uploads Spans top-to-bottom, where a Span is uploaded only after
39-
its parent Span has been uploaded. This is necessary for Flow Traces, where
40-
the parent Span is a Flow Log and the children are the Logs in the Trace.
41-
42-
The exporter keeps an upload queue and only uploads a Span if its direct parent has
43-
been uploaded.
44-
"""
45-
46-
# NOTE: LLM Instrumentors will only intercept calls to the provider made via the
47-
# official libraries e.g. import openai from openai. This is 100% the reason why
48-
# prompt call is not intercepted by the Instrumentor. The way to fix this is likely
49-
# overriding the hl_client.prompt.call utility. @James I'll do this since it will
50-
# involve looking at the EvaluationContext deep magic.
51-
5229
DEFAULT_NUMBER_THREADS = 4
5330

5431
def __init__(
@@ -62,8 +39,6 @@ def __init__(
6239
"""
6340
super().__init__()
6441
self._client = client
65-
# Uploaded spans translate to a Log on Humanloop. The IDs are required to link Logs in a Flow Trace
66-
self._span_to_uploaded_log_id: dict[int, Optional[str]] = {}
6742
# Work queue for the threads uploading the spans
6843
self._upload_queue: Queue = Queue()
6944
# Worker threads to export the spans
@@ -81,9 +56,6 @@ def __init__(
8156
for thread in self._threads:
8257
thread.start()
8358
logger.debug("Exporter Thread %s started", thread.ident)
84-
# Flow Log Span ID mapping to children Spans that must be uploaded first
85-
self._spans_left_in_trace: dict[int, set[int]] = {}
86-
self._traces: list[set[str]] = []
8759

8860
def export(self, spans: Sequence[ReadableSpan]) -> SpanExportResult:
8961
if self._shutdown:
@@ -122,40 +94,29 @@ def _do_work(self):
12294
continue
12395

12496
span_to_export, evaluation_context = thread_args
125-
span_file_type = span_to_export.attributes.get(HUMANLOOP_FILE_TYPE_KEY)
126-
if span_file_type is None:
97+
file_type = span_to_export.attributes.get(HUMANLOOP_FILE_TYPE_KEY)
98+
file_path = span_to_export.attributes.get(HUMANLOOP_PATH_KEY)
99+
if file_type is None:
127100
raise ValueError("Span does not have type set")
128101

129-
if span_file_type == "flow":
130-
log_args = read_from_opentelemetry_span(
131-
span=span_to_export,
132-
key=HUMANLOOP_LOG_KEY,
133-
)
134-
log_args = {
135-
**log_args,
136-
"log_status": "complete",
137-
}
102+
log_args = read_from_opentelemetry_span(
103+
span=span_to_export,
104+
key=HUMANLOOP_LOG_KEY,
105+
)
138106

139107
if evaluation_context:
140-
log_args = read_from_opentelemetry_span(
141-
span=span_to_export,
142-
key=HUMANLOOP_LOG_KEY,
143-
)
144-
span_file_path = read_from_opentelemetry_span(
145-
span=span_to_export,
146-
key=HUMANLOOP_PATH_KEY,
147-
)
148-
if span_file_path == evaluation_context.path:
108+
if file_path == evaluation_context.path:
149109
log_args = {
150110
**log_args,
151111
"source_datapoint_id": evaluation_context.source_datapoint_id,
152112
"run_id": evaluation_context.run_id,
153113
}
154-
write_to_opentelemetry_span(
155-
span=span_to_export,
156-
key=HUMANLOOP_LOG_KEY,
157-
value=log_args,
158-
)
114+
115+
write_to_opentelemetry_span(
116+
span=span_to_export,
117+
key=HUMANLOOP_LOG_KEY,
118+
value=log_args,
119+
)
159120

160121
response = requests.post(
161122
f"{self._client._client_wrapper.get_base_url()}/import/otel",
@@ -166,7 +127,7 @@ def _do_work(self):
166127
# TODO: handle
167128
pass
168129
else:
169-
if evaluation_context and span_file_path == evaluation_context.path:
130+
if evaluation_context and file_path == evaluation_context.path:
170131
log_id = response.json()["log_id"]
171132
evaluation_context.callback(log_id)
172133

src/humanloop/otel/processor.py

Lines changed: 8 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -23,39 +23,24 @@ class CompletableSpan(TypedDict):
2323

2424

2525
class HumanloopSpanProcessor(SimpleSpanProcessor):
26-
"""Enrich Humanloop spans with data from their children spans.
27-
28-
The decorators add Instrumentors to the OpenTelemetry TracerProvider
29-
that log interactions with common LLM libraries. These Instrumentors
30-
produce Spans which contain information that can be used to enrich the
31-
Humanloop File Kernels.
32-
33-
For example, Instrumentors for LLM provider libraries intercept
34-
hyperparameters used in the API call to the model to build the
35-
Prompt File definition when using the @prompt decorator.
36-
37-
Spans created that are not created by Humanloop decorators, such as
38-
those created by the Instrumentors mentioned above, will be passed
39-
to the Exporter as they are.
40-
"""
41-
4226
def __init__(self, exporter: SpanExporter) -> None:
4327
super().__init__(exporter)
4428

4529
def on_start(self, span: Span, parent_context):
4630
if is_llm_provider_call(span):
47-
context = get_prompt_context()
48-
prompt_path, prompt_template = context.path, context.template
49-
if context:
50-
span.set_attribute(HUMANLOOP_PATH_KEY, context.path)
31+
prompt_context = get_prompt_context()
32+
if prompt_context:
33+
path, template = prompt_context.path, prompt_context.template
34+
span.set_attribute(HUMANLOOP_PATH_KEY, path)
5135
span.set_attribute(HUMANLOOP_FILE_TYPE_KEY, "prompt")
52-
if prompt_template:
36+
if template:
5337
span.set_attribute(
5438
f"{HUMANLOOP_FILE_KEY}.template",
55-
prompt_template,
39+
template,
5640
)
5741
else:
58-
raise ValueError(f"Provider call outside @prompt context manager: {prompt_path}")
42+
# TODO: handle
43+
raise ValueError("Provider call outside @prompt context manager")
5944
trace_id = get_trace_id()
6045
if trace_id:
6146
span.set_attribute(f"{HUMANLOOP_LOG_KEY}.trace_parent_id", trace_id)

src/humanloop/overload.py

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

66
from humanloop.context import get_trace_id
77
from humanloop.eval_utils.run import HumanloopUtilityError
8-
from humanloop.flows.client import FlowsClient
98

109
from humanloop.prompts.client import PromptsClient
1110
from humanloop.types.create_evaluator_log_response import CreateEvaluatorLogResponse

src/humanloop/utilities/flow.py

Lines changed: 30 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
import logging
22
from functools import wraps
3-
from typing import Any, Callable, Mapping, Optional, Sequence
3+
from typing import Any, Callable, Mapping, Optional, Sequence, TypeVar
4+
from typing_extensions import ParamSpec
45

56
from opentelemetry.trace import Span, Tracer
67
from opentelemetry import context as context_api
78
import requests
89

910
from humanloop.base_client import BaseHumanloop
1011
from humanloop.context import get_trace_id, set_trace_id
12+
from humanloop.types.chat_message import ChatMessage
1113
from humanloop.utilities.helpers import bind_args
1214
from humanloop.eval_utils.types import File
1315
from humanloop.otel.constants import (
@@ -21,6 +23,10 @@
2123
logger = logging.getLogger("humanloop.sdk")
2224

2325

26+
P = ParamSpec("P")
27+
R = TypeVar("R")
28+
29+
2430
def flow(
2531
client: "BaseHumanloop",
2632
opentelemetry_tracer: Tracer,
@@ -29,19 +35,19 @@ def flow(
2935
):
3036
flow_kernel = {"attributes": attributes or {}}
3137

32-
def decorator(func: Callable):
38+
def decorator(func: Callable[P, R]) -> Callable[P, R]:
3339
decorator_path = path or func.__name__
3440
file_type = "flow"
3541

3642
@wraps(func)
37-
def wrapper(*args: Sequence[Any], **kwargs: Mapping[str, Any]) -> Any:
43+
def wrapper(*args: P.args, **kwargs: P.kwargs) -> Optional[R]:
3844
span: Span
3945
with opentelemetry_tracer.start_as_current_span("humanloop.flow") as span: # type: ignore
4046
trace_id = get_trace_id()
4147
args_to_func = bind_args(func, args, kwargs)
4248

4349
# Create the trace ahead so we have a parent ID to reference
44-
log_inputs = {
50+
init_log_inputs = {
4551
"inputs": {k: v for k, v in args_to_func.items() if k != "messages"},
4652
"messages": args_to_func.get("messages"),
4753
"trace_parent_id": trace_id,
@@ -53,7 +59,7 @@ def wrapper(*args: Sequence[Any], **kwargs: Mapping[str, Any]) -> Any:
5359
"path": path,
5460
"flow": flow_kernel,
5561
"log_status": "incomplete",
56-
**log_inputs,
62+
**init_log_inputs,
5763
},
5864
).json()
5965
# log = client.flows.log(
@@ -66,34 +72,37 @@ def wrapper(*args: Sequence[Any], **kwargs: Mapping[str, Any]) -> Any:
6672
span.set_attribute(HUMANLOOP_PATH_KEY, decorator_path)
6773
span.set_attribute(HUMANLOOP_FILE_TYPE_KEY, file_type)
6874

69-
# Call the decorated function
75+
func_output: Optional[R]
76+
log_output: str
77+
log_error: Optional[str]
78+
log_output_message: ChatMessage
7079
try:
71-
output = func(*args, **kwargs)
80+
func_output = func(*args, **kwargs)
7281
if (
73-
isinstance(output, dict)
74-
and len(output.keys()) == 2
75-
and "role" in output
76-
and "content" in output
82+
isinstance(func_output, dict)
83+
and len(func_output.keys()) == 2
84+
and "role" in func_output
85+
and "content" in func_output
7786
):
78-
output_message = output
79-
output = None
87+
log_output_message = ChatMessage(**func_output)
88+
log_output = None
8089
else:
81-
output = process_output(func=func, output=output)
82-
output_message = None
83-
error = None
90+
log_output = process_output(func=func, output=func_output)
91+
log_output_message = None
92+
log_error = None
8493
except Exception as e:
8594
logger.error(f"Error calling {func.__name__}: {e}")
8695
output = None
87-
output_message = None
88-
error = str(e)
96+
log_output_message = None
97+
log_error = str(e)
8998

9099
flow_log = {
91100
"inputs": {k: v for k, v in args_to_func.items() if k != "messages"},
92101
"messages": args_to_func.get("messages"),
93102
"log_status": "complete",
94-
"output": output,
95-
"error": error,
96-
"output_message": output_message,
103+
"output": log_output,
104+
"error": log_error,
105+
"output_message": log_output_message,
97106
"id": init_log["id"],
98107
}
99108

0 commit comments

Comments
 (0)