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.
Files changed (79) hide show
  1. prompture/__init__.py +264 -23
  2. prompture/_version.py +34 -0
  3. prompture/agent.py +924 -0
  4. prompture/agent_types.py +156 -0
  5. prompture/aio/__init__.py +74 -0
  6. prompture/async_agent.py +880 -0
  7. prompture/async_conversation.py +789 -0
  8. prompture/async_core.py +803 -0
  9. prompture/async_driver.py +193 -0
  10. prompture/async_groups.py +551 -0
  11. prompture/cache.py +469 -0
  12. prompture/callbacks.py +55 -0
  13. prompture/cli.py +63 -4
  14. prompture/conversation.py +826 -0
  15. prompture/core.py +894 -263
  16. prompture/cost_mixin.py +51 -0
  17. prompture/discovery.py +187 -0
  18. prompture/driver.py +206 -5
  19. prompture/drivers/__init__.py +175 -67
  20. prompture/drivers/airllm_driver.py +109 -0
  21. prompture/drivers/async_airllm_driver.py +26 -0
  22. prompture/drivers/async_azure_driver.py +123 -0
  23. prompture/drivers/async_claude_driver.py +113 -0
  24. prompture/drivers/async_google_driver.py +316 -0
  25. prompture/drivers/async_grok_driver.py +97 -0
  26. prompture/drivers/async_groq_driver.py +90 -0
  27. prompture/drivers/async_hugging_driver.py +61 -0
  28. prompture/drivers/async_lmstudio_driver.py +148 -0
  29. prompture/drivers/async_local_http_driver.py +44 -0
  30. prompture/drivers/async_ollama_driver.py +135 -0
  31. prompture/drivers/async_openai_driver.py +102 -0
  32. prompture/drivers/async_openrouter_driver.py +102 -0
  33. prompture/drivers/async_registry.py +133 -0
  34. prompture/drivers/azure_driver.py +42 -9
  35. prompture/drivers/claude_driver.py +257 -34
  36. prompture/drivers/google_driver.py +295 -42
  37. prompture/drivers/grok_driver.py +35 -32
  38. prompture/drivers/groq_driver.py +33 -26
  39. prompture/drivers/hugging_driver.py +6 -6
  40. prompture/drivers/lmstudio_driver.py +97 -19
  41. prompture/drivers/local_http_driver.py +6 -6
  42. prompture/drivers/ollama_driver.py +168 -23
  43. prompture/drivers/openai_driver.py +184 -9
  44. prompture/drivers/openrouter_driver.py +37 -25
  45. prompture/drivers/registry.py +306 -0
  46. prompture/drivers/vision_helpers.py +153 -0
  47. prompture/field_definitions.py +106 -96
  48. prompture/group_types.py +147 -0
  49. prompture/groups.py +530 -0
  50. prompture/image.py +180 -0
  51. prompture/logging.py +80 -0
  52. prompture/model_rates.py +217 -0
  53. prompture/persistence.py +254 -0
  54. prompture/persona.py +482 -0
  55. prompture/runner.py +49 -47
  56. prompture/scaffold/__init__.py +1 -0
  57. prompture/scaffold/generator.py +84 -0
  58. prompture/scaffold/templates/Dockerfile.j2 +12 -0
  59. prompture/scaffold/templates/README.md.j2 +41 -0
  60. prompture/scaffold/templates/config.py.j2 +21 -0
  61. prompture/scaffold/templates/env.example.j2 +8 -0
  62. prompture/scaffold/templates/main.py.j2 +86 -0
  63. prompture/scaffold/templates/models.py.j2 +40 -0
  64. prompture/scaffold/templates/requirements.txt.j2 +5 -0
  65. prompture/serialization.py +218 -0
  66. prompture/server.py +183 -0
  67. prompture/session.py +117 -0
  68. prompture/settings.py +19 -1
  69. prompture/tools.py +219 -267
  70. prompture/tools_schema.py +254 -0
  71. prompture/validator.py +3 -3
  72. prompture-0.0.38.dev2.dist-info/METADATA +369 -0
  73. prompture-0.0.38.dev2.dist-info/RECORD +77 -0
  74. {prompture-0.0.29.dev8.dist-info → prompture-0.0.38.dev2.dist-info}/WHEEL +1 -1
  75. prompture-0.0.29.dev8.dist-info/METADATA +0 -368
  76. prompture-0.0.29.dev8.dist-info/RECORD +0 -27
  77. {prompture-0.0.29.dev8.dist-info → prompture-0.0.38.dev2.dist-info}/entry_points.txt +0 -0
  78. {prompture-0.0.29.dev8.dist-info → prompture-0.0.38.dev2.dist-info}/licenses/LICENSE +0 -0
  79. {prompture-0.0.29.dev8.dist-info → prompture-0.0.38.dev2.dist-info}/top_level.txt +0 -0
prompture/persona.py ADDED
@@ -0,0 +1,482 @@
1
+ """Persona templates module for Prompture.
2
+
3
+ Reusable, composable system prompt definitions with template variables,
4
+ layered composition, and a thread-safe registry — mirroring the
5
+ ``field_definitions.py`` pattern.
6
+
7
+ Features:
8
+ - Frozen ``Persona`` dataclass with template rendering
9
+ - Composition via ``extend()``, ``with_constraints()``, and ``+`` operator
10
+ - Thread-safe trait registry for reusable prompt fragments
11
+ - Thread-safe global persona registry with dict-like proxy
12
+ - 5 built-in personas for common use cases
13
+ - JSON/YAML serialization and directory loading
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import collections.abc
19
+ import dataclasses
20
+ import json
21
+ import logging
22
+ import threading
23
+ import warnings
24
+ from pathlib import Path
25
+ from typing import Any
26
+
27
+ from .field_definitions import _apply_templates, _get_template_variables
28
+
29
+ logger = logging.getLogger("prompture.persona")
30
+
31
+ _SERIALIZATION_VERSION = 1
32
+
33
+
34
+ # ------------------------------------------------------------------
35
+ # Persona dataclass
36
+ # ------------------------------------------------------------------
37
+
38
+
39
+ @dataclasses.dataclass(frozen=True)
40
+ class Persona:
41
+ """A reusable system prompt template with metadata.
42
+
43
+ Instances are immutable (frozen). Use :meth:`extend`,
44
+ :meth:`with_constraints`, or the ``+`` operator to derive new
45
+ personas.
46
+
47
+ Args:
48
+ name: Short identifier for this persona.
49
+ system_prompt: The system prompt template. May contain
50
+ ``{{variable}}`` placeholders.
51
+ description: Human-readable description of the persona's purpose.
52
+ traits: Tuple of trait names to resolve from the trait registry
53
+ during :meth:`render`.
54
+ variables: Default template variable values.
55
+ constraints: List of constraint strings appended as a
56
+ ``## Constraints`` section during :meth:`render`.
57
+ model_hint: Suggested model string (e.g. ``"openai/gpt-4"``).
58
+ Used as a default when no model is explicitly provided.
59
+ settings: Default driver options (e.g. ``{"temperature": 0.0}``).
60
+ """
61
+
62
+ name: str
63
+ system_prompt: str
64
+ description: str = ""
65
+ traits: tuple[str, ...] = ()
66
+ variables: dict[str, Any] = dataclasses.field(default_factory=dict)
67
+ constraints: list[str] = dataclasses.field(default_factory=list)
68
+ model_hint: str | None = None
69
+ settings: dict[str, Any] = dataclasses.field(default_factory=dict)
70
+
71
+ def render(self, **kwargs: Any) -> str:
72
+ """Render the system prompt with template variable substitution.
73
+
74
+ Variable precedence (highest wins):
75
+ 1. ``kwargs`` passed to this method
76
+ 2. ``self.variables``
77
+ 3. Built-in template variables (``{{current_year}}``, etc.)
78
+
79
+ Registered traits (from the trait registry) are appended between
80
+ the main prompt body and the constraints section.
81
+ """
82
+ # Merge variables: built-in < self.variables < kwargs
83
+ merged_vars = _get_template_variables()
84
+ merged_vars.update(self.variables)
85
+ merged_vars.update(kwargs)
86
+
87
+ rendered = _apply_templates(self.system_prompt, merged_vars)
88
+
89
+ # Append registered traits
90
+ if self.traits:
91
+ trait_texts: list[str] = []
92
+ for trait_name in self.traits:
93
+ text = get_trait(trait_name)
94
+ if text is not None:
95
+ trait_texts.append(_apply_templates(text, merged_vars))
96
+ if trait_texts:
97
+ rendered += "\n\n" + "\n\n".join(trait_texts)
98
+
99
+ # Append constraints
100
+ if self.constraints:
101
+ rendered_constraints = [_apply_templates(c, merged_vars) for c in self.constraints]
102
+ rendered += "\n\n## Constraints\n" + "\n".join(f"- {c}" for c in rendered_constraints)
103
+
104
+ return rendered
105
+
106
+ # ------------------------------------------------------------------
107
+ # Composition helpers
108
+ # ------------------------------------------------------------------
109
+
110
+ def extend(self, additional_instructions: str) -> Persona:
111
+ """Return a new Persona with *additional_instructions* appended."""
112
+ return dataclasses.replace(
113
+ self,
114
+ system_prompt=self.system_prompt + "\n\n" + additional_instructions,
115
+ )
116
+
117
+ def with_constraints(self, new_constraints: list[str]) -> Persona:
118
+ """Return a new Persona with *new_constraints* appended."""
119
+ return dataclasses.replace(
120
+ self,
121
+ constraints=[*self.constraints, *new_constraints],
122
+ )
123
+
124
+ def __add__(self, other: Persona) -> Persona:
125
+ """Merge two personas. Right-side values win on conflict."""
126
+ if not isinstance(other, Persona):
127
+ return NotImplemented
128
+
129
+ # Warn on variable conflicts
130
+ for key in self.variables:
131
+ if key in other.variables and self.variables[key] != other.variables[key]:
132
+ warnings.warn(
133
+ f"Persona variable '{key}' conflict: "
134
+ f"'{self.variables[key]}' overridden by '{other.variables[key]}'",
135
+ stacklevel=2,
136
+ )
137
+
138
+ merged_vars = {**self.variables, **other.variables}
139
+ merged_settings = {**self.settings, **other.settings}
140
+ merged_traits = tuple(dict.fromkeys(self.traits + other.traits)) # dedupe, preserve order
141
+
142
+ return Persona(
143
+ name=f"{self.name}+{other.name}",
144
+ system_prompt=self.system_prompt + "\n\n" + other.system_prompt,
145
+ description=f"{self.description}; {other.description}" if self.description and other.description else self.description or other.description,
146
+ traits=merged_traits,
147
+ variables=merged_vars,
148
+ constraints=[*self.constraints, *other.constraints],
149
+ model_hint=other.model_hint or self.model_hint,
150
+ settings=merged_settings,
151
+ )
152
+
153
+ # ------------------------------------------------------------------
154
+ # Serialization
155
+ # ------------------------------------------------------------------
156
+
157
+ def to_dict(self) -> dict[str, Any]:
158
+ """Serialize to a JSON-compatible dictionary."""
159
+ data: dict[str, Any] = {
160
+ "version": _SERIALIZATION_VERSION,
161
+ "name": self.name,
162
+ "system_prompt": self.system_prompt,
163
+ }
164
+ if self.description:
165
+ data["description"] = self.description
166
+ if self.traits:
167
+ data["traits"] = list(self.traits)
168
+ if self.variables:
169
+ data["variables"] = dict(self.variables)
170
+ if self.constraints:
171
+ data["constraints"] = list(self.constraints)
172
+ if self.model_hint is not None:
173
+ data["model_hint"] = self.model_hint
174
+ if self.settings:
175
+ data["settings"] = dict(self.settings)
176
+ return data
177
+
178
+ @classmethod
179
+ def from_dict(cls, data: dict[str, Any]) -> Persona:
180
+ """Deserialize from a dictionary."""
181
+ return cls(
182
+ name=data["name"],
183
+ system_prompt=data["system_prompt"],
184
+ description=data.get("description", ""),
185
+ traits=tuple(data.get("traits", ())),
186
+ variables=dict(data.get("variables", {})),
187
+ constraints=list(data.get("constraints", [])),
188
+ model_hint=data.get("model_hint"),
189
+ settings=dict(data.get("settings", {})),
190
+ )
191
+
192
+ def save_json(self, path: str | Path) -> None:
193
+ """Write this persona to a JSON file."""
194
+ path = Path(path)
195
+ path.write_text(json.dumps(self.to_dict(), indent=2), encoding="utf-8")
196
+
197
+ @classmethod
198
+ def load_json(cls, path: str | Path) -> Persona:
199
+ """Load a persona from a JSON file."""
200
+ path = Path(path)
201
+ data = json.loads(path.read_text(encoding="utf-8"))
202
+ return cls.from_dict(data)
203
+
204
+ def save_yaml(self, path: str | Path) -> None:
205
+ """Write this persona to a YAML file. Requires ``pyyaml``."""
206
+ try:
207
+ import yaml
208
+ except ImportError:
209
+ raise ImportError("pyyaml is required for YAML support. Install with: pip install pyyaml") from None
210
+ path = Path(path)
211
+ path.write_text(yaml.safe_dump(self.to_dict(), default_flow_style=False), encoding="utf-8")
212
+
213
+ @classmethod
214
+ def load_yaml(cls, path: str | Path) -> Persona:
215
+ """Load a persona from a YAML file. Requires ``pyyaml``."""
216
+ try:
217
+ import yaml
218
+ except ImportError:
219
+ raise ImportError("pyyaml is required for YAML support. Install with: pip install pyyaml") from None
220
+ path = Path(path)
221
+ data = yaml.safe_load(path.read_text(encoding="utf-8"))
222
+ return cls.from_dict(data)
223
+
224
+
225
+ # ------------------------------------------------------------------
226
+ # Trait registry
227
+ # ------------------------------------------------------------------
228
+
229
+ _trait_registry_lock = threading.Lock()
230
+ _trait_registry: dict[str, str] = {}
231
+
232
+
233
+ def register_trait(name: str, text: str) -> None:
234
+ """Register a named trait text fragment."""
235
+ with _trait_registry_lock:
236
+ _trait_registry[name] = text
237
+
238
+
239
+ def get_trait(name: str) -> str | None:
240
+ """Retrieve a trait by name, or ``None`` if not found."""
241
+ with _trait_registry_lock:
242
+ return _trait_registry.get(name)
243
+
244
+
245
+ def get_trait_names() -> list[str]:
246
+ """Return all registered trait names."""
247
+ with _trait_registry_lock:
248
+ return list(_trait_registry.keys())
249
+
250
+
251
+ def reset_trait_registry() -> None:
252
+ """Clear all registered traits."""
253
+ with _trait_registry_lock:
254
+ _trait_registry.clear()
255
+
256
+
257
+ # ------------------------------------------------------------------
258
+ # Global persona registry
259
+ # ------------------------------------------------------------------
260
+
261
+ _persona_registry_lock = threading.Lock()
262
+ _persona_global_registry: dict[str, Persona] = {}
263
+
264
+
265
+ def register_persona(persona: Persona) -> None:
266
+ """Register a persona in the global registry."""
267
+ with _persona_registry_lock:
268
+ _persona_global_registry[persona.name] = persona
269
+
270
+
271
+ def get_persona(name: str) -> Persona | None:
272
+ """Retrieve a persona by name, or ``None`` if not found."""
273
+ with _persona_registry_lock:
274
+ return _persona_global_registry.get(name)
275
+
276
+
277
+ def get_persona_names() -> list[str]:
278
+ """Return all registered persona names."""
279
+ with _persona_registry_lock:
280
+ return list(_persona_global_registry.keys())
281
+
282
+
283
+ def get_persona_registry_snapshot() -> dict[str, Persona]:
284
+ """Return a shallow copy of the current persona registry."""
285
+ with _persona_registry_lock:
286
+ return dict(_persona_global_registry)
287
+
288
+
289
+ def clear_persona_registry() -> None:
290
+ """Remove all personas from the global registry."""
291
+ with _persona_registry_lock:
292
+ _persona_global_registry.clear()
293
+
294
+
295
+ def reset_persona_registry() -> None:
296
+ """Reset the global registry to contain only built-in personas."""
297
+ with _persona_registry_lock:
298
+ _persona_global_registry.clear()
299
+ _persona_global_registry.update(BASE_PERSONAS)
300
+
301
+
302
+ # ------------------------------------------------------------------
303
+ # Persona registry proxy (dict-like access)
304
+ # ------------------------------------------------------------------
305
+
306
+
307
+ class _PersonaRegistryProxy(dict, collections.abc.MutableMapping):
308
+ """Dict-like proxy for the global persona registry.
309
+
310
+ Allows ``PERSONAS["json_extractor"]`` style access.
311
+ """
312
+
313
+ def __getitem__(self, key: str) -> Persona:
314
+ persona = get_persona(key)
315
+ if persona is None:
316
+ raise KeyError(f"Persona '{key}' not found in registry. Available: {', '.join(get_persona_names())}")
317
+ return persona
318
+
319
+ def __setitem__(self, key: str, value: Persona) -> None:
320
+ if not isinstance(value, Persona):
321
+ raise TypeError(f"Expected Persona instance, got {type(value).__name__}")
322
+ with _persona_registry_lock:
323
+ _persona_global_registry[key] = value
324
+
325
+ def __delitem__(self, key: str) -> None:
326
+ with _persona_registry_lock:
327
+ if key in _persona_global_registry:
328
+ del _persona_global_registry[key]
329
+ else:
330
+ raise KeyError(f"Persona '{key}' not found in registry")
331
+
332
+ def __contains__(self, key: object) -> bool:
333
+ return key in get_persona_names()
334
+
335
+ def __iter__(self):
336
+ return iter(get_persona_names())
337
+
338
+ def keys(self):
339
+ return get_persona_names()
340
+
341
+ def values(self):
342
+ with _persona_registry_lock:
343
+ return list(_persona_global_registry.values())
344
+
345
+ def items(self):
346
+ with _persona_registry_lock:
347
+ return list(_persona_global_registry.items())
348
+
349
+ def __len__(self) -> int:
350
+ with _persona_registry_lock:
351
+ return len(_persona_global_registry)
352
+
353
+ def get(self, key: str, default: Any = None) -> Any:
354
+ persona = get_persona(key)
355
+ return persona if persona is not None else default
356
+
357
+ def __repr__(self) -> str:
358
+ return f"PERSONAS({get_persona_names()})"
359
+
360
+
361
+ # ------------------------------------------------------------------
362
+ # Built-in personas
363
+ # ------------------------------------------------------------------
364
+
365
+ BASE_PERSONAS: dict[str, Persona] = {
366
+ "json_extractor": Persona(
367
+ name="json_extractor",
368
+ system_prompt=(
369
+ "You are a precise data extraction assistant. "
370
+ "Your sole task is to extract structured information from the provided text "
371
+ "and return it as valid JSON. Do not add commentary, explanations, or markdown formatting."
372
+ ),
373
+ description="Precise JSON extraction with strict output formatting.",
374
+ constraints=[
375
+ "Output ONLY valid JSON — no markdown fences, no prose.",
376
+ "Use null for unknown or missing values.",
377
+ "Preserve original data types (numbers as numbers, booleans as booleans).",
378
+ ],
379
+ settings={"temperature": 0.0},
380
+ ),
381
+ "data_analyst": Persona(
382
+ name="data_analyst",
383
+ system_prompt=(
384
+ "You are a quantitative data analyst. "
385
+ "Analyze data rigorously, cite sources for claims, and present findings with precision. "
386
+ "Use statistical reasoning where appropriate."
387
+ ),
388
+ description="Quantitative analysis with cited sources.",
389
+ traits=(),
390
+ constraints=[
391
+ "Cite the source of any factual claim.",
392
+ "Distinguish between correlation and causation.",
393
+ "State confidence levels when making inferences.",
394
+ ],
395
+ ),
396
+ "text_summarizer": Persona(
397
+ name="text_summarizer",
398
+ system_prompt=(
399
+ "You are a text summarization assistant. "
400
+ "Produce concise summaries that capture the key points of the input text. "
401
+ "Limit your summary to {{max_sentences}} sentences unless instructed otherwise."
402
+ ),
403
+ description="Configurable text summarization.",
404
+ variables={"max_sentences": "3"},
405
+ constraints=[
406
+ "Stay within the sentence limit.",
407
+ "Do not introduce information not present in the source text.",
408
+ ],
409
+ ),
410
+ "code_reviewer": Persona(
411
+ name="code_reviewer",
412
+ system_prompt=(
413
+ "You are an expert code reviewer. "
414
+ "Analyze code for correctness, performance, security, and readability. "
415
+ "Structure your feedback using the following format:\n\n"
416
+ "## Summary\n"
417
+ "Brief overview of the code.\n\n"
418
+ "## Issues\n"
419
+ "Numbered list of issues found.\n\n"
420
+ "## Suggestions\n"
421
+ "Numbered list of improvement suggestions."
422
+ ),
423
+ description="Structured code review feedback.",
424
+ constraints=[
425
+ "Focus on substantive issues, not style preferences.",
426
+ "Provide concrete fix suggestions, not vague advice.",
427
+ ],
428
+ ),
429
+ "concise_assistant": Persona(
430
+ name="concise_assistant",
431
+ system_prompt=(
432
+ "You are a concise assistant. Answer questions directly and briefly. "
433
+ "Do not elaborate unless explicitly asked."
434
+ ),
435
+ description="Brief, no-elaboration responses.",
436
+ constraints=[
437
+ "Keep responses under 3 sentences when possible.",
438
+ "No filler phrases or unnecessary politeness.",
439
+ ],
440
+ ),
441
+ }
442
+
443
+
444
+ def _initialize_persona_registry() -> None:
445
+ """Populate the global registry with built-in personas."""
446
+ with _persona_registry_lock:
447
+ if not _persona_global_registry:
448
+ _persona_global_registry.update(BASE_PERSONAS)
449
+
450
+
451
+ # Initialize on import
452
+ _initialize_persona_registry()
453
+
454
+ # Public proxy instance
455
+ PERSONAS = _PersonaRegistryProxy()
456
+
457
+
458
+ # ------------------------------------------------------------------
459
+ # Directory loading
460
+ # ------------------------------------------------------------------
461
+
462
+
463
+ def load_personas_from_directory(path: str | Path) -> list[Persona]:
464
+ """Bulk-load persona files from a directory and register them.
465
+
466
+ Supports ``.json``, ``.yaml``, and ``.yml`` files.
467
+ Returns a list of loaded personas.
468
+ """
469
+ directory = Path(path)
470
+ loaded: list[Persona] = []
471
+
472
+ for file_path in sorted(directory.iterdir()):
473
+ if file_path.suffix == ".json":
474
+ persona = Persona.load_json(file_path)
475
+ register_persona(persona)
476
+ loaded.append(persona)
477
+ elif file_path.suffix in (".yaml", ".yml"):
478
+ persona = Persona.load_yaml(file_path)
479
+ register_persona(persona)
480
+ loaded.append(persona)
481
+
482
+ return loaded
prompture/runner.py CHANGED
@@ -1,13 +1,15 @@
1
1
  """Test suite runner for executing JSON validation tests across multiple models."""
2
- from typing import Dict, Any, List
3
2
 
4
- from .core import ask_for_json, Driver
3
+ from typing import Any
4
+
5
5
  from prompture.validator import validate_against_schema
6
6
 
7
+ from .core import Driver, ask_for_json
8
+
7
9
 
8
- def run_suite_from_spec(spec: Dict[str, Any], drivers: Dict[str, Driver]) -> Dict[str, Any]:
10
+ def run_suite_from_spec(spec: dict[str, Any], drivers: dict[str, Driver]) -> dict[str, Any]:
9
11
  """Run a test suite specified by a spec dictionary across multiple models.
10
-
12
+
11
13
  Args:
12
14
  spec: A dictionary containing the test suite specification with the structure:
13
15
  {
@@ -21,7 +23,7 @@ def run_suite_from_spec(spec: Dict[str, Any], drivers: Dict[str, Driver]) -> Dic
21
23
  }, ...]
22
24
  }
23
25
  drivers: A dictionary mapping driver names to driver instances
24
-
26
+
25
27
  Returns:
26
28
  A dictionary containing test results with the structure:
27
29
  {
@@ -42,67 +44,67 @@ def run_suite_from_spec(spec: Dict[str, Any], drivers: Dict[str, Driver]) -> Dic
42
44
  }
43
45
  """
44
46
  results = []
45
-
47
+
46
48
  for test in spec["tests"]:
47
49
  for model in spec["models"]:
48
50
  driver = drivers.get(model["driver"])
49
51
  if not driver:
50
52
  continue
51
-
53
+
52
54
  # Run test for each input
53
55
  for input_data in test["inputs"]:
54
56
  # Format prompt template with input data
55
57
  try:
56
58
  prompt = test["prompt_template"].format(**input_data)
57
59
  except KeyError as e:
58
- results.append({
59
- "test_id": test["id"],
60
- "model_id": model["id"],
61
- "input": input_data,
62
- "prompt": test["prompt_template"],
63
- "error": f"Template formatting error: missing key {e}",
64
- "validation": {"ok": False, "error": "Prompt formatting failed", "data": None},
65
- "usage": {"total_tokens": 0, "cost": 0}
66
- })
60
+ results.append(
61
+ {
62
+ "test_id": test["id"],
63
+ "model_id": model["id"],
64
+ "input": input_data,
65
+ "prompt": test["prompt_template"],
66
+ "error": f"Template formatting error: missing key {e}",
67
+ "validation": {"ok": False, "error": "Prompt formatting failed", "data": None},
68
+ "usage": {"total_tokens": 0, "cost": 0},
69
+ }
70
+ )
67
71
  continue
68
-
72
+
69
73
  # Get JSON response from model
70
74
  try:
71
75
  response = ask_for_json(
72
76
  driver=driver,
73
77
  content_prompt=prompt,
74
78
  json_schema=test["schema"],
75
- options=model.get("options", {})
79
+ options=model.get("options", {}),
76
80
  )
77
-
81
+
78
82
  # Validate response against schema
79
- validation = validate_against_schema(
80
- response["json_string"],
81
- test["schema"]
83
+ validation = validate_against_schema(response["json_string"], test["schema"])
84
+
85
+ results.append(
86
+ {
87
+ "test_id": test["id"],
88
+ "model_id": model["id"],
89
+ "input": input_data,
90
+ "prompt": prompt,
91
+ "response": response["json_object"],
92
+ "validation": validation,
93
+ "usage": response["usage"],
94
+ }
82
95
  )
83
-
84
- results.append({
85
- "test_id": test["id"],
86
- "model_id": model["id"],
87
- "input": input_data,
88
- "prompt": prompt,
89
- "response": response["json_object"],
90
- "validation": validation,
91
- "usage": response["usage"]
92
- })
93
-
96
+
94
97
  except Exception as e:
95
- results.append({
96
- "test_id": test["id"],
97
- "model_id": model["id"],
98
- "input": input_data,
99
- "prompt": prompt,
100
- "error": str(e),
101
- "validation": {"ok": False, "error": "Model response error", "data": None},
102
- "usage": {"total_tokens": 0, "cost": 0}
103
- })
104
-
105
- return {
106
- "meta": spec.get("meta", {}),
107
- "results": results
108
- }
98
+ results.append(
99
+ {
100
+ "test_id": test["id"],
101
+ "model_id": model["id"],
102
+ "input": input_data,
103
+ "prompt": prompt,
104
+ "error": str(e),
105
+ "validation": {"ok": False, "error": "Model response error", "data": None},
106
+ "usage": {"total_tokens": 0, "cost": 0},
107
+ }
108
+ )
109
+
110
+ return {"meta": spec.get("meta", {}), "results": results}
@@ -0,0 +1 @@
1
+ """Project scaffolding for Prompture-based FastAPI apps."""