learning-commons-evaluators 0.1.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.
Files changed (35) hide show
  1. learning_commons_evaluators/__init__.py +154 -0
  2. learning_commons_evaluators/config.py +27 -0
  3. learning_commons_evaluators/errors.py +31 -0
  4. learning_commons_evaluators/evaluators/__init__.py +29 -0
  5. learning_commons_evaluators/evaluators/base.py +434 -0
  6. learning_commons_evaluators/evaluators/conventionality.py +117 -0
  7. learning_commons_evaluators/evaluators/vocabulary.py +195 -0
  8. learning_commons_evaluators/logger.py +99 -0
  9. learning_commons_evaluators/providers/__init__.py +15 -0
  10. learning_commons_evaluators/providers/langchain.py +108 -0
  11. learning_commons_evaluators/py.typed +0 -0
  12. learning_commons_evaluators/schemas/__init__.py +75 -0
  13. learning_commons_evaluators/schemas/common_inputs.py +101 -0
  14. learning_commons_evaluators/schemas/config.py +166 -0
  15. learning_commons_evaluators/schemas/conventionality.py +32 -0
  16. learning_commons_evaluators/schemas/errors.py +592 -0
  17. learning_commons_evaluators/schemas/evaluator.py +220 -0
  18. learning_commons_evaluators/schemas/input_specs.py +131 -0
  19. learning_commons_evaluators/schemas/metadata.py +132 -0
  20. learning_commons_evaluators/schemas/text_complexity.py +48 -0
  21. learning_commons_evaluators/schemas/vocabulary.py +68 -0
  22. learning_commons_evaluators/settings/__init__.py +9 -0
  23. learning_commons_evaluators/settings/_generated_conventionality_settings.py +101 -0
  24. learning_commons_evaluators/settings/_generated_vocabulary_settings.py +404 -0
  25. learning_commons_evaluators/settings/conventionality/__init__.py +0 -0
  26. learning_commons_evaluators/settings/conventionality/contracts.toml +130 -0
  27. learning_commons_evaluators/settings/load_settings.py +196 -0
  28. learning_commons_evaluators/settings/vocabulary/__init__.py +0 -0
  29. learning_commons_evaluators/settings/vocabulary/contracts.toml +641 -0
  30. learning_commons_evaluators/version.py +10 -0
  31. learning_commons_evaluators-0.1.0.dist-info/METADATA +93 -0
  32. learning_commons_evaluators-0.1.0.dist-info/RECORD +35 -0
  33. learning_commons_evaluators-0.1.0.dist-info/WHEEL +5 -0
  34. learning_commons_evaluators-0.1.0.dist-info/licenses/LICENSE +21 -0
  35. learning_commons_evaluators-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,154 @@
1
+ """Learning Commons Evaluators – Python SDK for educational text evaluators."""
2
+
3
+ # Bind __version__ first so consumers reading it during package init never see AttributeError.
4
+ from learning_commons_evaluators.version import __description__, __version__ # noqa: I001
5
+
6
+ # Config
7
+ from learning_commons_evaluators.config import (
8
+ AnthropicLLMProviderConfig,
9
+ EvaluatorConfig,
10
+ GoogleLLMProviderConfig,
11
+ LLMProviderConfig,
12
+ OpenAILLMProviderConfig,
13
+ PromptSettings,
14
+ TelemetryConfig,
15
+ create_config,
16
+ create_config_no_telemetry,
17
+ create_config_telemetry_with_full_input,
18
+ )
19
+
20
+ # Errors
21
+ from learning_commons_evaluators.errors import (
22
+ APIError,
23
+ AuthenticationError,
24
+ ConfigurationError,
25
+ EvaluatorError,
26
+ InputValidationError,
27
+ NetworkError,
28
+ OutputValidationError,
29
+ RateLimitError,
30
+ RequestTimeoutError,
31
+ wrap_provider_error,
32
+ )
33
+
34
+ # Evaluators
35
+ from learning_commons_evaluators.evaluators import (
36
+ BaseEvaluator,
37
+ ConventionalityEvaluator,
38
+ InputT,
39
+ OutputT,
40
+ VocabularyEvaluationInput,
41
+ VocabularyEvaluator,
42
+ )
43
+ from learning_commons_evaluators.evaluators.conventionality import (
44
+ ConventionalityEvaluationInput,
45
+ )
46
+
47
+ # Logger (uses Python standard logging)
48
+ from learning_commons_evaluators.logger import (
49
+ SDK_LOGGER_NAME,
50
+ Logger,
51
+ create_logger,
52
+ create_silent_logger,
53
+ get_logger,
54
+ )
55
+ from learning_commons_evaluators.schemas.common_inputs import (
56
+ GradeInputField,
57
+ TextInputField,
58
+ )
59
+ from learning_commons_evaluators.schemas.config import EvaluationSettings, LLMProvider
60
+ from learning_commons_evaluators.schemas.conventionality import (
61
+ ConventionalityEvaluationSettings,
62
+ ConventionalityOutput,
63
+ )
64
+
65
+ # Schemas (core)
66
+ from learning_commons_evaluators.schemas.evaluator import (
67
+ EvaluationAnswer,
68
+ EvaluationExplanation,
69
+ EvaluationInput,
70
+ EvaluationResult,
71
+ InputField,
72
+ )
73
+ from learning_commons_evaluators.schemas.input_specs import (
74
+ AnyInputSpec,
75
+ GradeInputSpec,
76
+ InputSpec,
77
+ TextInputSpec,
78
+ )
79
+ from learning_commons_evaluators.schemas.metadata import (
80
+ EvaluationMetadata,
81
+ EvaluatorMaturity,
82
+ EvaluatorMetadata,
83
+ Status,
84
+ TokenUsage,
85
+ )
86
+ from learning_commons_evaluators.schemas.text_complexity import (
87
+ TextComplexityEvaluationInput,
88
+ )
89
+ from learning_commons_evaluators.schemas.vocabulary import (
90
+ VocabularyComplexityOutput,
91
+ VocabularyEvaluationSettings,
92
+ normalize_complexity_output,
93
+ )
94
+
95
+ __all__ = [
96
+ "__description__",
97
+ "__version__",
98
+ "APIError",
99
+ "AnthropicLLMProviderConfig",
100
+ "AuthenticationError",
101
+ "BaseEvaluator",
102
+ "ConfigurationError",
103
+ "ConventionalityEvaluationInput",
104
+ "ConventionalityEvaluationSettings",
105
+ "ConventionalityEvaluator",
106
+ "ConventionalityOutput",
107
+ "EvaluationAnswer",
108
+ "EvaluationExplanation",
109
+ "EvaluationInput",
110
+ "EvaluationMetadata",
111
+ "EvaluationResult",
112
+ "EvaluationSettings",
113
+ "EvaluatorConfig",
114
+ "EvaluatorError",
115
+ "EvaluatorMaturity",
116
+ "EvaluatorMetadata",
117
+ "GoogleLLMProviderConfig",
118
+ "AnyInputSpec",
119
+ "GradeInputField",
120
+ "GradeInputSpec",
121
+ "InputField",
122
+ "InputSpec",
123
+ "InputT",
124
+ "InputValidationError",
125
+ "TextInputSpec",
126
+ "LLMProvider",
127
+ "LLMProviderConfig",
128
+ "Logger",
129
+ "NetworkError",
130
+ "OpenAILLMProviderConfig",
131
+ "OutputT",
132
+ "OutputValidationError",
133
+ "PromptSettings",
134
+ "RateLimitError",
135
+ "RequestTimeoutError",
136
+ "SDK_LOGGER_NAME",
137
+ "Status",
138
+ "TelemetryConfig",
139
+ "TextComplexityEvaluationInput",
140
+ "TextInputField",
141
+ "TokenUsage",
142
+ "VocabularyEvaluationInput",
143
+ "VocabularyEvaluationSettings",
144
+ "VocabularyEvaluator",
145
+ "VocabularyComplexityOutput",
146
+ "normalize_complexity_output",
147
+ "create_config",
148
+ "create_config_no_telemetry",
149
+ "create_config_telemetry_with_full_input",
150
+ "create_logger",
151
+ "create_silent_logger",
152
+ "get_logger",
153
+ "wrap_provider_error",
154
+ ]
@@ -0,0 +1,27 @@
1
+ """Re-export evaluator config from schemas.config for package-level imports."""
2
+
3
+ from learning_commons_evaluators.schemas.config import (
4
+ AnthropicLLMProviderConfig,
5
+ EvaluatorConfig,
6
+ GoogleLLMProviderConfig,
7
+ LLMProviderConfig,
8
+ OpenAILLMProviderConfig,
9
+ PromptSettings,
10
+ TelemetryConfig,
11
+ create_config,
12
+ create_config_no_telemetry,
13
+ create_config_telemetry_with_full_input,
14
+ )
15
+
16
+ __all__ = [
17
+ "AnthropicLLMProviderConfig",
18
+ "EvaluatorConfig",
19
+ "GoogleLLMProviderConfig",
20
+ "OpenAILLMProviderConfig",
21
+ "LLMProviderConfig",
22
+ "PromptSettings",
23
+ "TelemetryConfig",
24
+ "create_config",
25
+ "create_config_no_telemetry",
26
+ "create_config_telemetry_with_full_input",
27
+ ]
@@ -0,0 +1,31 @@
1
+ """Re-export error types from schemas.errors for package-level imports."""
2
+
3
+ from learning_commons_evaluators.schemas.errors import (
4
+ APIError,
5
+ AuthenticationError,
6
+ ConfigurationError,
7
+ EvaluatorError,
8
+ InputValidationError,
9
+ NetworkError,
10
+ OutputValidationError,
11
+ RateLimitError,
12
+ RequestTimeoutError,
13
+ format_error_for_metadata,
14
+ sanitize_pydantic_errors,
15
+ wrap_provider_error,
16
+ )
17
+
18
+ __all__ = [
19
+ "APIError",
20
+ "AuthenticationError",
21
+ "ConfigurationError",
22
+ "EvaluatorError",
23
+ "InputValidationError",
24
+ "NetworkError",
25
+ "OutputValidationError",
26
+ "RateLimitError",
27
+ "RequestTimeoutError",
28
+ "format_error_for_metadata",
29
+ "sanitize_pydantic_errors",
30
+ "wrap_provider_error",
31
+ ]
@@ -0,0 +1,29 @@
1
+ """Evaluator implementations.
2
+
3
+ Config types (``EvaluatorConfig``, ``GoogleLLMProviderConfig``, etc.) are
4
+ exported from the top-level :mod:`learning_commons_evaluators` package, not
5
+ from here. Import evaluator classes directly from this sub-package only when
6
+ you want to be explicit about the source.
7
+ """
8
+
9
+ from learning_commons_evaluators.evaluators.base import BaseEvaluator, InputT, OutputT
10
+ from learning_commons_evaluators.evaluators.conventionality import (
11
+ ConventionalityEvaluationInput,
12
+ ConventionalityEvaluator,
13
+ )
14
+ from learning_commons_evaluators.evaluators.vocabulary import (
15
+ VocabularyEvaluationInput,
16
+ VocabularyEvaluator,
17
+ )
18
+ from learning_commons_evaluators.schemas.vocabulary import normalize_complexity_output
19
+
20
+ __all__ = [
21
+ "BaseEvaluator",
22
+ "ConventionalityEvaluationInput",
23
+ "ConventionalityEvaluator",
24
+ "InputT",
25
+ "OutputT",
26
+ "VocabularyEvaluationInput",
27
+ "VocabularyEvaluator",
28
+ "normalize_complexity_output",
29
+ ]
@@ -0,0 +1,434 @@
1
+ """Base evaluator class. Config types live in schemas.config."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import time
7
+ from abc import ABC, abstractmethod
8
+ from collections.abc import Awaitable, Callable
9
+ from typing import Any, Generic, TypeVar, overload
10
+
11
+ from langchain_core.exceptions import OutputParserException
12
+ from pydantic import BaseModel
13
+ from pydantic import ValidationError as PydanticValidationError
14
+
15
+ from learning_commons_evaluators.providers import (
16
+ create_provider,
17
+ token_usage_from_aimessage,
18
+ )
19
+ from learning_commons_evaluators.schemas.config import (
20
+ EvaluationSettings,
21
+ EvaluatorConfig,
22
+ PromptSettings,
23
+ )
24
+ from learning_commons_evaluators.schemas.errors import (
25
+ EvaluatorError,
26
+ OutputValidationError,
27
+ format_error_for_metadata,
28
+ sanitize_pydantic_errors,
29
+ wrap_provider_error,
30
+ )
31
+ from learning_commons_evaluators.schemas.evaluator import (
32
+ EvaluationInput,
33
+ EvaluationResult,
34
+ )
35
+ from learning_commons_evaluators.schemas.metadata import (
36
+ PROMPT_STEP_EXTRA_PROMPT_SETTINGS,
37
+ PROMPT_STEP_EXTRA_TOKEN_USAGE,
38
+ EvaluationMetadata,
39
+ EvaluatorMetadata,
40
+ Status,
41
+ StepMetadata,
42
+ TokenUsage,
43
+ prompt_settings_to_extras_value,
44
+ )
45
+
46
+ InputT = TypeVar("InputT", bound=EvaluationInput)
47
+ OutputT = TypeVar("OutputT", bound=EvaluationResult)
48
+ SettingsT = TypeVar("SettingsT", bound=EvaluationSettings)
49
+ StepResultT = TypeVar("StepResultT")
50
+ ParsedT = TypeVar("ParsedT", bound=BaseModel)
51
+
52
+
53
+ class BaseEvaluator(ABC, Generic[InputT, OutputT, SettingsT]):
54
+ """
55
+ Abstract base class for all evaluators.
56
+ Subclasses must set ``metadata``, ``default_evaluation_settings``, and implement
57
+ :meth:`evaluate_impl`.
58
+
59
+ Callers run an evaluation with :meth:`evaluate` (async: ``await evaluator.evaluate(...)``)
60
+ or :meth:`evaluate_sync` from synchronous code (uses :func:`asyncio.run`). If a loop is
61
+ already running on this thread, :meth:`evaluate_sync` raises :exc:`RuntimeError`; use
62
+ ``await`` :meth:`evaluate` in that case.
63
+
64
+ Pass ``default_evaluation_settings`` at construction to override the class-level
65
+ defaults for that instance (used when :meth:`evaluate` is called without
66
+ ``evaluation_settings``).
67
+ """
68
+
69
+ config: EvaluatorConfig
70
+ metadata: EvaluatorMetadata
71
+ default_evaluation_settings: SettingsT
72
+
73
+ def __init__(
74
+ self,
75
+ config: EvaluatorConfig,
76
+ *,
77
+ default_evaluation_settings: SettingsT | None = None,
78
+ ) -> None:
79
+ self.config = config
80
+ if default_evaluation_settings is not None:
81
+ self.default_evaluation_settings = default_evaluation_settings
82
+ # TODO: validate config
83
+
84
+ async def evaluate(
85
+ self,
86
+ input: InputT,
87
+ evaluation_settings: SettingsT | None = None,
88
+ ) -> OutputT:
89
+ """Evaluate the given input and return a structured result.
90
+
91
+ Validates the input, delegates to :meth:`evaluate_impl`, records timing
92
+ and status on the returned metadata, and logs start/end events via the
93
+ configured logger. If ``evaluation_settings`` is ``None``, a deep copy of
94
+ the instance's :attr:`default_evaluation_settings` is used (from the
95
+ constructor keyword when given, otherwise the subclass class attribute).
96
+
97
+ The ``finally`` block always runs so ``processing_time_ms`` and the end log
98
+ line are emitted even when validation or the implementation raises; telemetry
99
+ hooks remain TODOs here until wired.
100
+
101
+ Args:
102
+ input: Typed input for this evaluator.
103
+ evaluation_settings: Optional override for evaluation settings.
104
+ Defaults to :attr:`default_evaluation_settings` (constructor or class).
105
+
106
+ Returns:
107
+ A typed result whose ``metadata.status`` is
108
+ :attr:`~learning_commons_evaluators.schemas.metadata.Status.succeeded`
109
+ on success.
110
+
111
+ Raises:
112
+ InputValidationError: Input fails validation.
113
+ ConfigurationError: No provider config for the required LLM provider.
114
+ APIError (or subclasses): The LLM API call failed.
115
+
116
+ On failure, :attr:`~learning_commons_evaluators.schemas.metadata.EvaluationMetadata.status`
117
+ and ``error_details`` are set on the in-memory metadata object and included in the
118
+ evaluation end log record; no result object is returned because this method re-raises.
119
+ """
120
+ if evaluation_settings is None:
121
+ evaluation_settings = self.default_evaluation_settings.model_copy(deep=True)
122
+ start = time.perf_counter()
123
+ evaluation_metadata = EvaluationMetadata(
124
+ evaluator_metadata=self.metadata,
125
+ evaluation_settings=evaluation_settings,
126
+ input_metadata=input.input_metadata(),
127
+ )
128
+ self.config.logger.info(
129
+ "evaluation start",
130
+ extra={"evaluation_metadata": evaluation_metadata},
131
+ )
132
+ try:
133
+ input.validate()
134
+ result = await self.evaluate_impl(input, evaluation_settings, evaluation_metadata)
135
+ evaluation_metadata.status = Status.succeeded
136
+ result.metadata = evaluation_metadata
137
+ return result
138
+ except Exception as e:
139
+ evaluation_metadata.status = Status.failed
140
+ evaluation_metadata.error_details = format_error_for_metadata(e)
141
+ raise
142
+ finally:
143
+ evaluation_metadata.processing_time_ms = (time.perf_counter() - start) * 1000
144
+ self.config.logger.info(
145
+ "evaluation end",
146
+ extra={"evaluation_metadata": evaluation_metadata},
147
+ )
148
+ # TODO: add full input to telemetry if enabled
149
+ # TODO: send_telemetry(evaluation_metadata)
150
+
151
+ def evaluate_sync(
152
+ self,
153
+ input: InputT,
154
+ evaluation_settings: SettingsT | None = None,
155
+ ) -> OutputT:
156
+ """Run :meth:`evaluate` to completion from synchronous code.
157
+
158
+ This is a thin wrapper around :func:`asyncio.run` over :meth:`evaluate`. Use it
159
+ from scripts, REPLs, or tests that are not already inside an asyncio event loop.
160
+
161
+ Args:
162
+ input: Same as :meth:`evaluate`.
163
+ evaluation_settings: Same as :meth:`evaluate`.
164
+
165
+ Returns:
166
+ Same typed result as :meth:`evaluate` on success.
167
+
168
+ Raises:
169
+ Same exceptions as :meth:`evaluate`.
170
+ RuntimeError: If this thread already has a running asyncio event loop; use
171
+ ``await evaluator.evaluate(...)`` instead of :meth:`evaluate_sync`.
172
+
173
+ Note:
174
+ Do not call this method when a running event loop is active (for example
175
+ from inside an ``async def`` without nesting another ``asyncio.run``); prefer
176
+ awaiting :meth:`evaluate` instead.
177
+ """
178
+ try:
179
+ asyncio.get_running_loop()
180
+ except RuntimeError:
181
+ pass
182
+ else:
183
+ raise RuntimeError(
184
+ "evaluate_sync() cannot be used while an asyncio event loop is running in "
185
+ "this thread; use await evaluator.evaluate(...) from async code instead."
186
+ ) from None
187
+ return asyncio.run(self.evaluate(input, evaluation_settings))
188
+
189
+ @abstractmethod
190
+ async def evaluate_impl(
191
+ self,
192
+ input: InputT,
193
+ evaluation_settings: SettingsT,
194
+ evaluation_metadata: EvaluationMetadata,
195
+ ) -> OutputT:
196
+ """Implement the evaluator-specific logic for one run.
197
+
198
+ Subclasses perform prompt steps (often via :meth:`execute_prompt_chain_step`),
199
+ assemble the typed result, and return it. The base :meth:`evaluate` assigns
200
+ ``evaluation_metadata`` onto ``result.metadata`` after a successful return.
201
+
202
+ Args:
203
+ input: Validated evaluation input.
204
+ evaluation_settings: Resolved settings for this run (already deep-copied
205
+ when the caller omitted overrides).
206
+ evaluation_metadata: Run metadata; populate ``step_details`` and related
207
+ fields as steps execute.
208
+
209
+ Returns:
210
+ A fully constructed result object (``metadata`` may still point at the
211
+ same ``evaluation_metadata`` instance; the base layer sets status and timing).
212
+ """
213
+ ...
214
+
215
+ async def execute_step(
216
+ self,
217
+ step_name: str,
218
+ evaluation_metadata: EvaluationMetadata,
219
+ implementation_function: Callable[[], Awaitable[StepResultT]],
220
+ *,
221
+ extras: dict[str, Any] | None = None,
222
+ ) -> StepResultT:
223
+ """Await ``implementation_function`` and record step metadata on ``evaluation_metadata``.
224
+
225
+ ``step_name`` is always the step id. Optional ``extras`` is copied into
226
+ :attr:`StepMetadata.extras` (merged with any updates made during the step, e.g. token usage).
227
+
228
+ ``implementation_function`` must be a zero-argument callable that returns an
229
+ awaitable (typically an ``async def`` with no parameters, or a lambda that
230
+ returns one). The awaited value may be any type (e.g. a Pydantic model, a plain
231
+ ``str``, or ``None``); the same type is returned to the caller.
232
+ """
233
+ start = time.perf_counter()
234
+ step_extras = dict(extras) if extras is not None else {}
235
+ step_metadata = StepMetadata(step_id=step_name, extras=step_extras)
236
+ self.config.logger.info("step start", extra={"step_metadata": step_metadata})
237
+ try:
238
+ result = await implementation_function()
239
+ step_metadata.status = Status.succeeded
240
+ return result
241
+ except Exception as e:
242
+ step_metadata.status = Status.failed
243
+ step_metadata.error_details = format_error_for_metadata(e)
244
+ raise
245
+ finally:
246
+ step_metadata.processing_time_ms = (time.perf_counter() - start) * 1000
247
+ self.config.logger.info("step end", extra={"step_metadata": step_metadata})
248
+ evaluation_metadata.step_details[step_name] = step_metadata
249
+
250
+ @overload
251
+ async def execute_prompt_chain_step(
252
+ self,
253
+ step_name: str,
254
+ prompt_settings: PromptSettings,
255
+ evaluation_metadata: EvaluationMetadata,
256
+ template: Any,
257
+ chain_inputs: dict[str, Any],
258
+ parser_output_type: None = None,
259
+ ) -> str: ...
260
+
261
+ @overload
262
+ async def execute_prompt_chain_step(
263
+ self,
264
+ step_name: str,
265
+ prompt_settings: PromptSettings,
266
+ evaluation_metadata: EvaluationMetadata,
267
+ template: Any,
268
+ chain_inputs: dict[str, Any],
269
+ parser_output_type: type[ParsedT],
270
+ json_dict_normalizer: Callable[[dict], dict] | None = None,
271
+ ) -> ParsedT: ...
272
+
273
+ async def execute_prompt_chain_step(
274
+ self,
275
+ step_name: str,
276
+ prompt_settings: PromptSettings,
277
+ evaluation_metadata: EvaluationMetadata,
278
+ template: Any,
279
+ chain_inputs: dict[str, Any],
280
+ parser_output_type: type[BaseModel] | None = None,
281
+ json_dict_normalizer: Callable[[dict], dict] | None = None,
282
+ ) -> BaseModel | str:
283
+ """Run a prompt chain (template | LLM) with ``ainvoke``, record metadata, and return the result.
284
+
285
+ The LangChain runnable ``template | provider`` is invoked asynchronously via
286
+ :meth:`~langchain_core.runnables.base.Runnable.ainvoke`. When ``parser_output_type``
287
+ is a Pydantic model class, the LLM response is parsed as JSON and returned as an
288
+ instance of that class. When it is ``None`` (the default), the raw response content
289
+ is returned as a plain ``str`` (no JSON parser) — use that for steps that produce
290
+ unstructured prose (e.g. a background-knowledge assumption).
291
+
292
+ Provider config (e.g. API key) is resolved from ``self.config`` by
293
+ ``prompt_settings.provider_type``.
294
+
295
+ Args:
296
+ step_name: Identifier for this step in ``evaluation_metadata.step_details``.
297
+ prompt_settings: Provider type, model, and temperature for the LLM call.
298
+ evaluation_metadata: Metadata for the full evaluation; step metadata and
299
+ token usage are updated in place.
300
+ template: A LangChain prompt template.
301
+ chain_inputs: Variables to format the template and invoke the chain.
302
+ parser_output_type: Pydantic model class for JSON parsing, or ``None``
303
+ to return the raw text response.
304
+ json_dict_normalizer: When set with ``parser_output_type``, parse the
305
+ model response as JSON into a plain dict (no Pydantic parse),
306
+ apply this function (e.g. notebook-style ``normalize_complexity_output``),
307
+ then validate with ``parser_output_type``. Format instructions for the
308
+ prompt should still be built from the same ``parser_output_type`` via
309
+ :class:`~langchain_core.output_parsers.JsonOutputParser`.
310
+
311
+ Returns:
312
+ Parsed instance of ``parser_output_type`` when it is a model class; plain
313
+ ``str`` when ``parser_output_type`` is omitted or ``None``.
314
+
315
+ Raises:
316
+ ConfigurationError: No provider config for ``prompt_settings.provider_type``.
317
+ OutputValidationError: The LLM response didn't satisfy the expected
318
+ output schema — invalid JSON (LangChain ``OutputParserException``),
319
+ JSON that didn't match the Pydantic model (``pydantic.ValidationError``),
320
+ a non-object JSON value when using ``json_dict_normalizer``, or
321
+ ``TypeError`` / ``ValueError`` from ``json_dict_normalizer`` itself.
322
+ The original exception is reachable via ``__cause__``.
323
+ APIError (or other ``EvaluatorError`` subclasses):
324
+ :func:`~learning_commons_evaluators.schemas.errors.wrap_provider_error`
325
+ output for LangChain or HTTP failures from the LLM provider.
326
+ ValueError: If ``json_dict_normalizer`` is set but ``parser_output_type`` is omitted.
327
+ """
328
+ if json_dict_normalizer is not None and parser_output_type is None:
329
+ raise ValueError("json_dict_normalizer requires parser_output_type to be set")
330
+ # Populated after a successful LLM invoke so we can attach usage even if parsing fails.
331
+ token_usage: TokenUsage | None = None
332
+
333
+ async def _run_chain() -> BaseModel | str:
334
+ nonlocal token_usage
335
+ try:
336
+ provider = create_provider(prompt_settings, self.config)
337
+ llm_chain: Any = template | provider
338
+ ai_message = await llm_chain.ainvoke(chain_inputs)
339
+ token_usage = token_usage_from_aimessage(ai_message, prompt_settings)
340
+ if parser_output_type is None:
341
+ return str(ai_message.content)
342
+ from langchain_core.output_parsers.json import JsonOutputParser
343
+
344
+ if json_dict_normalizer is not None:
345
+ loose = JsonOutputParser()
346
+ parsed_dict = await loose.ainvoke(ai_message)
347
+ if not isinstance(parsed_dict, dict):
348
+ # JSON parsed cleanly but the top-level value isn't an object
349
+ # (e.g. the LLM returned a JSON array or scalar). That's an
350
+ # output-shape failure, not a parse failure — surface it as
351
+ # OutputValidationError so callers can treat it consistently
352
+ # with schema-mismatch errors, and avoid the TypeError that
353
+ # ``dict(parsed_dict)`` would raise on a non-dict.
354
+ raise OutputValidationError(
355
+ "Model output is not a JSON object",
356
+ provider=prompt_settings.provider_type,
357
+ model=prompt_settings.model,
358
+ )
359
+ try:
360
+ normalized = json_dict_normalizer(parsed_dict)
361
+ except (TypeError, ValueError) as norm_err:
362
+ raise OutputValidationError(
363
+ "Model output could not be normalized before validation",
364
+ provider=prompt_settings.provider_type,
365
+ model=prompt_settings.model,
366
+ ) from norm_err
367
+ return parser_output_type.model_validate(normalized)
368
+
369
+ parser = JsonOutputParser(pydantic_object=parser_output_type)
370
+ raw = await parser.ainvoke(ai_message)
371
+ if isinstance(raw, parser_output_type):
372
+ return raw
373
+ return parser_output_type.model_validate(raw)
374
+ except EvaluatorError:
375
+ raise
376
+ except (PydanticValidationError, OutputParserException) as e:
377
+ # The provider returned a response that didn't match the expected schema.
378
+ # This covers both Pydantic schema-validation failures and LangChain
379
+ # JSON-parse failures (``OutputParserException``). Wrap so callers can
380
+ # discriminate output-parse failures from other API errors and so the
381
+ # message that lands in ``EvaluationMetadata.error_details`` stays
382
+ # sanitized — the original error (which may include LLM output snippets)
383
+ # is reachable via ``__cause__`` for debugging.
384
+ validation_errors = (
385
+ sanitize_pydantic_errors(e.errors())
386
+ if isinstance(e, PydanticValidationError)
387
+ else None
388
+ )
389
+ raise OutputValidationError(
390
+ provider=prompt_settings.provider_type,
391
+ model=prompt_settings.model,
392
+ validation_errors=validation_errors,
393
+ ) from e
394
+ except (KeyboardInterrupt, SystemExit):
395
+ raise
396
+ except Exception as e:
397
+ raise wrap_provider_error(
398
+ e,
399
+ provider=prompt_settings.provider_type,
400
+ model=prompt_settings.model,
401
+ ) from e
402
+
403
+ try:
404
+ return await self.execute_step(
405
+ step_name,
406
+ evaluation_metadata,
407
+ _run_chain,
408
+ extras={
409
+ PROMPT_STEP_EXTRA_PROMPT_SETTINGS: prompt_settings_to_extras_value(
410
+ prompt_settings
411
+ ),
412
+ },
413
+ )
414
+ finally:
415
+ if token_usage is not None:
416
+ self.update_total_token_usage(token_usage, evaluation_metadata)
417
+ step = evaluation_metadata.step_details.get(step_name)
418
+ if step is not None:
419
+ step.extras[PROMPT_STEP_EXTRA_TOKEN_USAGE] = token_usage.model_dump(mode="json")
420
+
421
+ def update_total_token_usage(
422
+ self, token_usage: TokenUsage, evaluation_metadata: EvaluationMetadata
423
+ ) -> None:
424
+ """Record token usage for the current run."""
425
+ if token_usage.provider_type not in evaluation_metadata.total_token_usage:
426
+ evaluation_metadata.total_token_usage[token_usage.provider_type] = token_usage
427
+ else:
428
+ current_total = evaluation_metadata.total_token_usage[token_usage.provider_type]
429
+ evaluation_metadata.total_token_usage[token_usage.provider_type] = TokenUsage(
430
+ provider_type=current_total.provider_type,
431
+ model=current_total.model,
432
+ input_tokens=current_total.input_tokens + token_usage.input_tokens,
433
+ output_tokens=current_total.output_tokens + token_usage.output_tokens,
434
+ )