Skip to content

Commit ca79e73

Browse files
committed
feat: basic features for P
1 parent ccc7b33 commit ca79e73

File tree

9 files changed

+341
-21
lines changed

9 files changed

+341
-21
lines changed

.github/workflows/test.yml

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
name: test
2+
3+
on:
4+
push:
5+
branches:
6+
- main
7+
- dev
8+
paths-ignore:
9+
- '**/*.md'
10+
- '**/*.ipynb'
11+
- 'examples/**'
12+
pull_request:
13+
branches:
14+
- main
15+
- dev
16+
paths-ignore:
17+
- '**/*.md'
18+
- '**/*.ipynb'
19+
- 'examples/**'
20+
21+
jobs:
22+
test:
23+
name: Tests on ${{ matrix.os }} for ${{ matrix.python-version }}
24+
strategy:
25+
matrix:
26+
python-version: [3.11]
27+
os: [ubuntu-latest]
28+
runs-on: ${{ matrix.os }}
29+
timeout-minutes: 10
30+
steps:
31+
- uses: actions/checkout@v4
32+
- name: Set up Python ${{ matrix.python-version }}
33+
uses: actions/setup-python@v3
34+
with:
35+
python-version: ${{ matrix.python-version }}
36+
- name: Install dependencies
37+
run: |
38+
python -m pip install --upgrade pip
39+
pip install -r requirements.txt
40+
pip install -r requirements-dev.txt
41+
- name: Lint with flake8
42+
run: |
43+
flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
44+
- name: Build and Test
45+
run: |
46+
python -m pytest -o log_cli=true -o log_cli_level="INFO" --cov=drive_flow --cov-report=xml -v ./
47+
- name: Check codecov file
48+
id: check_files
49+
uses: andstor/file-existence-action@v1
50+
with:
51+
files: './coverage.xml'
52+
- name: Upload coverage from test to Codecov
53+
uses: codecov/codecov-action@v5
54+
with:
55+
file: ./coverage.xml
56+
token: ${{ secrets.CODECOV_TOKEN }}

prompt_string/__init__.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1+
from .string import PromptString as P
2+
13
__author__ = "Gus Ye"
2-
__version__ = "0.0.1.dev1"
4+
__version__ = "0.0.1"
35
__url__ = "https://github.com/memodb-io/prompt-string"
4-
5-
print("prompt-string is testing")

prompt_string/string.py

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
from typing import Optional, Literal
2+
from functools import wraps
3+
from . import token
4+
5+
6+
def to_prompt_string(func):
7+
@wraps(func)
8+
def wrapper(self: "PromptString", *args, **kwargs):
9+
result = func(self, *args, **kwargs)
10+
return PromptString(result, **self._meta_info)
11+
12+
return wrapper
13+
14+
15+
class PromptString(str):
16+
17+
def __new__(
18+
cls,
19+
*args,
20+
role: Optional[Literal["system", "user", "assistant"]] = None,
21+
**kwargs,
22+
):
23+
instance = str.__new__(cls, *args, **kwargs)
24+
instance.__prompt_string_tokens = token.get_encoded_tokens(instance)
25+
instance.__prompt_string_role = role
26+
instance.__prompt_string_kwargs = {
27+
"role": role,
28+
}
29+
return instance
30+
31+
@property
32+
def role(self):
33+
return self.__prompt_string_role
34+
35+
@property
36+
def _meta_info(self):
37+
return self.__prompt_string_kwargs
38+
39+
@role.setter
40+
def role(self, value):
41+
self.__prompt_string_role = value
42+
43+
def __len__(self):
44+
return len(self.__prompt_string_tokens)
45+
# return len(token.get_encoded_tokens(super().__str__()))
46+
47+
@to_prompt_string
48+
def __getitem__(self, index):
49+
if isinstance(index, slice):
50+
return token.get_decoded_tokens(self.__prompt_string_tokens[index])
51+
elif isinstance(index, int):
52+
return token.get_decoded_tokens([self.__prompt_string_tokens[index]])
53+
else:
54+
raise ValueError(f"Invalid index type: {type(index)}")
55+
56+
def message(self, style="openai"):
57+
if style == "openai":
58+
return {
59+
"role": self.role,
60+
"content": super().__str__(),
61+
}
62+
else:
63+
raise ValueError(f"Invalid style: {style}")
64+
65+
def __add__(self, other):
66+
if isinstance(other, PromptString):
67+
return PromptString(super().__add__(other), **other._meta_info)
68+
elif isinstance(other, str):
69+
return PromptString(super().__add__(other), **self._meta_info)
70+
else:
71+
raise ValueError(f"Invalid type for Prompt Concatenation: {type(other)}")
72+
73+
def __truediv__(self, other):
74+
from .string_chain import PromptChain
75+
76+
assert isinstance(other, PromptString)
77+
return PromptChain([self, other])
78+
79+
@to_prompt_string
80+
def replace(self, old, new, count=-1):
81+
return super().replace(old, new, count)
82+
83+
@to_prompt_string
84+
def format(self, *args, **kwargs):
85+
return super().format(*args, **kwargs)

prompt_string/string_chain.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
from .string import PromptString
2+
3+
TOTAL_ROLES = {"system", "user", "assistant"}
4+
DEFAULT_ROLE_ORDER = ["user", "assistant"]
5+
6+
7+
class PromptChain:
8+
def __init__(self, prompts: list[PromptString], default_start_role: str = "user"):
9+
assert all(isinstance(p, PromptString) for p in prompts)
10+
self.__prompts = prompts
11+
self.__start_role = default_start_role
12+
13+
def __len__(self):
14+
return len(self.__prompts)
15+
16+
def __getitem__(self, index):
17+
if isinstance(index, int):
18+
return self.__prompts[index]
19+
elif isinstance(index, slice):
20+
return PromptChain(
21+
self.__prompts[index], default_start_role=self.__start_role
22+
)
23+
else:
24+
raise ValueError(f"Invalid index type: {type(index)}")
25+
26+
@property
27+
def infer_roles(self):
28+
if not len(self.__prompts):
29+
return []
30+
results = []
31+
iter_prompts = self.__prompts
32+
if self.__start_role in ["system", "assistant"]:
33+
results.append(self.__start_role)
34+
iter_prompts = iter_prompts[1:]
35+
for i, p in enumerate(iter_prompts):
36+
default_role = DEFAULT_ROLE_ORDER[i % len(DEFAULT_ROLE_ORDER)]
37+
results.append(p.role or default_role)
38+
return results
39+
40+
@property
41+
def roles(self):
42+
return [p.role for p in self.__prompts]
43+
44+
def __truediv__(self, other):
45+
if isinstance(other, PromptChain):
46+
return PromptChain(
47+
self.__prompts + other.__prompts, default_start_role=self.__start_role
48+
)
49+
elif isinstance(other, PromptString):
50+
return PromptChain(
51+
self.__prompts + [other], default_start_role=self.__start_role
52+
)
53+
else:
54+
raise ValueError(f"Invalid type for PromptChain Division: {type(other)}")
55+
56+
def messages(self, style="openai"):
57+
if style == "openai":
58+
ms = [p.message() for p in self.__prompts]
59+
roles = self.infer_roles
60+
for i in range(len(ms)):
61+
ms[i]["role"] = roles[i]
62+
return ms
63+
else:
64+
raise ValueError(f"Invalid style: {style}")
65+
66+
def __str__(self):
67+
return str(self.messages())

prompt_string/token.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
from tiktoken import encoding_for_model
2+
3+
4+
USE_ENCODER = None
5+
6+
7+
def get_encoded_tokens(content: str) -> list[int]:
8+
return USE_ENCODER.encode(content)
9+
10+
11+
def get_decoded_tokens(tokens: list[int]) -> str:
12+
return USE_ENCODER.decode(tokens)
13+
14+
15+
def truncate_string(content: str, max_tokens: int):
16+
return get_decoded_tokens(get_encoded_tokens(content)[:max_tokens])
17+
18+
19+
def setup_encoder(model: str = "gpt-4o"):
20+
global USE_ENCODER
21+
USE_ENCODER = encoding_for_model(model)
22+
23+
24+
setup_encoder()

prompt_string/types.py

Whitespace-only changes.

readme.md

Lines changed: 49 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -16,15 +16,16 @@ Prompt is essentially a string, but it should behave somewhat differently from a
1616

1717
👨 **Role & Concatenation**: Prompt strings should have designated roles (e.g., `system`, `user`, `assistant`) and should be concatenated in a specific manner.
1818

19-
🦆 **Binding Functions**: A prompt string contains logic and instructions, so having some binding functions for AI-related stuff is beneficial and necessary (e.g., convert to OpenAI Message Format).
2019

2120

21+
## Features
2222

23-
**Few promises in `prompt-string`:**
23+
`prompt-string` provides two types:
2424

25-
- `prompt-string` inherits from `string`. Therefore, aside from the mentioned features, its other behaviors are just like those of a `string` in Python.
26-
- `prompt-string` won't add OpenAI and other AI SDKs as dependencies; it is simply a toolkit for prompts.
27-
- `prompt-string` will be super light and fast, with no heavy processes running behind the scenes.
25+
- `P` for prompt, inherits from `string`. Length, Slicing and concatenation are modified and support new attributes like `.role`.
26+
- `p = P("You're a helpful assistant")`
27+
- `PC` for prompt chain, act like `list[P]`. Link a series of prompt and support `.messages(...)`
28+
- `pc = p1 / p2 / p3`
2829

2930

3031

@@ -50,35 +51,65 @@ print("Decoded result of the second token", prompt[2])
5051
print("The decoded result of first five tokens", prompt[:5])
5152
```
5253

54+
`P` supports some `str` native methods to still return a `P` object:
55+
56+
- `.format`
57+
- `.replace`
5358

59+
```python
60+
prompt = P("you're a helpful assistant. {temp}")
61+
62+
print(len(prompt.format(temp="End of instructions")))
63+
print(len(prompt.replace("{temp}", ""))
64+
```
5465

55-
#### Role & Concatenation
66+
> 🧐 Raise an issue if you think other methods should be supported
67+
68+
69+
70+
#### Role
5671

5772
```python
5873
from prompt_string import P
5974

60-
sp = P("you're a helpful assistant.", "system")
61-
up = P("How are you?", "user")
75+
sp = P("you're a helpful assistant.", role="system")
76+
up = P("How are you?", role="user")
6277

63-
print(sp.role, up.role, (sp+up).role)
78+
print(sp.role, up.role, (sp+up).roles)
6479
print(sp + up)
80+
81+
print(sp.message())
6582
```
6683

67-
- role can be `None`, `str`, `list[str]`
84+
- role can be `None`, `str` for `P`
6885
- For single prompt, like `sp`, the role is `str`(*e.g.* `system`) or `None`
69-
- For concatenated prompts, like `sp+up`, the role is `list[str]`(*e.g.* `['system', 'user']`)
86+
- `sp+up` will concatenate two prompt string and generate a new `P`, whose role will be updated if the latter one has one.
87+
- For example, `sp+up`'s role is `user`, `sp+P('Hi')`'s role is `system`
7088

7189

90+
- `.message(...)` return a JSON object of this prompt.
7291

73-
#### Binding Functions
7492

75-
```python
76-
from prompt_string import P
7793

78-
sp = P("you're a helpful assistant.")
79-
up = P("How are you?")
94+
#### Concatenation
8095

81-
print((sp+up).messages())
96+
```python
97+
pc = sp / up
98+
print(pc.roles)
99+
print(pc.messages())
82100
```
83101

84-
- `messages` will return the OpenAI-Compatible messages format, where you can directly pass it to `client.chat.completions.create(messages=...)`
102+
For concatenated prompts, like `sp / up`, the type will be converted to `PC` (prompt chain), `PC` has below things:
103+
104+
- `.roles`, a list of roles. For example, `(sp|up).roles` is `['system', 'user']`
105+
- `.messages(...)` pack prompts into OpenAI-Compatible messages JSON, where you can directly pass it to `client.chat.completions.create(messages=...)`.
106+
- `messages` will assume the first role is `user`, then proceed in the order of user-assistant. When a prompt has a role, it will use that role. check `pc.infer_role` for final roles in messages.
107+
108+
109+
110+
## Few promises in `prompt-string`
111+
112+
- `P` inherits from `string`. Therefore, aside from the mentioned features, its other behaviors are just like those of a `string` in Python.
113+
- `prompt-string` won't add OpenAI and other AI SDKs as dependencies; it is simply a toolkit for prompts.
114+
- `prompt-string` will be super light and fast, with no heavy processes running behind the scenes.
115+

requirements-dev.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
pytest
2+
pytest-cov

0 commit comments

Comments
 (0)