prompture 0.0.33.dev2__py3-none-any.whl → 0.0.34.dev1__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 (55) hide show
  1. prompture/__init__.py +112 -54
  2. prompture/_version.py +34 -0
  3. prompture/aio/__init__.py +74 -0
  4. prompture/async_conversation.py +484 -0
  5. prompture/async_core.py +803 -0
  6. prompture/async_driver.py +131 -0
  7. prompture/cache.py +469 -0
  8. prompture/callbacks.py +50 -0
  9. prompture/cli.py +7 -3
  10. prompture/conversation.py +504 -0
  11. prompture/core.py +475 -352
  12. prompture/cost_mixin.py +51 -0
  13. prompture/discovery.py +41 -36
  14. prompture/driver.py +125 -5
  15. prompture/drivers/__init__.py +63 -57
  16. prompture/drivers/airllm_driver.py +13 -20
  17. prompture/drivers/async_airllm_driver.py +26 -0
  18. prompture/drivers/async_azure_driver.py +117 -0
  19. prompture/drivers/async_claude_driver.py +107 -0
  20. prompture/drivers/async_google_driver.py +132 -0
  21. prompture/drivers/async_grok_driver.py +91 -0
  22. prompture/drivers/async_groq_driver.py +84 -0
  23. prompture/drivers/async_hugging_driver.py +61 -0
  24. prompture/drivers/async_lmstudio_driver.py +79 -0
  25. prompture/drivers/async_local_http_driver.py +44 -0
  26. prompture/drivers/async_ollama_driver.py +125 -0
  27. prompture/drivers/async_openai_driver.py +96 -0
  28. prompture/drivers/async_openrouter_driver.py +96 -0
  29. prompture/drivers/async_registry.py +80 -0
  30. prompture/drivers/azure_driver.py +36 -15
  31. prompture/drivers/claude_driver.py +86 -40
  32. prompture/drivers/google_driver.py +86 -58
  33. prompture/drivers/grok_driver.py +29 -38
  34. prompture/drivers/groq_driver.py +27 -32
  35. prompture/drivers/hugging_driver.py +6 -6
  36. prompture/drivers/lmstudio_driver.py +26 -13
  37. prompture/drivers/local_http_driver.py +6 -6
  38. prompture/drivers/ollama_driver.py +90 -23
  39. prompture/drivers/openai_driver.py +36 -15
  40. prompture/drivers/openrouter_driver.py +31 -31
  41. prompture/field_definitions.py +106 -96
  42. prompture/logging.py +80 -0
  43. prompture/model_rates.py +16 -15
  44. prompture/runner.py +49 -47
  45. prompture/session.py +117 -0
  46. prompture/settings.py +11 -1
  47. prompture/tools.py +172 -265
  48. prompture/validator.py +3 -3
  49. {prompture-0.0.33.dev2.dist-info → prompture-0.0.34.dev1.dist-info}/METADATA +18 -20
  50. prompture-0.0.34.dev1.dist-info/RECORD +54 -0
  51. prompture-0.0.33.dev2.dist-info/RECORD +0 -30
  52. {prompture-0.0.33.dev2.dist-info → prompture-0.0.34.dev1.dist-info}/WHEEL +0 -0
  53. {prompture-0.0.33.dev2.dist-info → prompture-0.0.34.dev1.dist-info}/entry_points.txt +0 -0
  54. {prompture-0.0.33.dev2.dist-info → prompture-0.0.34.dev1.dist-info}/licenses/LICENSE +0 -0
  55. {prompture-0.0.33.dev2.dist-info → prompture-0.0.34.dev1.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,803 @@
1
+ """Async core utilities: async versions of all public extraction functions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ import logging
8
+ import sys
9
+ from datetime import date, datetime
10
+ from decimal import Decimal
11
+ from typing import Any, Literal, Union
12
+
13
+ try:
14
+ import toon
15
+ except ImportError:
16
+ toon = None
17
+
18
+ from pydantic import BaseModel
19
+
20
+ from .async_driver import AsyncDriver
21
+ from .core import (
22
+ _calculate_token_savings,
23
+ _dataframe_to_toon,
24
+ _json_to_toon,
25
+ normalize_field_value,
26
+ )
27
+ from .drivers.async_registry import get_async_driver_for_model
28
+ from .field_definitions import get_registry_snapshot
29
+ from .tools import (
30
+ clean_json_text,
31
+ convert_value,
32
+ get_field_default,
33
+ )
34
+
35
+ logger = logging.getLogger("prompture.async_core")
36
+
37
+
38
+ async def clean_json_text_with_ai(
39
+ driver: AsyncDriver, text: str, model_name: str = "", options: dict[str, Any] | None = None
40
+ ) -> str:
41
+ """Use LLM to fix malformed JSON strings (async version)."""
42
+ if options is None:
43
+ options = {}
44
+ try:
45
+ json.loads(text)
46
+ return text
47
+ except json.JSONDecodeError:
48
+ pass
49
+
50
+ prompt = (
51
+ "The following text is supposed to be a single JSON object, but it is malformed. "
52
+ "Please correct it and return only the valid JSON object. Do not add any explanations or markdown. "
53
+ f"The text to correct is:\n\n{text}"
54
+ )
55
+ resp = await driver.generate(prompt, options)
56
+ raw = resp.get("text", "")
57
+ cleaned = clean_json_text(raw)
58
+ return cleaned
59
+
60
+
61
+ async def render_output(
62
+ driver: AsyncDriver,
63
+ content_prompt: str,
64
+ output_format: Literal["text", "html", "markdown"] = "text",
65
+ model_name: str = "",
66
+ options: dict[str, Any] | None = None,
67
+ system_prompt: str | None = None,
68
+ ) -> dict[str, Any]:
69
+ """Send a prompt and return the raw output in the requested format (async version)."""
70
+ if options is None:
71
+ options = {}
72
+ if output_format not in ("text", "html", "markdown"):
73
+ raise ValueError(f"Unsupported output_format '{output_format}'. Use 'text', 'html', or 'markdown'.")
74
+
75
+ instruct = ""
76
+ if output_format == "text":
77
+ instruct = (
78
+ "Return ONLY the raw text content. Do not use markdown formatting, "
79
+ "code fences, or conversational filler. Just the text."
80
+ )
81
+ elif output_format == "html":
82
+ instruct = (
83
+ "Return ONLY valid HTML code. Do not wrap it in markdown code fences "
84
+ "(like ```html ... ```). Do not include conversational filler."
85
+ )
86
+ elif output_format == "markdown":
87
+ instruct = "Return valid markdown content. You may use standard markdown formatting."
88
+
89
+ full_prompt = f"{content_prompt}\n\nSYSTEM INSTRUCTION: {instruct}"
90
+
91
+ # Use generate_messages when system_prompt is provided
92
+ if system_prompt is not None:
93
+ messages = [
94
+ {"role": "system", "content": system_prompt},
95
+ {"role": "user", "content": full_prompt},
96
+ ]
97
+ resp = await driver.generate_messages(messages, options)
98
+ else:
99
+ resp = await driver.generate(full_prompt, options)
100
+ raw = resp.get("text", "")
101
+
102
+ if output_format in ("text", "html"):
103
+ cleaned = raw.strip()
104
+ if cleaned.startswith("```") and cleaned.endswith("```"):
105
+ lines = cleaned.splitlines()
106
+ if len(lines) >= 2:
107
+ cleaned = "\n".join(lines[1:-1])
108
+ raw = cleaned
109
+
110
+ usage = {
111
+ **resp.get("meta", {}),
112
+ "raw_response": resp,
113
+ "total_tokens": resp.get("meta", {}).get("total_tokens", 0),
114
+ "prompt_tokens": resp.get("meta", {}).get("prompt_tokens", 0),
115
+ "completion_tokens": resp.get("meta", {}).get("completion_tokens", 0),
116
+ "cost": resp.get("meta", {}).get("cost", 0.0),
117
+ "model_name": model_name or getattr(driver, "model", ""),
118
+ }
119
+
120
+ return {"text": raw, "usage": usage, "output_format": output_format}
121
+
122
+
123
+ async def ask_for_json(
124
+ driver: AsyncDriver,
125
+ content_prompt: str,
126
+ json_schema: dict[str, Any],
127
+ ai_cleanup: bool = True,
128
+ model_name: str = "",
129
+ options: dict[str, Any] | None = None,
130
+ output_format: Literal["json", "toon"] = "json",
131
+ cache: bool | None = None,
132
+ json_mode: Literal["auto", "on", "off"] = "auto",
133
+ system_prompt: str | None = None,
134
+ ) -> dict[str, Any]:
135
+ """Send a prompt and return structured JSON output plus usage metadata (async version)."""
136
+ if options is None:
137
+ options = {}
138
+ if output_format not in ("json", "toon"):
139
+ raise ValueError(f"Unsupported output_format '{output_format}'. Use 'json' or 'toon'.")
140
+
141
+ # --- cache lookup ---
142
+ from .cache import get_cache, make_cache_key
143
+
144
+ _cache = get_cache()
145
+ use_cache = cache if cache is not None else _cache.enabled
146
+ _force = cache is True
147
+ cache_key: str | None = None
148
+ if use_cache:
149
+ cache_key = make_cache_key(
150
+ prompt=content_prompt,
151
+ model_name=model_name,
152
+ schema=json_schema,
153
+ options=options,
154
+ output_format=output_format,
155
+ )
156
+ cached = _cache.get(cache_key, force=_force)
157
+ if cached is not None:
158
+ cached["usage"]["cache_hit"] = True
159
+ return cached
160
+
161
+ schema_string = json.dumps(json_schema, indent=2)
162
+ if output_format == "toon" and toon is None:
163
+ raise RuntimeError(
164
+ "TOON requested but 'python-toon' is not installed. Install it with 'pip install python-toon'."
165
+ )
166
+
167
+ # Determine whether to use native JSON mode
168
+ use_json_mode = False
169
+ if json_mode == "on":
170
+ use_json_mode = True
171
+ elif json_mode == "auto":
172
+ use_json_mode = getattr(driver, "supports_json_mode", False)
173
+
174
+ if use_json_mode:
175
+ options = {**options, "json_mode": True}
176
+ if getattr(driver, "supports_json_schema", False):
177
+ options["json_schema"] = json_schema
178
+
179
+ # Adjust instruction prompt based on JSON mode capabilities
180
+ if use_json_mode and getattr(driver, "supports_json_schema", False):
181
+ # Schema enforced by API — minimal instruction
182
+ instruct = "Extract data matching the requested schema.\nIf a value is unknown use null."
183
+ elif use_json_mode:
184
+ # JSON guaranteed but schema not enforced by API
185
+ instruct = (
186
+ "Return a JSON object that validates against this schema:\n"
187
+ f"{schema_string}\n\n"
188
+ "If a value is unknown use null."
189
+ )
190
+ else:
191
+ # Existing prompt-based enforcement
192
+ instruct = (
193
+ "Return only a single JSON object (no markdown, no extra text) that validates against this JSON schema:\n"
194
+ f"{schema_string}\n\n"
195
+ "If a value is unknown use null. Use double quotes for keys and strings."
196
+ )
197
+ if output_format == "toon":
198
+ instruct += "\n\n(Respond with JSON only; Prompture will convert to TOON.)"
199
+
200
+ full_prompt = f"{content_prompt}\n\n{instruct}"
201
+
202
+ # Use generate_messages when system_prompt is provided
203
+ if system_prompt is not None:
204
+ messages = [
205
+ {"role": "system", "content": system_prompt},
206
+ {"role": "user", "content": full_prompt},
207
+ ]
208
+ resp = await driver.generate_messages(messages, options)
209
+ else:
210
+ resp = await driver.generate(full_prompt, options)
211
+ raw = resp.get("text", "")
212
+ cleaned = clean_json_text(raw)
213
+
214
+ try:
215
+ json_obj = json.loads(cleaned)
216
+ json_string = cleaned
217
+ toon_string = None
218
+ if output_format == "toon":
219
+ toon_string = toon.encode(json_obj)
220
+
221
+ usage = {
222
+ **resp.get("meta", {}),
223
+ "raw_response": resp,
224
+ "total_tokens": resp.get("meta", {}).get("total_tokens", 0),
225
+ "prompt_tokens": resp.get("meta", {}).get("prompt_tokens", 0),
226
+ "completion_tokens": resp.get("meta", {}).get("completion_tokens", 0),
227
+ "cost": resp.get("meta", {}).get("cost", 0.0),
228
+ "model_name": model_name or getattr(driver, "model", ""),
229
+ }
230
+ result = {"json_string": json_string, "json_object": json_obj, "usage": usage}
231
+ if toon_string is not None:
232
+ result["toon_string"] = toon_string
233
+ result["output_format"] = "toon"
234
+ else:
235
+ result["output_format"] = "json"
236
+
237
+ # --- cache store ---
238
+ if use_cache and cache_key is not None:
239
+ cached_copy = {**result, "usage": {**result["usage"], "raw_response": {}}}
240
+ _cache.set(cache_key, cached_copy, force=_force)
241
+
242
+ return result
243
+ except json.JSONDecodeError as e:
244
+ if ai_cleanup:
245
+ cleaned_fixed = await clean_json_text_with_ai(driver, cleaned, model_name, options)
246
+ try:
247
+ json_obj = json.loads(cleaned_fixed)
248
+ result = {
249
+ "json_string": cleaned_fixed,
250
+ "json_object": json_obj,
251
+ "usage": {
252
+ "prompt_tokens": 0,
253
+ "completion_tokens": 0,
254
+ "total_tokens": 0,
255
+ "cost": 0.0,
256
+ "model_name": options.get("model", getattr(driver, "model", "")),
257
+ "raw_response": {},
258
+ },
259
+ "output_format": "json" if output_format != "toon" else "toon",
260
+ }
261
+ if output_format == "toon":
262
+ result["toon_string"] = toon.encode(json_obj)
263
+
264
+ # --- cache store (ai cleanup path) ---
265
+ if use_cache and cache_key is not None:
266
+ _cache.set(cache_key, result, force=_force)
267
+
268
+ return result
269
+ except json.JSONDecodeError:
270
+ raise e from None
271
+ else:
272
+ raise e
273
+
274
+
275
+ async def extract_and_jsonify(
276
+ text: str,
277
+ json_schema: dict[str, Any],
278
+ *,
279
+ model_name: str = "",
280
+ instruction_template: str = "Extract information from the following text:",
281
+ ai_cleanup: bool = True,
282
+ output_format: Literal["json", "toon"] = "json",
283
+ options: dict[str, Any] | None = None,
284
+ json_mode: Literal["auto", "on", "off"] = "auto",
285
+ system_prompt: str | None = None,
286
+ ) -> dict[str, Any]:
287
+ """Extract structured information using automatic async driver selection (async version)."""
288
+ if options is None:
289
+ options = {}
290
+ if not isinstance(text, str):
291
+ raise ValueError("Text input must be a string")
292
+ if not text or not text.strip():
293
+ raise ValueError("Text input cannot be empty")
294
+
295
+ actual_model = model_name or options.get("model", "")
296
+ driver = options.pop("driver", None)
297
+
298
+ if driver is None:
299
+ if not actual_model:
300
+ raise ValueError("Model name cannot be empty")
301
+ if "/" not in actual_model:
302
+ raise ValueError("Invalid model string format. Expected format: 'provider/model'")
303
+ try:
304
+ driver = get_async_driver_for_model(actual_model)
305
+ except ValueError as e:
306
+ if "Unsupported provider" in str(e):
307
+ raise ValueError(f"Unsupported provider in model name: {actual_model}") from e
308
+ raise
309
+
310
+ try:
311
+ provider, model_id = actual_model.split("/", 1)
312
+ if not provider:
313
+ raise ValueError("Provider cannot be empty in model name")
314
+ except ValueError:
315
+ provider = model_id = actual_model
316
+
317
+ opts = {**options, "model": model_id}
318
+ content_prompt = f"{instruction_template} {text}"
319
+
320
+ try:
321
+ return await ask_for_json(
322
+ driver,
323
+ content_prompt,
324
+ json_schema,
325
+ ai_cleanup,
326
+ model_id,
327
+ opts,
328
+ output_format=output_format,
329
+ json_mode=json_mode,
330
+ system_prompt=system_prompt,
331
+ )
332
+ except Exception as e:
333
+ if "pytest" in sys.modules:
334
+ import pytest
335
+
336
+ pytest.skip(f"Connection error occurred: {e}")
337
+ raise
338
+
339
+
340
+ async def manual_extract_and_jsonify(
341
+ driver: AsyncDriver,
342
+ text: str,
343
+ json_schema: dict[str, Any],
344
+ model_name: str = "",
345
+ instruction_template: str = "Extract information from the following text:",
346
+ ai_cleanup: bool = True,
347
+ output_format: Literal["json", "toon"] = "json",
348
+ options: dict[str, Any] | None = None,
349
+ json_mode: Literal["auto", "on", "off"] = "auto",
350
+ system_prompt: str | None = None,
351
+ ) -> dict[str, Any]:
352
+ """Extract structured information using an explicitly provided async driver."""
353
+ if options is None:
354
+ options = {}
355
+ if not isinstance(text, str):
356
+ raise ValueError("Text input must be a string")
357
+ if not text or not text.strip():
358
+ raise ValueError("Text input cannot be empty")
359
+
360
+ logger.info("[async-manual] Starting async manual extraction")
361
+
362
+ opts = dict(options)
363
+ if model_name:
364
+ opts["model"] = model_name
365
+
366
+ content_prompt = f"{instruction_template} {text}"
367
+
368
+ result = await ask_for_json(
369
+ driver,
370
+ content_prompt,
371
+ json_schema,
372
+ ai_cleanup,
373
+ model_name,
374
+ opts,
375
+ output_format=output_format,
376
+ json_mode=json_mode,
377
+ system_prompt=system_prompt,
378
+ )
379
+ return result
380
+
381
+
382
+ async def extract_with_model(
383
+ model_cls: type[BaseModel],
384
+ text: str,
385
+ model_name: str,
386
+ instruction_template: str = "Extract information from the following text:",
387
+ ai_cleanup: bool = True,
388
+ output_format: Literal["json", "toon"] = "json",
389
+ options: dict[str, Any] | None = None,
390
+ cache: bool | None = None,
391
+ json_mode: Literal["auto", "on", "off"] = "auto",
392
+ system_prompt: str | None = None,
393
+ ) -> dict[str, Any]:
394
+ """Extract structured information into a Pydantic model instance (async version)."""
395
+ if options is None:
396
+ options = {}
397
+ if not isinstance(text, str) or not text.strip():
398
+ raise ValueError("Text input cannot be empty")
399
+
400
+ # --- cache lookup ---
401
+ from .cache import get_cache, make_cache_key
402
+
403
+ _cache = get_cache()
404
+ use_cache = cache if cache is not None else _cache.enabled
405
+ _force = cache is True
406
+ cache_key: str | None = None
407
+ if use_cache:
408
+ schema_for_key = model_cls.model_json_schema()
409
+ cache_key = make_cache_key(
410
+ prompt=f"{instruction_template} {text}",
411
+ model_name=model_name,
412
+ schema=schema_for_key,
413
+ options=options,
414
+ output_format=output_format,
415
+ pydantic_qualname=model_cls.__qualname__,
416
+ )
417
+ cached = _cache.get(cache_key, force=_force)
418
+ if cached is not None:
419
+ cached["usage"]["cache_hit"] = True
420
+ cached["model"] = model_cls(**cached["json_object"])
421
+ return type(
422
+ "ExtractResult",
423
+ (dict,),
424
+ {"__getattr__": lambda self, key: self.get(key), "__call__": lambda self: self["model"]},
425
+ )(cached)
426
+
427
+ logger.info("[async-extract] Starting async extract_with_model")
428
+
429
+ schema = model_cls.model_json_schema()
430
+
431
+ result = await extract_and_jsonify(
432
+ text=text,
433
+ json_schema=schema,
434
+ model_name=model_name,
435
+ instruction_template=instruction_template,
436
+ ai_cleanup=ai_cleanup,
437
+ output_format=output_format,
438
+ options=options,
439
+ json_mode=json_mode,
440
+ system_prompt=system_prompt,
441
+ )
442
+
443
+ json_object = result["json_object"]
444
+ schema_properties = schema.get("properties", {})
445
+
446
+ for field_name, field_info in model_cls.model_fields.items():
447
+ if field_name in json_object and field_name in schema_properties:
448
+ field_def = {
449
+ "nullable": not schema_properties[field_name].get("type")
450
+ or "null"
451
+ in (
452
+ schema_properties[field_name].get("anyOf", [])
453
+ if isinstance(schema_properties[field_name].get("anyOf"), list)
454
+ else []
455
+ ),
456
+ "default": field_info.default
457
+ if hasattr(field_info, "default") and field_info.default is not ...
458
+ else None,
459
+ }
460
+ json_object[field_name] = normalize_field_value(json_object[field_name], field_info.annotation, field_def)
461
+
462
+ model_instance = model_cls(**json_object)
463
+
464
+ result_dict = {"json_string": result["json_string"], "json_object": result["json_object"], "usage": result["usage"]}
465
+
466
+ # --- cache store ---
467
+ if use_cache and cache_key is not None:
468
+ cached_copy = {
469
+ "json_string": result_dict["json_string"],
470
+ "json_object": result_dict["json_object"],
471
+ "usage": {**result_dict["usage"], "raw_response": {}},
472
+ }
473
+ _cache.set(cache_key, cached_copy)
474
+
475
+ result_dict["model"] = model_instance
476
+
477
+ return type(
478
+ "ExtractResult",
479
+ (dict,),
480
+ {"__getattr__": lambda self, key: self.get(key), "__call__": lambda self: self["model"]},
481
+ )(result_dict)
482
+
483
+
484
+ async def stepwise_extract_with_model(
485
+ model_cls: type[BaseModel],
486
+ text: str,
487
+ *,
488
+ model_name: str,
489
+ instruction_template: str = "Extract the {field_name} from the following text:",
490
+ ai_cleanup: bool = True,
491
+ fields: list[str] | None = None,
492
+ field_definitions: dict[str, Any] | None = None,
493
+ options: dict[str, Any] | None = None,
494
+ json_mode: Literal["auto", "on", "off"] = "auto",
495
+ system_prompt: str | None = None,
496
+ share_context: bool = False,
497
+ ) -> dict[str, Union[str, dict[str, Any]]]:
498
+ """Extract information field-by-field using sequential async LLM calls."""
499
+ if not text or not text.strip():
500
+ raise ValueError("Text input cannot be empty")
501
+
502
+ # When share_context=True, delegate to AsyncConversation-based extraction
503
+ if share_context:
504
+ from .async_conversation import AsyncConversation
505
+
506
+ conv = AsyncConversation(model_name=model_name, system_prompt=system_prompt, options=options)
507
+ return await conv._stepwise_extract(
508
+ model_cls=model_cls,
509
+ text=text,
510
+ instruction_template=instruction_template,
511
+ ai_cleanup=ai_cleanup,
512
+ fields=fields,
513
+ field_definitions=field_definitions,
514
+ json_mode=json_mode,
515
+ )
516
+
517
+ if field_definitions is None:
518
+ field_definitions = get_registry_snapshot()
519
+
520
+ data = {}
521
+ validation_errors = []
522
+ field_results = {}
523
+ options = options or {}
524
+
525
+ accumulated_usage = {
526
+ "prompt_tokens": 0,
527
+ "completion_tokens": 0,
528
+ "total_tokens": 0,
529
+ "cost": 0.0,
530
+ "model_name": model_name,
531
+ "field_usages": {},
532
+ }
533
+
534
+ valid_fields = set(model_cls.model_fields.keys())
535
+
536
+ if fields is not None:
537
+ invalid_fields = set(fields) - valid_fields
538
+ if invalid_fields:
539
+ raise KeyError(f"Fields not found in model: {', '.join(invalid_fields)}")
540
+ field_items = [(name, model_cls.model_fields[name]) for name in fields]
541
+ else:
542
+ field_items = list(model_cls.model_fields.items())
543
+
544
+ for field_name, field_info in field_items:
545
+ field_schema = {
546
+ "value": {
547
+ "type": "integer" if field_info.annotation is int else "string",
548
+ "description": field_info.description or f"Value for {field_name}",
549
+ }
550
+ }
551
+
552
+ try:
553
+ result = await extract_and_jsonify(
554
+ text=text,
555
+ json_schema=field_schema,
556
+ model_name=model_name,
557
+ instruction_template=instruction_template.format(field_name=field_name),
558
+ ai_cleanup=ai_cleanup,
559
+ options=options,
560
+ json_mode=json_mode,
561
+ system_prompt=system_prompt,
562
+ )
563
+
564
+ field_usage = result.get("usage", {})
565
+ accumulated_usage["prompt_tokens"] += field_usage.get("prompt_tokens", 0)
566
+ accumulated_usage["completion_tokens"] += field_usage.get("completion_tokens", 0)
567
+ accumulated_usage["total_tokens"] += field_usage.get("total_tokens", 0)
568
+ accumulated_usage["cost"] += field_usage.get("cost", 0.0)
569
+ accumulated_usage["field_usages"][field_name] = field_usage
570
+
571
+ extracted_value = result["json_object"]["value"]
572
+
573
+ if isinstance(extracted_value, dict) and "value" in extracted_value:
574
+ raw_value = extracted_value["value"]
575
+ else:
576
+ raw_value = extracted_value
577
+
578
+ field_def = {}
579
+ if field_definitions and field_name in field_definitions:
580
+ field_def = field_definitions[field_name] if isinstance(field_definitions[field_name], dict) else {}
581
+
582
+ nullable = field_def.get("nullable", True)
583
+ default_value = field_def.get("default")
584
+ if (
585
+ default_value is None
586
+ and hasattr(field_info, "default")
587
+ and field_info.default is not ...
588
+ and str(field_info.default) != "PydanticUndefined"
589
+ ):
590
+ default_value = field_info.default
591
+
592
+ normalize_def = {"nullable": nullable, "default": default_value}
593
+ raw_value = normalize_field_value(raw_value, field_info.annotation, normalize_def)
594
+
595
+ try:
596
+ converted_value = convert_value(raw_value, field_info.annotation, allow_shorthand=True)
597
+ data[field_name] = converted_value
598
+ field_results[field_name] = {"status": "success", "used_default": False}
599
+ except ValueError as e:
600
+ error_msg = f"Type conversion failed for {field_name}: {e!s}"
601
+ has_default = _has_default(field_name, field_info, field_definitions)
602
+ if not has_default:
603
+ validation_errors.append(error_msg)
604
+ default_value = get_field_default(field_name, field_info, field_definitions)
605
+ data[field_name] = default_value
606
+ field_results[field_name] = {"status": "conversion_failed", "error": error_msg, "used_default": True}
607
+ except Exception as e:
608
+ error_msg = f"Extraction failed for {field_name}: {e!s}"
609
+ has_default = _has_default(field_name, field_info, field_definitions)
610
+ if not has_default:
611
+ validation_errors.append(error_msg)
612
+ default_value = get_field_default(field_name, field_info, field_definitions)
613
+ data[field_name] = default_value
614
+ field_results[field_name] = {"status": "extraction_failed", "error": error_msg, "used_default": True}
615
+ accumulated_usage["field_usages"][field_name] = {
616
+ "error": str(e),
617
+ "status": "failed",
618
+ "used_default": True,
619
+ "default_value": default_value,
620
+ }
621
+
622
+ if validation_errors:
623
+ accumulated_usage["validation_errors"] = validation_errors
624
+
625
+ try:
626
+ model_instance = model_cls(**data)
627
+ model_dict = model_instance.model_dump()
628
+
629
+ class ExtendedJSONEncoder(json.JSONEncoder):
630
+ def default(self, obj):
631
+ if isinstance(obj, (datetime, date)):
632
+ return obj.isoformat()
633
+ if isinstance(obj, Decimal):
634
+ return str(obj)
635
+ return super().default(obj)
636
+
637
+ json_string = json.dumps(model_dict, cls=ExtendedJSONEncoder)
638
+
639
+ result = {
640
+ "json_string": json_string,
641
+ "json_object": json.loads(json_string),
642
+ "usage": accumulated_usage,
643
+ "field_results": field_results,
644
+ }
645
+ result["model"] = model_instance
646
+ return type(
647
+ "ExtractResult",
648
+ (dict,),
649
+ {"__getattr__": lambda self, key: self.get(key), "__call__": lambda self: self["model"]},
650
+ )(result)
651
+ except Exception as e:
652
+ error_msg = f"Model validation error: {e!s}"
653
+ if "validation_errors" not in accumulated_usage:
654
+ accumulated_usage["validation_errors"] = []
655
+ accumulated_usage["validation_errors"].append(error_msg)
656
+
657
+ error_result = {
658
+ "json_string": "{}",
659
+ "json_object": {},
660
+ "usage": accumulated_usage,
661
+ "field_results": field_results,
662
+ "error": error_msg,
663
+ }
664
+ return type(
665
+ "ExtractResult",
666
+ (dict,),
667
+ {"__getattr__": lambda self, key: self.get(key), "__call__": lambda self: None},
668
+ )(error_result)
669
+
670
+
671
+ async def extract_from_data(
672
+ data: Union[list[dict[str, Any]], dict[str, Any]],
673
+ question: str,
674
+ json_schema: dict[str, Any],
675
+ *,
676
+ model_name: str,
677
+ data_key: str | None = None,
678
+ instruction_template: str = "Analyze the following data and answer: {question}",
679
+ ai_cleanup: bool = True,
680
+ options: dict[str, Any] | None = None,
681
+ system_prompt: str | None = None,
682
+ ) -> dict[str, Any]:
683
+ """Extract information from structured data via TOON format (async version)."""
684
+ if not question or not question.strip():
685
+ raise ValueError("Question cannot be empty")
686
+ if not json_schema:
687
+ raise ValueError("JSON schema cannot be empty")
688
+ if options is None:
689
+ options = {}
690
+
691
+ toon_data = _json_to_toon(data, data_key)
692
+
693
+ json_data = json.dumps(data if isinstance(data, list) else data.get(data_key, data), indent=2)
694
+ token_savings = _calculate_token_savings(json_data, toon_data)
695
+
696
+ content_prompt = instruction_template.format(question=question)
697
+ full_prompt = f"{content_prompt}\n\nData (in TOON format):\n{toon_data}"
698
+
699
+ driver = get_async_driver_for_model(model_name)
700
+ result = await ask_for_json(
701
+ driver=driver,
702
+ content_prompt=full_prompt,
703
+ json_schema=json_schema,
704
+ ai_cleanup=ai_cleanup,
705
+ model_name=model_name.split("/")[-1] if "/" in model_name else model_name,
706
+ options=options,
707
+ output_format="json",
708
+ system_prompt=system_prompt,
709
+ )
710
+
711
+ result["toon_data"] = toon_data
712
+ result["token_savings"] = token_savings
713
+ return result
714
+
715
+
716
+ async def extract_from_pandas(
717
+ df,
718
+ question: str,
719
+ json_schema: dict[str, Any],
720
+ *,
721
+ model_name: str,
722
+ instruction_template: str = "Analyze the following data and answer: {question}",
723
+ ai_cleanup: bool = True,
724
+ options: dict[str, Any] | None = None,
725
+ system_prompt: str | None = None,
726
+ ) -> dict[str, Any]:
727
+ """Extract information from a Pandas DataFrame via TOON format (async version)."""
728
+ if not question or not question.strip():
729
+ raise ValueError("Question cannot be empty")
730
+ if not json_schema:
731
+ raise ValueError("JSON schema cannot be empty")
732
+ if options is None:
733
+ options = {}
734
+
735
+ toon_data = _dataframe_to_toon(df)
736
+
737
+ json_data = df.to_json(indent=2, orient="records")
738
+ token_savings = _calculate_token_savings(json_data, toon_data)
739
+
740
+ dataframe_info = {
741
+ "shape": df.shape,
742
+ "columns": list(df.columns),
743
+ "dtypes": {col: str(dtype) for col, dtype in df.dtypes.items()},
744
+ }
745
+
746
+ content_prompt = instruction_template.format(question=question)
747
+ full_prompt = f"{content_prompt}\n\nData (in TOON format):\n{toon_data}"
748
+
749
+ driver = get_async_driver_for_model(model_name)
750
+ result = await ask_for_json(
751
+ driver=driver,
752
+ content_prompt=full_prompt,
753
+ json_schema=json_schema,
754
+ ai_cleanup=ai_cleanup,
755
+ model_name=model_name.split("/")[-1] if "/" in model_name else model_name,
756
+ options=options,
757
+ output_format="json",
758
+ system_prompt=system_prompt,
759
+ )
760
+
761
+ result["toon_data"] = toon_data
762
+ result["token_savings"] = token_savings
763
+ result["dataframe_info"] = dataframe_info
764
+ return result
765
+
766
+
767
+ async def gather_extract(
768
+ text: str,
769
+ json_schema: dict[str, Any],
770
+ model_names: list[str],
771
+ **kwargs: Any,
772
+ ) -> list[dict[str, Any]]:
773
+ """Extract from the same text using multiple models concurrently.
774
+
775
+ Args:
776
+ text: The raw text to extract information from.
777
+ json_schema: JSON schema defining the expected structure.
778
+ model_names: List of model identifiers (e.g., ``["openai/gpt-4", "claude/claude-3-5-haiku-20241022"]``).
779
+ **kwargs: Extra keyword arguments forwarded to :func:`extract_and_jsonify`.
780
+
781
+ Returns:
782
+ A list of result dicts, one per model (order matches *model_names*).
783
+ """
784
+ tasks = [extract_and_jsonify(text=text, json_schema=json_schema, model_name=name, **kwargs) for name in model_names]
785
+ return list(await asyncio.gather(*tasks))
786
+
787
+
788
+ # ---------------------------------------------------------------------------
789
+ # Private helpers
790
+ # ---------------------------------------------------------------------------
791
+
792
+
793
+ def _has_default(field_name: str, field_info: Any, field_definitions: dict[str, Any] | None) -> bool:
794
+ """Check whether a Pydantic field has a usable default value."""
795
+ if field_definitions and field_name in field_definitions:
796
+ fd = field_definitions[field_name]
797
+ if isinstance(fd, dict) and "default" in fd:
798
+ return True
799
+ if hasattr(field_info, "default"):
800
+ val = field_info.default
801
+ if val is not ... and str(val) != "PydanticUndefined":
802
+ return True
803
+ return False