evalring 0.2.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.
EvalRing/config.py ADDED
@@ -0,0 +1,216 @@
1
+ """
2
+ Provider configuration and credential resolution.
3
+
4
+ EvalRing talks to any OpenAI-compatible chat-completions endpoint. Rather than
5
+ hard-coding a vendor, every component resolves its credentials through
6
+ :func:`resolve_credentials`, which reads a documented, ordered list of
7
+ environment variables.
8
+
9
+ See ``docs/CONFIGURATION.md`` for the full table and worked examples.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import os
15
+ from dataclasses import dataclass
16
+ from typing import Final
17
+
18
+ #: Base URL applied when a provider is selected but no explicit URL is given.
19
+ #: ``None`` means "let the OpenAI SDK use its own default".
20
+ _PROVIDER_DEFAULT_BASE_URL: Final[dict[str, str | None]] = {
21
+ "evalring": None,
22
+ "openai": None,
23
+ "openrouter": "https://openrouter.ai/api/v1",
24
+ "radium": "https://api.radium.cloud/v1",
25
+ }
26
+
27
+ #: Ordered credential sources: (provider, api-key var, base-url var).
28
+ #: The first variable that is set wins. ``EVALRING_*`` is the vendor-neutral
29
+ #: form and should be preferred by new users; the remaining entries exist so
30
+ #: that existing provider-specific environments keep working unchanged.
31
+ _CREDENTIAL_SOURCES: Final[tuple[tuple[str, str, str], ...]] = (
32
+ ("evalring", "EVALRING_API_KEY", "EVALRING_BASE_URL"),
33
+ ("openai", "OPENAI_API_KEY", "OPENAI_BASE_URL"),
34
+ ("openrouter", "OPENROUTER_API_KEY", "OPENROUTER_BASE_URL"),
35
+ ("openrouter", "OPEN_ROUTER_KEY", "OPENROUTER_BASE_URL"),
36
+ ("radium", "RADIUM_API_KEY", "RADIUM_BASE_URL"),
37
+ )
38
+
39
+ #: Ordered model-name variables, checked when no model is passed explicitly.
40
+ _MODEL_VARS: Final[tuple[str, ...]] = (
41
+ "EVALRING_MODEL",
42
+ "OPENAI_MODEL",
43
+ "OPENROUTER_MODEL",
44
+ "OPEN_ROUTER_MODEL",
45
+ "RADIUM_MODEL",
46
+ )
47
+
48
+ #: Every variable EvalRing will read for an API key, in precedence order.
49
+ CREDENTIAL_ENV_VARS: Final[tuple[str, ...]] = tuple(
50
+ key_var for _, key_var, _ in _CREDENTIAL_SOURCES
51
+ )
52
+
53
+
54
+ @dataclass(frozen=True)
55
+ class ProviderCredentials:
56
+ """Resolved endpoint configuration for an OpenAI-compatible provider.
57
+
58
+ Attributes:
59
+ api_key: The API key, or ``None`` when nothing was configured.
60
+ base_url: The endpoint to call, or ``None`` to use the SDK default
61
+ (``https://api.openai.com/v1``).
62
+ provider: Short identifier of the source that supplied the key, one of
63
+ ``"evalring"``, ``"openai"``, ``"openrouter"``, ``"radium"``, or
64
+ ``"explicit"`` when the key was passed in code.
65
+ source: Human-readable description of where the key came from, safe to
66
+ print in diagnostics (never contains the key itself).
67
+ """
68
+
69
+ api_key: str | None
70
+ base_url: str | None
71
+ provider: str
72
+ source: str
73
+
74
+ def require_key(self) -> str:
75
+ """Return the API key, raising a descriptive error when absent.
76
+
77
+ Raises:
78
+ MissingCredentialsError: If no API key was resolved.
79
+ """
80
+ if not self.api_key:
81
+ raise MissingCredentialsError()
82
+ return self.api_key
83
+
84
+
85
+ class MissingCredentialsError(ValueError):
86
+ """Raised when no API key could be resolved from arguments or environment."""
87
+
88
+ def __init__(self, message: str | None = None) -> None:
89
+ super().__init__(
90
+ message
91
+ or (
92
+ "No API key found. Pass api_key=... explicitly, or set one of: "
93
+ + ", ".join(CREDENTIAL_ENV_VARS)
94
+ + ". For a non-OpenAI endpoint also set EVALRING_BASE_URL. "
95
+ "See docs/CONFIGURATION.md."
96
+ )
97
+ )
98
+
99
+
100
+ def _clean(value: str | None) -> str | None:
101
+ """Normalize an environment value, treating blank strings as unset."""
102
+ if value is None:
103
+ return None
104
+ stripped = value.strip()
105
+ return stripped or None
106
+
107
+
108
+ def resolve_credentials(
109
+ api_key: str | None = None,
110
+ base_url: str | None = None,
111
+ *,
112
+ env: dict[str, str] | None = None,
113
+ ) -> ProviderCredentials:
114
+ """Resolve the API key and base URL for an OpenAI-compatible provider.
115
+
116
+ Explicit arguments always win. Otherwise the variables listed in
117
+ ``_CREDENTIAL_SOURCES`` are consulted in order; the first one that is set
118
+ determines both the key and, unless overridden, the base URL.
119
+
120
+ Args:
121
+ api_key: Explicit API key. When given, no key variable is consulted.
122
+ base_url: Explicit endpoint. When given, no URL variable is consulted.
123
+ env: Environment mapping to read from. Defaults to ``os.environ``;
124
+ supplying one makes the function trivially testable.
125
+
126
+ Returns:
127
+ A :class:`ProviderCredentials` describing the selected endpoint. The
128
+ ``api_key`` field may be ``None``; call
129
+ :meth:`ProviderCredentials.require_key` at the point of use so the
130
+ error message is raised where it is actionable.
131
+
132
+ Example:
133
+ >>> creds = resolve_credentials(env={"EVALRING_API_KEY": "sk-test"})
134
+ >>> creds.provider
135
+ 'evalring'
136
+ """
137
+ environ = os.environ if env is None else env
138
+
139
+ explicit_key = _clean(api_key)
140
+ explicit_url = _clean(base_url)
141
+
142
+ if explicit_key:
143
+ return ProviderCredentials(
144
+ api_key=explicit_key,
145
+ base_url=explicit_url or _clean(environ.get("EVALRING_BASE_URL")),
146
+ provider="explicit",
147
+ source="api_key argument",
148
+ )
149
+
150
+ for provider, key_var, url_var in _CREDENTIAL_SOURCES:
151
+ key = _clean(environ.get(key_var))
152
+ if not key:
153
+ continue
154
+ resolved_url = (
155
+ explicit_url
156
+ or _clean(environ.get("EVALRING_BASE_URL"))
157
+ or _clean(environ.get(url_var))
158
+ or _PROVIDER_DEFAULT_BASE_URL.get(provider)
159
+ )
160
+ return ProviderCredentials(
161
+ api_key=key,
162
+ base_url=resolved_url,
163
+ provider=provider,
164
+ source=f"${key_var}",
165
+ )
166
+
167
+ return ProviderCredentials(
168
+ api_key=None,
169
+ base_url=explicit_url or _clean(environ.get("EVALRING_BASE_URL")),
170
+ provider="none",
171
+ source="unset",
172
+ )
173
+
174
+
175
+ def resolve_model_name(
176
+ model_name: str | None = None,
177
+ *,
178
+ default: str | None = None,
179
+ env: dict[str, str] | None = None,
180
+ ) -> str | None:
181
+ """Resolve the model identifier to evaluate.
182
+
183
+ Args:
184
+ model_name: Explicit model identifier; returned unchanged when given.
185
+ default: Value returned when neither the argument nor any environment
186
+ variable is set.
187
+ env: Environment mapping to read from. Defaults to ``os.environ``.
188
+
189
+ Returns:
190
+ The resolved model identifier, or ``default`` when nothing is set.
191
+ """
192
+ environ = os.environ if env is None else env
193
+
194
+ explicit = _clean(model_name)
195
+ if explicit:
196
+ return explicit
197
+
198
+ for var in _MODEL_VARS:
199
+ value = _clean(environ.get(var))
200
+ if value:
201
+ return value
202
+
203
+ return default
204
+
205
+
206
+ def has_any_credentials(env: dict[str, str] | None = None) -> bool:
207
+ """Report whether any recognized API-key variable is set.
208
+
209
+ Args:
210
+ env: Environment mapping to read from. Defaults to ``os.environ``.
211
+
212
+ Returns:
213
+ ``True`` if at least one credential variable holds a non-blank value.
214
+ """
215
+ environ = os.environ if env is None else env
216
+ return any(_clean(environ.get(var)) for var in CREDENTIAL_ENV_VARS)
@@ -0,0 +1,8 @@
1
+ """
2
+ Dataset module for EvalRing.
3
+ """
4
+
5
+ from .base import BaseDataset, DataSample
6
+ from .implementations import CSVDataset, DataFrameDataset, JSONDataset
7
+
8
+ __all__ = ["BaseDataset", "DataSample", "JSONDataset", "CSVDataset", "DataFrameDataset"]
@@ -0,0 +1,217 @@
1
+ import asyncio
2
+ import os
3
+ from typing import Any, cast
4
+
5
+ import pandas as pd
6
+ from tqdm.asyncio import tqdm
7
+
8
+ from ..config import resolve_credentials, resolve_model_name
9
+ from ..logging_utils import get_logger
10
+
11
+ logger = get_logger(__name__)
12
+
13
+
14
+ class AIDataGenerator:
15
+ """
16
+ A concurrent data generator that rewrites text instances from one class
17
+ label to another using an LLM, guided by a system prompt (typically the
18
+ class definitions).
19
+
20
+ Requires the optional ``datagen`` extra: ``pip install evalring[datagen]``.
21
+
22
+ Args:
23
+ api_key: API key. Resolved from the environment when omitted; see
24
+ :func:`EvalRing.config.resolve_credentials`.
25
+ model_name: Model identifier. Defaults to ``$EVALRING_MODEL``, then
26
+ ``"gpt-4o-mini"``.
27
+ base_url: OpenAI-compatible endpoint. Resolved alongside the API key
28
+ when omitted.
29
+ max_concurrent: Maximum number of in-flight requests.
30
+ system_prompt: System message prepended to every rewrite request.
31
+ """
32
+
33
+ def __init__(
34
+ self,
35
+ api_key: str | None = None,
36
+ model_name: str | None = None,
37
+ base_url: str | None = None,
38
+ max_concurrent: int = 50,
39
+ system_prompt: str = "",
40
+ ):
41
+ try:
42
+ from openai import AsyncOpenAI
43
+ except ImportError as exc: # pragma: no cover - exercised via extras
44
+ raise ImportError(
45
+ "AIDataGenerator requires the 'openai' package. "
46
+ "Install it with: pip install evalring[datagen]"
47
+ ) from exc
48
+
49
+ credentials = resolve_credentials(api_key, base_url)
50
+ self.credentials = credentials
51
+ self.api_key = credentials.require_key()
52
+ self.base_url = credentials.base_url
53
+ self.model_name = resolve_model_name(model_name, default="gpt-4o-mini") or "gpt-4o-mini"
54
+ self.max_concurrent = max_concurrent
55
+ self.system_prompt = system_prompt
56
+
57
+ self.client = AsyncOpenAI(api_key=self.api_key, base_url=self.base_url)
58
+ self.semaphore = asyncio.Semaphore(self.max_concurrent)
59
+
60
+ async def _generate_single(
61
+ self, text: str, original_label: str, target_label: str, retries: int = 3
62
+ ) -> str:
63
+ """
64
+ Rewrites a single piece of text to fit the target_label.
65
+ """
66
+ async with self.semaphore:
67
+ prompt = (
68
+ f"You are an expert data generator and text rewriter.\n"
69
+ f"I will provide you with a user's text that is currently classified as '{original_label}'.\n"
70
+ f"Your task is to rewrite this text from the exact same user's perspective, but modifying it so that it "
71
+ f"naturally, realistically, and accurately fits the definition of the '{target_label}' class.\n\n"
72
+ f"CRITICAL INSTRUCTIONS:\n"
73
+ f"1. You MUST maintain the original first-person perspective of the poster.\n"
74
+ f"2. You MUST NOT write a reply, supportive message, or answer to the text.\n"
75
+ f"3. Do not make up your own class, strictly map the text to represent '{target_label}'.\n\n"
76
+ f"Original Text (Label: {original_label}):\n{text}\n\n"
77
+ f"Please provide ONLY the rewritten text for the '{target_label}' class. "
78
+ f"Do not include quotes, conversational filler, or markdown blocks. "
79
+ f"It should read like an authentic user post."
80
+ )
81
+
82
+ messages: list[Any] = []
83
+ if self.system_prompt:
84
+ messages.append({"role": "system", "content": self.system_prompt})
85
+
86
+ messages.append({"role": "user", "content": prompt})
87
+
88
+ for attempt in range(retries):
89
+ try:
90
+ response = await self.client.chat.completions.create(
91
+ model=self.model_name, messages=messages, temperature=0.7, max_tokens=1000
92
+ )
93
+
94
+ content = response.choices[0].message.content
95
+ if content:
96
+ return content.strip()
97
+ except Exception as e:
98
+ if attempt == retries - 1:
99
+ logger.warning(
100
+ "Error generating data for text (target=%s): %s", target_label, e
101
+ )
102
+ return ""
103
+ await asyncio.sleep(2**attempt)
104
+ return ""
105
+
106
+ async def generate_batch(
107
+ self, texts: list[str], current_labels: list[str], target_labels: list[str]
108
+ ) -> list[str]:
109
+ """
110
+ Concurrently process a batch of text rewrites.
111
+ """
112
+ tasks = [
113
+ self._generate_single(text, orig, tgt)
114
+ for text, orig, tgt in zip(texts, current_labels, target_labels, strict=False)
115
+ ]
116
+
117
+ # tqdm.gather provides a nice progress bar for the async tasks
118
+ results = await tqdm.gather(*tasks, desc="Generating data")
119
+ return results
120
+
121
+ def run_dataframe(
122
+ self,
123
+ df: pd.DataFrame,
124
+ text_col: str,
125
+ label_col: str,
126
+ target_col: str,
127
+ replace_original: bool = False,
128
+ ) -> pd.DataFrame:
129
+ """
130
+ Synchronous wrapper to process a Pandas DataFrame.
131
+ """
132
+ # Ensure we have an active event loop for the async execution
133
+ try:
134
+ loop = asyncio.get_running_loop()
135
+ except RuntimeError:
136
+ loop = asyncio.new_event_loop()
137
+ asyncio.set_event_loop(loop)
138
+
139
+ if loop.is_running():
140
+ import nest_asyncio
141
+
142
+ nest_asyncio.apply(loop)
143
+
144
+ texts = []
145
+ current_labels = []
146
+ target_labels = []
147
+ original_indices = []
148
+
149
+ is_list_of_classes = isinstance(target_col, (list, tuple, pd.Series))
150
+ target_col_list = list(target_col) if is_list_of_classes else None
151
+
152
+ for idx, row in df.iterrows():
153
+ text = row[text_col]
154
+ orig = row[label_col]
155
+
156
+ if is_list_of_classes:
157
+ # generate for all target classes except the original
158
+ targets = [t for t in (target_col_list or []) if t != orig]
159
+ else:
160
+ # generate specifically for the target_col specified in the row
161
+ targets = [row[target_col]]
162
+
163
+ for tgt in targets:
164
+ texts.append(text)
165
+ current_labels.append(orig)
166
+ target_labels.append(tgt)
167
+ original_indices.append(idx)
168
+
169
+ results = loop.run_until_complete(self.generate_batch(texts, current_labels, target_labels))
170
+
171
+ # Positional selection with a list of row numbers yields a DataFrame, but
172
+ # the pandas stubs describe iloc's return as a union that collapses to
173
+ # Series once numpy's own stubs are not in play.
174
+ df_out = cast(pd.DataFrame, df.iloc[list(original_indices)]).copy()
175
+
176
+ if replace_original:
177
+ df_out[text_col] = results
178
+ df_out[label_col] = target_labels
179
+ if not is_list_of_classes and isinstance(target_col, str):
180
+ if target_col in df_out.columns:
181
+ df_out.drop(columns=[target_col], inplace=True)
182
+ else:
183
+ df_out["rewritten_text"] = results
184
+ df_out["target_label"] = target_labels
185
+
186
+ return df_out
187
+
188
+ def process_csv(
189
+ self,
190
+ input_csv_path: str,
191
+ text_col: str,
192
+ label_col: str,
193
+ target_col: str,
194
+ output_csv_path: str | None = None,
195
+ ) -> str:
196
+ """
197
+ Reads a CSV, generates new texts based on the target class, replaces the original
198
+ text and label fields, and saves to {original}_generated.csv.
199
+ """
200
+ if not output_csv_path:
201
+ base, ext = os.path.splitext(input_csv_path)
202
+ output_csv_path = f"{base}_generated{ext}"
203
+
204
+ df = pd.read_csv(input_csv_path)
205
+
206
+ logger.info("Loaded %d rows from %s", len(df), input_csv_path)
207
+ df_generated = self.run_dataframe(
208
+ df=df,
209
+ text_col=text_col,
210
+ label_col=label_col,
211
+ target_col=target_col,
212
+ replace_original=True,
213
+ )
214
+
215
+ df_generated.to_csv(output_csv_path, index=False)
216
+ logger.info("Saved generated dataset to %s", output_csv_path)
217
+ return output_csv_path
@@ -0,0 +1,238 @@
1
+ """
2
+ Base classes for datasets in the EvalRing framework.
3
+ """
4
+
5
+ import json
6
+ from abc import ABC, abstractmethod
7
+ from collections.abc import Iterator
8
+ from dataclasses import dataclass, field
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ import pandas as pd
13
+
14
+
15
+ @dataclass
16
+ class DataSample:
17
+ """Represents a single data sample for evaluation."""
18
+
19
+ id: str
20
+ input_text: str
21
+ target_output: Any
22
+ metadata: dict[str, Any] = field(default_factory=dict)
23
+
24
+ def to_dict(self) -> dict[str, Any]:
25
+ """Convert to dictionary representation."""
26
+ return {
27
+ "id": self.id,
28
+ "input_text": self.input_text,
29
+ "target_output": self.target_output,
30
+ "metadata": self.metadata,
31
+ }
32
+
33
+
34
+ class BaseDataset(ABC):
35
+ """
36
+ Abstract base class for all datasets in EvalRing.
37
+
38
+ This class provides a standardized interface for loading and accessing
39
+ different types of datasets (JSON, CSV, DataFrame, etc.).
40
+ """
41
+
42
+ def __init__(self, name: str, description: str | None = None, version: str = "1.0"):
43
+ self.name = name
44
+ self.description = description or f"Dataset: {name}"
45
+ self.version = version
46
+ self._samples: list[DataSample] = []
47
+ self._metadata: dict[str, Any] = {}
48
+
49
+ @abstractmethod
50
+ def load_data(
51
+ self,
52
+ source: str | Path | pd.DataFrame,
53
+ text_field: str = "text",
54
+ label_field: str = "label",
55
+ id_field: str | None = None,
56
+ **kwargs: Any,
57
+ ) -> None:
58
+ """Load samples from the given source.
59
+
60
+ Args:
61
+ source: File path or in-memory frame, depending on the subclass.
62
+ text_field: Column or key holding the model input.
63
+ label_field: Column or key holding the ground-truth label.
64
+ id_field: Column or key holding a unique per-sample ID. When
65
+ ``None`` the row index is used, which is only safe for datasets
66
+ that are never resumed or retried.
67
+ **kwargs: Reader-specific options (for example ``pandas.read_csv``
68
+ keyword arguments).
69
+ """
70
+ pass
71
+
72
+ @abstractmethod
73
+ def validate_data(self) -> bool:
74
+ """
75
+ Validate the loaded data for consistency and correctness.
76
+
77
+ Returns:
78
+ True if data is valid, False otherwise
79
+ """
80
+ pass
81
+
82
+ def assert_unique_ids(
83
+ self, *, expected_count: int | None = None, context: str = "dataset"
84
+ ) -> None:
85
+ """Raise if sample IDs are not unique.
86
+
87
+ Unique per-sample IDs are required for:
88
+ - resuming partial runs
89
+ - retrying failures
90
+ - merging per-sample metrics
91
+ """
92
+ ids = [str(s.id) for s in self._samples]
93
+ unique_ids = set(ids)
94
+
95
+ if expected_count is None:
96
+ expected_count = len(ids)
97
+
98
+ if len(ids) != expected_count:
99
+ raise ValueError(
100
+ f"[{context}] Expected {expected_count} samples, but found {len(ids)}."
101
+ )
102
+
103
+ if len(unique_ids) != len(ids):
104
+ # Provide a small, actionable duplicate preview.
105
+ from collections import Counter
106
+
107
+ counts = Counter(ids)
108
+ dupes = [k for k, v in counts.items() if v > 1]
109
+ preview = dupes[:10]
110
+ raise ValueError(
111
+ f"[{context}] Duplicate sample IDs detected: unique={len(unique_ids)} total={len(ids)} "
112
+ f"(first {len(preview)} duplicates: {preview}). "
113
+ "Fix by using a per-row question/message ID (e.g., an explicit 'ID' column or row index), not a user ID."
114
+ )
115
+
116
+ def add_sample(self, sample: DataSample) -> None:
117
+ """Add a single sample to the dataset."""
118
+ self._samples.append(sample)
119
+
120
+ def get_sample(self, index: int) -> DataSample:
121
+ """Get a sample by index."""
122
+ return self._samples[index]
123
+
124
+ def get_sample_by_id(self, sample_id: str) -> DataSample | None:
125
+ """Get a sample by its ID."""
126
+ for sample in self._samples:
127
+ if sample.id == sample_id:
128
+ return sample
129
+ return None
130
+
131
+ def __len__(self) -> int:
132
+ """Return the number of samples in the dataset."""
133
+ return len(self._samples)
134
+
135
+ def __iter__(self) -> Iterator[DataSample]:
136
+ """Iterate over samples in the dataset."""
137
+ return iter(self._samples)
138
+
139
+ def __getitem__(self, index: int | slice) -> DataSample | list[DataSample]:
140
+ """Get sample(s) by index or slice."""
141
+ return self._samples[index]
142
+
143
+ def get_statistics(self) -> dict[str, Any]:
144
+ """Get dataset statistics."""
145
+ if not self._samples:
146
+ return {"total_samples": 0}
147
+
148
+ # Basic statistics
149
+ stats = {
150
+ "total_samples": len(self._samples),
151
+ "name": self.name,
152
+ "version": self.version,
153
+ "description": self.description,
154
+ }
155
+
156
+ # Try to infer additional statistics
157
+ try:
158
+ input_lengths = [len(sample.input_text) for sample in self._samples]
159
+ stats.update(
160
+ {
161
+ "avg_input_length": sum(input_lengths) / len(input_lengths),
162
+ "min_input_length": min(input_lengths),
163
+ "max_input_length": max(input_lengths),
164
+ }
165
+ )
166
+ except Exception:
167
+ pass
168
+
169
+ return stats
170
+
171
+ def split(
172
+ self, train_ratio: float = 0.8, seed: int | None = None
173
+ ) -> tuple["BaseDataset", "BaseDataset"]:
174
+ """
175
+ Split the dataset into train and test sets.
176
+
177
+ Args:
178
+ train_ratio: Ratio of data to use for training
179
+ seed: Random seed for reproducible splits
180
+
181
+ Returns:
182
+ Tuple of (train_dataset, test_dataset)
183
+ """
184
+ import random
185
+
186
+ if seed is not None:
187
+ random.seed(seed)
188
+
189
+ indices = list(range(len(self._samples)))
190
+ random.shuffle(indices)
191
+
192
+ split_idx = int(len(indices) * train_ratio)
193
+ train_indices = indices[:split_idx]
194
+ test_indices = indices[split_idx:]
195
+
196
+ train_dataset = self.__class__(name=f"{self.name}_train", version=self.version)
197
+ test_dataset = self.__class__(name=f"{self.name}_test", version=self.version)
198
+
199
+ for idx in train_indices:
200
+ train_dataset.add_sample(self._samples[idx])
201
+
202
+ for idx in test_indices:
203
+ test_dataset.add_sample(self._samples[idx])
204
+
205
+ return train_dataset, test_dataset
206
+
207
+ def save(self, filepath: str | Path) -> None:
208
+ """Save dataset to file."""
209
+ data = {
210
+ "name": self.name,
211
+ "description": self.description,
212
+ "version": self.version,
213
+ "metadata": self._metadata,
214
+ "samples": [sample.to_dict() for sample in self._samples],
215
+ }
216
+
217
+ with open(filepath, "w") as f:
218
+ json.dump(data, f, indent=2)
219
+
220
+ def load_from_file(self, filepath: str | Path) -> None:
221
+ """Load dataset from file."""
222
+ with open(filepath) as f:
223
+ data = json.load(f)
224
+
225
+ self.name = data.get("name", self.name)
226
+ self.description = data.get("description", self.description)
227
+ self.version = data.get("version", self.version)
228
+ self._metadata = data.get("metadata", {})
229
+
230
+ self._samples = []
231
+ for sample_data in data.get("samples", []):
232
+ sample = DataSample(
233
+ id=sample_data["id"],
234
+ input_text=sample_data["input_text"],
235
+ target_output=sample_data["target_output"],
236
+ metadata=sample_data.get("metadata", {}),
237
+ )
238
+ self._samples.append(sample)