Coverage for haystack/components/evaluators/llm_evaluator.py: 99%

160 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-21 13:53 +0000

1# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai> 

2# 

3# SPDX-License-Identifier: Apache-2.0 

4 

5import asyncio 

6import json 

7from typing import Any 

8 

9from tqdm import tqdm 

10from tqdm.asyncio import tqdm as async_tqdm 

11 

12from haystack import component, default_from_dict, default_to_dict, logging 

13from haystack.components.builders import PromptBuilder 

14from haystack.components.generators.chat.openai import OpenAIChatGenerator 

15from haystack.components.generators.chat.types import ChatGenerator 

16from haystack.components.generators.utils import _trace_chat_generator_run 

17from haystack.core.serialization import component_to_dict 

18from haystack.dataclasses.chat_message import ChatMessage 

19from haystack.utils import deserialize_chatgenerator_inplace, deserialize_type, serialize_type 

20from haystack.utils.misc import _parse_dict_from_json 

21 

22logger = logging.getLogger(__name__) 

23 

24 

25@component 

26class LLMEvaluator: 

27 """ 

28 Uses an LLM to evaluate inputs based on a prompt containing instructions and examples. 

29 

30 The default API requires an OpenAI API key to be provided as an environment variable "OPENAI_API_KEY". 

31 The inputs are lists that are user-defined depending on the desired metric. 

32 The output is a dictionary with a key `results` containing a list of evaluation results. 

33 Each result is a dictionary with user-defined keys and values of either 0 for FALSE or 1 for TRUE respectively. 

34 

35 Usage example: 

36 ```python 

37 from haystack.components.evaluators import LLMEvaluator 

38 evaluator = LLMEvaluator( 

39 instructions="Is this answer problematic for children?", 

40 inputs=[("predicted_answers", list[str])], 

41 outputs=["score"], 

42 examples=[ 

43 {"inputs": {"predicted_answers": "Damn, this is straight outta hell!!!"}, "outputs": {"score": 1}}, 

44 {"inputs": {"predicted_answers": "Football is the most popular sport."}, "outputs": {"score": 0}}, 

45 ], 

46 ) 

47 predicted_answers = [ 

48 "Football is the most popular sport with around 4 billion followers worldwide", 

49 "Python language was created by Guido van Rossum.", 

50 ] 

51 results = evaluator.run(predicted_answers=predicted_answers) 

52 print(results) 

53 # {'results': [{'score': 0}, {'score': 0}]} 

54 ``` 

55 """ 

56 

57 def __init__( 

58 self, 

59 instructions: str, 

60 inputs: list[tuple[str, type[list]]], 

61 outputs: list[str], 

62 examples: list[dict[str, Any]], 

63 progress_bar: bool = True, 

64 *, 

65 raise_on_failure: bool = True, 

66 chat_generator: ChatGenerator | None = None, 

67 ) -> None: 

68 """ 

69 Creates an instance of LLMEvaluator. 

70 

71 If no LLM is specified using the `chat_generator` parameter, the component will use OpenAI in JSON mode. 

72 

73 :param instructions: 

74 The prompt instructions to use for evaluation. 

75 Should be a question about the inputs that can be answered with yes or no. 

76 :param inputs: 

77 The inputs that the component expects as incoming connections and that it evaluates. 

78 Each input is a tuple of an input name and input type. Input types must be lists. 

79 :param outputs: 

80 Output names of the evaluation results. They correspond to keys in the output dictionary. 

81 :param examples: 

82 Few-shot examples conforming to the expected input and output format as defined in the `inputs` and 

83 `outputs` parameters. 

84 Each example is a dictionary with keys "inputs" and "outputs" 

85 They contain the input and output as dictionaries respectively. 

86 :param raise_on_failure: 

87 If True, the component will raise an exception on an unsuccessful API call. 

88 :param progress_bar: 

89 Whether to show a progress bar during the evaluation. 

90 :param chat_generator: 

91 a ChatGenerator instance which represents the LLM. 

92 In order for the component to work, the LLM should be configured to return a JSON object. For example, 

93 when using the OpenAIChatGenerator, you should pass `{"response_format": {"type": "json_object"}}` in the 

94 `generation_kwargs`. 

95 """ 

96 self.validate_init_parameters(inputs, outputs, examples) 

97 component.set_input_types(self, **dict(inputs)) 

98 

99 self.raise_on_failure = raise_on_failure 

100 self.instructions = instructions 

101 self.inputs = inputs 

102 self.outputs = outputs 

103 self.examples = examples 

104 self.progress_bar = progress_bar 

105 

106 template = self.prepare_template() 

107 self.builder = PromptBuilder(template=template) 

108 

109 if chat_generator is not None: 

110 self._chat_generator = chat_generator 

111 else: 

112 generation_kwargs = {"response_format": {"type": "json_object"}, "seed": 42} 

113 self._chat_generator = OpenAIChatGenerator(generation_kwargs=generation_kwargs) 

114 

115 def warm_up(self) -> None: 

116 """ 

117 Warm up the underlying chat generator. 

118 """ 

119 if hasattr(self._chat_generator, "warm_up"): 

120 self._chat_generator.warm_up() 

121 

122 async def warm_up_async(self) -> None: 

123 """ 

124 Warm up the underlying chat generator on the serving event loop. 

125 """ 

126 if hasattr(self._chat_generator, "warm_up_async"): 

127 await self._chat_generator.warm_up_async() 

128 elif hasattr(self._chat_generator, "warm_up"): 

129 self._chat_generator.warm_up() 

130 

131 def close(self) -> None: 

132 """ 

133 Release the underlying chat generator's resources. 

134 """ 

135 if hasattr(self._chat_generator, "close"): 

136 self._chat_generator.close() 

137 

138 async def close_async(self) -> None: 

139 """ 

140 Release the underlying chat generator's async resources. 

141 """ 

142 if hasattr(self._chat_generator, "close_async"): 

143 await self._chat_generator.close_async() 

144 elif hasattr(self._chat_generator, "close"): 

145 self._chat_generator.close() 

146 

147 @staticmethod 

148 def validate_init_parameters( 

149 inputs: list[tuple[str, type[list]]], outputs: list[str], examples: list[dict[str, Any]] 

150 ) -> None: 

151 """ 

152 Validate the init parameters. 

153 

154 :param inputs: 

155 The inputs to validate. 

156 :param outputs: 

157 The outputs to validate. 

158 :param examples: 

159 The examples to validate. 

160 

161 :raises ValueError: 

162 If the inputs are not a list of tuples with a string and a type of list. 

163 If the outputs are not a list of strings. 

164 If the examples are not a list of dictionaries. 

165 If any example does not have keys "inputs" and "outputs" with values that are dictionaries with string keys. 

166 """ 

167 # Validate inputs 

168 if ( 

169 not isinstance(inputs, list) 

170 or not all(isinstance(_input, tuple) for _input in inputs) 

171 or not all(isinstance(_input[0], str) and _input[1] is not list and len(_input) == 2 for _input in inputs) 

172 ): 

173 msg = ( 

174 f"LLM evaluator expects inputs to be a list of tuples. Each tuple must contain an input name and " 

175 f"type of list but received {inputs}." 

176 ) 

177 raise ValueError(msg) 

178 

179 # Validate outputs 

180 if not isinstance(outputs, list) or not all(isinstance(output, str) for output in outputs): 

181 msg = f"LLM evaluator expects outputs to be a list of str but received {outputs}." 

182 raise ValueError(msg) 

183 

184 # Validate examples are lists of dicts 

185 if not isinstance(examples, list) or not all(isinstance(example, dict) for example in examples): 

186 msg = f"LLM evaluator expects examples to be a list of dictionaries but received {examples}." 

187 raise ValueError(msg) 

188 

189 # Validate each example 

190 for example in examples: 

191 if ( 

192 {"inputs", "outputs"} != example.keys() 

193 or not all(isinstance(example[param], dict) for param in ["inputs", "outputs"]) 

194 or not all(isinstance(key, str) for param in ["inputs", "outputs"] for key in example[param]) 

195 ): 

196 msg = ( 

197 f"LLM evaluator expects each example to have keys `inputs` and `outputs` with values that are " 

198 f"dictionaries with str keys but received {example}." 

199 ) 

200 raise ValueError(msg) 

201 

202 @component.output_types(results=list[dict[str, Any]], meta=list[dict[str, Any]] | None) 

203 def run(self, **inputs: Any) -> dict[str, Any]: 

204 """ 

205 Run the LLM evaluator. 

206 

207 :param inputs: 

208 The input values to evaluate. The keys are the input names and the values are lists of input values. 

209 :returns: 

210 A dictionary with a `results` entry that contains a list of results. 

211 Each result is a dictionary containing the keys as defined in the `outputs` parameter of the LLMEvaluator 

212 and the evaluation results as the values. If an exception occurs for a particular input value, the result 

213 will be `None` for that entry. 

214 If the API is "openai" and the response contains a "meta" key, the metadata from OpenAI will be included 

215 in the output dictionary, under the key "meta". 

216 :raises ValueError: 

217 Only in the case that `raise_on_failure` is set to True and the received inputs are not lists or have 

218 different lengths, or if the output is not a valid JSON or doesn't contain the expected keys. 

219 """ 

220 self.warm_up() 

221 

222 self.validate_input_parameters(dict(self.inputs), inputs) 

223 

224 # inputs is a dictionary with keys being input names and values being a list of input values 

225 # We need to iterate through the lists in parallel for all keys of the dictionary 

226 input_names, values = inputs.keys(), list(zip(*inputs.values(), strict=True)) 

227 list_of_input_names_to_values = [dict(zip(input_names, v, strict=True)) for v in values] 

228 

229 results: list[dict[str, Any] | None] = [] 

230 metadata = [] 

231 errors = 0 

232 for input_names_to_values in tqdm(list_of_input_names_to_values, disable=not self.progress_bar): 

233 prompt = self.builder.run(**input_names_to_values) 

234 messages = [ChatMessage.from_user(prompt["prompt"])] 

235 try: 

236 with _trace_chat_generator_run(self._chat_generator, {"messages": messages}) as span: 

237 result = self._chat_generator.run(messages=messages) 

238 span.set_content_tag("haystack.component.output", result) 

239 except Exception as e: 

240 if self.raise_on_failure: 

241 raise ValueError(f"Error while generating response for prompt: {prompt}. Error: {e}") from e 

242 logger.warning("Error while generating response for prompt: {prompt}. Error: {e}", prompt=prompt, e=e) 

243 results.append(None) 

244 errors += 1 

245 continue 

246 

247 parsed_result = _parse_dict_from_json( 

248 result["replies"][0].text, expected_keys=self.outputs, raise_on_failure=self.raise_on_failure 

249 ) 

250 if parsed_result is None: 

251 results.append(None) 

252 errors += 1 

253 else: 

254 results.append(parsed_result) 

255 

256 if result["replies"][0].meta: 

257 metadata.append(result["replies"][0].meta) 

258 

259 if errors > 0: 

260 logger.warning( 

261 "LLM evaluator failed for {errors} out of {len(list_of_input_names_to_values)} inputs.", 

262 errors=errors, 

263 len=len(list_of_input_names_to_values), 

264 ) 

265 

266 return {"results": results, "meta": metadata or None} 

267 

268 @component.output_types(results=list[dict[str, Any]], meta=list[dict[str, Any]] | None) 

269 async def run_async(self, **inputs: Any) -> dict[str, Any]: 

270 """ 

271 Run the LLM evaluator asynchronously 

272 

273 :param inputs: 

274 The input values to evaluate. The keys are the input names and the values are lists of input values. 

275 :returns: 

276 A dictionary with a `results` entry that contains a list of results. 

277 Each result is a dictionary containing the keys as defined in the `outputs` parameter of the LLMEvaluator 

278 and the evaluation results as the values. If an exception occurs for a particular input value, the result 

279 will be `None` for that entry. 

280 If the API is "openai" and the response contains a "meta" key, the metadata from OpenAI will be included 

281 in the output dictionary, under the key "meta". 

282 :raises TypeError: 

283 If the chat generator does not support async execution. 

284 :raises ValueError: 

285 Only in the case that `raise_on_failure` is set to True and the received inputs are not lists or have 

286 different lengths, or if the output is not a valid JSON or doesn't contain the expected keys. 

287 """ 

288 

289 await self.warm_up_async() 

290 

291 self.validate_input_parameters(dict(self.inputs), inputs) 

292 

293 # inputs is a dictionary with keys being input names and values being a list of input values 

294 # We need to iterate through the lists in parallel for all keys of the dictionary 

295 input_names, values = inputs.keys(), list(zip(*inputs.values(), strict=True)) 

296 list_of_input_names_to_values = [dict(zip(input_names, v, strict=True)) for v in values] 

297 

298 results: list[dict[str, Any] | None] = [] 

299 metadata = [] 

300 errors = 0 

301 

302 generator_has_async = hasattr(self._chat_generator, "run_async") 

303 for input_names_to_values in async_tqdm(list_of_input_names_to_values, disable=not self.progress_bar): 

304 prompt = self.builder.run(**input_names_to_values) 

305 messages = [ChatMessage.from_user(prompt["prompt"])] 

306 try: 

307 with _trace_chat_generator_run(self._chat_generator, {"messages": messages}) as span: 

308 if generator_has_async: 

309 result = await self._chat_generator.run_async(messages=messages) # type: ignore[attr-defined] 

310 else: 

311 logger.debug( 

312 "{generator_type} does not implement 'run_async'." 

313 " Running the synchronous 'run' method in a thread to avoid blocking the event loop.", 

314 generator_type=type(self._chat_generator).__name__, 

315 ) 

316 result = await asyncio.to_thread(self._chat_generator.run, messages=messages) 

317 span.set_content_tag("haystack.component.output", result) 

318 except Exception as e: 

319 if self.raise_on_failure: 

320 raise ValueError(f"Error while generating response for prompt: {prompt}. Error: {e}") from e 

321 logger.warning("Error while generating response for prompt: {prompt}. Error: {e}", prompt=prompt, e=e) 

322 results.append(None) 

323 errors += 1 

324 continue 

325 

326 parsed_result = _parse_dict_from_json( 

327 result["replies"][0].text, expected_keys=self.outputs, raise_on_failure=self.raise_on_failure 

328 ) 

329 if parsed_result is None: 

330 results.append(None) 

331 errors += 1 

332 else: 

333 results.append(parsed_result) 

334 

335 if result["replies"][0].meta: 

336 metadata.append(result["replies"][0].meta) 

337 

338 if errors > 0: 

339 logger.warning( 

340 "LLM evaluator failed for {errors} out of {len(list_of_input_names_to_values)} inputs.", 

341 errors=errors, 

342 len=len(list_of_input_names_to_values), 

343 ) 

344 

345 return {"results": results, "meta": metadata or None} 

346 

347 def prepare_template(self) -> str: 

348 """ 

349 Prepare the prompt template. 

350 

351 Combine instructions, inputs, outputs, and examples into one prompt template with the following format: 

352 Instructions: 

353 `<instructions>` 

354 

355 Generate the response in JSON format with the following keys: 

356 `<list of output keys>` 

357 Consider the instructions and the examples below to determine those values. 

358 

359 Examples: 

360 `<examples>` 

361 

362 Inputs: 

363 `<inputs>` 

364 Outputs: 

365 

366 :returns: 

367 The prompt template. 

368 """ 

369 inputs_section = ( 

370 "{" + ", ".join([f'"{input_socket[0]}": {{{{ {input_socket[0]} }}}}' for input_socket in self.inputs]) + "}" 

371 ) 

372 

373 examples_section = "\n".join( 

374 [ 

375 "Inputs:\n" + json.dumps(example["inputs"]) + "\nOutputs:\n" + json.dumps(example["outputs"]) 

376 for example in self.examples 

377 ] 

378 ) 

379 return ( 

380 f"Instructions:\n" 

381 f"{self.instructions}\n\n" 

382 f"Generate the response in JSON format with the following keys:\n" 

383 f"{json.dumps(self.outputs)}\n" 

384 f"Consider the instructions and the examples below to determine those values.\n\n" 

385 f"Examples:\n" 

386 f"{examples_section}\n\n" 

387 f"Inputs:\n" 

388 f"{inputs_section}\n" 

389 f"Outputs:\n" 

390 ) 

391 

392 def to_dict(self) -> dict[str, Any]: 

393 """ 

394 Serialize this component to a dictionary. 

395 

396 :returns: 

397 The serialized component as a dictionary. 

398 """ 

399 # Since we cannot currently serialize tuples, convert the inputs to a list. 

400 inputs = [[name, serialize_type(type_)] for name, type_ in self.inputs] 

401 return default_to_dict( 

402 self, 

403 instructions=self.instructions, 

404 inputs=inputs, 

405 outputs=self.outputs, 

406 examples=self.examples, 

407 chat_generator=component_to_dict(obj=self._chat_generator, name="chat_generator"), 

408 progress_bar=self.progress_bar, 

409 raise_on_failure=self.raise_on_failure, 

410 ) 

411 

412 @classmethod 

413 def from_dict(cls, data: dict[str, Any]) -> "LLMEvaluator": 

414 """ 

415 Deserialize this component from a dictionary. 

416 

417 :param data: 

418 The dictionary representation of this component. 

419 :returns: 

420 The deserialized component instance. 

421 """ 

422 data["init_parameters"]["inputs"] = [ 

423 (name, deserialize_type(type_)) for name, type_ in data["init_parameters"]["inputs"] 

424 ] 

425 

426 if data["init_parameters"].get("chat_generator"): 

427 deserialize_chatgenerator_inplace(data["init_parameters"], key="chat_generator") 

428 

429 return default_from_dict(cls, data) 

430 

431 @staticmethod 

432 def validate_input_parameters(expected: dict[str, Any], received: dict[str, Any]) -> None: 

433 """ 

434 Validate the input parameters. 

435 

436 :param expected: 

437 The expected input parameters. 

438 :param received: 

439 The received input parameters. 

440 

441 :raises ValueError: 

442 If not all expected inputs are present in the received inputs 

443 If the received inputs are not lists or have different lengths 

444 """ 

445 # Validate that all expected inputs are present in the received inputs 

446 for param in expected: 

447 if param not in received: 

448 msg = f"LLM evaluator expected input parameter '{param}' but received only {received.keys()}." 

449 raise ValueError(msg) 

450 

451 # Validate that all received inputs are lists 

452 if not all(isinstance(_input, list) for _input in received.values()): 

453 msg = ( 

454 "LLM evaluator expects all input values to be lists but received " 

455 f"{[type(_input) for _input in received.values()]}." 

456 ) 

457 raise ValueError(msg) 

458 

459 # Validate that all received inputs are of the same length 

460 inputs = received.values() 

461 length = len(next(iter(inputs))) 

462 if not all(len(_input) == length for _input in inputs): 

463 msg = ( 

464 f"LLM evaluator expects all input lists to have the same length but received {inputs} with lengths " 

465 f"{[len(_input) for _input in inputs]}." 

466 ) 

467 raise ValueError(msg)