prompty 0.1.1__py3-none-any.whl

Sign up to get free protection for your applications and to get access to all the features.
prompty/__init__.py ADDED
@@ -0,0 +1,261 @@
1
+ import json
2
+ import traceback
3
+ from pathlib import Path
4
+ from typing import Dict, List, Union
5
+ from .core import (
6
+ Frontmatter,
7
+ InvokerFactory,
8
+ ModelSettings,
9
+ Prompty,
10
+ PropertySettings,
11
+ TemplateSettings,
12
+ param_hoisting,
13
+ )
14
+
15
+ from .renderers import *
16
+ from .parsers import *
17
+ from .executors import *
18
+ from .processors import *
19
+
20
+
21
+ def load_global_config(
22
+ prompty_path: Path = Path.cwd(), configuration: str = "default"
23
+ ) -> Dict[str, any]:
24
+ # prompty.config laying around?
25
+ prompty_config = list(Path.cwd().glob("**/prompty.json"))
26
+
27
+ # if there is one load it
28
+ if len(prompty_config) > 0:
29
+ # pick the nearest prompty.json
30
+ config = sorted(
31
+ [
32
+ c
33
+ for c in prompty_config
34
+ if len(c.parent.parts) <= len(prompty_path.parts)
35
+ ],
36
+ key=lambda p: len(p.parts),
37
+ )[-1]
38
+
39
+ with open(config, "r") as f:
40
+ c = json.load(f)
41
+ if configuration in c:
42
+ return c[configuration]
43
+ else:
44
+ raise ValueError(f'Item "{configuration}" not found in "{config}"')
45
+
46
+ return {}
47
+
48
+
49
+ def headless(
50
+ api: str,
51
+ content: str | List[str] | dict,
52
+ configuration: Dict[str, any] = {},
53
+ parameters: Dict[str, any] = {},
54
+ connection: str = "default",
55
+ ) -> Prompty:
56
+ # get caller's path (to get relative path for prompty.json)
57
+ caller = Path(traceback.extract_stack()[-2].filename)
58
+ templateSettings = TemplateSettings(type="NOOP", parser="NOOP")
59
+ modelSettings = ModelSettings(
60
+ api=api,
61
+ configuration=Prompty.normalize(
62
+ param_hoisting(
63
+ configuration, load_global_config(caller.parent, connection)
64
+ ),
65
+ caller.parent,
66
+ ),
67
+ parameters=parameters,
68
+ )
69
+
70
+ return Prompty(model=modelSettings, template=templateSettings, content=content)
71
+
72
+
73
+ def load(prompty_file: str, configuration: str = "default") -> Prompty:
74
+ p = Path(prompty_file)
75
+ if not p.is_absolute():
76
+ # get caller's path (take into account trace frame)
77
+ caller = Path(traceback.extract_stack()[-2].filename)
78
+ p = Path(caller.parent / p).resolve().absolute()
79
+
80
+ # load dictionary from prompty file
81
+ matter = Frontmatter.read_file(p)
82
+ attributes = matter["attributes"]
83
+ content = matter["body"]
84
+
85
+ # normalize attribute dictionary resolve keys and files
86
+ attributes = Prompty.normalize(attributes, p.parent)
87
+
88
+ # load global configuration
89
+ global_config = Prompty.normalize(
90
+ load_global_config(p.parent, configuration), p.parent
91
+ )
92
+ if "model" not in attributes:
93
+ attributes["model"] = {}
94
+
95
+ if "configuration" not in attributes["model"]:
96
+ attributes["model"]["configuration"] = global_config
97
+ else:
98
+ attributes["model"]["configuration"] = param_hoisting(
99
+ attributes["model"]["configuration"],
100
+ global_config,
101
+ )
102
+
103
+ # pull model settings out of attributes
104
+ try:
105
+ model = ModelSettings(**attributes.pop("model"))
106
+ except Exception as e:
107
+ raise ValueError(f"Error in model settings: {e}")
108
+
109
+ # pull template settings
110
+ try:
111
+ if "template" in attributes:
112
+ t = attributes.pop("template")
113
+ if isinstance(t, dict):
114
+ template = TemplateSettings(**t)
115
+ # has to be a string denoting the type
116
+ else:
117
+ template = TemplateSettings(type=t, parser="prompty")
118
+ else:
119
+ template = TemplateSettings(type="jinja2", parser="prompty")
120
+ except Exception as e:
121
+ raise ValueError(f"Error in template loader: {e}")
122
+
123
+ # formalize inputs and outputs
124
+ if "inputs" in attributes:
125
+ try:
126
+ inputs = {
127
+ k: PropertySettings(**v) for (k, v) in attributes.pop("inputs").items()
128
+ }
129
+ except Exception as e:
130
+ raise ValueError(f"Error in inputs: {e}")
131
+ else:
132
+ inputs = {}
133
+ if "outputs" in attributes:
134
+ try:
135
+ outputs = {
136
+ k: PropertySettings(**v) for (k, v) in attributes.pop("outputs").items()
137
+ }
138
+ except Exception as e:
139
+ raise ValueError(f"Error in outputs: {e}")
140
+ else:
141
+ outputs = {}
142
+
143
+ # recursive loading of base prompty
144
+ if "base" in attributes:
145
+ # load the base prompty from the same directory as the current prompty
146
+ base = load(p.parent / attributes["base"])
147
+ # hoist the base prompty's attributes to the current prompty
148
+ model.api = base.model.api if model.api == "" else model.api
149
+ model.configuration = param_hoisting(
150
+ model.configuration, base.model.configuration
151
+ )
152
+ model.parameters = param_hoisting(model.parameters, base.model.parameters)
153
+ model.response = param_hoisting(model.response, base.model.response)
154
+ attributes["sample"] = param_hoisting(attributes, base.sample, "sample")
155
+
156
+ p = Prompty(
157
+ **attributes,
158
+ model=model,
159
+ inputs=inputs,
160
+ outputs=outputs,
161
+ template=template,
162
+ content=content,
163
+ file=p,
164
+ basePrompty=base,
165
+ )
166
+ else:
167
+ p = Prompty(
168
+ **attributes,
169
+ model=model,
170
+ inputs=inputs,
171
+ outputs=outputs,
172
+ template=template,
173
+ content=content,
174
+ file=p,
175
+ )
176
+ return p
177
+
178
+
179
+ def prepare(
180
+ prompt: Prompty,
181
+ inputs: Dict[str, any] = {},
182
+ ):
183
+ inputs = param_hoisting(inputs, prompt.sample)
184
+
185
+ if prompt.template.type == "NOOP":
186
+ render = prompt.content
187
+ else:
188
+ # render
189
+ renderer = InvokerFactory.create_renderer(prompt.template.type, prompt)
190
+ render = renderer(inputs)
191
+
192
+ if prompt.template.parser == "NOOP":
193
+ result = render
194
+ else:
195
+ # parse [parser].[api]
196
+ parser = InvokerFactory.create_parser(
197
+ f"{prompt.template.parser}.{prompt.model.api}", prompt
198
+ )
199
+ result = parser(render)
200
+
201
+ return result
202
+
203
+
204
+ def run(
205
+ prompt: Prompty,
206
+ content: dict | list | str,
207
+ configuration: Dict[str, any] = {},
208
+ parameters: Dict[str, any] = {},
209
+ raw: bool = False,
210
+ ):
211
+ # invoker = InvokerFactory()
212
+
213
+ if configuration != {}:
214
+ prompt.model.configuration = param_hoisting(
215
+ configuration, prompt.model.configuration
216
+ )
217
+
218
+ if parameters != {}:
219
+ prompt.model.parameters = param_hoisting(parameters, prompt.model.parameters)
220
+
221
+ # execute
222
+ executor = InvokerFactory.create_executor(
223
+ prompt.model.configuration["type"], prompt
224
+ )
225
+ result = executor(content)
226
+
227
+ # skip?
228
+ if not raw:
229
+ # process
230
+ processor = InvokerFactory.create_processor(
231
+ prompt.model.configuration["type"], prompt
232
+ )
233
+ result = processor(result)
234
+
235
+ return result
236
+
237
+
238
+ def execute(
239
+ prompt: Union[str, Prompty],
240
+ configuration: Dict[str, any] = {},
241
+ parameters: Dict[str, any] = {},
242
+ inputs: Dict[str, any] = {},
243
+ raw: bool = False,
244
+ connection: str = "default",
245
+ ):
246
+
247
+ if isinstance(prompt, str):
248
+ path = Path(prompt)
249
+ if not path.is_absolute():
250
+ # get caller's path (take into account trace frame)
251
+ caller = Path(traceback.extract_stack()[-2].filename)
252
+ path = Path(caller.parent / path).resolve().absolute()
253
+ prompt = load(path, connection)
254
+
255
+ # prepare content
256
+ content = prepare(prompt, inputs)
257
+
258
+ # run LLM model
259
+ result = run(prompt, content, configuration, parameters, raw)
260
+
261
+ return result
prompty/core.py ADDED
@@ -0,0 +1,305 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import re
5
+ import yaml
6
+ import json
7
+ import abc
8
+ from pathlib import Path
9
+ from pydantic import BaseModel, Field, FilePath
10
+ from typing import List, Literal, Dict, Callable, TypeVar
11
+
12
+
13
+ T = TypeVar("T")
14
+
15
+
16
+ class PropertySettings(BaseModel):
17
+ type: Literal["string", "number", "array", "object", "boolean"]
18
+ default: str | int | float | List | dict | bool = Field(default=None)
19
+ description: str = Field(default="")
20
+
21
+
22
+ class ModelSettings(BaseModel):
23
+ api: str = Field(default="")
24
+ configuration: dict = Field(default={})
25
+ parameters: dict = Field(default={})
26
+ response: dict = Field(default={})
27
+
28
+ def model_dump_safe(self) -> dict:
29
+ d = self.model_dump()
30
+ d["configuration"] = {
31
+ k: "*" * len(v) if "key" in k.lower() or "secret" in k.lower() else v
32
+ for k, v in d["configuration"].items()
33
+ }
34
+ return d
35
+
36
+
37
+ class TemplateSettings(BaseModel):
38
+ type: str = Field(default="jinja2")
39
+ parser: str = Field(default="")
40
+
41
+
42
+ class Prompty(BaseModel):
43
+ # metadata
44
+ name: str = Field(default="")
45
+ description: str = Field(default="")
46
+ authors: List[str] = Field(default=[])
47
+ tags: List[str] = Field(default=[])
48
+ version: str = Field(default="")
49
+ base: str = Field(default="")
50
+ basePrompty: Prompty | None = Field(default=None)
51
+ # model
52
+ model: ModelSettings = Field(default_factory=ModelSettings)
53
+
54
+ # sample
55
+ sample: dict = Field(default={})
56
+
57
+ # input / output
58
+ inputs: Dict[str, PropertySettings] = Field(default={})
59
+ outputs: Dict[str, PropertySettings] = Field(default={})
60
+
61
+ # template
62
+ template: TemplateSettings
63
+
64
+ file: FilePath = Field(default="")
65
+ content: str | List[str] | dict = Field(default="")
66
+
67
+ def to_safe_dict(self) -> Dict[str, any]:
68
+ d = {}
69
+ for k, v in self:
70
+ if v != "" and v != {} and v != [] and v != None:
71
+ if k == "model":
72
+ d[k] = v.model_dump_safe()
73
+ elif k == "template":
74
+ d[k] = v.model_dump()
75
+ elif k == "inputs" or k == "outputs":
76
+ d[k] = {k: v.model_dump() for k, v in v.items()}
77
+ elif k == "file":
78
+ d[k] = (
79
+ str(self.file.as_posix())
80
+ if isinstance(self.file, Path)
81
+ else self.file
82
+ )
83
+ elif k == "basePrompty":
84
+ # no need to serialize basePrompty
85
+ continue
86
+
87
+ else:
88
+ d[k] = v
89
+ return d
90
+
91
+ # generate json representation of the prompty
92
+ def to_safe_json(self) -> str:
93
+ d = self.to_safe_dict()
94
+ return json.dumps(d)
95
+
96
+ @staticmethod
97
+ def _process_file(file: str, parent: Path) -> any:
98
+ file = Path(parent / Path(file)).resolve().absolute()
99
+ if file.exists():
100
+ with open(str(file), "r") as f:
101
+ items = json.load(f)
102
+ if isinstance(items, list):
103
+ return [Prompty.normalize(value, parent) for value in items]
104
+ elif isinstance(items, dict):
105
+ return {
106
+ key: Prompty.normalize(value, parent)
107
+ for key, value in items.items()
108
+ }
109
+ else:
110
+ return items
111
+ else:
112
+ raise FileNotFoundError(f"File {file} not found")
113
+
114
+ @staticmethod
115
+ def _process_env(variable: str, env_error=True) -> any:
116
+ if variable in os.environ.keys():
117
+ return os.environ[variable]
118
+ else:
119
+ if env_error:
120
+ raise ValueError(f"Variable {variable} not found in environment")
121
+ else:
122
+ return ""
123
+
124
+ @staticmethod
125
+ def normalize(attribute: any, parent: Path, env_error=True) -> any:
126
+ if isinstance(attribute, str):
127
+ attribute = attribute.strip()
128
+ if attribute.startswith("${") and attribute.endswith("}"):
129
+ # check if env or file
130
+ variable = attribute[2:-1].split(":")
131
+ if variable[0] == "env" and len(variable) > 1:
132
+ return Prompty._process_env(variable[1], env_error)
133
+ elif variable[0] == "file" and len(variable) > 1:
134
+ return Prompty._process_file(variable[1], parent)
135
+ else:
136
+ # old way of doing things for back compatibility
137
+ v = Prompty._process_env(variable[0], False)
138
+ if len(v) == 0:
139
+ if len(variable) > 1:
140
+ return variable[1]
141
+ else:
142
+ if env_error:
143
+ raise ValueError(
144
+ f"Variable {variable[0]} not found in environment"
145
+ )
146
+ else:
147
+ return v
148
+ else:
149
+ return v
150
+ elif (
151
+ attribute.startswith("file:")
152
+ and Path(parent / attribute.split(":")[1]).exists()
153
+ ):
154
+ # old way of doing things for back compatibility
155
+ return Prompty._process_file(attribute.split(":")[1], parent)
156
+ else:
157
+ return attribute
158
+ elif isinstance(attribute, list):
159
+ return [Prompty.normalize(value, parent) for value in attribute]
160
+ elif isinstance(attribute, dict):
161
+ return {
162
+ key: Prompty.normalize(value, parent)
163
+ for key, value in attribute.items()
164
+ }
165
+ else:
166
+ return attribute
167
+
168
+
169
+ def param_hoisting(
170
+ top: Dict[str, any], bottom: Dict[str, any], top_key: str = None
171
+ ) -> Dict[str, any]:
172
+ if top_key:
173
+ new_dict = {**top[top_key]} if top_key in top else {}
174
+ else:
175
+ new_dict = {**top}
176
+ for key, value in bottom.items():
177
+ if not key in new_dict:
178
+ new_dict[key] = value
179
+ return new_dict
180
+
181
+
182
+ class Invoker(abc.ABC):
183
+ def __init__(self, prompty: Prompty) -> None:
184
+ self.prompty = prompty
185
+
186
+ @abc.abstractmethod
187
+ def invoke(self, data: any) -> any:
188
+ pass
189
+
190
+ def __call__(self, data: any) -> any:
191
+ return self.invoke(data)
192
+
193
+
194
+ class InvokerFactory:
195
+ _renderers: Dict[str, Invoker] = {}
196
+ _parsers: Dict[str, Invoker] = {}
197
+ _executors: Dict[str, Invoker] = {}
198
+ _processors: Dict[str, Invoker] = {}
199
+
200
+ @classmethod
201
+ def register_renderer(cls, name: str) -> Callable:
202
+ def inner_wrapper(wrapped_class: Invoker) -> Callable:
203
+ cls._renderers[name] = wrapped_class
204
+ return wrapped_class
205
+
206
+ return inner_wrapper
207
+
208
+ @classmethod
209
+ def register_parser(cls, name: str) -> Callable:
210
+ def inner_wrapper(wrapped_class: Invoker) -> Callable:
211
+ cls._parsers[name] = wrapped_class
212
+ return wrapped_class
213
+
214
+ return inner_wrapper
215
+
216
+ @classmethod
217
+ def register_executor(cls, name: str) -> Callable:
218
+ def inner_wrapper(wrapped_class: Invoker) -> Callable:
219
+ cls._executors[name] = wrapped_class
220
+ return wrapped_class
221
+
222
+ return inner_wrapper
223
+
224
+ @classmethod
225
+ def register_processor(cls, name: str) -> Callable:
226
+ def inner_wrapper(wrapped_class: Invoker) -> Callable:
227
+ cls._processors[name] = wrapped_class
228
+ return wrapped_class
229
+
230
+ return inner_wrapper
231
+
232
+ @classmethod
233
+ def create_renderer(cls, name: str, prompty: Prompty) -> Invoker:
234
+ if name not in cls._renderers:
235
+ raise ValueError(f"Renderer {name} not found")
236
+ return cls._renderers[name](prompty)
237
+
238
+ @classmethod
239
+ def create_parser(cls, name: str, prompty: Prompty) -> Invoker:
240
+ if name not in cls._parsers:
241
+ raise ValueError(f"Parser {name} not found")
242
+ return cls._parsers[name](prompty)
243
+
244
+ @classmethod
245
+ def create_executor(cls, name: str, prompty: Prompty) -> Invoker:
246
+ if name not in cls._executors:
247
+ raise ValueError(f"Executor {name} not found")
248
+ return cls._executors[name](prompty)
249
+
250
+ @classmethod
251
+ def create_processor(cls, name: str, prompty: Prompty) -> Invoker:
252
+ if name not in cls._processors:
253
+ raise ValueError(f"Processor {name} not found")
254
+ return cls._processors[name](prompty)
255
+
256
+
257
+ @InvokerFactory.register_renderer("NOOP")
258
+ @InvokerFactory.register_parser("NOOP")
259
+ @InvokerFactory.register_executor("NOOP")
260
+ @InvokerFactory.register_processor("NOOP")
261
+ @InvokerFactory.register_parser("prompty.embedding")
262
+ @InvokerFactory.register_parser("prompty.image")
263
+ @InvokerFactory.register_parser("prompty.completion")
264
+ class NoOp(Invoker):
265
+ def invoke(self, data: any) -> any:
266
+ return data
267
+
268
+
269
+ class Frontmatter:
270
+ _yaml_delim = r"(?:---|\+\+\+)"
271
+ _yaml = r"(.*?)"
272
+ _content = r"\s*(.+)$"
273
+ _re_pattern = r"^\s*" + _yaml_delim + _yaml + _yaml_delim + _content
274
+ _regex = re.compile(_re_pattern, re.S | re.M)
275
+
276
+ @classmethod
277
+ def read_file(cls, path):
278
+ """Reads file at path and returns dict with separated frontmatter.
279
+ See read() for more info on dict return value.
280
+ """
281
+ with open(path, encoding="utf-8") as file:
282
+ file_contents = file.read()
283
+ return cls.read(file_contents)
284
+
285
+ @classmethod
286
+ def read(cls, string):
287
+ """Returns dict with separated frontmatter from string.
288
+
289
+ Returned dict keys:
290
+ attributes -- extracted YAML attributes in dict form.
291
+ body -- string contents below the YAML separators
292
+ frontmatter -- string representation of YAML
293
+ """
294
+ fmatter = ""
295
+ body = ""
296
+ result = cls._regex.search(string)
297
+
298
+ if result:
299
+ fmatter = result.group(1)
300
+ body = result.group(2)
301
+ return {
302
+ "attributes": yaml.load(fmatter, Loader=yaml.FullLoader),
303
+ "body": body,
304
+ "frontmatter": fmatter,
305
+ }
prompty/executors.py ADDED
@@ -0,0 +1,70 @@
1
+ import azure.identity
2
+ from openai import AzureOpenAI
3
+ from .core import Invoker, InvokerFactory, Prompty
4
+ from pathlib import Path
5
+
6
+
7
+ @InvokerFactory.register_executor("azure")
8
+ @InvokerFactory.register_executor("azure_openai")
9
+ class AzureOpenAIExecutor(Invoker):
10
+ def __init__(self, prompty: Prompty) -> None:
11
+ self.prompty = prompty
12
+ kwargs = {
13
+ key: value
14
+ for key, value in self.prompty.model.configuration.items()
15
+ if key != "type"
16
+ }
17
+
18
+ # no key, use default credentials
19
+ if "api_key" not in kwargs:
20
+ # managed identity if client id
21
+ if "client_id" in kwargs:
22
+ default_credential = azure.identity.ManagedIdentityCredential(
23
+ client_id=kwargs.pop("client_id"),
24
+ )
25
+ # default credential
26
+ else:
27
+ default_credential = azure.identity.DefaultAzureCredential(
28
+ exclude_shared_token_cache_credential=True
29
+ )
30
+
31
+ kwargs["azure_ad_token_provider"] = (
32
+ azure.identity.get_bearer_token_provider(
33
+ default_credential, "https://cognitiveservices.azure.com/.default"
34
+ )
35
+ )
36
+
37
+ self.client = AzureOpenAI(
38
+ default_headers={"User-Agent": "prompty/0.1.0"},
39
+ **kwargs,
40
+ )
41
+
42
+ self.api = self.prompty.model.api
43
+ self.deployment = self.prompty.model.configuration["azure_deployment"]
44
+ self.parameters = self.prompty.model.parameters
45
+
46
+ def invoke(self, data: any) -> any:
47
+ if self.api == "chat":
48
+ response = self.client.chat.completions.create(
49
+ model=self.deployment,
50
+ messages=data if isinstance(data, list) else [data],
51
+ **self.parameters,
52
+ )
53
+ elif self.api == "completion":
54
+ response = self.client.completions.create(
55
+ prompt=data.item,
56
+ model=self.deployment,
57
+ **self.parameters,
58
+ )
59
+
60
+ elif self.api == "embedding":
61
+ response = self.client.embeddings.create(
62
+ input=data if isinstance(data, list) else [data],
63
+ model=self.deployment,
64
+ **self.parameters,
65
+ )
66
+
67
+ elif self.api == "image":
68
+ raise NotImplementedError("Azure OpenAI Image API is not implemented yet")
69
+
70
+ return response
prompty/parsers.py ADDED
@@ -0,0 +1,103 @@
1
+ import re
2
+ import base64
3
+ from .core import Invoker, InvokerFactory, Prompty
4
+
5
+
6
+ @InvokerFactory.register_parser("prompty.chat")
7
+ class PromptyChatParser(Invoker):
8
+ def __init__(self, prompty: Prompty) -> None:
9
+ self.prompty = prompty
10
+ self.roles = ["assistant", "function", "system", "user"]
11
+ self.path = self.prompty.file.parent
12
+
13
+ def inline_image(self, image_item: str) -> str:
14
+ # pass through if it's a url or base64 encoded
15
+ if image_item.startswith("http") or image_item.startswith("data"):
16
+ return image_item
17
+ # otherwise, it's a local file - need to base64 encode it
18
+ else:
19
+ image_path = self.path / image_item
20
+ with open(image_path, "rb") as f:
21
+ base64_image = base64.b64encode(f.read()).decode("utf-8")
22
+
23
+ if image_path.suffix == ".png":
24
+ return f"data:image/png;base64,{base64_image}"
25
+ elif image_path.suffix == ".jpg":
26
+ return f"data:image/jpeg;base64,{base64_image}"
27
+ elif image_path.suffix == ".jpeg":
28
+ return f"data:image/jpeg;base64,{base64_image}"
29
+ else:
30
+ raise ValueError(
31
+ f"Invalid image format {image_path.suffix} - currently only .png and .jpg / .jpeg are supported."
32
+ )
33
+
34
+ def parse_content(self, content: str):
35
+ """for parsing inline images"""
36
+ # regular expression to parse markdown images
37
+ image = r"(?P<alt>!\[[^\]]*\])\((?P<filename>.*?)(?=\"|\))\)"
38
+ matches = re.findall(image, content, flags=re.MULTILINE)
39
+ if len(matches) > 0:
40
+ content_items = []
41
+ content_chunks = re.split(image, content, flags=re.MULTILINE)
42
+ current_chunk = 0
43
+ for i in range(len(content_chunks)):
44
+ # image entry
45
+ if (
46
+ current_chunk < len(matches)
47
+ and content_chunks[i] == matches[current_chunk][0]
48
+ ):
49
+ content_items.append(
50
+ {
51
+ "type": "image_url",
52
+ "image_url": {
53
+ "url": self.inline_image(
54
+ matches[current_chunk][1].split(" ")[0].strip()
55
+ )
56
+ },
57
+ }
58
+ )
59
+ # second part of image entry
60
+ elif (
61
+ current_chunk < len(matches)
62
+ and content_chunks[i] == matches[current_chunk][1]
63
+ ):
64
+ current_chunk += 1
65
+ # text entry
66
+ else:
67
+ if len(content_chunks[i].strip()) > 0:
68
+ content_items.append(
69
+ {"type": "text", "text": content_chunks[i].strip()}
70
+ )
71
+ return content_items
72
+ else:
73
+ return content
74
+
75
+ def invoke(self, data: str) -> str:
76
+ messages = []
77
+ separator = r"(?i)^\s*#?\s*(" + "|".join(self.roles) + r")\s*:\s*\n"
78
+
79
+ # get valid chunks - remove empty items
80
+ chunks = [
81
+ item
82
+ for item in re.split(separator, data, flags=re.MULTILINE)
83
+ if len(item.strip()) > 0
84
+ ]
85
+
86
+ # if no starter role, then inject system role
87
+ if not chunks[0].strip().lower() in self.roles:
88
+ chunks.insert(0, "system")
89
+
90
+ # if last chunk is role entry, then remove (no content?)
91
+ if chunks[-1].strip().lower() in self.roles:
92
+ chunks.pop()
93
+
94
+ if len(chunks) % 2 != 0:
95
+ raise ValueError("Invalid prompt format")
96
+
97
+ # create messages
98
+ for i in range(0, len(chunks), 2):
99
+ role = chunks[i].strip().lower()
100
+ content = chunks[i + 1].strip()
101
+ messages.append({"role": role, "content": self.parse_content(content)})
102
+
103
+ return messages
prompty/processors.py ADDED
@@ -0,0 +1,55 @@
1
+ from pydantic import BaseModel
2
+ from openai.types.completion import Completion
3
+ from .core import Invoker, InvokerFactory, Prompty
4
+ from openai.types.chat.chat_completion import ChatCompletion
5
+ from openai.types.create_embedding_response import CreateEmbeddingResponse
6
+
7
+
8
+
9
+ class ToolCall(BaseModel):
10
+ id: str
11
+ name: str
12
+ arguments: str
13
+
14
+
15
+ @InvokerFactory.register_processor("openai")
16
+ @InvokerFactory.register_processor("azure")
17
+ @InvokerFactory.register_processor("azure_openai")
18
+ class OpenAIProcessor(Invoker):
19
+ def __init__(self, prompty: Prompty) -> None:
20
+ self.prompty = prompty
21
+
22
+ def invoke(self, data: any) -> any:
23
+
24
+ assert (
25
+ isinstance(data, ChatCompletion)
26
+ or isinstance(data, Completion)
27
+ or isinstance(data, CreateEmbeddingResponse)
28
+ )
29
+ if isinstance(data, ChatCompletion):
30
+ # TODO: Check for streaming response
31
+ response = data.choices[0].message
32
+ # tool calls available in response
33
+ if response.tool_calls:
34
+ return [
35
+ ToolCall(
36
+ id=tool_call.id,
37
+ name=tool_call.function.name,
38
+ arguments=tool_call.function.arguments,
39
+ )
40
+ for tool_call in response.tool_calls
41
+ ]
42
+ else:
43
+ return response.content
44
+
45
+ elif isinstance(data, Completion):
46
+ return data.choices[0].text
47
+ elif isinstance(data, CreateEmbeddingResponse):
48
+ if len(data.data) == 0:
49
+ raise ValueError("Invalid data")
50
+ elif len(data.data) == 1:
51
+ return data.data[0].embedding
52
+ else:
53
+ return [item.embedding for item in data.data]
54
+ else:
55
+ raise ValueError("Invalid data type")
prompty/renderers.py ADDED
@@ -0,0 +1,22 @@
1
+ from jinja2 import DictLoader, Environment
2
+ from .core import Invoker, InvokerFactory, Prompty
3
+
4
+
5
+ @InvokerFactory.register_renderer("jinja2")
6
+ class Jinja2Renderer(Invoker):
7
+ def __init__(self, prompty: Prompty) -> None:
8
+ self.prompty = prompty
9
+ self.templates = {}
10
+ # generate template dictionary
11
+ cur_prompt = self.prompty
12
+ while cur_prompt:
13
+ self.templates[cur_prompt.file.name] = cur_prompt.content
14
+ cur_prompt = cur_prompt.basePrompty
15
+
16
+ self.name = self.prompty.file.name
17
+
18
+ def invoke(self, data: any) -> any:
19
+ env = Environment(loader=DictLoader(self.templates))
20
+ t = env.get_template(self.name)
21
+ generated = t.render(**data)
22
+ return generated
@@ -0,0 +1,15 @@
1
+ Metadata-Version: 2.1
2
+ Name: prompty
3
+ Version: 0.1.1
4
+ Summary: Prompty is a new asset class and format for LLM prompts that aims to provide observability, understandability, and portability for developers. It includes spec, tooling, and a runtime. This Prompty runtime supports Python
5
+ Author-Email: Seth Juarez <seth.juarez@microsoft.com>
6
+ License: MIT
7
+ Requires-Python: >=3.9
8
+ Requires-Dist: pyyaml>=6.0.1
9
+ Requires-Dist: pydantic>=2.8.2
10
+ Requires-Dist: jinja2>=3.1.4
11
+ Requires-Dist: openai>=1.35.10
12
+ Requires-Dist: azure-identity>=1.17.1
13
+ Description-Content-Type: text/markdown
14
+
15
+ # prompty
@@ -0,0 +1,9 @@
1
+ prompty-0.1.1.dist-info/METADATA,sha256=fkJx_0VrHNxhOcuQHhgaQSuyevJdErISkPbzA5A2noM,581
2
+ prompty-0.1.1.dist-info/WHEEL,sha256=mbxFTmdEUhG7evcdMkR3aBt9SWcoFBJ4CDwnfguNegA,90
3
+ prompty/__init__.py,sha256=PP7fVje52try-QcjVnSc44MkF8DVYXeCTfbyRlltqZI,7625
4
+ prompty/core.py,sha256=AIQCA-aN9i9tckcObGoRMMS4rjRZzSTSKxM-o6hXuDw,10085
5
+ prompty/executors.py,sha256=KlvlDXxpwNCXNpkvigG_jNcaBTz8eP3pFiVjPPnyjk0,2448
6
+ prompty/parsers.py,sha256=e7Wf4hnDzrcau_4WsaQT9Jeqcuf1gYvL4KERCnWnVXQ,3993
7
+ prompty/processors.py,sha256=x3LCtXhElsaa6bJ82-x_QFNpc7ddgDcyimcNOtni-ow,1856
8
+ prompty/renderers.py,sha256=-NetGbPujfRLgofsbU8ty7Ap-y4oFb47bqE21I-vx8o,745
9
+ prompty-0.1.1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: pdm-backend (2.3.2)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any