472 lines
14 KiB
Python
472 lines
14 KiB
Python
from __future__ import annotations
|
|
import json
|
|
import re
|
|
import inspect
|
|
import random
|
|
import statistics
|
|
from typing import (
|
|
Any,
|
|
List,
|
|
Optional,
|
|
Protocol,
|
|
Sequence,
|
|
Tuple,
|
|
Union,
|
|
Dict,
|
|
)
|
|
from deepeval.utils import serialize_to_json
|
|
from deepeval.errors import DeepEvalError
|
|
from deepeval.prompt.api import PromptType, PromptMessage
|
|
from deepeval.prompt.prompt import Prompt
|
|
from deepeval.errors import DeepEvalError
|
|
from deepeval.metrics.base_metric import BaseMetric, BaseConversationalMetric
|
|
from deepeval.prompt.prompt import Prompt
|
|
from deepeval.prompt.api import PromptMessage
|
|
from deepeval.optimizer.types import (
|
|
ModelCallback,
|
|
PromptConfigurationId,
|
|
PromptConfiguration,
|
|
PromptConfigSnapshot,
|
|
OptimizationReport,
|
|
)
|
|
from deepeval.dataset.golden import Golden, ConversationalGolden
|
|
|
|
|
|
def split_goldens(
|
|
goldens: Union[List[Golden], List[ConversationalGolden]],
|
|
pareto_size: int,
|
|
*,
|
|
random_state: random.Random,
|
|
) -> Tuple[
|
|
Union[List[Golden], List[ConversationalGolden]],
|
|
Union[List[Golden], List[ConversationalGolden]],
|
|
]:
|
|
"""
|
|
Split `goldens` into two disjoint parts:
|
|
|
|
- d_feedback: items not selected for the Pareto validation set
|
|
- d_pareto: `pareto_size` items for instance-wise Pareto scoring
|
|
|
|
The selection is deterministic given `seed`. Within each split, the
|
|
original order from `goldens` is preserved.
|
|
|
|
Args:
|
|
goldens: Full list/sequence of examples.
|
|
pareto_size: Number of items to allocate to the Pareto set bound between [0, len(goldens)].
|
|
random_state: A shared `random.Random` instance that provides the source
|
|
of randomness. For reproducible runs, pass the same object used by
|
|
the GEPA loop constructed from `GEPA.random_seed`
|
|
|
|
Returns:
|
|
(d_feedback, d_pareto)
|
|
"""
|
|
if pareto_size < 0:
|
|
raise ValueError("pareto_size must be >= 0")
|
|
|
|
total = len(goldens)
|
|
|
|
if total == 0:
|
|
# nothing to split
|
|
return [], []
|
|
|
|
# With a single example, we cannot form a meaningful feedback set.
|
|
# callers like GEPARunner should enforce a minimum of 2 goldens for
|
|
# optimization.
|
|
if total == 1:
|
|
return [], list(goldens)
|
|
|
|
# For total >= 2, ensure that we always leave at least one example
|
|
# for d_feedback. This keeps the splits disjoint while still honoring
|
|
# pareto_size as a target up to (total - 1).
|
|
chosen_size = min(pareto_size, total - 1)
|
|
|
|
indices = list(range(total))
|
|
random_state.shuffle(indices)
|
|
|
|
pareto_indices = set(indices[:chosen_size])
|
|
|
|
d_pareto = [goldens[i] for i in range(total) if i in pareto_indices]
|
|
d_feedback = [goldens[i] for i in range(total) if i not in pareto_indices]
|
|
|
|
return d_feedback, d_pareto
|
|
|
|
|
|
def invoke_model_callback(
|
|
*,
|
|
model_callback: ModelCallback,
|
|
prompt: Prompt,
|
|
golden: Union["Golden", "ConversationalGolden"],
|
|
) -> str:
|
|
"""
|
|
Call a user provided model_callback in a synchronous context.
|
|
|
|
Raises if the callback returns an awaitable.
|
|
"""
|
|
result = model_callback(prompt, golden)
|
|
if inspect.isawaitable(result):
|
|
raise DeepEvalError(
|
|
"model_callback returned an awaitable from a synchronous context. "
|
|
"Either declare the callback as `async def` and use async optimization, or call "
|
|
"`model.generate(...)` instead of `model.a_generate(...)` inside a sync callback."
|
|
)
|
|
return result
|
|
|
|
|
|
async def a_invoke_model_callback(
|
|
*,
|
|
model_callback: ModelCallback,
|
|
prompt: Prompt,
|
|
golden: Union["Golden", "ConversationalGolden"],
|
|
) -> str:
|
|
"""
|
|
Call a user provided model_callback in an async context.
|
|
|
|
Supports both sync and async callbacks.
|
|
"""
|
|
result = model_callback(prompt, golden)
|
|
if inspect.isawaitable(result):
|
|
return await result
|
|
return result
|
|
|
|
|
|
###########
|
|
# Reports #
|
|
###########
|
|
|
|
|
|
def build_prompt_config_snapshots(
|
|
prompt_configurations_by_id: Dict[
|
|
PromptConfigurationId, "PromptConfiguration"
|
|
],
|
|
) -> Dict[PromptConfigurationId, PromptConfigSnapshot]:
|
|
"""
|
|
Build snapshots of all prompt configurations.
|
|
"""
|
|
snapshots: Dict[PromptConfigurationId, PromptConfigSnapshot] = {}
|
|
|
|
for cfg_id, cfg in prompt_configurations_by_id.items():
|
|
snapshots[cfg_id] = PromptConfigSnapshot(
|
|
parent=cfg.parent,
|
|
prompts=dict(cfg.prompts),
|
|
)
|
|
|
|
return snapshots
|
|
|
|
|
|
def inflate_prompts_from_report(
|
|
report: OptimizationReport,
|
|
) -> Dict[str, Dict[str, Prompt]]:
|
|
"""
|
|
Build a mapping from configuration id -> { module_id -> Prompt }.
|
|
|
|
This is a convenience for users who want to work with real Prompt
|
|
instances instead of raw snapshots.
|
|
|
|
Returns:
|
|
{
|
|
"<config_id>": {
|
|
"<module_id>": Prompt(...),
|
|
...
|
|
},
|
|
...
|
|
}
|
|
"""
|
|
inflated: Dict[str, Dict[str, Prompt]] = {}
|
|
|
|
for cfg_id, cfg_snapshot in report.prompt_configurations.items():
|
|
module_prompts: Dict[str, Prompt] = {}
|
|
|
|
for module_id, module_snapshot in cfg_snapshot.prompts.items():
|
|
if module_snapshot.type == "TEXT":
|
|
module_prompts[module_id] = Prompt(
|
|
text_template=module_snapshot.text_template or ""
|
|
)
|
|
else: # "LIST"
|
|
messages = [
|
|
PromptMessage(role=m.role, content=m.content)
|
|
for m in module_snapshot.messages or []
|
|
]
|
|
module_prompts[module_id] = Prompt(messages_template=messages)
|
|
|
|
inflated[cfg_id] = module_prompts
|
|
|
|
return inflated
|
|
|
|
|
|
def get_best_prompts_from_report(
|
|
report: OptimizationReport,
|
|
) -> Dict[str, Prompt]:
|
|
"""
|
|
Convenience wrapper returning the best configuration's module prompts.
|
|
"""
|
|
all_prompts = inflate_prompts_from_report(report)
|
|
return all_prompts.get(report.best_id, {})
|
|
|
|
|
|
##############
|
|
# Validation #
|
|
##############
|
|
def _format_type_names(types: Tuple[type, ...]) -> str:
|
|
names = [t.__name__ for t in types]
|
|
if len(names) == 1:
|
|
return names[0]
|
|
if len(names) == 2:
|
|
return f"{names[0]} or {names[1]}"
|
|
return ", ".join(names[:-1]) + f", or {names[-1]}"
|
|
|
|
|
|
def validate_instance(
|
|
*,
|
|
component: str,
|
|
param_name: str,
|
|
value: Any,
|
|
expected_types: Union[type, Tuple[type, ...]],
|
|
allow_none: bool = False,
|
|
) -> Any:
|
|
"""
|
|
Generic type validator.
|
|
|
|
- component: Intended to help identify what is being validated.
|
|
e.g. "PromptOptimizer.__init__", "PromptOptimizer.optimize", etc.
|
|
- param_name: the name of the parameter being validated
|
|
- value: the actual value passed.
|
|
- expected_types: a type or tuple of types to accept.
|
|
- allow_none: if True, None is allowed and returned as-is.
|
|
"""
|
|
if value is None and allow_none:
|
|
return value
|
|
|
|
if not isinstance(expected_types, tuple):
|
|
expected_types = (expected_types,)
|
|
|
|
if not isinstance(value, expected_types):
|
|
expected_desc = _format_type_names(expected_types)
|
|
raise DeepEvalError(
|
|
f"{component} expected `{param_name}` to be an instance of "
|
|
f"{expected_desc}, but received {type(value).__name__!r} instead."
|
|
)
|
|
return value
|
|
|
|
|
|
def validate_sequence_of(
|
|
*,
|
|
component: str,
|
|
param_name: str,
|
|
value: Any,
|
|
expected_item_types: Union[type, Tuple[type, ...]],
|
|
sequence_types: Tuple[type, ...] = (list, tuple),
|
|
allow_none: bool = False,
|
|
) -> Any:
|
|
"""
|
|
Generic container validator.
|
|
|
|
- Ensures `value` is one of `sequence_types` (list by default).
|
|
- Ensures each item is an instance of `expected_item_types`.
|
|
|
|
Returns the original `value` on success.
|
|
"""
|
|
if value is None:
|
|
if allow_none:
|
|
return value
|
|
raise DeepEvalError(
|
|
f"{component} expected `{param_name}` to be a "
|
|
f"{_format_type_names(sequence_types)} of "
|
|
f"{_format_type_names(expected_item_types if isinstance(expected_item_types, tuple) else (expected_item_types,))}, "
|
|
"but received None instead."
|
|
)
|
|
|
|
if not isinstance(sequence_types, tuple):
|
|
sequence_types = (sequence_types,)
|
|
|
|
if not isinstance(value, sequence_types):
|
|
expected_seq = _format_type_names(sequence_types)
|
|
raise DeepEvalError(
|
|
f"{component} expected `{param_name}` to be a {expected_seq}, "
|
|
f"but received {type(value).__name__!r} instead."
|
|
)
|
|
|
|
if not isinstance(expected_item_types, tuple):
|
|
expected_item_types = (expected_item_types,)
|
|
|
|
for index, item in enumerate(value):
|
|
if not isinstance(item, expected_item_types):
|
|
expected_items = _format_type_names(expected_item_types)
|
|
raise DeepEvalError(
|
|
f"{component} expected all elements of `{param_name}` to be "
|
|
f"instances of {expected_items}, but element at index {index} "
|
|
f"has type {type(item).__name__!r}."
|
|
)
|
|
|
|
return value
|
|
|
|
|
|
def validate_callback(
|
|
*,
|
|
component: str,
|
|
model_callback: Optional[ModelCallback],
|
|
) -> ModelCallback:
|
|
"""
|
|
Ensure that `model_callback` is provided.
|
|
|
|
- `model_callback` should be a callable that performs generation and
|
|
returns the model output.
|
|
|
|
Returns `model_callback` unchanged on success.
|
|
"""
|
|
if model_callback is None:
|
|
raise DeepEvalError(
|
|
f"{component} requires a `model_callback`.\n\n"
|
|
"supply a custom callable via `model_callback=` that performs "
|
|
"generation and returns the model output."
|
|
)
|
|
return model_callback
|
|
|
|
|
|
def validate_metrics(
|
|
*,
|
|
component: str,
|
|
metrics: Union[List[BaseMetric], List[BaseConversationalMetric]],
|
|
) -> Union[List[BaseMetric], List[BaseConversationalMetric]]:
|
|
|
|
if metrics is None or not len(metrics):
|
|
raise DeepEvalError(
|
|
f"{component} requires a `metrics`.\n\n"
|
|
"supply one or more DeepEval metrics via `metrics=`"
|
|
)
|
|
|
|
validate_sequence_of(
|
|
component=component,
|
|
param_name="metrics",
|
|
value=metrics,
|
|
expected_item_types=(BaseMetric, BaseConversationalMetric),
|
|
sequence_types=(list, tuple),
|
|
)
|
|
return list(metrics)
|
|
|
|
|
|
def validate_int_in_range(
|
|
*,
|
|
component: str,
|
|
param_name: str,
|
|
value: int,
|
|
min_inclusive: Optional[int] = None,
|
|
max_exclusive: Optional[int] = None,
|
|
) -> int:
|
|
"""
|
|
Validate that an int is within range [min_inclusive, max_exclusive).
|
|
|
|
- If `min_inclusive` is not None, value must be >= min_inclusive.
|
|
- If `max_exclusive` is not None, value must be < max_exclusive.
|
|
|
|
Returns the validated int on success.
|
|
"""
|
|
value = validate_instance(
|
|
component=component,
|
|
param_name=param_name,
|
|
value=value,
|
|
expected_types=int,
|
|
)
|
|
|
|
# Lower bound check
|
|
if min_inclusive is not None and value < min_inclusive:
|
|
if max_exclusive is None:
|
|
raise DeepEvalError(
|
|
f"{component} expected `{param_name}` to be >= {min_inclusive}, "
|
|
f"but received {value!r} instead."
|
|
)
|
|
max_inclusive = max_exclusive - 1
|
|
raise DeepEvalError(
|
|
f"{component} expected `{param_name}` to be between "
|
|
f"{min_inclusive} and {max_inclusive} (inclusive), "
|
|
f"but received {value!r} instead."
|
|
)
|
|
|
|
# Upper bound check (half-open, < max_exclusive)
|
|
if max_exclusive is not None and value >= max_exclusive:
|
|
if min_inclusive is None:
|
|
raise DeepEvalError(
|
|
f"{component} expected `{param_name}` to be < {max_exclusive}, "
|
|
f"but received {value!r} instead."
|
|
)
|
|
max_inclusive = max_exclusive - 1
|
|
raise DeepEvalError(
|
|
f"{component} expected `{param_name}` to be between "
|
|
f"{min_inclusive} and {max_inclusive} (inclusive), "
|
|
f"but received {value!r} instead."
|
|
)
|
|
|
|
return value
|
|
|
|
|
|
##############
|
|
# Aggregates #
|
|
##############
|
|
|
|
|
|
class Aggregator(Protocol):
|
|
def __call__(self, scores: Sequence[float]) -> float: ...
|
|
|
|
|
|
def mean_of_all(scores: Sequence[float]) -> float:
|
|
return statistics.fmean(scores) if scores else 0.0
|
|
|
|
|
|
###########################
|
|
#### Prompt Utils #########
|
|
###########################
|
|
|
|
|
|
def _parse_prompt(prompt: Prompt) -> str:
|
|
if prompt.type == PromptType.TEXT:
|
|
return prompt.text_template
|
|
|
|
elif prompt.type == PromptType.LIST:
|
|
messages = [
|
|
{"role": msg.role, "content": msg.content}
|
|
for msg in prompt.messages_template
|
|
]
|
|
return serialize_to_json(messages, indent=4)
|
|
else:
|
|
raise DeepEvalError(f"Invalid prompt type: {prompt.type}")
|
|
|
|
|
|
def _create_prompt(old_prompt: Prompt, new_content: str) -> Prompt:
|
|
prompt_kwargs = {
|
|
"alias": old_prompt.alias,
|
|
"model_settings": old_prompt.model_settings,
|
|
"output_type": old_prompt.output_type,
|
|
"output_schema": old_prompt.output_schema,
|
|
"branch": old_prompt.branch,
|
|
"interpolation_type": old_prompt.interpolation_type,
|
|
"confident_api_key": old_prompt.confident_api_key,
|
|
}
|
|
|
|
if old_prompt.type == PromptType.TEXT:
|
|
prompt_kwargs["text_template"] = new_content
|
|
prompt_kwargs["messages_template"] = None
|
|
|
|
elif old_prompt.type == PromptType.LIST:
|
|
prompt_kwargs["text_template"] = None
|
|
|
|
try:
|
|
parsed_messages: List[Dict[str, str]] = json.loads(new_content)
|
|
|
|
messages_template = [
|
|
PromptMessage(role=msg.get("role"), content=msg.get("content"))
|
|
for msg in parsed_messages
|
|
]
|
|
prompt_kwargs["messages_template"] = messages_template
|
|
|
|
except json.JSONDecodeError as e:
|
|
raise DeepEvalError(
|
|
f"Failed to parse the LLM's rewritten messages into JSON: {e}"
|
|
)
|
|
except Exception as e:
|
|
raise DeepEvalError(f"Failed to reconstruct PromptMessages: {e}")
|
|
|
|
new_prompt = Prompt(**prompt_kwargs)
|
|
|
|
new_prompt.label = old_prompt.label
|
|
|
|
return new_prompt
|