prompture 0.0.29.dev8__py3-none-any.whl → 0.0.38.dev2__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.
- prompture/__init__.py +264 -23
- prompture/_version.py +34 -0
- prompture/agent.py +924 -0
- prompture/agent_types.py +156 -0
- prompture/aio/__init__.py +74 -0
- prompture/async_agent.py +880 -0
- prompture/async_conversation.py +789 -0
- prompture/async_core.py +803 -0
- prompture/async_driver.py +193 -0
- prompture/async_groups.py +551 -0
- prompture/cache.py +469 -0
- prompture/callbacks.py +55 -0
- prompture/cli.py +63 -4
- prompture/conversation.py +826 -0
- prompture/core.py +894 -263
- prompture/cost_mixin.py +51 -0
- prompture/discovery.py +187 -0
- prompture/driver.py +206 -5
- prompture/drivers/__init__.py +175 -67
- prompture/drivers/airllm_driver.py +109 -0
- prompture/drivers/async_airllm_driver.py +26 -0
- prompture/drivers/async_azure_driver.py +123 -0
- prompture/drivers/async_claude_driver.py +113 -0
- prompture/drivers/async_google_driver.py +316 -0
- prompture/drivers/async_grok_driver.py +97 -0
- prompture/drivers/async_groq_driver.py +90 -0
- prompture/drivers/async_hugging_driver.py +61 -0
- prompture/drivers/async_lmstudio_driver.py +148 -0
- prompture/drivers/async_local_http_driver.py +44 -0
- prompture/drivers/async_ollama_driver.py +135 -0
- prompture/drivers/async_openai_driver.py +102 -0
- prompture/drivers/async_openrouter_driver.py +102 -0
- prompture/drivers/async_registry.py +133 -0
- prompture/drivers/azure_driver.py +42 -9
- prompture/drivers/claude_driver.py +257 -34
- prompture/drivers/google_driver.py +295 -42
- prompture/drivers/grok_driver.py +35 -32
- prompture/drivers/groq_driver.py +33 -26
- prompture/drivers/hugging_driver.py +6 -6
- prompture/drivers/lmstudio_driver.py +97 -19
- prompture/drivers/local_http_driver.py +6 -6
- prompture/drivers/ollama_driver.py +168 -23
- prompture/drivers/openai_driver.py +184 -9
- prompture/drivers/openrouter_driver.py +37 -25
- prompture/drivers/registry.py +306 -0
- prompture/drivers/vision_helpers.py +153 -0
- prompture/field_definitions.py +106 -96
- prompture/group_types.py +147 -0
- prompture/groups.py +530 -0
- prompture/image.py +180 -0
- prompture/logging.py +80 -0
- prompture/model_rates.py +217 -0
- prompture/persistence.py +254 -0
- prompture/persona.py +482 -0
- prompture/runner.py +49 -47
- prompture/scaffold/__init__.py +1 -0
- prompture/scaffold/generator.py +84 -0
- prompture/scaffold/templates/Dockerfile.j2 +12 -0
- prompture/scaffold/templates/README.md.j2 +41 -0
- prompture/scaffold/templates/config.py.j2 +21 -0
- prompture/scaffold/templates/env.example.j2 +8 -0
- prompture/scaffold/templates/main.py.j2 +86 -0
- prompture/scaffold/templates/models.py.j2 +40 -0
- prompture/scaffold/templates/requirements.txt.j2 +5 -0
- prompture/serialization.py +218 -0
- prompture/server.py +183 -0
- prompture/session.py +117 -0
- prompture/settings.py +19 -1
- prompture/tools.py +219 -267
- prompture/tools_schema.py +254 -0
- prompture/validator.py +3 -3
- prompture-0.0.38.dev2.dist-info/METADATA +369 -0
- prompture-0.0.38.dev2.dist-info/RECORD +77 -0
- {prompture-0.0.29.dev8.dist-info → prompture-0.0.38.dev2.dist-info}/WHEEL +1 -1
- prompture-0.0.29.dev8.dist-info/METADATA +0 -368
- prompture-0.0.29.dev8.dist-info/RECORD +0 -27
- {prompture-0.0.29.dev8.dist-info → prompture-0.0.38.dev2.dist-info}/entry_points.txt +0 -0
- {prompture-0.0.29.dev8.dist-info → prompture-0.0.38.dev2.dist-info}/licenses/LICENSE +0 -0
- {prompture-0.0.29.dev8.dist-info → prompture-0.0.38.dev2.dist-info}/top_level.txt +0 -0
prompture/async_core.py
ADDED
|
@@ -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
|