llmexer 0.3.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.
llmexer/__init__.py ADDED
File without changes
@@ -0,0 +1 @@
1
+ """Base module containing reusable utilities and business logic."""
llmexer/base/dao.py ADDED
@@ -0,0 +1,432 @@
1
+ """Data access layer for experiment storage backed by SQLite + SQLAlchemy.
2
+
3
+ Each LLM provider that appears in a generation run gets its own table named
4
+ ``experiment_<provider>`` (e.g. ``experiment_ollama``). A provider table holds
5
+ the common identity/prompt columns, that provider's own parameter columns, the
6
+ result columns (including ``response_json``), and finally the SHA-256 hash
7
+ columns — so the generated rows and their future results live together in one
8
+ table per provider.
9
+
10
+ All SQLAlchemy access is funnelled through :class:`ExperimentDAO`; the rest of
11
+ the codebase passes and receives plain ``dict`` rows and never touches the
12
+ engine directly. SQLAlchemy *Core* is used (``MetaData`` + ``Table`` built at
13
+ runtime) because the set of tables and their columns is data-driven by which
14
+ providers are present.
15
+ """
16
+
17
+ import math
18
+ import os
19
+ from datetime import datetime, timezone
20
+ from typing import Any, Dict, List, Optional
21
+
22
+ from sqlalchemy import (
23
+ Column,
24
+ Float,
25
+ Integer,
26
+ MetaData,
27
+ String,
28
+ Table,
29
+ Text,
30
+ case,
31
+ create_engine,
32
+ func,
33
+ insert,
34
+ or_,
35
+ select,
36
+ update,
37
+ )
38
+
39
+ from llmexer.base.experiment import (
40
+ COMMON_IDENTITY_COLUMNS,
41
+ COMMON_PARAM_COLUMNS,
42
+ HASH_COLUMNS,
43
+ PROVIDER_PARAM_COLUMNS,
44
+ RESULT_COLUMNS,
45
+ )
46
+ from llmexer.exceptions import LLMExerException
47
+ from llmexer.logger import get_logger
48
+
49
+ logger = get_logger()
50
+
51
+ TABLE_PREFIX = "experiment_"
52
+ DB_PREFIX = "experiment"
53
+ DB_SUFFIX = ".db"
54
+
55
+ # SQLite is dynamically typed, so these affinities are advisory — but declaring
56
+ # them keeps the schema self-documenting and portable to other backends.
57
+ COLUMN_TYPES: Dict[str, Any] = {
58
+ # identity / prompt
59
+ "ID": Integer,
60
+ "code": String,
61
+ "prompt": Text,
62
+ "tokens_estimate": Integer,
63
+ "original_data": Text,
64
+ "model_name": String,
65
+ "provider_name": String,
66
+ "prompt_hash": String(64),
67
+ "original_data_hash": String(64),
68
+ # common params
69
+ "profile_name": String,
70
+ "temperature": Float,
71
+ "top_p": Float,
72
+ "max_tokens": Integer,
73
+ # provider-specific params
74
+ "ollama_context_window": Integer,
75
+ "ollama_repeat_penalty": Float,
76
+ "vllm_min_p": Float,
77
+ "vllm_best_of": Integer,
78
+ "openai_seed": Integer,
79
+ "gemini_thinking_level": String,
80
+ # results
81
+ "response_text": Text,
82
+ "usage_tokens": Integer,
83
+ "status": String,
84
+ "state": String,
85
+ "call_count": Integer,
86
+ "total_tokens": Integer,
87
+ "elapsed_seconds": Float,
88
+ "timestamp": String,
89
+ "response_json": Text,
90
+ }
91
+
92
+
93
+ def table_name_for(provider: str) -> str:
94
+ """Return the table name for a provider (``experiment_<provider>``)."""
95
+
96
+ return f"{TABLE_PREFIX}{str(provider).lower()}"
97
+
98
+
99
+ def provider_from_table_name(name: str) -> str:
100
+ """Inverse of :func:`table_name_for`."""
101
+
102
+ return name[len(TABLE_PREFIX) :] if name.startswith(TABLE_PREFIX) else name
103
+
104
+
105
+ def _provider_columns(provider: str) -> List[str]:
106
+ """Ordered column list for a provider's table."""
107
+
108
+ extra = PROVIDER_PARAM_COLUMNS.get(str(provider).lower(), [])
109
+ return (
110
+ list(COMMON_IDENTITY_COLUMNS)
111
+ + list(COMMON_PARAM_COLUMNS)
112
+ + list(extra)
113
+ + list(RESULT_COLUMNS)
114
+ + list(HASH_COLUMNS)
115
+ )
116
+
117
+
118
+ def _clean_value(value: Any) -> Any:
119
+ """Normalise pandas/NumPy scalars and NaN into plain Python for SQLite."""
120
+
121
+ if value is None:
122
+ return None
123
+ # NumPy / pandas scalars expose ``.item()``; unwrap to a Python scalar.
124
+ if hasattr(value, "item") and not isinstance(value, (str, bytes)):
125
+ try:
126
+ value = value.item()
127
+ except (ValueError, AttributeError):
128
+ pass
129
+ if isinstance(value, float) and math.isnan(value):
130
+ return None
131
+ return value
132
+
133
+
134
+ def list_db_files(folder: str) -> List[str]:
135
+ """Return sorted ``experiment*.db`` filenames in ``folder`` (may be empty)."""
136
+
137
+ if not os.path.isdir(folder):
138
+ return []
139
+ return sorted(
140
+ f
141
+ for f in os.listdir(folder)
142
+ if f.startswith(DB_PREFIX) and f.endswith(DB_SUFFIX)
143
+ )
144
+
145
+
146
+ def _counter_of(filename: str) -> int:
147
+ """Extract the trailing ``_NN`` counter from an experiment db filename."""
148
+
149
+ stem = filename[: -len(DB_SUFFIX)] if filename.endswith(DB_SUFFIX) else filename
150
+ token = stem.rsplit("_", 1)[-1]
151
+ try:
152
+ return int(token)
153
+ except ValueError:
154
+ return 0
155
+
156
+
157
+ def next_db_filename(
158
+ folder: str,
159
+ prefix: str = DB_PREFIX,
160
+ suffix: str = DB_SUFFIX,
161
+ date: Optional[str] = None,
162
+ ) -> str:
163
+ """Compute the next ``experiment_<date>_<NN>.db`` name for ``folder``.
164
+
165
+ The counter is a zero-padded sequential number starting at ``01``,
166
+ one greater than the highest counter among existing ``experiment*.db``
167
+ files. ``date`` defaults to today's UTC ``YYYYMMDD``.
168
+ """
169
+
170
+ if date is None:
171
+ date = datetime.now(timezone.utc).strftime("%Y%m%d")
172
+ existing = [
173
+ f for f in list_db_files(folder) if f.startswith(prefix) and f.endswith(suffix)
174
+ ]
175
+ counter = max((_counter_of(f) for f in existing), default=0) + 1
176
+ return f"{prefix}_{date}_{counter:02d}{suffix}"
177
+
178
+
179
+ def latest_db(folder: str) -> Optional[str]:
180
+ """Return the path of the highest-counter ``experiment*.db`` or ``None``."""
181
+
182
+ files = list_db_files(folder)
183
+ if not files:
184
+ return None
185
+ newest = max(files, key=_counter_of)
186
+ return os.path.join(folder, newest)
187
+
188
+
189
+ class ExperimentDAO:
190
+ """SQLAlchemy-Core data access object for a single experiment database.
191
+
192
+ Typical lifecycles::
193
+
194
+ # generate
195
+ with ExperimentDAO(db_path, create=True) as dao:
196
+ dao.insert_rows("ollama", rows)
197
+
198
+ # run / stats
199
+ with ExperimentDAO(db_path) as dao:
200
+ for row in dao.fetch_rows(provider="ollama"):
201
+ ...
202
+ dao.update_result("ollama", row["ID"], result)
203
+ """
204
+
205
+ def __init__(self, db_path: str, create: bool = False):
206
+ self.db_path = db_path
207
+ self.metadata = MetaData()
208
+ self._tables: Dict[str, Table] = {}
209
+
210
+ if not create and not os.path.exists(db_path):
211
+ raise LLMExerException(f"Experiment database not found: '{db_path}'.")
212
+
213
+ self.engine = create_engine(f"sqlite:///{db_path}")
214
+
215
+ if not create:
216
+ self.metadata.reflect(bind=self.engine)
217
+ for table in self.metadata.tables.values():
218
+ if table.name.startswith(TABLE_PREFIX):
219
+ self._tables[provider_from_table_name(table.name)] = table
220
+
221
+ # ----------------------------------------------------------------- schema
222
+ def _build_table(self, provider: str) -> Table:
223
+ columns = [
224
+ Column(name, COLUMN_TYPES[name], primary_key=(name == "ID"))
225
+ for name in _provider_columns(provider)
226
+ ]
227
+ return Table(table_name_for(provider), self.metadata, *columns)
228
+
229
+ def ensure_provider_table(self, provider: str) -> Table:
230
+ """Return the (built, not yet created) Table for ``provider``."""
231
+
232
+ key = str(provider).lower()
233
+ if key not in self._tables:
234
+ self._tables[key] = self._build_table(key)
235
+ return self._tables[key]
236
+
237
+ def create_tables(self) -> None:
238
+ """Create all tables registered so far that do not yet exist."""
239
+
240
+ self.metadata.create_all(bind=self.engine)
241
+
242
+ def provider_tables(self) -> Dict[str, Table]:
243
+ """Mapping of provider name -> Table for every known provider table."""
244
+
245
+ return dict(self._tables)
246
+
247
+ def _table_for(self, provider: str) -> Table:
248
+ key = str(provider).lower()
249
+ if key not in self._tables:
250
+ raise LLMExerException(
251
+ f"No experiment table for provider '{provider}' in '{self.db_path}'."
252
+ )
253
+ return self._tables[key]
254
+
255
+ # ---------------------------------------------------------------- generate
256
+ def insert_rows(self, provider: str, rows: List[dict]) -> int:
257
+ """Bulk-insert generated rows into a provider's table.
258
+
259
+ Each row dict is filtered to the table's columns (extra keys ignored,
260
+ missing columns left NULL) and cleaned of NaN/NumPy scalars. The table
261
+ is created on first insert if it does not yet exist.
262
+ """
263
+
264
+ if not rows:
265
+ return 0
266
+ table = self.ensure_provider_table(provider)
267
+ self.create_tables()
268
+ valid = set(table.c.keys())
269
+ payload = [
270
+ {k: _clean_value(v) for k, v in row.items() if k in valid} for row in rows
271
+ ]
272
+ with self.engine.begin() as conn:
273
+ conn.execute(insert(table), payload)
274
+ logger.info(
275
+ f"Inserted {len(payload)} row(s) into '{table.name}' of '{self.db_path}'."
276
+ )
277
+ return len(payload)
278
+
279
+ # --------------------------------------------------------------------- run
280
+ def fetch_rows(
281
+ self,
282
+ provider: Optional[str] = None,
283
+ id_experiment: Optional[Any] = None,
284
+ ) -> List[dict]:
285
+ """Return rows across all (or one) provider tables, ordered by ID.
286
+
287
+ Every row dict carries an extra ``_provider`` key identifying its table.
288
+ ``id_experiment`` matches the numeric ``ID`` or the ``code`` column.
289
+ """
290
+
291
+ if provider is not None:
292
+ key = str(provider).lower()
293
+ tables = {key: self._tables[key]} if key in self._tables else {}
294
+ else:
295
+ tables = self._tables
296
+
297
+ results: List[dict] = []
298
+ with self.engine.connect() as conn:
299
+ for prov, table in tables.items():
300
+ stmt = select(table)
301
+ if id_experiment is not None:
302
+ conditions = [table.c.code == str(id_experiment)]
303
+ try:
304
+ conditions.append(table.c.ID == int(id_experiment))
305
+ except (TypeError, ValueError):
306
+ pass
307
+ stmt = stmt.where(or_(*conditions))
308
+ stmt = stmt.order_by(table.c.ID)
309
+ for mapping in conn.execute(stmt).mappings():
310
+ row = dict(mapping)
311
+ row["_provider"] = prov
312
+ results.append(row)
313
+ results.sort(key=lambda r: (r.get("ID") if r.get("ID") is not None else 0))
314
+ return results
315
+
316
+ def update_result(self, provider: str, row_id: Any, result: dict) -> None:
317
+ """Write result columns back onto a single row, keyed by its ``ID``."""
318
+
319
+ table = self._table_for(provider)
320
+ valid = set(table.c.keys())
321
+ values = {k: _clean_value(v) for k, v in result.items() if k in valid}
322
+ if not values:
323
+ return
324
+ with self.engine.begin() as conn:
325
+ conn.execute(update(table).where(table.c.ID == row_id).values(**values))
326
+
327
+ # ------------------------------------------------------------------- stats
328
+ def stats(self) -> Dict[str, Any]:
329
+ """Aggregate statistics across every provider table in the database."""
330
+
331
+ total = finished = running = errors = total_tokens = 0
332
+ providers: Dict[str, int] = {}
333
+ models: Dict[str, Dict[str, Any]] = {}
334
+
335
+ with self.engine.connect() as conn:
336
+ for prov, table in self._tables.items():
337
+
338
+ def count(condition=None) -> int:
339
+ stmt = select(func.count()).select_from(table)
340
+ if condition is not None:
341
+ stmt = stmt.where(condition)
342
+ return int(conn.execute(stmt).scalar() or 0)
343
+
344
+ rows = count()
345
+ total += rows
346
+ providers[prov] = providers.get(prov, 0) + rows
347
+ finished += count(table.c.status == "success")
348
+ errors += count(table.c.status.like("Error%"))
349
+ running += count(table.c.state == "running")
350
+
351
+ token_sum = conn.execute(
352
+ select(
353
+ func.sum(
354
+ func.coalesce(table.c.total_tokens, table.c.usage_tokens, 0)
355
+ )
356
+ ).select_from(table)
357
+ ).scalar()
358
+ total_tokens += int(token_sum or 0)
359
+
360
+ # Per-model aggregates: counts of finished (status "success") and
361
+ # open (pending/unrun, NULL status) rows, plus tokens and elapsed
362
+ # time accumulated over the model's *finished* rows only.
363
+ is_finished = table.c.status == "success"
364
+ finished_tokens = func.coalesce(
365
+ table.c.total_tokens, table.c.usage_tokens, 0
366
+ )
367
+ for (
368
+ name,
369
+ cnt,
370
+ fin,
371
+ opn,
372
+ toks,
373
+ secs,
374
+ ) in conn.execute(
375
+ select(
376
+ table.c.model_name,
377
+ func.count(),
378
+ func.sum(case((is_finished, 1), else_=0)),
379
+ func.sum(case((table.c.status.is_(None), 1), else_=0)),
380
+ func.sum(case((is_finished, finished_tokens), else_=0)),
381
+ func.sum(
382
+ case(
383
+ (
384
+ is_finished,
385
+ func.coalesce(table.c.elapsed_seconds, 0),
386
+ ),
387
+ else_=0,
388
+ )
389
+ ),
390
+ ).group_by(table.c.model_name)
391
+ ):
392
+ agg = models.setdefault(
393
+ str(name),
394
+ {
395
+ "requests": 0,
396
+ "finished": 0,
397
+ "open": 0,
398
+ "tokens": 0,
399
+ "elapsed_seconds": 0.0,
400
+ },
401
+ )
402
+ agg["requests"] += int(cnt or 0)
403
+ agg["finished"] += int(fin or 0)
404
+ agg["open"] += int(opn or 0)
405
+ agg["tokens"] += int(toks or 0)
406
+ agg["elapsed_seconds"] += float(secs or 0.0)
407
+
408
+ # Mean elapsed time per finished request (over the cross-table totals).
409
+ for agg in models.values():
410
+ agg["avg_elapsed_seconds"] = (
411
+ agg["elapsed_seconds"] / agg["finished"] if agg["finished"] else 0.0
412
+ )
413
+
414
+ return {
415
+ "total": total,
416
+ "finished": finished,
417
+ "running": running,
418
+ "errors": errors,
419
+ "total_tokens": total_tokens,
420
+ "providers": providers,
421
+ "models": models,
422
+ }
423
+
424
+ # ----------------------------------------------------------------- cleanup
425
+ def close(self) -> None:
426
+ self.engine.dispose()
427
+
428
+ def __enter__(self) -> "ExperimentDAO":
429
+ return self
430
+
431
+ def __exit__(self, *exc: Any) -> None:
432
+ self.close()
@@ -0,0 +1,135 @@
1
+ """Base methods and feature to be used in experiment CLI command."""
2
+
3
+ import os
4
+ import uuid
5
+
6
+ DIR_EXPERIMENT = "experiment"
7
+ DIR_RESPONSES = "responses"
8
+
9
+ _OUTPUT_COLUMNS = [
10
+ "ID",
11
+ "code",
12
+ "prompt",
13
+ "tokens_estimate",
14
+ "original_data",
15
+ "model_name",
16
+ "provider_name",
17
+ "prompt_hash",
18
+ "original_data_hash",
19
+ "profile_name",
20
+ "temperature",
21
+ "top_p",
22
+ "max_tokens",
23
+ "ollama_context_window",
24
+ "ollama_repeat_penalty",
25
+ "vllm_min_p",
26
+ "vllm_best_of",
27
+ "openai_seed",
28
+ "gemini_thinking_level",
29
+ ]
30
+
31
+ # Parameter columns copied from each ``llm-params.csv`` row into a generated
32
+ # row (``model_name``/``provider`` from that file are the join key / captured by
33
+ # llm-models.csv, so they are not duplicated here).
34
+ _PARAM_COLUMNS = [
35
+ "profile_name",
36
+ "temperature",
37
+ "top_p",
38
+ "max_tokens",
39
+ "ollama_context_window",
40
+ "ollama_repeat_penalty",
41
+ "vllm_min_p",
42
+ "vllm_best_of",
43
+ "openai_seed",
44
+ "gemini_thinking_level",
45
+ ]
46
+
47
+ # --------------------------------------------------------------------- SQLite
48
+ # Schema partition for the per-provider SQLite tables. Each provider table is
49
+ # built from COMMON_IDENTITY_COLUMNS + COMMON_PARAM_COLUMNS + that provider's
50
+ # entry in PROVIDER_PARAM_COLUMNS + RESULT_COLUMNS + HASH_COLUMNS. This keeps
51
+ # every provider's parameters in its own table (e.g. the openai table has no
52
+ # ollama_* columns), with the reproducibility hashes trailing at the end.
53
+
54
+ # Identity / prompt columns shared by every provider table.
55
+ COMMON_IDENTITY_COLUMNS = [
56
+ "ID",
57
+ "code",
58
+ "prompt",
59
+ "tokens_estimate",
60
+ "original_data",
61
+ "model_name",
62
+ "provider_name",
63
+ ]
64
+
65
+ # Parameter columns shared by every provider table. ``model_name`` and
66
+ # ``provider_name`` (identity columns) already capture the model/provider, so
67
+ # they are not duplicated here.
68
+ COMMON_PARAM_COLUMNS = [
69
+ "profile_name",
70
+ "temperature",
71
+ "top_p",
72
+ "max_tokens",
73
+ ]
74
+
75
+ # Parameter columns specific to each provider (keyed by lower-cased provider).
76
+ # A provider not listed here gets no extra parameter columns.
77
+ PROVIDER_PARAM_COLUMNS = {
78
+ "ollama": ["ollama_context_window", "ollama_repeat_penalty"],
79
+ "vllm": ["vllm_min_p", "vllm_best_of"],
80
+ "openai": ["openai_seed"],
81
+ "gemini": ["gemini_thinking_level"],
82
+ }
83
+
84
+ # Result columns written back once a row has been run. ``response_json`` stores
85
+ # the full per-call JSON payload (the same dict also exported to responses/).
86
+ RESULT_COLUMNS = [
87
+ "response_text",
88
+ "usage_tokens",
89
+ "status",
90
+ "state",
91
+ "call_count",
92
+ "total_tokens",
93
+ "elapsed_seconds",
94
+ "timestamp",
95
+ "response_json",
96
+ ]
97
+
98
+ # SHA-256 reproducibility hashes, kept as the trailing columns of every table.
99
+ HASH_COLUMNS = [
100
+ "prompt_hash",
101
+ "original_data_hash",
102
+ ]
103
+
104
+
105
+ def generate_project_id() -> str:
106
+ """
107
+ Generate a unique project ID formatted as 'YYYYMMDD-GUID'
108
+
109
+ Returns:
110
+ str: A string in the format 'YYYYMMDD-UUID'.
111
+ """
112
+ from datetime import datetime, timezone
113
+
114
+ now_utc = datetime.now(timezone.utc)
115
+ formatted_datetime = now_utc.strftime("%Y%m%d")
116
+ unique_id = str(uuid.uuid4())[:8]
117
+ return f"{formatted_datetime}-{unique_id}"
118
+
119
+
120
+ def _is_experiment_initialized(experiment_path: str) -> bool:
121
+ """Check if an experiment has been initialized with required CSV files."""
122
+ experiment_subdir_path = os.path.join(experiment_path, DIR_EXPERIMENT)
123
+ required_files = ["data.csv", "llm-params.csv", "mapping.csv", "llm-models.csv"]
124
+ return all(
125
+ os.path.exists(os.path.join(experiment_subdir_path, f)) for f in required_files
126
+ )
127
+
128
+
129
+ def _get_generated_experiment_files(experiment_path: str) -> list[str]:
130
+ """Get the sorted list of generated experiment databases (``experiment_*.db``)."""
131
+ # Local import to avoid a circular import (``dao`` imports from this module).
132
+ from llmexer.base.dao import list_db_files
133
+
134
+ experiment_subdir_path = os.path.join(experiment_path, DIR_EXPERIMENT)
135
+ return list_db_files(experiment_subdir_path)
@@ -0,0 +1,22 @@
1
+ """Core result types for LLM request execution."""
2
+
3
+ from dataclasses import asdict, dataclass, field
4
+ from datetime import datetime
5
+ from typing import Any, Dict, Optional
6
+
7
+
8
+ @dataclass
9
+ class LLMRunResult:
10
+ model: str
11
+ provider: str
12
+ prompt: str
13
+ profile: str
14
+ parameters: Dict[str, Any]
15
+ response_text: str
16
+ usage_tokens: Optional[int] = None
17
+ status: str = "success"
18
+ timestamp: str = field(default_factory=lambda: datetime.now().isoformat())
19
+ raw: Optional[Dict[str, Any]] = field(default=None, repr=False)
20
+
21
+ def model_dump(self) -> dict:
22
+ return asdict(self)