azure-ai-evaluation 1.2.0__py3-none-any.whl → 1.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of azure-ai-evaluation might be problematic. Click here for more details.

Files changed (134) hide show
  1. azure/ai/evaluation/__init__.py +42 -14
  2. azure/ai/evaluation/_azure/_models.py +6 -6
  3. azure/ai/evaluation/_common/constants.py +6 -2
  4. azure/ai/evaluation/_common/rai_service.py +38 -4
  5. azure/ai/evaluation/_common/raiclient/__init__.py +34 -0
  6. azure/ai/evaluation/_common/raiclient/_client.py +128 -0
  7. azure/ai/evaluation/_common/raiclient/_configuration.py +87 -0
  8. azure/ai/evaluation/_common/raiclient/_model_base.py +1235 -0
  9. azure/ai/evaluation/_common/raiclient/_patch.py +20 -0
  10. azure/ai/evaluation/_common/raiclient/_serialization.py +2050 -0
  11. azure/ai/evaluation/_common/raiclient/_version.py +9 -0
  12. azure/ai/evaluation/_common/raiclient/aio/__init__.py +29 -0
  13. azure/ai/evaluation/_common/raiclient/aio/_client.py +130 -0
  14. azure/ai/evaluation/_common/raiclient/aio/_configuration.py +87 -0
  15. azure/ai/evaluation/_common/raiclient/aio/_patch.py +20 -0
  16. azure/ai/evaluation/_common/raiclient/aio/operations/__init__.py +25 -0
  17. azure/ai/evaluation/_common/raiclient/aio/operations/_operations.py +981 -0
  18. azure/ai/evaluation/_common/raiclient/aio/operations/_patch.py +20 -0
  19. azure/ai/evaluation/_common/raiclient/models/__init__.py +60 -0
  20. azure/ai/evaluation/_common/raiclient/models/_enums.py +18 -0
  21. azure/ai/evaluation/_common/raiclient/models/_models.py +651 -0
  22. azure/ai/evaluation/_common/raiclient/models/_patch.py +20 -0
  23. azure/ai/evaluation/_common/raiclient/operations/__init__.py +25 -0
  24. azure/ai/evaluation/_common/raiclient/operations/_operations.py +1225 -0
  25. azure/ai/evaluation/_common/raiclient/operations/_patch.py +20 -0
  26. azure/ai/evaluation/_common/raiclient/py.typed +1 -0
  27. azure/ai/evaluation/_common/utils.py +30 -10
  28. azure/ai/evaluation/_constants.py +10 -0
  29. azure/ai/evaluation/_converters/__init__.py +3 -0
  30. azure/ai/evaluation/_converters/_ai_services.py +804 -0
  31. azure/ai/evaluation/_converters/_models.py +302 -0
  32. azure/ai/evaluation/_evaluate/_batch_run/__init__.py +10 -3
  33. azure/ai/evaluation/_evaluate/_batch_run/_run_submitter_client.py +104 -0
  34. azure/ai/evaluation/_evaluate/_batch_run/batch_clients.py +82 -0
  35. azure/ai/evaluation/_evaluate/_eval_run.py +1 -1
  36. azure/ai/evaluation/_evaluate/_evaluate.py +36 -4
  37. azure/ai/evaluation/_evaluators/_bleu/_bleu.py +23 -3
  38. azure/ai/evaluation/_evaluators/_code_vulnerability/__init__.py +5 -0
  39. azure/ai/evaluation/_evaluators/_code_vulnerability/_code_vulnerability.py +120 -0
  40. azure/ai/evaluation/_evaluators/_coherence/_coherence.py +21 -2
  41. azure/ai/evaluation/_evaluators/_common/_base_eval.py +43 -3
  42. azure/ai/evaluation/_evaluators/_common/_base_multi_eval.py +3 -1
  43. azure/ai/evaluation/_evaluators/_common/_base_prompty_eval.py +43 -4
  44. azure/ai/evaluation/_evaluators/_common/_base_rai_svc_eval.py +16 -4
  45. azure/ai/evaluation/_evaluators/_content_safety/_content_safety.py +42 -5
  46. azure/ai/evaluation/_evaluators/_content_safety/_hate_unfairness.py +15 -0
  47. azure/ai/evaluation/_evaluators/_content_safety/_self_harm.py +15 -0
  48. azure/ai/evaluation/_evaluators/_content_safety/_sexual.py +15 -0
  49. azure/ai/evaluation/_evaluators/_content_safety/_violence.py +15 -0
  50. azure/ai/evaluation/_evaluators/_f1_score/_f1_score.py +28 -4
  51. azure/ai/evaluation/_evaluators/_fluency/_fluency.py +21 -2
  52. azure/ai/evaluation/_evaluators/_gleu/_gleu.py +26 -3
  53. azure/ai/evaluation/_evaluators/_groundedness/_groundedness.py +21 -3
  54. azure/ai/evaluation/_evaluators/_intent_resolution/__init__.py +7 -0
  55. azure/ai/evaluation/_evaluators/_intent_resolution/_intent_resolution.py +152 -0
  56. azure/ai/evaluation/_evaluators/_intent_resolution/intent_resolution.prompty +161 -0
  57. azure/ai/evaluation/_evaluators/_meteor/_meteor.py +26 -3
  58. azure/ai/evaluation/_evaluators/_qa/_qa.py +51 -7
  59. azure/ai/evaluation/_evaluators/_relevance/_relevance.py +26 -2
  60. azure/ai/evaluation/_evaluators/_response_completeness/__init__.py +7 -0
  61. azure/ai/evaluation/_evaluators/_response_completeness/_response_completeness.py +157 -0
  62. azure/ai/evaluation/_evaluators/_response_completeness/response_completeness.prompty +99 -0
  63. azure/ai/evaluation/_evaluators/_retrieval/_retrieval.py +21 -2
  64. azure/ai/evaluation/_evaluators/_rouge/_rouge.py +113 -4
  65. azure/ai/evaluation/_evaluators/_service_groundedness/_service_groundedness.py +23 -3
  66. azure/ai/evaluation/_evaluators/_similarity/_similarity.py +24 -5
  67. azure/ai/evaluation/_evaluators/_task_adherence/__init__.py +7 -0
  68. azure/ai/evaluation/_evaluators/_task_adherence/_task_adherence.py +148 -0
  69. azure/ai/evaluation/_evaluators/_task_adherence/task_adherence.prompty +117 -0
  70. azure/ai/evaluation/_evaluators/_tool_call_accuracy/__init__.py +9 -0
  71. azure/ai/evaluation/_evaluators/_tool_call_accuracy/_tool_call_accuracy.py +292 -0
  72. azure/ai/evaluation/_evaluators/_tool_call_accuracy/tool_call_accuracy.prompty +71 -0
  73. azure/ai/evaluation/_evaluators/_ungrounded_attributes/__init__.py +5 -0
  74. azure/ai/evaluation/_evaluators/_ungrounded_attributes/_ungrounded_attributes.py +103 -0
  75. azure/ai/evaluation/_evaluators/_xpia/xpia.py +2 -0
  76. azure/ai/evaluation/_exceptions.py +5 -1
  77. azure/ai/evaluation/_legacy/__init__.py +3 -0
  78. azure/ai/evaluation/_legacy/_batch_engine/__init__.py +9 -0
  79. azure/ai/evaluation/_legacy/_batch_engine/_config.py +45 -0
  80. azure/ai/evaluation/_legacy/_batch_engine/_engine.py +368 -0
  81. azure/ai/evaluation/_legacy/_batch_engine/_exceptions.py +88 -0
  82. azure/ai/evaluation/_legacy/_batch_engine/_logging.py +292 -0
  83. azure/ai/evaluation/_legacy/_batch_engine/_openai_injector.py +23 -0
  84. azure/ai/evaluation/_legacy/_batch_engine/_result.py +99 -0
  85. azure/ai/evaluation/_legacy/_batch_engine/_run.py +121 -0
  86. azure/ai/evaluation/_legacy/_batch_engine/_run_storage.py +128 -0
  87. azure/ai/evaluation/_legacy/_batch_engine/_run_submitter.py +217 -0
  88. azure/ai/evaluation/_legacy/_batch_engine/_status.py +25 -0
  89. azure/ai/evaluation/_legacy/_batch_engine/_trace.py +105 -0
  90. azure/ai/evaluation/_legacy/_batch_engine/_utils.py +82 -0
  91. azure/ai/evaluation/_legacy/_batch_engine/_utils_deprecated.py +131 -0
  92. azure/ai/evaluation/_legacy/prompty/__init__.py +36 -0
  93. azure/ai/evaluation/_legacy/prompty/_connection.py +182 -0
  94. azure/ai/evaluation/_legacy/prompty/_exceptions.py +59 -0
  95. azure/ai/evaluation/_legacy/prompty/_prompty.py +313 -0
  96. azure/ai/evaluation/_legacy/prompty/_utils.py +545 -0
  97. azure/ai/evaluation/_legacy/prompty/_yaml_utils.py +99 -0
  98. azure/ai/evaluation/_red_team/__init__.py +3 -0
  99. azure/ai/evaluation/_red_team/_attack_objective_generator.py +192 -0
  100. azure/ai/evaluation/_red_team/_attack_strategy.py +42 -0
  101. azure/ai/evaluation/_red_team/_callback_chat_target.py +74 -0
  102. azure/ai/evaluation/_red_team/_default_converter.py +21 -0
  103. azure/ai/evaluation/_red_team/_red_team.py +1858 -0
  104. azure/ai/evaluation/_red_team/_red_team_result.py +246 -0
  105. azure/ai/evaluation/_red_team/_utils/__init__.py +3 -0
  106. azure/ai/evaluation/_red_team/_utils/constants.py +64 -0
  107. azure/ai/evaluation/_red_team/_utils/formatting_utils.py +164 -0
  108. azure/ai/evaluation/_red_team/_utils/logging_utils.py +139 -0
  109. azure/ai/evaluation/_red_team/_utils/strategy_utils.py +188 -0
  110. azure/ai/evaluation/_safety_evaluation/__init__.py +3 -0
  111. azure/ai/evaluation/_safety_evaluation/_generated_rai_client.py +0 -0
  112. azure/ai/evaluation/_safety_evaluation/_safety_evaluation.py +741 -0
  113. azure/ai/evaluation/_version.py +2 -1
  114. azure/ai/evaluation/simulator/_adversarial_scenario.py +3 -1
  115. azure/ai/evaluation/simulator/_adversarial_simulator.py +61 -27
  116. azure/ai/evaluation/simulator/_conversation/__init__.py +4 -5
  117. azure/ai/evaluation/simulator/_conversation/_conversation.py +4 -0
  118. azure/ai/evaluation/simulator/_model_tools/_generated_rai_client.py +145 -0
  119. azure/ai/evaluation/simulator/_model_tools/_proxy_completion_model.py +2 -0
  120. azure/ai/evaluation/simulator/_model_tools/_rai_client.py +71 -1
  121. {azure_ai_evaluation-1.2.0.dist-info → azure_ai_evaluation-1.4.0.dist-info}/METADATA +75 -15
  122. azure_ai_evaluation-1.4.0.dist-info/RECORD +197 -0
  123. {azure_ai_evaluation-1.2.0.dist-info → azure_ai_evaluation-1.4.0.dist-info}/WHEEL +1 -1
  124. azure/ai/evaluation/_evaluators/_multimodal/__init__.py +0 -20
  125. azure/ai/evaluation/_evaluators/_multimodal/_content_safety_multimodal.py +0 -132
  126. azure/ai/evaluation/_evaluators/_multimodal/_content_safety_multimodal_base.py +0 -55
  127. azure/ai/evaluation/_evaluators/_multimodal/_hate_unfairness.py +0 -100
  128. azure/ai/evaluation/_evaluators/_multimodal/_protected_material.py +0 -124
  129. azure/ai/evaluation/_evaluators/_multimodal/_self_harm.py +0 -100
  130. azure/ai/evaluation/_evaluators/_multimodal/_sexual.py +0 -100
  131. azure/ai/evaluation/_evaluators/_multimodal/_violence.py +0 -100
  132. azure_ai_evaluation-1.2.0.dist-info/RECORD +0 -125
  133. {azure_ai_evaluation-1.2.0.dist-info → azure_ai_evaluation-1.4.0.dist-info}/NOTICE.txt +0 -0
  134. {azure_ai_evaluation-1.2.0.dist-info → azure_ai_evaluation-1.4.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,545 @@
1
+ # ---------------------------------------------------------
2
+ # Copyright (c) Microsoft Corporation. All rights reserved.
3
+ # ---------------------------------------------------------
4
+
5
+ import copy
6
+ from dataclasses import dataclass, is_dataclass, fields
7
+ import os
8
+ import re
9
+ import json
10
+ import base64
11
+ from pathlib import Path
12
+ from typing import (
13
+ Any,
14
+ AsyncGenerator,
15
+ Dict,
16
+ Final,
17
+ List,
18
+ Mapping,
19
+ MutableMapping,
20
+ Optional,
21
+ Sequence,
22
+ Set,
23
+ Tuple,
24
+ Type,
25
+ TypeVar,
26
+ Union,
27
+ cast,
28
+ )
29
+
30
+ from jinja2 import Template
31
+ from openai import AsyncStream
32
+ from openai.types.chat import ChatCompletion, ChatCompletionChunk
33
+
34
+ from azure.ai.evaluation._constants import DefaultOpenEncoding
35
+ from azure.ai.evaluation._legacy.prompty._exceptions import (
36
+ InvalidInputError,
37
+ JinjaTemplateError,
38
+ PromptyException,
39
+ )
40
+
41
+ from azure.ai.evaluation._legacy.prompty._yaml_utils import load_yaml
42
+
43
+
44
+ # region: Resolving references
45
+
46
+
47
+ @dataclass
48
+ class PromptyModelConfiguration:
49
+ """
50
+ A dataclass that represents a model config of prompty.
51
+
52
+ :param api: Type of the LLM request, default value is chat.
53
+ :type api: str
54
+ :param configuration: Prompty model connection configuration
55
+ :type configuration: dict
56
+ :param parameters: Params of the LLM request.
57
+ :type parameters: dict
58
+ :param response: Return the complete response or the first choice, default value is first.
59
+ :type response: str
60
+ """
61
+
62
+ configuration: dict
63
+ parameters: Dict[str, Any]
64
+ response: str = "first"
65
+ model: Optional[str] = None
66
+ # _overflow: Dict[str, Any] = field(default_factory=dict)
67
+
68
+ def __post_init__(self):
69
+ if not isinstance(self.configuration, dict):
70
+ raise PromptyException("The configuration of the model must be a dictionary.")
71
+
72
+ if not self.model:
73
+ self.model = self.configuration.get("azure_deployment", None) or self.configuration.get("model", None)
74
+
75
+
76
+ T = TypeVar("T")
77
+
78
+
79
+ def dataclass_from_dict(cls: Type[T], data: Dict[str, Any]) -> T:
80
+ """Helper function to make creating dataclass instances from dictionaries easier.
81
+ Unlike using cls(**data), this function will ignore any keys in the dictionary that
82
+ are not fields in the dataclass. If the dataclass optionally contains an _overflow
83
+ field, any extra key/value paris will be placed in that field.
84
+
85
+ This does no type checking and inspects only the key names.
86
+
87
+ :param Type[T] cls: The dataclass type to create.
88
+ :param Dict[str, Any] data: The dictionary to create the dataclass instance from.
89
+ :return: The dataclass instance.
90
+ :rtype: T
91
+ """
92
+ if not is_dataclass(cls):
93
+ raise ValueError("This function only works with @dataclass Types")
94
+
95
+ fields_set: Set[str] = {f.name for f in fields(cls)}
96
+
97
+ params: Dict[str, Any] = {}
98
+ overflow: Dict[str, Any] = {}
99
+
100
+ for key, value in data.items():
101
+ if key in fields_set:
102
+ params[key] = value
103
+ else:
104
+ overflow[key] = value
105
+
106
+ if "_overflow" in fields_set:
107
+ params["_overflow"] = overflow
108
+
109
+ return cast(T, cls(**params))
110
+
111
+
112
+ def resolve_references(origin: Mapping[str, Any], base_path: Optional[Path] = None) -> Dict[str, Any]:
113
+ """Resolve all reference in the object.
114
+
115
+ :param Mapping[str, Any] origin: The object to resolve.
116
+ :param Path|None base_path: The base path to resolve the file reference.
117
+ :return: The resolved object.
118
+ :rtype: Dict[str, Any]
119
+ """
120
+
121
+ def _resolve_references(origin: Any, base_path: Optional[Path] = None) -> Any:
122
+ if isinstance(origin, str):
123
+ return _resolve_reference(origin, base_path=base_path)
124
+ if isinstance(origin, list):
125
+ return [_resolve_references(item, base_path=base_path) for item in origin]
126
+ if isinstance(origin, dict):
127
+ return {key: _resolve_references(value, base_path=base_path) for key, value in origin.items()}
128
+ return origin
129
+
130
+ return {k: _resolve_references(v, base_path=base_path) for k, v in origin.items()}
131
+
132
+
133
+ def _resolve_reference(reference: str, base_path: Optional[Path] = None) -> Union[str, dict]:
134
+ """
135
+ Resolve the reference, two types are supported, env, file.
136
+ When the string format is ${env:ENV_NAME}, the environment variable value will be returned.
137
+ When the string format is ${file:file_path}, return the loaded json object.
138
+
139
+ :param str reference: The reference string.
140
+ :param Path|None base_path: The base path to resolve the file reference.
141
+ :return: The resolved reference.
142
+ :rtype: str | dict
143
+ """
144
+ pattern = r"\$\{(\w+):(.*)\}"
145
+ match = re.match(pattern, reference)
146
+ if match:
147
+ reference_type, value = match.groups()
148
+ if reference_type == "env":
149
+ return os.environ.get(value, reference)
150
+
151
+ if reference_type == "file":
152
+ if not Path(value).is_absolute() and base_path:
153
+ path = Path(base_path) / value
154
+ else:
155
+ path = Path(value)
156
+
157
+ if not path.exists():
158
+ raise PromptyException(f"Cannot find the reference file {value}.")
159
+
160
+ with open(path, "r", encoding=DefaultOpenEncoding.READ) as f:
161
+ if path.suffix.lower() == ".json":
162
+ return json.load(f)
163
+ if path.suffix.lower() in [".yml", ".yaml"]:
164
+ return load_yaml(f)
165
+ return f.read()
166
+
167
+ # TODO ralphe: logging?
168
+ # logger.warning(f"Unknown reference type {reference_type}, return original value {reference}.")
169
+ return reference
170
+
171
+ return reference
172
+
173
+
174
+ def update_dict_recursively(origin_dict: Mapping[str, Any], overwrite_dict: Mapping[str, Any]) -> Dict[str, Any]:
175
+ updated_dict: Dict[str, Any] = {}
176
+ for k, v in overwrite_dict.items():
177
+ if isinstance(v, dict):
178
+ updated_dict[k] = update_dict_recursively(origin_dict.get(k, {}), v)
179
+ else:
180
+ updated_dict[k] = v
181
+ for k, v in origin_dict.items():
182
+ if k not in updated_dict:
183
+ updated_dict[k] = v
184
+ return updated_dict
185
+
186
+
187
+ # endregion
188
+
189
+
190
+ # region: Jinja template rendering
191
+
192
+ VALID_ROLES = ["system", "user", "assistant", "function"]
193
+ """Valid roles for the OpenAI Chat API"""
194
+
195
+ PROMPTY_ROLE_SEPARATOR_PATTERN = re.compile(
196
+ r"(?i)^\s*#?\s*(" + "|".join(VALID_ROLES) + r")\s*:\s*\n", flags=re.MULTILINE
197
+ )
198
+ """Pattern to match the role separator in a prompty template"""
199
+
200
+ MARKDOWN_IMAGE_PATTERN = re.compile(r"(?P<match>!\[[^\]]*\]\(.*?(?=\"|\))\))", flags=re.MULTILINE)
201
+ """Pattern to match markdown syntax for embedding images such as ![alt text](url).
202
+ This uses a 'hack' where by naming the capture group, using re.split() will cause
203
+ the named capture group to appear in the list of split parts"""
204
+
205
+ IMAGE_URL_PARSING_PATTERN = re.compile(
206
+ r"^!\[(?P<alt_text>[^\]]+)\]\((?P<link>(?P<scheme>[^:]+(?=:))?:?(?P<mime_type>[^;]+(?=;))?;?(?P<data>[^\)]*))\)$"
207
+ )
208
+ """Pattern used to parse the image URL from the markdown syntax. This caputres the following groups:
209
+ - alt_text: The alt text for the image
210
+ - link: The full link
211
+ - scheme: The scheme used in the link (e.g. data, http, https)
212
+ - mime_type: The mime type of the image (only for data URLs)
213
+ - data: The data part of the URL (only for data URLs)
214
+ """
215
+
216
+ DEFAULT_IMAGE_MIME_TYPE: Final[str] = "image/*"
217
+ """The mime type to use when we don't know the image type"""
218
+
219
+ FILE_EXT_TO_MIME: Final[Mapping[str, str]] = {
220
+ ".apng": "image/apng", # cspell:ignore apng
221
+ ".avif": "image/avif",
222
+ ".bmp": "image/bmp",
223
+ ".gif": "image/gif",
224
+ ".heic": "image/heic",
225
+ ".heif": "image/heif",
226
+ ".ico": "image/vnd.microsoft.icon",
227
+ ".jpg": "image/jpeg",
228
+ ".jpeg": "image/jpeg",
229
+ ".png": "image/png",
230
+ ".svg": "image/svg+xml",
231
+ ".tif": "image/tiff",
232
+ ".tiff": "image/tiff",
233
+ ".webp": "image/webp",
234
+ }
235
+ """Mapping of file extensions to mime types"""
236
+
237
+
238
+ def render_jinja_template(template_str: str, *, trim_blocks=True, keep_trailing_newline=True, **kwargs) -> str:
239
+ try:
240
+ template = Template(template_str, trim_blocks=trim_blocks, keep_trailing_newline=keep_trailing_newline)
241
+ return template.render(**kwargs)
242
+ except Exception as e: # pylint: disable=broad-except
243
+ raise PromptyException(f"Failed to render jinja template - {type(e).__name__}: {str(e)}") from e
244
+
245
+
246
+ def build_messages(
247
+ *, prompt: str, working_dir: Path, image_detail: str = "auto", **kwargs: Any
248
+ ) -> Sequence[Mapping[str, Any]]:
249
+ # keep_trailing_newline=True is to keep the last \n in the prompt to avoid converting "user:\t\n" to "user:".
250
+ chat_str = render_jinja_template(prompt, trim_blocks=True, keep_trailing_newline=True, **kwargs)
251
+ messages = _parse_chat(chat_str, working_dir, image_detail)
252
+ return messages
253
+
254
+
255
+ def _parse_chat(chat_str: str, working_dir: Path, image_detail: str) -> Sequence[Mapping[str, Any]]:
256
+ # openai chat api only supports VALID_ROLES as role names.
257
+ # customer can add single # in front of role name for markdown highlight.
258
+ # and we still support role name without # prefix for backward compatibility.
259
+
260
+ chunks = re.split(PROMPTY_ROLE_SEPARATOR_PATTERN, chat_str)
261
+ chat_list: List[Dict[str, Any]] = []
262
+
263
+ for chunk in chunks:
264
+ last_message = chat_list[-1] if len(chat_list) > 0 else None
265
+
266
+ # =======================================================================================================
267
+ # NOTE: The Promptflow code supported tool calls but used eval() to parse them. This is an unacceptable
268
+ # security risk. Since none of the current evaluators use tool calls, this functionality has been
269
+ # removed.
270
+ # =======================================================================================================
271
+
272
+ # if is_tool_chunk(last_message):
273
+ # parse_tools(last_message, chunk, hash2images, image_detail)
274
+ # continue
275
+ # if last_message and "role" in last_message and last_message["role"] == "assistant":
276
+ # parsed_result = _try_parse_tool_calls(chunk)
277
+ # if parsed_result is not None:
278
+ # last_message["tool_calls"] = parsed_result
279
+ # continue
280
+
281
+ if (
282
+ last_message
283
+ and "role" in last_message # pylint: disable=unsupported-membership-test
284
+ and "content" not in last_message # pylint: disable=unsupported-membership-test
285
+ and "tool_calls" not in last_message # pylint: disable=unsupported-membership-test
286
+ ):
287
+ parsed_result = _try_parse_name_and_content(chunk)
288
+ if parsed_result is None:
289
+ if last_message["role"] == "function": # pylint: disable=unsubscriptable-object
290
+ # "name" is required if the role is "function"
291
+ raise JinjaTemplateError(
292
+ "Failed to parse function role prompt. Please make sure the prompt follows the "
293
+ "format: 'name:\\nfunction_name\\ncontent:\\nfunction_content'. "
294
+ "'name' is required if role is function, and it should be the name of the function "
295
+ "whose response is in the content. May contain a-z, A-Z, 0-9, and underscores, "
296
+ "with a maximum length of 64 characters. See more details in "
297
+ "https://platform.openai.com/docs/api-reference/chat/create#chat/create-name"
298
+ )
299
+
300
+ # "name" is optional for other role types.
301
+ last_message["content"] = _to_content_str_or_list( # pylint: disable=unsupported-assignment-operation
302
+ chunk, working_dir, image_detail
303
+ )
304
+ else:
305
+ last_message["name"] = parsed_result[0] # pylint: disable=unsupported-assignment-operation
306
+ last_message["content"] = _to_content_str_or_list( # pylint: disable=unsupported-assignment-operation
307
+ parsed_result[1], working_dir, image_detail
308
+ )
309
+ else:
310
+ if chunk.strip() == "":
311
+ continue
312
+ # Check if prompt follows chat api message format and has valid role.
313
+ # References: https://platform.openai.com/docs/api-reference/chat/create.
314
+ role = chunk.strip().lower()
315
+ _validate_role(role)
316
+ new_message = {"role": role}
317
+ chat_list.append(new_message)
318
+ return chat_list
319
+
320
+
321
+ def _validate_role(role: str):
322
+ if role not in VALID_ROLES:
323
+ valid_roles_str = ", ".join(VALID_ROLES)
324
+ error_message = (
325
+ f"The Chat API requires a specific format for prompt definition, and the prompt should include separate "
326
+ f"lines as role delimiters: {valid_roles_str}.\n"
327
+ f"Current parsed role '{role}' does not meet the requirement. If you intend to use the Completion API, "
328
+ f"please select the appropriate API type and deployment name."
329
+ )
330
+ raise JinjaTemplateError(message=error_message)
331
+
332
+
333
+ def _to_content_str_or_list(chat_str: str, working_dir: Path, image_detail: str) -> Union[str, List[Dict[str, Any]]]:
334
+ chunks = [c for c in (chunk.strip() for chunk in re.split(MARKDOWN_IMAGE_PATTERN, chat_str)) if c]
335
+ if len(chunks) <= 1:
336
+ return chat_str.strip()
337
+
338
+ messages: List[Dict[str, Any]] = []
339
+ for chunk in chunks:
340
+ if chunk.startswith("![") and chunk.endswith(")"):
341
+ messages.append(_inline_image(chunk, working_dir, image_detail))
342
+ else:
343
+ messages.append({"type": "text", "text": chunk})
344
+ return messages
345
+
346
+
347
+ def _inline_image(image: str, working_dir: Path, image_detail: str) -> Dict[str, Any]:
348
+ """This accepts an image URL in markdown format, and parses that into a message containing the image details
349
+ to be sent to AI service. In the case of local file images, they will be loaded and their contents encoded
350
+ into a base 64 data URI. Internal URLs will remained untouched. It can can accept http(s), ftp(s), as well
351
+ as data URIs.
352
+
353
+ :param str image: The image URL in markdown format (e.g. ![alternative text](https://www.bing.com/favicon.ico))
354
+ :param Path working_dir: The working directory to use when resolving relative file paths
355
+ :param str image_detail: The image detail to use when sending the image to the AI service
356
+ :return: The image message to send to the AI service
357
+ :rtype: Mapping[str, Any]"""
358
+
359
+ def local_to_base64(local_file: str, mime_type: Optional[str]) -> str:
360
+ path = Path(local_file)
361
+ if not path.is_absolute():
362
+ path = working_dir / local_file
363
+ if not path.exists():
364
+ # TODO ralphe logging?
365
+ # logger.warning(f"Cannot find the image path {image_content},
366
+ # it will be regarded as {type(image_str)}.")
367
+ raise InvalidInputError(f"Cannot find the image path '{path.as_posix()}'")
368
+
369
+ base64_encoded = base64.b64encode(path.read_bytes()).decode("utf-8")
370
+ if not mime_type:
371
+ mime_type = FILE_EXT_TO_MIME.get(path.suffix.lower(), DEFAULT_IMAGE_MIME_TYPE)
372
+ return f"data:{mime_type};base64,{base64_encoded}"
373
+
374
+ match = re.match(IMAGE_URL_PARSING_PATTERN, image)
375
+ if not match:
376
+ raise InvalidInputError(f"Invalid image URL '{image}'")
377
+
378
+ inlined_uri: str
379
+ mime_type: Optional[str] = None
380
+
381
+ scheme: str = (match.group("scheme") or "").strip().lower()
382
+ if scheme in ["http", "https", "ftp", "ftps"]:
383
+ # nothing special to do here, pass through full URI as is
384
+ inlined_uri = (match.group("link") or "").strip()
385
+ elif scheme == "data":
386
+ mime_type = (match.group("mime_type") or "").strip()
387
+ data: str = (match.group("data") or "").strip()
388
+
389
+ # data urls may contain local paths too
390
+ if data[:5].lower() == "path:":
391
+ inlined_uri = local_to_base64(data[5:].strip(), mime_type)
392
+ elif data[:6].lower() == "base64":
393
+ # nothing special to do here, pass through full URI as is
394
+ inlined_uri = (match.group("link") or "").strip()
395
+ else:
396
+ raise InvalidInputError(f"Invalid image data URL '{image}'")
397
+ else:
398
+ # assume it's a file path
399
+ inlined_uri = local_to_base64((match.group("link") or "").strip(), mime_type)
400
+
401
+ if not inlined_uri:
402
+ raise InvalidInputError(f"Failed to determine how to inline the following image URL '{image}'")
403
+
404
+ return {
405
+ "type": "image_url",
406
+ "image_url": {
407
+ "url": inlined_uri,
408
+ "detail": image_detail,
409
+ },
410
+ }
411
+
412
+
413
+ def _try_parse_name_and_content(role_prompt: str) -> Optional[Tuple[str, str]]:
414
+ # customer can add ## in front of name/content for markdown highlight.
415
+ # and we still support name/content without ## prefix for backward compatibility.
416
+ # TODO ralphe: This maybe has something to do with parsing functions or tool calls but I'm not sure
417
+ pattern = r"\n*#{0,2}\s*name\s*:\s*\n+\s*(\S+)\s*\n*#{0,2}\s*content\s*:\s*\n?(.*)"
418
+ match = re.search(pattern, role_prompt, re.DOTALL)
419
+ if match:
420
+ return match.group(1), match.group(2)
421
+ return None
422
+
423
+
424
+ # endregion
425
+
426
+
427
+ # region OpenAI connections and requests
428
+
429
+ OpenAIChatResponseType = Union[ChatCompletion, AsyncStream[ChatCompletionChunk]]
430
+
431
+
432
+ def prepare_open_ai_request_params(
433
+ model_config: PromptyModelConfiguration, template: Union[str, Sequence[Mapping[str, Any]]]
434
+ ) -> MutableMapping[str, Any]:
435
+ params = copy.deepcopy(model_config.parameters)
436
+ # if isinstance(connection, AzureOpenAIConnection):
437
+ # params.setdefault("extra_headers", {}).update({"ms-azure-ai-promptflow-called-from": "promptflow-core"})
438
+ params["model"] = model_config.model
439
+ params["messages"] = template
440
+
441
+ # NOTE:
442
+ # - Tool calls have been disabled due to a security issue in the implementation. See comment earlier in
443
+ # this file for more details
444
+ # - Removing the validation of function calls in favour of letting the service do that validation. This
445
+ # removes a maintenance burden from the SDK should the service definition for function calls change.
446
+
447
+ # # functions and function_call are deprecated and are replaced by tools and tool_choice.
448
+ # # if both are provided, tools and tool_choice are used and functions and function_call are ignored.
449
+ # if "tools" in params:
450
+ # validate_tools(params["tools"])
451
+ # params["tool_choice"] = validate_tool_choice(params.get("tool_choice", None))
452
+ # else:
453
+ # if "functions" in params:
454
+ # _validate_functions(params["functions"])
455
+ # params["function_call"] = validate_function_call(params.get("function_call", None))
456
+
457
+ return params
458
+
459
+
460
+ async def format_llm_response(
461
+ response: OpenAIChatResponseType,
462
+ is_first_choice: bool,
463
+ response_format: Optional[Mapping[str, Any]] = None,
464
+ outputs: Optional[Mapping[str, Any]] = None,
465
+ ) -> Union[OpenAIChatResponseType, AsyncGenerator[str, None], str, Mapping[str, Any]]:
466
+ """
467
+ Format LLM response
468
+
469
+ If is_first_choice is false, it will directly return LLM response.
470
+ If is_first_choice is true, behavior as blow:
471
+ response_format: type: text
472
+ - n: None/1/2
473
+ Return the first choice content. Return type is string.
474
+ - stream: True
475
+ Return generator list of first choice content. Return type is generator[str]
476
+ response_format: type: json_object
477
+ - n : None/1/2
478
+ Return json dict of the first choice. Return type is dict
479
+ - stream: True
480
+ Return json dict of the first choice. Return type is dict
481
+ - outputs
482
+ Extract corresponding output in the json dict to the first choice. Return type is dict.
483
+
484
+ :param response: LLM response.
485
+ :type response:
486
+ :param is_first_choice: If true, it will return the first item in response choices, else it will return all response
487
+ :type is_first_choice: bool
488
+ :param response_format: An object specifying the format that the model must output.
489
+ :type response_format: str
490
+ :param outputs: Extract corresponding output in json format response
491
+ :type outputs: dict
492
+ :return: Formatted LLM response.
493
+ :rtype: Union[str, dict, Response]
494
+ """
495
+
496
+ def format_choice(item: str) -> Union[str, Mapping[str, Any]]:
497
+ # response_format is one of text or json_object.
498
+ # https://platform.openai.com/docs/api-reference/chat/create#chat-create-response_format
499
+ if not is_json_format:
500
+ return item
501
+
502
+ result_dict = json.loads(item)
503
+ if not outputs:
504
+ return result_dict
505
+
506
+ # return the keys in outputs
507
+ output_results = {}
508
+ for key in outputs:
509
+ if key not in result_dict:
510
+ raise InvalidInputError(f"Cannot find '{key}' in response {list(result_dict.keys())}")
511
+ output_results[key] = result_dict[key]
512
+ return output_results
513
+
514
+ async def format_stream(llm_response: AsyncStream[ChatCompletionChunk]) -> AsyncGenerator[str, None]:
515
+ cur_index = None
516
+ async for chunk in llm_response:
517
+ if len(chunk.choices) > 0 and chunk.choices[0].delta.content:
518
+ if cur_index is None:
519
+ cur_index = chunk.choices[0].index
520
+ if cur_index != chunk.choices[0].index:
521
+ return
522
+ yield chunk.choices[0].delta.content
523
+
524
+ if not is_first_choice:
525
+ return response
526
+
527
+ is_json_format = isinstance(response_format, dict) and response_format.get("type") == "json_object"
528
+ if isinstance(response, AsyncStream):
529
+ if not is_json_format:
530
+ return format_stream(llm_response=response)
531
+ content = "".join([item async for item in format_stream(llm_response=response)])
532
+ return format_choice(content)
533
+
534
+ # When calling function/tool, function_call/tool_call response will be returned as a field in message,
535
+ # so we need return message directly. Otherwise, we only return content.
536
+ # https://platform.openai.com/docs/api-reference/chat/object#chat/object-choices
537
+ if response.choices[0].finish_reason in ["tool_calls", "function_calls"]:
538
+ response_content = response.model_dump()["choices"][0]["message"]
539
+ else:
540
+ response_content = getattr(response.choices[0].message, "content", "")
541
+ result = format_choice(response_content)
542
+ return result
543
+
544
+
545
+ # endregion
@@ -0,0 +1,99 @@
1
+ # ---------------------------------------------------------
2
+ # Copyright (c) Microsoft Corporation. All rights reserved.
3
+ # ---------------------------------------------------------
4
+
5
+ from os import PathLike
6
+ from typing import IO, Any, Dict, Optional, Union, cast
7
+
8
+ from ruamel.yaml import YAML, YAMLError # cspell:ignore ruamel
9
+
10
+ from azure.ai.evaluation._constants import DefaultOpenEncoding
11
+ from azure.ai.evaluation._legacy.prompty._exceptions import MissingRequiredInputError
12
+
13
+
14
+ def load_yaml(source: Optional[Union[str, PathLike, IO]]) -> Dict:
15
+ # null check - just return an empty dict.
16
+ # Certain CLI commands rely on this behavior to produce a resource
17
+ # via CLI, which is then populated through CLArgs.
18
+ """Load a local YAML file or a readable stream object.
19
+
20
+ .. note::
21
+
22
+ 1. For a local file yaml
23
+
24
+ .. code-block:: python
25
+
26
+ yaml_path = "path/to/yaml"
27
+ content = load_yaml(yaml_path)
28
+
29
+ 2. For a readable stream object
30
+
31
+ .. code-block:: python
32
+
33
+ with open("path/to/yaml", "r", encoding="utf-8") as f:
34
+ content = load_yaml(f)
35
+
36
+
37
+ :param source: The relative or absolute path to the local file, or a readable stream object.
38
+ :type source: str
39
+ :return: A dictionary representation of the local file's contents.
40
+ :rtype: Dict
41
+ """
42
+
43
+ if source is None:
44
+ return {}
45
+
46
+ # pylint: disable=redefined-builtin
47
+ input: Optional[IO] = None
48
+ must_open_file = False
49
+ try: # check source type by duck-typing it as an IOBase
50
+ readable = cast(IO, source).readable()
51
+ if not readable: # source is misformatted stream or file
52
+ msg = "File Permissions Error: The already-open \n\n inputted file is not readable."
53
+ raise PermissionError(msg)
54
+ # source is an already-open stream or file, we can read() from it directly.
55
+ input = cast(IO, source)
56
+ except AttributeError:
57
+ # source has no writable() function, assume it's a string or file path.
58
+ must_open_file = True
59
+
60
+ if must_open_file: # If supplied a file path, open it.
61
+ try:
62
+ input = open( # pylint: disable=consider-using-with
63
+ cast(Union[PathLike, str], source), "r", encoding=DefaultOpenEncoding.READ
64
+ )
65
+ except OSError: # FileNotFoundError introduced in Python 3
66
+ e = FileNotFoundError(f"No such file or directory: {source}")
67
+ raise MissingRequiredInputError(str(e), privacy_info=[str(source)]) from e
68
+ # input should now be a readable file or stream. Parse it.
69
+ try:
70
+ yaml = YAML()
71
+ yaml.preserve_quotes = True
72
+ cfg = yaml.load(input)
73
+ except YAMLError as e:
74
+ msg = f"Error while parsing yaml file: {source} \n\n {str(e)}"
75
+ raise YAMLError(msg) from e
76
+ finally:
77
+ if input and must_open_file:
78
+ input.close()
79
+
80
+ return cfg or {}
81
+
82
+
83
+ def load_yaml_string(yaml_string: str) -> Dict[str, Any]:
84
+ """Load a yaml string.
85
+
86
+ .. code-block:: python
87
+
88
+ yaml_string = "some yaml string"
89
+ object = load_yaml_string(yaml_string)
90
+
91
+
92
+ :param yaml_string: A yaml string.
93
+ :type yaml_string: str
94
+ :return: A dictionary representation of the yaml string.
95
+ :rtype: Dict
96
+ """
97
+ yaml = YAML()
98
+ yaml.preserve_quotes = True
99
+ return yaml.load(yaml_string)
@@ -0,0 +1,3 @@
1
+ # ---------------------------------------------------------
2
+ # Copyright (c) Microsoft Corporation. All rights reserved.
3
+ # ---------------------------------------------------------