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 +0 -0
- llmexer/base/__init__.py +1 -0
- llmexer/base/dao.py +432 -0
- llmexer/base/experiment.py +135 -0
- llmexer/base/llm_core.py +22 -0
- llmexer/base/llm_manager.py +288 -0
- llmexer/base/llm_provider.py +278 -0
- llmexer/base/papers.py +267 -0
- llmexer/base/search.py +381 -0
- llmexer/base/search_openalex.py +296 -0
- llmexer/base/search_semantic_scholar.py +151 -0
- llmexer/cli.py +148 -0
- llmexer/commands/__init__.py +0 -0
- llmexer/commands/experiment.py +734 -0
- llmexer/commands/papers.py +481 -0
- llmexer/commands/project.py +90 -0
- llmexer/commands/search.py +858 -0
- llmexer/commands/self.py +58 -0
- llmexer/common.py +133 -0
- llmexer/configs.py +33 -0
- llmexer/constants.py +29 -0
- llmexer/exceptions.py +42 -0
- llmexer/logger.py +32 -0
- llmexer/version.py +39 -0
- llmexer-0.3.0.dist-info/METADATA +432 -0
- llmexer-0.3.0.dist-info/RECORD +30 -0
- llmexer-0.3.0.dist-info/WHEEL +5 -0
- llmexer-0.3.0.dist-info/entry_points.txt +2 -0
- llmexer-0.3.0.dist-info/licenses/LICENSE +21 -0
- llmexer-0.3.0.dist-info/top_level.txt +1 -0
llmexer/__init__.py
ADDED
|
File without changes
|
llmexer/base/__init__.py
ADDED
|
@@ -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)
|
llmexer/base/llm_core.py
ADDED
|
@@ -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)
|