llmio 0.0.1__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.
llmio/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ from .assistant import Assistant
2
+
3
+
4
+ __all__ = [
5
+ "Assistant",
6
+ ]
llmio/assistant.py ADDED
@@ -0,0 +1,262 @@
1
+ from typing import Literal, Optional, Callable, Type, Any
2
+ from dataclasses import dataclass
3
+ import textwrap
4
+ from datetime import datetime
5
+ from inspect import isclass, signature
6
+
7
+ import jinja2
8
+ import pydantic
9
+ import openai
10
+ from polyfactory.factories.pydantic_factory import ModelFactory
11
+
12
+ from llmio import model, prompts
13
+
14
+
15
+ ENGINES = {
16
+ "gpt-3.5-turbo",
17
+ "gpt-4",
18
+ }
19
+
20
+
21
+ @dataclass
22
+ class Command:
23
+ function: Callable
24
+
25
+ @property
26
+ def name(self):
27
+ return self.function.__name__
28
+
29
+ @property
30
+ def is_pydantic_input(self):
31
+ return bool(
32
+ len(self.input_annotations) == 1
33
+ and issubclass(list(self.input_annotations.values())[0], pydantic.BaseModel)
34
+ )
35
+
36
+ @property
37
+ def is_pydantic_output(self):
38
+ annotation = self.function.__annotations__["return"]
39
+ if isclass(annotation) and issubclass(annotation, pydantic.BaseModel):
40
+ return True
41
+ return False
42
+
43
+ @property
44
+ def input_annotations(self):
45
+ return {
46
+ name: annotation
47
+ for name, annotation in self.function.__annotations__.items()
48
+ if name not in {"return", "state"}
49
+ }
50
+
51
+ @property
52
+ def params(self) -> pydantic.BaseModel:
53
+ if self.is_pydantic_input:
54
+ return list(self.input_annotations.values())[0]
55
+
56
+ return model.model_from_function(self.function)
57
+
58
+ @property
59
+ def returns(self) -> Type[pydantic.BaseModel]:
60
+ annotation = self.function.__annotations__["return"]
61
+ if isclass(annotation) and issubclass(annotation, pydantic.BaseModel):
62
+ return annotation
63
+
64
+ return pydantic.create_model("Result", result=(annotation, ...))
65
+
66
+ @property
67
+ def description(self):
68
+ if self.function.__doc__ is None:
69
+ return ""
70
+ return textwrap.dedent(self.function.__doc__).strip()
71
+
72
+ def explain(self):
73
+ return jinja2.Template(
74
+ prompts.DEFAULT_COMMAND_PROMPT,
75
+ trim_blocks=True,
76
+ lstrip_blocks=True,
77
+ ).render(
78
+ name=self.name,
79
+ description=self.description,
80
+ params=[
81
+ (
82
+ key,
83
+ value["type"],
84
+ value.get("description", "-"),
85
+ key in self.params.schema()["required"],
86
+ )
87
+ for key, value in self.params.schema()["properties"].items()
88
+ ],
89
+ returns=[
90
+ (key, value["type"], value.get("description", "-"))
91
+ for key, value in self.returns.schema()["properties"].items()
92
+ ],
93
+ mock_data=self.mock_data().json(),
94
+ )
95
+
96
+ def command_model(self) -> Any:
97
+ return pydantic.create_model(
98
+ "Model", command=(Literal[self.name], ...), params=(self.params, ...)
99
+ )
100
+
101
+ def mock_data(self):
102
+ class Mocker(ModelFactory):
103
+ __model__ = self.command_model()
104
+
105
+ return Mocker.build()
106
+
107
+ def execute(self, params, state=None):
108
+ kwargs = {}
109
+ if "state" in signature(self.function).parameters:
110
+ kwargs["state"] = state
111
+
112
+ if self.is_pydantic_input:
113
+ result = self.function(params, **kwargs)
114
+ else:
115
+ result = self.function(**params.dict(), **kwargs)
116
+
117
+ if self.is_pydantic_output:
118
+ return result
119
+ return self.returns(result=result)
120
+
121
+
122
+ class Assistant:
123
+ def __init__(
124
+ self,
125
+ key: str,
126
+ description: str,
127
+ engine: str = "gpt-4",
128
+ command_header: Optional[str] = None,
129
+ ):
130
+ openai.api_key = key
131
+
132
+ if engine not in ENGINES:
133
+ raise ValueError(f"Unknown engine {engine}")
134
+
135
+ self.engine = engine
136
+ self.description = description
137
+ self.commands: list[Command] = []
138
+
139
+ if command_header is None:
140
+ self.command_header = prompts.DEFAULT_COMMAND_HEADER
141
+ else:
142
+ self.command_header = command_header
143
+
144
+ self._prompt_inspectors: list[Callable] = []
145
+ self._output_inspectors: list[Callable] = []
146
+
147
+ def system_prompt(self) -> str:
148
+ return jinja2.Template(
149
+ prompts.DEFAULT_SYSTEM_PROMPT,
150
+ trim_blocks=True,
151
+ lstrip_blocks=True,
152
+ ).render(
153
+ description=self.description,
154
+ command_header=self.command_header,
155
+ commands=self.commands,
156
+ current_time=datetime.now().isoformat(),
157
+ )
158
+
159
+ def _get_system_prompt(self) -> dict[str, str]:
160
+ return {"role": "system", "content": self.system_prompt()}
161
+
162
+ def create_prompt(
163
+ self, message_history: list[dict[str, str]]
164
+ ) -> list[dict[str, str]]:
165
+ return [
166
+ self._get_system_prompt(),
167
+ *message_history,
168
+ ]
169
+
170
+ def command(self, function: Callable):
171
+ input_annotations = {
172
+ name: annotation
173
+ for name, annotation in function.__annotations__.items()
174
+ if name not in {"return", "state"}
175
+ }
176
+
177
+ assert (
178
+ len(input_annotations) == 1
179
+ and issubclass(list(input_annotations.values())[0], pydantic.BaseModel)
180
+ ) or (
181
+ not any(
182
+ issubclass(annotation, pydantic.BaseModel)
183
+ for annotation in input_annotations.values()
184
+ )
185
+ )
186
+
187
+ assert "return" in function.__annotations__
188
+
189
+ self.commands.append(
190
+ Command(function=function),
191
+ )
192
+ return function
193
+
194
+ def inspect_prompt(self, function: Callable):
195
+ self._prompt_inspectors.append(function)
196
+ return function
197
+
198
+ def inspect_output(self, function: Callable):
199
+ self._output_inspectors.append(function)
200
+ return function
201
+
202
+ def _run_prompt_inspectors(self, prompt: list[dict[str, str]], state) -> None:
203
+ for inspector in self._prompt_inspectors:
204
+ kwargs = {}
205
+ if "state" in signature(inspector).parameters:
206
+ kwargs["state"] = state
207
+ inspector(prompt, **kwargs)
208
+
209
+ def _run_content_inspectors(self, content: str, state) -> None:
210
+ for inspector in self._output_inspectors:
211
+ kwargs = {}
212
+ if "state" in signature(inspector).parameters:
213
+ kwargs["state"] = state
214
+ inspector(content, **kwargs)
215
+
216
+ def speak(
217
+ self,
218
+ message: str,
219
+ history: Optional[list[dict[str, str]]] = None,
220
+ state=None,
221
+ role="user",
222
+ ) -> tuple[str, list[dict[str, str]]]:
223
+ if history is None:
224
+ history = []
225
+ history = history[:]
226
+ history.append(
227
+ {
228
+ "role": role,
229
+ "content": message,
230
+ }
231
+ )
232
+
233
+ prompt = self.create_prompt(history)
234
+ self._run_prompt_inspectors(prompt, state)
235
+
236
+ result = openai.ChatCompletion.create(
237
+ model=self.engine,
238
+ messages=prompt,
239
+ )
240
+ content = result["choices"][0]["message"]["content"]
241
+ self._run_content_inspectors(content, state)
242
+
243
+ history.append(
244
+ {
245
+ "role": "assistant",
246
+ "content": content,
247
+ }
248
+ )
249
+ for command in self.commands:
250
+ cmd_model = command.command_model()
251
+ try:
252
+ inputs = cmd_model.parse_raw(content)
253
+ except pydantic.ValidationError:
254
+ continue
255
+ result = command.execute(inputs.params, state=state)
256
+ return self.speak(
257
+ result.json(),
258
+ history=history,
259
+ role="system",
260
+ state=state,
261
+ )
262
+ return content, history
llmio/model.py ADDED
@@ -0,0 +1,31 @@
1
+ from typing import Any, Callable, Dict, Mapping, Tuple
2
+ from inspect import Parameter, signature
3
+
4
+ from pydantic import create_model
5
+ from pydantic.typing import get_all_type_hints
6
+ from pydantic.utils import to_camel
7
+
8
+
9
+ def model_from_function(function: Callable):
10
+ parameters: Mapping[str, Parameter] = signature(function).parameters
11
+
12
+ type_hints = get_all_type_hints(function)
13
+ fields: Dict[str, Tuple[Any, Any]] = {}
14
+ for name, param in parameters.items():
15
+ if name == "state":
16
+ continue
17
+
18
+ if param.annotation is param.empty:
19
+ annotation = Any
20
+ else:
21
+ annotation = type_hints[name]
22
+
23
+ default = ... if param.default is param.empty else param.default
24
+ if param.kind == Parameter.POSITIONAL_OR_KEYWORD:
25
+ fields[name] = annotation, default
26
+ else:
27
+ raise ValueError(
28
+ "Unable to parse function signature. Only named arguments supported."
29
+ )
30
+
31
+ return create_model(to_camel(function.__name__), **fields) # type: ignore
@@ -0,0 +1,82 @@
1
+ import textwrap
2
+
3
+
4
+ DEFAULT_COMMAND_HEADER = (
5
+ textwrap.dedent(
6
+ """
7
+ The following commands can be used.
8
+ If you intend to execute a command, only write a valid command and nothing else.
9
+ Do not try to both speak and execute a command at the same time,
10
+ as it will not be accepted as a command.
11
+ Also do not try to execute multiple commands at once.
12
+ You can chain commands, but if so, only execute one command at a time,
13
+ and then execute the next commands afterward.
14
+ Every time a command is executed, the results will be shown as a system message,
15
+ and you then get to either execute a new command or output a normal message
16
+ intended to the user.
17
+ Every time you return a normal text, this will stop the command iteration,
18
+ and the text will be shown to the user. Because of this, do not hint that
19
+ you will execute a command by saying something like "Ok, I will now do X".
20
+ Instead, first execute the command, and then write a normal message to the user.
21
+ Do not talk explicitly about the commands to the user,
22
+ these are hidden and only serve as your interface to the application backend.
23
+
24
+ Remember to always EITHER output a command (in json), or a normal message,
25
+ never both at the same time.
26
+ """
27
+ )
28
+ .strip()
29
+ .replace("\n", " ")
30
+ )
31
+
32
+
33
+ DEFAULT_COMMAND_PROMPT = textwrap.dedent(
34
+ """
35
+ Command: {{name}}
36
+ {% if description %}
37
+ Description:
38
+ {{description}}
39
+ {% endif %}
40
+ {% if params %}
41
+
42
+ Parameters:
43
+ | Name | Type | Description | Required |
44
+ | ---- | ---- | ----------- | -------- |
45
+ {% for param_name, param_type, param_desc, param_required in params %}
46
+ | {{param_name}} | {{param_type}} | {{param_desc}} | {{param_required}} |
47
+ {% endfor %}
48
+ {% endif %}
49
+
50
+ Returns:
51
+ | Name | Type | Description |
52
+ | ---- | ---- | ----------- |
53
+ {% for res_name, res_type, res_desc in returns %}
54
+ | {{res_name}} | {{res_type}} | {{res_desc}} |
55
+ {% endfor %}
56
+
57
+ Example usage:
58
+ {{mock_data}}
59
+
60
+ ---
61
+ """
62
+ ).strip()
63
+
64
+
65
+ DEFAULT_SYSTEM_PROMPT = textwrap.dedent(
66
+ """
67
+ {{description}}
68
+
69
+ {{command_header}}
70
+
71
+ {% for command in commands %}
72
+ {{command.explain()}}
73
+ {% endfor %} \
74
+
75
+ System parameters:
76
+ The current time is {{current_time}}
77
+
78
+ You are limited to only answer question regarding the scope described above
79
+ and the available commands defined below.
80
+ For all other questions, politely decline to answer.
81
+ """
82
+ ).strip()
@@ -0,0 +1,73 @@
1
+ Metadata-Version: 2.1
2
+ Name: llmio
3
+ Version: 0.0.1
4
+ Summary: Easily connect large language models into your application
5
+ Project-URL: Homepage, https://github.com/badgeir/llmio
6
+ Project-URL: Bug Tracker, https://github.com/badgeir/llmio/issues
7
+ Author-email: Peter Leupi <pleupi123@gmail.com>
8
+ License-File: LICENSE
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Operating System :: OS Independent
11
+ Classifier: Programming Language :: Python :: 3
12
+ Requires-Python: >=3.10
13
+ Requires-Dist: jinja2
14
+ Requires-Dist: openai
15
+ Requires-Dist: polyfactory
16
+ Requires-Dist: pydantic
17
+ Provides-Extra: examples
18
+ Requires-Dist: bs4; extra == 'examples'
19
+ Description-Content-Type: text/markdown
20
+
21
+ # llmio
22
+ ## Easily connect large language models into your application.
23
+
24
+ ![pylint](https://github.com/badgeir/llmio/actions/workflows/pylint.yml/badge.svg)
25
+ ![mypy](https://github.com/badgeir/llmio/actions/workflows/mypy.yml/badge.svg)
26
+ ![ruff](https://github.com/badgeir/llmio/actions/workflows/ruff.yml/badge.svg)
27
+ ![tests](https://github.com/badgeir/llmio/actions/workflows/test.yml/badge.svg)
28
+
29
+ # Setup
30
+
31
+ ```
32
+ pip install llmio
33
+ ```
34
+
35
+ # Example
36
+
37
+ ``` python
38
+ import llmio
39
+
40
+
41
+ assistant = llmio.Assistant(
42
+ description="""
43
+ You are a calculator.
44
+ Always use the provided commands to perform calculations,
45
+ never try to calculate on your own.
46
+ When being given a math problem, do not explain the steps,
47
+ only execute them necessary commands and then present the answer.
48
+ """,
49
+ key="<openai-key>",
50
+ )
51
+
52
+
53
+ @assistant.command
54
+ def add(num1: float, num2: float) -> float:
55
+ """
56
+ Add two numbers
57
+ """
58
+ print(f"Adding {num1} + {num2}")
59
+ return num1 + num2
60
+
61
+
62
+ @assistant.command
63
+ def multiply(num1: float, num2: float) -> float:
64
+ """
65
+ Multiply two numbers
66
+ """
67
+ print(f"Multiplying {num1} * {num2}")
68
+ return num1 * num2
69
+
70
+
71
+ reply, _ = assistant.speak("calculate the answer of (10 + 20) * 1337")
72
+ print(reply)
73
+ ```
@@ -0,0 +1,8 @@
1
+ llmio/__init__.py,sha256=45J1oXd5fVw_C5UrQF_cCC5z3Gc_qWEwPYLBlz_DpTw,66
2
+ llmio/assistant.py,sha256=f5G5Kk0MmG1uqNDMdCywoP3flefTtDolJL1t5ZgFwxA,7684
3
+ llmio/model.py,sha256=tTaG3ZMH1ZoziXddM3RyyZ90lwCGJpLBKQW1V1wEJ3I,1050
4
+ llmio/prompts/__init__.py,sha256=KdofnNu06VQojLzljyTjvm8qw3iQw-EHCDXmuTV83d4,2483
5
+ llmio-0.0.1.dist-info/METADATA,sha256=jdIeSOfUlM0-Yi_qZFTHpnQvzMVr_11t3MihJ5WpLgU,1919
6
+ llmio-0.0.1.dist-info/WHEEL,sha256=EI2JsGydwUL5GP9t6kzZv7G3HDPi7FuZDDf9In6amRM,87
7
+ llmio-0.0.1.dist-info/licenses/LICENSE,sha256=QQpsve0-VHf467xw_343sMYyvurffTYPV7Mgh3Hmmoo,1064
8
+ llmio-0.0.1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.14.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2023 badgeir
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.