prompture 0.0.36.dev1__py3-none-any.whl → 0.0.37.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.
- prompture/__init__.py +120 -2
- prompture/_version.py +2 -2
- prompture/agent.py +925 -0
- prompture/agent_types.py +156 -0
- prompture/async_agent.py +879 -0
- prompture/async_conversation.py +199 -17
- prompture/async_driver.py +24 -0
- prompture/async_groups.py +551 -0
- prompture/conversation.py +213 -18
- prompture/core.py +30 -12
- prompture/discovery.py +24 -1
- prompture/driver.py +38 -0
- prompture/drivers/__init__.py +5 -1
- prompture/drivers/async_azure_driver.py +7 -1
- prompture/drivers/async_claude_driver.py +7 -1
- prompture/drivers/async_google_driver.py +24 -4
- prompture/drivers/async_grok_driver.py +7 -1
- prompture/drivers/async_groq_driver.py +7 -1
- prompture/drivers/async_lmstudio_driver.py +59 -3
- prompture/drivers/async_ollama_driver.py +7 -0
- prompture/drivers/async_openai_driver.py +7 -1
- prompture/drivers/async_openrouter_driver.py +7 -1
- prompture/drivers/async_registry.py +5 -1
- prompture/drivers/azure_driver.py +7 -1
- prompture/drivers/claude_driver.py +7 -1
- prompture/drivers/google_driver.py +24 -4
- prompture/drivers/grok_driver.py +7 -1
- prompture/drivers/groq_driver.py +7 -1
- prompture/drivers/lmstudio_driver.py +58 -6
- prompture/drivers/ollama_driver.py +7 -0
- prompture/drivers/openai_driver.py +7 -1
- prompture/drivers/openrouter_driver.py +7 -1
- prompture/drivers/vision_helpers.py +153 -0
- prompture/group_types.py +147 -0
- prompture/groups.py +530 -0
- prompture/image.py +180 -0
- prompture/persistence.py +254 -0
- prompture/persona.py +482 -0
- prompture/serialization.py +218 -0
- prompture/settings.py +1 -0
- {prompture-0.0.36.dev1.dist-info → prompture-0.0.37.dev1.dist-info}/METADATA +1 -1
- prompture-0.0.37.dev1.dist-info/RECORD +77 -0
- prompture-0.0.36.dev1.dist-info/RECORD +0 -66
- {prompture-0.0.36.dev1.dist-info → prompture-0.0.37.dev1.dist-info}/WHEEL +0 -0
- {prompture-0.0.36.dev1.dist-info → prompture-0.0.37.dev1.dist-info}/entry_points.txt +0 -0
- {prompture-0.0.36.dev1.dist-info → prompture-0.0.37.dev1.dist-info}/licenses/LICENSE +0 -0
- {prompture-0.0.36.dev1.dist-info → prompture-0.0.37.dev1.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
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
"""Conversation serialization — pure data transforms for export/import.
|
|
2
|
+
|
|
3
|
+
Handles converting Conversation state to/from plain dicts suitable for
|
|
4
|
+
JSON serialization. No I/O is performed here; see :mod:`persistence`
|
|
5
|
+
for file and database storage.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import copy
|
|
11
|
+
from datetime import datetime, timezone
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from .image import ImageContent
|
|
15
|
+
from .session import UsageSession
|
|
16
|
+
|
|
17
|
+
EXPORT_VERSION = 1
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# ------------------------------------------------------------------
|
|
21
|
+
# Message content helpers
|
|
22
|
+
# ------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _serialize_message_content(content: Any) -> Any:
|
|
26
|
+
"""Convert ``ImageContent`` objects inside message content to plain dicts."""
|
|
27
|
+
if isinstance(content, str):
|
|
28
|
+
return content
|
|
29
|
+
|
|
30
|
+
if isinstance(content, list):
|
|
31
|
+
out: list[Any] = []
|
|
32
|
+
for block in content:
|
|
33
|
+
if isinstance(block, dict) and block.get("type") == "image":
|
|
34
|
+
source = block.get("source")
|
|
35
|
+
if isinstance(source, ImageContent):
|
|
36
|
+
out.append(
|
|
37
|
+
{
|
|
38
|
+
"type": "image",
|
|
39
|
+
"source": {
|
|
40
|
+
"data": source.data,
|
|
41
|
+
"media_type": source.media_type,
|
|
42
|
+
"source_type": source.source_type,
|
|
43
|
+
"url": source.url,
|
|
44
|
+
},
|
|
45
|
+
}
|
|
46
|
+
)
|
|
47
|
+
elif isinstance(source, dict):
|
|
48
|
+
out.append(block)
|
|
49
|
+
else:
|
|
50
|
+
out.append(block)
|
|
51
|
+
else:
|
|
52
|
+
out.append(block)
|
|
53
|
+
return out
|
|
54
|
+
|
|
55
|
+
return content
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _deserialize_message_content(content: Any) -> Any:
|
|
59
|
+
"""Reconstruct ``ImageContent`` objects from plain dicts in message content."""
|
|
60
|
+
if isinstance(content, str):
|
|
61
|
+
return content
|
|
62
|
+
|
|
63
|
+
if isinstance(content, list):
|
|
64
|
+
out: list[Any] = []
|
|
65
|
+
for block in content:
|
|
66
|
+
if isinstance(block, dict) and block.get("type") == "image":
|
|
67
|
+
source = block.get("source")
|
|
68
|
+
if isinstance(source, dict) and "media_type" in source:
|
|
69
|
+
out.append(
|
|
70
|
+
{
|
|
71
|
+
"type": "image",
|
|
72
|
+
"source": ImageContent(
|
|
73
|
+
data=source.get("data", ""),
|
|
74
|
+
media_type=source["media_type"],
|
|
75
|
+
source_type=source.get("source_type", "base64"),
|
|
76
|
+
url=source.get("url"),
|
|
77
|
+
),
|
|
78
|
+
}
|
|
79
|
+
)
|
|
80
|
+
else:
|
|
81
|
+
out.append(block)
|
|
82
|
+
else:
|
|
83
|
+
out.append(block)
|
|
84
|
+
return out
|
|
85
|
+
|
|
86
|
+
return content
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
# ------------------------------------------------------------------
|
|
90
|
+
# UsageSession export/import
|
|
91
|
+
# ------------------------------------------------------------------
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def export_usage_session(session: UsageSession) -> dict[str, Any]:
|
|
95
|
+
"""Serialize a :class:`UsageSession` to a plain dict."""
|
|
96
|
+
return {
|
|
97
|
+
"prompt_tokens": session.prompt_tokens,
|
|
98
|
+
"completion_tokens": session.completion_tokens,
|
|
99
|
+
"total_tokens": session.total_tokens,
|
|
100
|
+
"total_cost": session.total_cost,
|
|
101
|
+
"call_count": session.call_count,
|
|
102
|
+
"errors": session.errors,
|
|
103
|
+
"per_model": dict(session._per_model),
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def import_usage_session(data: dict[str, Any]) -> UsageSession:
|
|
108
|
+
"""Reconstruct a :class:`UsageSession` from an exported dict."""
|
|
109
|
+
session = UsageSession(
|
|
110
|
+
prompt_tokens=data.get("prompt_tokens", 0),
|
|
111
|
+
completion_tokens=data.get("completion_tokens", 0),
|
|
112
|
+
total_tokens=data.get("total_tokens", 0),
|
|
113
|
+
total_cost=data.get("total_cost", 0.0),
|
|
114
|
+
call_count=data.get("call_count", 0),
|
|
115
|
+
errors=data.get("errors", 0),
|
|
116
|
+
)
|
|
117
|
+
per_model = data.get("per_model", {})
|
|
118
|
+
for model, stats in per_model.items():
|
|
119
|
+
session._per_model[model] = dict(stats)
|
|
120
|
+
return session
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
# ------------------------------------------------------------------
|
|
124
|
+
# Conversation export/import
|
|
125
|
+
# ------------------------------------------------------------------
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def export_conversation(
|
|
129
|
+
*,
|
|
130
|
+
model_name: str,
|
|
131
|
+
system_prompt: str | None,
|
|
132
|
+
options: dict[str, Any],
|
|
133
|
+
messages: list[dict[str, Any]],
|
|
134
|
+
usage: dict[str, Any],
|
|
135
|
+
max_tool_rounds: int,
|
|
136
|
+
tools_metadata: list[dict[str, Any]] | None = None,
|
|
137
|
+
usage_session: UsageSession | None = None,
|
|
138
|
+
metadata: dict[str, Any] | None = None,
|
|
139
|
+
conversation_id: str,
|
|
140
|
+
strip_images: bool = False,
|
|
141
|
+
) -> dict[str, Any]:
|
|
142
|
+
"""Export conversation state to a JSON-serializable dict.
|
|
143
|
+
|
|
144
|
+
Args:
|
|
145
|
+
strip_images: When *True*, image blocks are removed from messages
|
|
146
|
+
and list-of-blocks content that becomes text-only is collapsed
|
|
147
|
+
to a plain string.
|
|
148
|
+
"""
|
|
149
|
+
serialized_messages: list[dict[str, Any]] = []
|
|
150
|
+
for msg in messages:
|
|
151
|
+
msg_copy = dict(msg)
|
|
152
|
+
content = msg_copy.get("content")
|
|
153
|
+
|
|
154
|
+
if strip_images and isinstance(content, list):
|
|
155
|
+
filtered = [b for b in content if not (isinstance(b, dict) and b.get("type") == "image")]
|
|
156
|
+
if len(filtered) == 1 and isinstance(filtered[0], dict) and filtered[0].get("type") == "text":
|
|
157
|
+
msg_copy["content"] = filtered[0]["text"]
|
|
158
|
+
elif filtered:
|
|
159
|
+
msg_copy["content"] = _serialize_message_content(filtered)
|
|
160
|
+
else:
|
|
161
|
+
msg_copy["content"] = ""
|
|
162
|
+
else:
|
|
163
|
+
msg_copy["content"] = _serialize_message_content(content)
|
|
164
|
+
|
|
165
|
+
serialized_messages.append(msg_copy)
|
|
166
|
+
|
|
167
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
168
|
+
meta = dict(metadata) if metadata else {}
|
|
169
|
+
meta.setdefault("created_at", now)
|
|
170
|
+
meta["last_active"] = now
|
|
171
|
+
meta["turn_count"] = usage.get("turns", 0)
|
|
172
|
+
|
|
173
|
+
export: dict[str, Any] = {
|
|
174
|
+
"version": EXPORT_VERSION,
|
|
175
|
+
"conversation_id": conversation_id,
|
|
176
|
+
"model_name": model_name,
|
|
177
|
+
"system_prompt": system_prompt,
|
|
178
|
+
"options": dict(options),
|
|
179
|
+
"messages": serialized_messages,
|
|
180
|
+
"usage": dict(usage),
|
|
181
|
+
"max_tool_rounds": max_tool_rounds,
|
|
182
|
+
"metadata": meta,
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if tools_metadata:
|
|
186
|
+
export["tools"] = tools_metadata
|
|
187
|
+
|
|
188
|
+
if usage_session is not None:
|
|
189
|
+
export["usage_session"] = export_usage_session(usage_session)
|
|
190
|
+
|
|
191
|
+
return export
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def import_conversation(data: dict[str, Any]) -> dict[str, Any]:
|
|
195
|
+
"""Validate and deserialize an exported conversation dict.
|
|
196
|
+
|
|
197
|
+
Returns a dict with deserialized messages (``ImageContent`` objects
|
|
198
|
+
reconstructed).
|
|
199
|
+
|
|
200
|
+
Raises:
|
|
201
|
+
ValueError: If the export version is unsupported.
|
|
202
|
+
"""
|
|
203
|
+
version = data.get("version")
|
|
204
|
+
if version != EXPORT_VERSION:
|
|
205
|
+
raise ValueError(f"Unsupported export version: {version}. Expected {EXPORT_VERSION}.")
|
|
206
|
+
|
|
207
|
+
result = copy.deepcopy(data)
|
|
208
|
+
|
|
209
|
+
# Deserialize message content
|
|
210
|
+
for msg in result.get("messages", []):
|
|
211
|
+
if "content" in msg:
|
|
212
|
+
msg["content"] = _deserialize_message_content(msg["content"])
|
|
213
|
+
|
|
214
|
+
# Deserialize usage_session if present
|
|
215
|
+
if "usage_session" in result and isinstance(result["usage_session"], dict):
|
|
216
|
+
result["usage_session"] = import_usage_session(result["usage_session"])
|
|
217
|
+
|
|
218
|
+
return result
|