lumera 0.4.6__py3-none-any.whl → 0.9.6__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.
lumera/llm.py ADDED
@@ -0,0 +1,481 @@
1
+ """
2
+ LLM operations for AI completions and embeddings.
3
+
4
+ This module provides a unified interface for LLM operations with pluggable
5
+ provider support. Currently implements OpenAI, with extensibility for other
6
+ providers (Anthropic, Google, etc.) in the future.
7
+
8
+ Available functions:
9
+ complete() - Single-turn LLM completion with prompt
10
+ chat() - Multi-turn chat completion with message history
11
+ embed() - Generate embeddings for text (single or batch)
12
+
13
+ Configuration:
14
+ OPENAI_API_KEY - Required for OpenAI provider
15
+ LUMERA_LLM_PROVIDER - Provider to use (default: "openai")
16
+
17
+ Example:
18
+ >>> from lumera import llm
19
+ >>> response = llm.complete("What is 2+2?", model="gpt-5.2-mini")
20
+ >>> print(response["content"])
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import os
26
+ from abc import ABC, abstractmethod
27
+ from typing import TYPE_CHECKING, Literal, NotRequired, TypedDict, Unpack
28
+
29
+ if TYPE_CHECKING:
30
+ import openai
31
+
32
+ __all__ = [
33
+ "complete",
34
+ "chat",
35
+ "embed",
36
+ "Message",
37
+ "LLMResponse",
38
+ "ProviderConfig",
39
+ "LLMProvider",
40
+ "get_provider",
41
+ "set_provider",
42
+ ]
43
+
44
+
45
+ # ---------------------------------------------------------------------------
46
+ # Type definitions
47
+ # ---------------------------------------------------------------------------
48
+
49
+
50
+ class Message(TypedDict):
51
+ """Chat message format compatible with OpenAI and other providers."""
52
+
53
+ role: Literal["system", "user", "assistant"]
54
+ content: str
55
+
56
+
57
+ class LLMResponse(TypedDict, total=False):
58
+ """LLM completion response."""
59
+
60
+ content: str # Response text (always present)
61
+ model: str # Model used
62
+ usage: dict[str, int] # Token usage: prompt_tokens, completion_tokens, total_tokens
63
+ finish_reason: str # "stop", "length", "content_filter", etc.
64
+ provider: str # Provider name (e.g., "openai", "anthropic")
65
+
66
+
67
+ class ProviderConfig(TypedDict, total=False):
68
+ """Configuration options for LLM providers."""
69
+
70
+ api_key: NotRequired[str] # API key (overrides Lumera/env lookup)
71
+ provider_name: NotRequired[str] # Provider name for get_access_token (default: "openai")
72
+
73
+
74
+ # ---------------------------------------------------------------------------
75
+ # Provider interface (for future extensibility)
76
+ # ---------------------------------------------------------------------------
77
+
78
+
79
+ class LLMProvider(ABC):
80
+ """Abstract base class for LLM providers.
81
+
82
+ Subclass this to add support for new providers (Anthropic, Google, etc.).
83
+ """
84
+
85
+ name: str = "base"
86
+
87
+ @abstractmethod
88
+ def complete(
89
+ self,
90
+ prompt: str,
91
+ *,
92
+ model: str,
93
+ temperature: float,
94
+ max_tokens: int | None,
95
+ system_prompt: str | None,
96
+ json_mode: bool,
97
+ ) -> LLMResponse:
98
+ """Generate a completion for a single prompt."""
99
+ ...
100
+
101
+ @abstractmethod
102
+ def chat(
103
+ self,
104
+ messages: list[Message],
105
+ *,
106
+ model: str,
107
+ temperature: float,
108
+ max_tokens: int | None,
109
+ json_mode: bool,
110
+ ) -> LLMResponse:
111
+ """Generate a chat completion from message history."""
112
+ ...
113
+
114
+ @abstractmethod
115
+ def embed(
116
+ self,
117
+ text: str | list[str],
118
+ *,
119
+ model: str,
120
+ ) -> list[float] | list[list[float]]:
121
+ """Generate embeddings for text."""
122
+ ...
123
+
124
+
125
+ # ---------------------------------------------------------------------------
126
+ # OpenAI provider implementation
127
+ # ---------------------------------------------------------------------------
128
+
129
+
130
+ class OpenAIProvider(LLMProvider):
131
+ """OpenAI provider implementation using the openai Python SDK."""
132
+
133
+ name = "openai"
134
+
135
+ # Model aliases for convenience
136
+ MODEL_ALIASES: dict[str, str] = {
137
+ "gpt-5.2": "gpt-5.2",
138
+ "gpt-5.2-mini": "gpt-5.2-mini",
139
+ "gpt-5.2-nano": "gpt-5.2-nano",
140
+ # Embedding models
141
+ "text-embedding-3-small": "text-embedding-3-small",
142
+ "text-embedding-3-large": "text-embedding-3-large",
143
+ }
144
+
145
+ DEFAULT_CHAT_MODEL = "gpt-5.2-mini"
146
+ DEFAULT_EMBEDDING_MODEL = "text-embedding-3-small"
147
+ DEFAULT_PROVIDER_NAME = "openai"
148
+
149
+ def __init__(
150
+ self,
151
+ api_key: str | None = None,
152
+ provider_name: str | None = None,
153
+ ) -> None:
154
+ """Initialize OpenAI provider.
155
+
156
+ Args:
157
+ api_key: OpenAI API key. If not provided, fetches from Lumera
158
+ using get_access_token(provider_name), or falls back
159
+ to OPENAI_API_KEY env var.
160
+ provider_name: Provider name for get_access_token lookup.
161
+ Defaults to "openai".
162
+ """
163
+ self._explicit_api_key = api_key
164
+ self._provider_name = provider_name or self.DEFAULT_PROVIDER_NAME
165
+ self._client: openai.OpenAI | None = None # noqa: F821
166
+
167
+ def _get_api_key(self) -> str:
168
+ """Get API key from explicit config, Lumera, or environment."""
169
+ # 1. Use explicitly provided key
170
+ if self._explicit_api_key:
171
+ return self._explicit_api_key
172
+
173
+ # 2. Try to fetch from Lumera platform
174
+ try:
175
+ from ._utils import get_access_token
176
+
177
+ return get_access_token(self._provider_name)
178
+ except Exception:
179
+ pass # Fall through to env var
180
+
181
+ # 3. Fall back to environment variable
182
+ env_key = os.environ.get("OPENAI_API_KEY")
183
+ if env_key:
184
+ return env_key
185
+
186
+ raise ValueError(
187
+ "OpenAI API key not configured. Either:\n"
188
+ f" 1. Configure '{self._provider_name}' provider in Lumera platform\n"
189
+ " 2. Set OPENAI_API_KEY environment variable\n"
190
+ " 3. Pass api_key to set_provider()"
191
+ )
192
+
193
+ @property
194
+ def client(self) -> openai.OpenAI: # noqa: F821
195
+ """Lazy-initialize OpenAI client."""
196
+ if self._client is None:
197
+ try:
198
+ import openai
199
+ except ImportError as e:
200
+ raise ImportError(
201
+ "OpenAI package not installed. Install with: pip install 'lumera[full]'"
202
+ ) from e
203
+
204
+ api_key = self._get_api_key()
205
+ self._client = openai.OpenAI(api_key=api_key)
206
+ return self._client
207
+
208
+ def _resolve_model(self, model: str) -> str:
209
+ """Resolve model alias to actual model name."""
210
+ return self.MODEL_ALIASES.get(model, model)
211
+
212
+ def complete(
213
+ self,
214
+ prompt: str,
215
+ *,
216
+ model: str,
217
+ temperature: float,
218
+ max_tokens: int | None,
219
+ system_prompt: str | None,
220
+ json_mode: bool,
221
+ ) -> LLMResponse:
222
+ """Generate a completion using OpenAI."""
223
+ messages: list[Message] = []
224
+ if system_prompt:
225
+ messages.append({"role": "system", "content": system_prompt})
226
+ messages.append({"role": "user", "content": prompt})
227
+
228
+ return self.chat(
229
+ messages,
230
+ model=model,
231
+ temperature=temperature,
232
+ max_tokens=max_tokens,
233
+ json_mode=json_mode,
234
+ )
235
+
236
+ def chat(
237
+ self,
238
+ messages: list[Message],
239
+ *,
240
+ model: str,
241
+ temperature: float,
242
+ max_tokens: int | None,
243
+ json_mode: bool,
244
+ ) -> LLMResponse:
245
+ """Generate a chat completion using OpenAI."""
246
+ resolved_model = self._resolve_model(model)
247
+
248
+ # Build request kwargs
249
+ kwargs: dict = {
250
+ "model": resolved_model,
251
+ "messages": messages, # type: ignore[arg-type]
252
+ "temperature": temperature,
253
+ }
254
+
255
+ if max_tokens is not None:
256
+ kwargs["max_tokens"] = max_tokens
257
+
258
+ if json_mode:
259
+ kwargs["response_format"] = {"type": "json_object"}
260
+
261
+ # Make API call
262
+ response = self.client.chat.completions.create(**kwargs)
263
+
264
+ # Extract response
265
+ choice = response.choices[0]
266
+ content = choice.message.content or ""
267
+
268
+ result: LLMResponse = {
269
+ "content": content,
270
+ "model": response.model,
271
+ "provider": self.name,
272
+ }
273
+
274
+ if choice.finish_reason:
275
+ result["finish_reason"] = choice.finish_reason
276
+
277
+ if response.usage:
278
+ result["usage"] = {
279
+ "prompt_tokens": response.usage.prompt_tokens,
280
+ "completion_tokens": response.usage.completion_tokens,
281
+ "total_tokens": response.usage.total_tokens,
282
+ }
283
+
284
+ return result
285
+
286
+ def embed(
287
+ self,
288
+ text: str | list[str],
289
+ *,
290
+ model: str,
291
+ ) -> list[float] | list[list[float]]:
292
+ """Generate embeddings using OpenAI."""
293
+ resolved_model = self._resolve_model(model)
294
+
295
+ # Normalize input to list
296
+ input_texts = [text] if isinstance(text, str) else text
297
+
298
+ response = self.client.embeddings.create(
299
+ model=resolved_model,
300
+ input=input_texts,
301
+ )
302
+
303
+ # Extract embeddings
304
+ embeddings = [item.embedding for item in response.data]
305
+
306
+ # Return single embedding if single input
307
+ if isinstance(text, str):
308
+ return embeddings[0]
309
+ return embeddings
310
+
311
+
312
+ # ---------------------------------------------------------------------------
313
+ # Provider registry and module-level state
314
+ # ---------------------------------------------------------------------------
315
+
316
+ # Registry of available providers
317
+ _PROVIDERS: dict[str, type[LLMProvider]] = {
318
+ "openai": OpenAIProvider,
319
+ }
320
+
321
+ # Current active provider instance
322
+ _current_provider: LLMProvider | None = None
323
+
324
+
325
+ def get_provider() -> LLMProvider:
326
+ """Get the current LLM provider instance.
327
+
328
+ Returns the configured provider, initializing it if necessary.
329
+ Provider is determined by LUMERA_LLM_PROVIDER env var (default: "openai").
330
+ """
331
+ global _current_provider
332
+
333
+ if _current_provider is None:
334
+ provider_name = os.environ.get("LUMERA_LLM_PROVIDER", "openai").lower()
335
+
336
+ if provider_name not in _PROVIDERS:
337
+ available = ", ".join(_PROVIDERS.keys())
338
+ raise ValueError(f"Unknown LLM provider: {provider_name}. Available: {available}")
339
+
340
+ provider_class = _PROVIDERS[provider_name]
341
+ _current_provider = provider_class()
342
+
343
+ return _current_provider
344
+
345
+
346
+ def set_provider(provider: LLMProvider | str, **kwargs: Unpack[ProviderConfig]) -> None:
347
+ """Set the active LLM provider.
348
+
349
+ Args:
350
+ provider: Either a provider instance or provider name string.
351
+ **kwargs: If provider is a string, kwargs are passed to provider constructor.
352
+
353
+ Example:
354
+ >>> llm.set_provider("openai", api_key="sk-...")
355
+ >>> # Or with a custom provider instance
356
+ >>> llm.set_provider(MyCustomProvider())
357
+ """
358
+ global _current_provider
359
+
360
+ if isinstance(provider, str):
361
+ if provider not in _PROVIDERS:
362
+ available = ", ".join(_PROVIDERS.keys())
363
+ raise ValueError(f"Unknown provider: {provider}. Available: {available}")
364
+ _current_provider = _PROVIDERS[provider](**kwargs)
365
+ else:
366
+ _current_provider = provider
367
+
368
+
369
+ # ---------------------------------------------------------------------------
370
+ # Public API functions
371
+ # ---------------------------------------------------------------------------
372
+
373
+
374
+ def complete(
375
+ prompt: str,
376
+ *,
377
+ model: str = "gpt-5.2-mini",
378
+ temperature: float = 0.7,
379
+ max_tokens: int | None = None,
380
+ system_prompt: str | None = None,
381
+ json_mode: bool = False,
382
+ ) -> LLMResponse:
383
+ """Get LLM completion for a prompt.
384
+
385
+ Args:
386
+ prompt: User prompt/question
387
+ model: Model to use (default: gpt-5.2-mini)
388
+ temperature: Sampling temperature 0.0 to 2.0 (default: 0.7)
389
+ max_tokens: Max tokens in response (None = model default)
390
+ system_prompt: Optional system message to set behavior
391
+ json_mode: Force JSON output (default: False)
392
+
393
+ Returns:
394
+ LLM response with content and metadata
395
+
396
+ Example:
397
+ >>> response = llm.complete(
398
+ ... prompt="Classify this deposit: ...",
399
+ ... system_prompt="You are an expert accountant.",
400
+ ... model="gpt-5.2-mini",
401
+ ... json_mode=True
402
+ ... )
403
+ >>> data = json.loads(response["content"])
404
+ """
405
+ provider = get_provider()
406
+ return provider.complete(
407
+ prompt,
408
+ model=model,
409
+ temperature=temperature,
410
+ max_tokens=max_tokens,
411
+ system_prompt=system_prompt,
412
+ json_mode=json_mode,
413
+ )
414
+
415
+
416
+ def chat(
417
+ messages: list[Message],
418
+ *,
419
+ model: str = "gpt-5.2-mini",
420
+ temperature: float = 0.7,
421
+ max_tokens: int | None = None,
422
+ json_mode: bool = False,
423
+ ) -> LLMResponse:
424
+ """Multi-turn chat completion.
425
+
426
+ Args:
427
+ messages: Conversation history with role and content
428
+ model: Model to use (default: gpt-5.2-mini)
429
+ temperature: Sampling temperature 0.0 to 2.0 (default: 0.7)
430
+ max_tokens: Max tokens in response (None = model default)
431
+ json_mode: Force JSON output (default: False)
432
+
433
+ Returns:
434
+ LLM response with assistant's message
435
+
436
+ Example:
437
+ >>> response = llm.chat([
438
+ ... {"role": "system", "content": "You are a helpful assistant."},
439
+ ... {"role": "user", "content": "What is 2+2?"},
440
+ ... {"role": "assistant", "content": "4"},
441
+ ... {"role": "user", "content": "What about 3+3?"}
442
+ ... ])
443
+ >>> print(response["content"])
444
+ """
445
+ provider = get_provider()
446
+ return provider.chat(
447
+ messages,
448
+ model=model,
449
+ temperature=temperature,
450
+ max_tokens=max_tokens,
451
+ json_mode=json_mode,
452
+ )
453
+
454
+
455
+ def embed(
456
+ text: str | list[str],
457
+ *,
458
+ model: str = "text-embedding-3-small",
459
+ ) -> list[float] | list[list[float]]:
460
+ """Generate embeddings for text.
461
+
462
+ Args:
463
+ text: Single string or list of strings to embed
464
+ model: Embedding model (default: text-embedding-3-small)
465
+
466
+ Returns:
467
+ Embedding vector (for single string) or list of vectors (for list)
468
+
469
+ Example:
470
+ >>> embedding = llm.embed("deposit payment notice")
471
+ >>> # Use for similarity search, semantic matching, etc.
472
+ >>>
473
+ >>> # Batch embeddings
474
+ >>> embeddings = llm.embed([
475
+ ... "payment notice",
476
+ ... "direct deposit",
477
+ ... "apportionment"
478
+ ... ])
479
+ """
480
+ provider = get_provider()
481
+ return provider.embed(text, model=model)
lumera/locks.py ADDED
@@ -0,0 +1,216 @@
1
+ """
2
+ Lock management for preventing concurrent operations.
3
+
4
+ Provides two types of locks:
5
+ 1. Record-level locks: Lock specific records (uses platform lm_locks table)
6
+ 2. Operation-level locks: Lock entire operations globally (requires custom collection)
7
+
8
+ Available functions:
9
+ claim_record_locks() - Lock specific records for processing
10
+ release_record_locks() - Release previously claimed record locks
11
+ acquire_operation_lock() - Lock an entire operation (NOT YET IMPLEMENTED)
12
+ release_operation_lock() - Release operation lock (NOT YET IMPLEMENTED)
13
+ operation_lock() - Context manager for operation locks (NOT YET IMPLEMENTED)
14
+
15
+ Example:
16
+ >>> from lumera import locks
17
+ >>> result = locks.claim_record_locks("export", "deposits", ["dep_1", "dep_2"])
18
+ >>> for id in result["claimed"]:
19
+ ... process(id)
20
+ >>> locks.release_record_locks("export", record_ids=result["claimed"])
21
+ """
22
+
23
+ __all__ = [
24
+ "claim_record_locks",
25
+ "release_record_locks",
26
+ "acquire_operation_lock",
27
+ "release_operation_lock",
28
+ "operation_lock",
29
+ ]
30
+
31
+ from contextlib import contextmanager
32
+ from typing import Any, Iterator
33
+
34
+ # Import platform lock primitives from the main SDK module
35
+ from .sdk import claim_locks as _claim_locks
36
+ from .sdk import release_locks as _release_locks
37
+
38
+
39
+ def claim_record_locks(
40
+ job_type: str,
41
+ collection: str,
42
+ record_ids: list[str],
43
+ *,
44
+ ttl_seconds: int = 900,
45
+ job_id: str | None = None,
46
+ ) -> dict[str, Any]:
47
+ """Claim record-level locks (using platform lm_locks).
48
+
49
+ Prevents multiple workers from processing the same records concurrently.
50
+ Uses the platform's built-in lm_locks table.
51
+
52
+ Args:
53
+ job_type: Workflow name (e.g., "deposit_processing")
54
+ collection: Collection name
55
+ record_ids: List of record IDs to lock
56
+ ttl_seconds: Lock duration in seconds (default 900 = 15 minutes)
57
+ job_id: Optional job identifier for grouping locks
58
+
59
+ Returns:
60
+ {
61
+ "claimed": ["id1", "id2"], # Successfully locked
62
+ "skipped": ["id3"], # Already locked by another process
63
+ "ttl_seconds": 900
64
+ }
65
+
66
+ Example:
67
+ >>> result = claim_record_locks(
68
+ ... job_type="export",
69
+ ... collection="deposits",
70
+ ... record_ids=["dep_1", "dep_2", "dep_3"]
71
+ ... )
72
+ >>> for dep_id in result["claimed"]:
73
+ ... process(dep_id)
74
+ >>> # Release when done
75
+ >>> release_record_locks("export", record_ids=result["claimed"])
76
+ """
77
+ return _claim_locks(
78
+ job_type=job_type,
79
+ collection=collection,
80
+ record_ids=record_ids,
81
+ ttl_seconds=ttl_seconds,
82
+ job_id=job_id,
83
+ )
84
+
85
+
86
+ def release_record_locks(
87
+ job_type: str,
88
+ *,
89
+ collection: str | None = None,
90
+ record_ids: list[str] | None = None,
91
+ job_id: str | None = None,
92
+ ) -> int:
93
+ """Release record-level locks.
94
+
95
+ Args:
96
+ job_type: Workflow name (required)
97
+ collection: Optional collection filter
98
+ record_ids: Optional specific records to release
99
+ job_id: Optional job identifier filter
100
+
101
+ Returns:
102
+ Number of locks released
103
+
104
+ Example:
105
+ >>> released = release_record_locks(
106
+ ... job_type="export",
107
+ ... record_ids=["dep_1", "dep_2"]
108
+ ... )
109
+ >>> print(f"Released {released} locks")
110
+ """
111
+ return _release_locks(
112
+ job_type=job_type, collection=collection, record_ids=record_ids, job_id=job_id
113
+ )
114
+
115
+
116
+ # Operation-level locks (simple key-value locks)
117
+ # Note: These would need a custom collection like "export_locks" to be implemented
118
+ # For now, providing the interface that should be implemented
119
+
120
+
121
+ def acquire_operation_lock(
122
+ lock_name: str, *, ttl_seconds: int = 600, wait: bool = False, wait_timeout: int = 30
123
+ ) -> bool:
124
+ """Acquire an operation-level lock.
125
+
126
+ For preventing concurrent execution of entire operations (like exports).
127
+ Uses a simple key-value lock, not tied to specific records.
128
+
129
+ Note: This requires a custom locks collection to be created.
130
+ See the Charter Impact export_locks collection as an example.
131
+
132
+ Args:
133
+ lock_name: Unique lock identifier (e.g., "csv_export")
134
+ ttl_seconds: Lock duration in seconds (default 600 = 10 minutes)
135
+ wait: If True, wait for lock to become available
136
+ wait_timeout: Max seconds to wait (if wait=True)
137
+
138
+ Returns:
139
+ True if lock acquired, False if already held (when wait=False)
140
+
141
+ Raises:
142
+ TimeoutError: If wait=True and timeout exceeded
143
+ NotImplementedError: If operation locks collection doesn't exist
144
+
145
+ Example:
146
+ >>> if acquire_operation_lock("csv_export"):
147
+ ... try:
148
+ ... perform_export()
149
+ ... finally:
150
+ ... release_operation_lock("csv_export")
151
+ ... else:
152
+ ... print("Export already in progress")
153
+ """
154
+ raise NotImplementedError(
155
+ "Operation-level locks require a custom locks collection. "
156
+ "Create a collection like 'operation_locks' with fields: "
157
+ "lock_name (text, unique), held_by (text), acquired_at (date), expires_at (date). "
158
+ "Then implement acquire/release using pb.search/create/delete."
159
+ )
160
+
161
+
162
+ def release_operation_lock(lock_name: str) -> bool:
163
+ """Release an operation-level lock.
164
+
165
+ Args:
166
+ lock_name: Lock identifier
167
+
168
+ Returns:
169
+ True if lock was released, False if wasn't held
170
+
171
+ Raises:
172
+ NotImplementedError: If operation locks collection doesn't exist
173
+
174
+ Example:
175
+ >>> release_operation_lock("csv_export")
176
+ """
177
+ raise NotImplementedError(
178
+ "Operation-level locks require a custom locks collection. "
179
+ "See acquire_operation_lock() for details."
180
+ )
181
+
182
+
183
+ @contextmanager
184
+ def operation_lock(
185
+ lock_name: str, *, ttl_seconds: int = 600, wait: bool = False, wait_timeout: int = 30
186
+ ) -> Iterator[None]:
187
+ """Context manager for operation locks.
188
+
189
+ Args:
190
+ lock_name: Lock identifier
191
+ ttl_seconds: Lock duration in seconds
192
+ wait: Wait for lock if held
193
+ wait_timeout: Max wait time in seconds
194
+
195
+ Raises:
196
+ NotImplementedError: If operation locks collection doesn't exist
197
+ TimeoutError: If wait=True and timeout exceeded
198
+
199
+ Example:
200
+ >>> with operation_lock("csv_export"):
201
+ ... perform_export()
202
+ ... # Lock automatically released on exit
203
+ """
204
+ # This would acquire the lock
205
+ acquired = acquire_operation_lock(
206
+ lock_name, ttl_seconds=ttl_seconds, wait=wait, wait_timeout=wait_timeout
207
+ )
208
+
209
+ if not acquired:
210
+ raise RuntimeError(f"Failed to acquire lock: {lock_name}")
211
+
212
+ try:
213
+ yield
214
+ finally:
215
+ # Always release the lock
216
+ release_operation_lock(lock_name)