GJDutils 0.2.2__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.
- gjdutils/__init__.py +12 -0
- gjdutils/audios.py +39 -0
- gjdutils/cacheing.py +237 -0
- gjdutils/cmd.py +149 -0
- gjdutils/colab.py +39 -0
- gjdutils/collections.py +36 -0
- gjdutils/decorators.py +34 -0
- gjdutils/dicts.py +216 -0
- gjdutils/dsci.py +202 -0
- gjdutils/dt.py +296 -0
- gjdutils/env.py +64 -0
- gjdutils/errors.py +12 -0
- gjdutils/files.py +140 -0
- gjdutils/functions.py +6 -0
- gjdutils/google_translate.py +80 -0
- gjdutils/hashing.py +32 -0
- gjdutils/html.py +87 -0
- gjdutils/indexing.py +97 -0
- gjdutils/iterfunc.py +99 -0
- gjdutils/jsons.py +70 -0
- gjdutils/lists.py +13 -0
- gjdutils/llm_utils.py +167 -0
- gjdutils/llms_claude.py +131 -0
- gjdutils/llms_openai.py +299 -0
- gjdutils/misc.py +30 -0
- gjdutils/num.py +77 -0
- gjdutils/obsolete/google_text_to_speech.py +46 -0
- gjdutils/obsolete/llms_obsolete.py +298 -0
- gjdutils/outloud_text_to_speech.py +230 -0
- gjdutils/prompt_templates.py +20 -0
- gjdutils/pypi_build.py +112 -0
- gjdutils/pytest_utils.py +24 -0
- gjdutils/rand.py +65 -0
- gjdutils/regex.py +78 -0
- gjdutils/requirements_dev.txt +2 -0
- gjdutils/runtime.py +19 -0
- gjdutils/sets.py +5 -0
- gjdutils/shell.py +69 -0
- gjdutils/sorteddict.py +34 -0
- gjdutils/stopwatch.py +79 -0
- gjdutils/strings.py +218 -0
- gjdutils/todo/convert_parquet.py +28 -0
- gjdutils/typ.py +37 -0
- gjdutils/voice_speechrecognition.py +29 -0
- gjdutils/web.py +68 -0
- gjdutils-0.2.2.dist-info/METADATA +101 -0
- gjdutils-0.2.2.dist-info/RECORD +49 -0
- gjdutils-0.2.2.dist-info/WHEEL +4 -0
- gjdutils-0.2.2.dist-info/licenses/LICENSE +21 -0
gjdutils/lists.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
def get_list_from_str_or_list(str_or_list: str | list[str]) -> list[str]:
|
|
2
|
+
"""
|
|
3
|
+
e.g.
|
|
4
|
+
- get_list_from_str_or_list("asdf") -> ["asdf"]
|
|
5
|
+
- get_list_from_str_or_list(["asdf"]) -> ["asdf"]
|
|
6
|
+
"""
|
|
7
|
+
if isinstance(str_or_list, str):
|
|
8
|
+
aliases = [str_or_list]
|
|
9
|
+
elif isinstance(str_or_list, list):
|
|
10
|
+
aliases = str_or_list
|
|
11
|
+
else:
|
|
12
|
+
raise Exception(f"Unknown typ: {type(str_or_list)}")
|
|
13
|
+
return aliases
|
gjdutils/llm_utils.py
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
import io
|
|
3
|
+
import json
|
|
4
|
+
from typing import Any, Literal, Optional
|
|
5
|
+
|
|
6
|
+
from anthropic import Anthropic
|
|
7
|
+
from openai import OpenAI
|
|
8
|
+
|
|
9
|
+
from gjdutils.llms_claude import call_claude_gpt
|
|
10
|
+
from gjdutils.llms_openai import call_openai_gpt
|
|
11
|
+
from gjdutils.strings import jinja_render
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
MODEL_TYPE = Literal["openai", "claude"]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def proc_llm_out_json(s: str):
|
|
18
|
+
# TODO probably we don't need this function any more, because OpenAI will guarantee JSON output
|
|
19
|
+
"""
|
|
20
|
+
If GPT-4 returns json output like this:
|
|
21
|
+
|
|
22
|
+
```json
|
|
23
|
+
...
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
This strips away that markdown wrapping.
|
|
27
|
+
|
|
28
|
+
Alternatively, consider using llm_prompt_json()
|
|
29
|
+
"""
|
|
30
|
+
s = s.strip()
|
|
31
|
+
# remove the markdown code wrapping
|
|
32
|
+
if s.startswith("```json") and s.endswith("```"):
|
|
33
|
+
s = s[7:-3]
|
|
34
|
+
try:
|
|
35
|
+
j = json.loads(s)
|
|
36
|
+
except:
|
|
37
|
+
print(s)
|
|
38
|
+
raise Exception("Could not parse JSON from LLM output")
|
|
39
|
+
return j
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def image_to_base64_resized(image_full_filen: str, resize_target_size_kb: int = 100):
|
|
43
|
+
from PIL import Image
|
|
44
|
+
|
|
45
|
+
# based on https://claude.ai/chat/d0eb1f39-3f42-4cb5-a2ec-5aa102c60ea0
|
|
46
|
+
assert resize_target_size_kb > 0
|
|
47
|
+
with Image.open(image_full_filen) as img_orig:
|
|
48
|
+
width_orig, height_orig = img_orig.size
|
|
49
|
+
# Calculate initial file size
|
|
50
|
+
temp_buffer = io.BytesIO()
|
|
51
|
+
img_orig.save(temp_buffer, format=img_orig.format)
|
|
52
|
+
img_resized = img_orig.copy()
|
|
53
|
+
current_size_kb = len(temp_buffer.getvalue()) / 1024
|
|
54
|
+
|
|
55
|
+
# Iteratively resize until file size is below target
|
|
56
|
+
resize_factor = 0.9
|
|
57
|
+
while current_size_kb > resize_target_size_kb:
|
|
58
|
+
resize_factor *= resize_factor
|
|
59
|
+
width = int(width_orig * resize_factor)
|
|
60
|
+
height = int(height_orig * resize_factor)
|
|
61
|
+
img_resized = img_orig.resize(
|
|
62
|
+
(width, height), Image.LANCZOS # type: ignore
|
|
63
|
+
) # type: ignore
|
|
64
|
+
# Check new file size
|
|
65
|
+
temp_buffer = io.BytesIO()
|
|
66
|
+
img_resized.save(temp_buffer, format=img_orig.format)
|
|
67
|
+
current_size_kb = len(temp_buffer.getvalue()) / 1024
|
|
68
|
+
|
|
69
|
+
# Convert final resized image to base64
|
|
70
|
+
img_bytes = io.BytesIO()
|
|
71
|
+
img_resized.save(img_bytes, format=img_orig.format)
|
|
72
|
+
img_buffer = img_bytes.getvalue()
|
|
73
|
+
|
|
74
|
+
return img_buffer
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def image_to_base64(img_full_filen: str, resize_target_size_kb: Optional[int] = None):
|
|
78
|
+
# from https://chat.openai.com/c/35f15af9-b947-4fa6-acbe-2a5ed26e7547
|
|
79
|
+
if resize_target_size_kb is None:
|
|
80
|
+
with open(img_full_filen, "rb") as image_file:
|
|
81
|
+
img_bytes = image_file.read()
|
|
82
|
+
else:
|
|
83
|
+
img_bytes = image_to_base64_resized(img_full_filen, resize_target_size_kb)
|
|
84
|
+
img_b64 = base64.b64encode(img_bytes).decode("utf-8")
|
|
85
|
+
return img_b64
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def image_to_base64_basic(image_filen: str) -> str:
|
|
89
|
+
with open(image_filen, "rb") as image_file:
|
|
90
|
+
return base64.b64encode(image_file.read()).decode("ascii")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def contents_for_images(image_filens: list[str], resize_target_size_kb: int):
|
|
94
|
+
# assert (
|
|
95
|
+
# 1 <= len(image_filens) <= 10
|
|
96
|
+
# ), "You can only provide between 1 and 10 images"
|
|
97
|
+
base64_images = []
|
|
98
|
+
new_contents = []
|
|
99
|
+
for image_filen in image_filens:
|
|
100
|
+
base64_image = image_to_base64(
|
|
101
|
+
image_filen, resize_target_size_kb=resize_target_size_kb
|
|
102
|
+
)
|
|
103
|
+
filen_content = {
|
|
104
|
+
"type": "text",
|
|
105
|
+
"text": f"Filename: {image_filen}",
|
|
106
|
+
}
|
|
107
|
+
img_content = {
|
|
108
|
+
"type": "image_url",
|
|
109
|
+
"image_url": {"url": f"data:image/jpeg;base64,{base64_image}"},
|
|
110
|
+
}
|
|
111
|
+
base64_images.append(base64_image)
|
|
112
|
+
new_contents.extend([filen_content, img_content])
|
|
113
|
+
return new_contents, base64_images
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def generate_gpt_from_template(
|
|
117
|
+
client: Anthropic | OpenAI,
|
|
118
|
+
prompt_template_var: str,
|
|
119
|
+
context_d: dict,
|
|
120
|
+
response_json: bool,
|
|
121
|
+
image_filens: list[str] | str | None = None,
|
|
122
|
+
model_type: MODEL_TYPE = "claude",
|
|
123
|
+
max_tokens: Optional[int] = None,
|
|
124
|
+
prompt_template_filen: str = "prompt_templates", # i.e. prompt_templates.py
|
|
125
|
+
verbose: int = 0,
|
|
126
|
+
) -> tuple[str | dict[str, Any], dict[str, Any]]:
|
|
127
|
+
"""
|
|
128
|
+
e.g.
|
|
129
|
+
generate_gpt_from_template("quick_search_for_word", {"word_tgt": word}, True, verbose)
|
|
130
|
+
"""
|
|
131
|
+
# dynamically import `template_var` from prompt_templates as `prompt_template`
|
|
132
|
+
prompt_template = getattr(__import__(prompt_template_filen), prompt_template_var)
|
|
133
|
+
prompt = jinja_render(prompt_template, context_d)
|
|
134
|
+
if model_type == "openai":
|
|
135
|
+
assert isinstance(client, OpenAI), "Expected OpenAI client"
|
|
136
|
+
out, _, extra = call_openai_gpt(
|
|
137
|
+
prompt,
|
|
138
|
+
client=client,
|
|
139
|
+
image_filens=image_filens,
|
|
140
|
+
response_json=response_json,
|
|
141
|
+
max_tokens=max_tokens,
|
|
142
|
+
)
|
|
143
|
+
else:
|
|
144
|
+
assert isinstance(client, Anthropic), "Expected Anthropic client"
|
|
145
|
+
out, extra = call_claude_gpt(
|
|
146
|
+
prompt,
|
|
147
|
+
client=client,
|
|
148
|
+
image_filens=image_filens,
|
|
149
|
+
response_json=response_json,
|
|
150
|
+
max_tokens=max_tokens if max_tokens is not None else 4096,
|
|
151
|
+
)
|
|
152
|
+
if response_json:
|
|
153
|
+
assert isinstance(out, dict), f"Expected dict, got {type(out)}"
|
|
154
|
+
else:
|
|
155
|
+
assert isinstance(out, str), f"Expected str, got {type(out)}"
|
|
156
|
+
if verbose >= 1:
|
|
157
|
+
print(
|
|
158
|
+
f"Called GPT on '{prompt_template_var}', context keys {list(context_d.keys())}"
|
|
159
|
+
)
|
|
160
|
+
extra.update(
|
|
161
|
+
{
|
|
162
|
+
"model_type": model_type,
|
|
163
|
+
"prompt_template": prompt_template_var,
|
|
164
|
+
"prompt_context_d": context_d,
|
|
165
|
+
}
|
|
166
|
+
)
|
|
167
|
+
return out, extra # type: ignore
|
gjdutils/llms_claude.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from anthropic import Anthropic, NOT_GIVEN
|
|
4
|
+
import os
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
from gjdutils.rand import DEFAULT_RANDOM_SEED
|
|
8
|
+
from gjdutils.llm_utils import image_to_base64_basic
|
|
9
|
+
|
|
10
|
+
CLAUDE_API_KEY = os.environ.get("CLAUDE_API_KEY")
|
|
11
|
+
# https://docs.anthropic.com/en/docs/about-claude/models
|
|
12
|
+
MODEL_NAME_CLAUDE_SONNET_GOOD_PINNED = "claude-3-5-sonnet-20240620"
|
|
13
|
+
MODEL_NAME_CLAUDE_SONNET_GOOD_LATEST = "claude-3-5-sonnet-latest"
|
|
14
|
+
MODEL_NAME_CLAUDE_SONNET_CHEAP_LATEST = "claude-3-5-haiku-latest"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def img_as_content_dict(img_filen: str):
|
|
18
|
+
media_type_from_extension = {
|
|
19
|
+
".jpg": "image/jpeg",
|
|
20
|
+
".jpeg": "image/jpeg",
|
|
21
|
+
".png": "image/png",
|
|
22
|
+
".webp": "image/webp",
|
|
23
|
+
".gif": "image/gif",
|
|
24
|
+
".bmp": "image/bmp",
|
|
25
|
+
".tiff": "image/tiff",
|
|
26
|
+
".ico": "image/vnd.microsoft.icon",
|
|
27
|
+
".svg": "image/svg+xml",
|
|
28
|
+
".heic": "image/heic",
|
|
29
|
+
".heif": "image/heif",
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
ext = Path(img_filen).suffix
|
|
33
|
+
if ext not in media_type_from_extension:
|
|
34
|
+
raise ValueError(f"Unknown image file extension: {img_filen}")
|
|
35
|
+
media_type = media_type_from_extension[ext]
|
|
36
|
+
|
|
37
|
+
img_base64 = image_to_base64_basic(img_filen)
|
|
38
|
+
return {
|
|
39
|
+
"type": "image",
|
|
40
|
+
"source": {
|
|
41
|
+
"type": "base64",
|
|
42
|
+
"media_type": media_type,
|
|
43
|
+
"data": img_base64,
|
|
44
|
+
},
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def call_claude_gpt(
|
|
49
|
+
prompt: str,
|
|
50
|
+
tools: Optional[list[dict]] = None,
|
|
51
|
+
image_filens: str | list[str] | None = None,
|
|
52
|
+
image_resize_target_size_kb: Optional[int] = 100,
|
|
53
|
+
client: Optional[Anthropic] = None,
|
|
54
|
+
model: str = MODEL_NAME_CLAUDE_SONNET_GOOD_LATEST,
|
|
55
|
+
temperature: Optional[float] = 0.001,
|
|
56
|
+
response_json: bool = False,
|
|
57
|
+
# seed: Optional[int] = DEFAULT_RANDOM_SEED,
|
|
58
|
+
max_tokens: int = 4096,
|
|
59
|
+
verbose: int = 0,
|
|
60
|
+
):
|
|
61
|
+
"""Call Claude API with support for text, images, and function calling"""
|
|
62
|
+
extra = locals()
|
|
63
|
+
extra.pop("client")
|
|
64
|
+
|
|
65
|
+
if tools is not None:
|
|
66
|
+
raise NotImplementedError(
|
|
67
|
+
"I think tools are supported, but not implemented in this function"
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
if client is None:
|
|
71
|
+
client = Anthropic(api_key=CLAUDE_API_KEY)
|
|
72
|
+
|
|
73
|
+
# Prepare image contents if provided
|
|
74
|
+
contents = []
|
|
75
|
+
if image_filens:
|
|
76
|
+
if isinstance(image_filens, str):
|
|
77
|
+
image_filens = [image_filens]
|
|
78
|
+
assert image_resize_target_size_kb is not None
|
|
79
|
+
for i, img_filen in enumerate(image_filens):
|
|
80
|
+
contents.extend(
|
|
81
|
+
[
|
|
82
|
+
{"type": "text", "text": f"Image {i+1}:"},
|
|
83
|
+
img_as_content_dict(image_filens[i]),
|
|
84
|
+
# {
|
|
85
|
+
# "type": "image",
|
|
86
|
+
# "source": {
|
|
87
|
+
# "type": "base64",
|
|
88
|
+
# "media_type": "image/jpeg", # Adjust based on actual image type
|
|
89
|
+
# "data": b64,
|
|
90
|
+
# },
|
|
91
|
+
# },
|
|
92
|
+
]
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
# Add text prompt
|
|
96
|
+
contents.append({"type": "text", "text": prompt})
|
|
97
|
+
|
|
98
|
+
# Prepare response format
|
|
99
|
+
response_format = {"type": "json_object"} if response_json else None
|
|
100
|
+
|
|
101
|
+
# Make API call
|
|
102
|
+
response = client.messages.create(
|
|
103
|
+
model=model,
|
|
104
|
+
max_tokens=max_tokens,
|
|
105
|
+
messages=[{"role": "user", "content": contents}],
|
|
106
|
+
temperature=temperature if temperature is not None else NOT_GIVEN,
|
|
107
|
+
# seed=seed,
|
|
108
|
+
# response_format=response_format,
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
msg = response.content[0].text # type: ignore
|
|
112
|
+
if response_json:
|
|
113
|
+
msg = json.loads(msg)
|
|
114
|
+
extra.update(
|
|
115
|
+
{
|
|
116
|
+
"response": response.model_dump(),
|
|
117
|
+
"msg": msg,
|
|
118
|
+
# "tool_calls": tool_calls,
|
|
119
|
+
"model": model,
|
|
120
|
+
"contents": contents,
|
|
121
|
+
}
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
if verbose >= 2:
|
|
125
|
+
print(f"PROMPT:\n{prompt}")
|
|
126
|
+
if verbose >= 1:
|
|
127
|
+
print(f"LLM MESSAGE:\n{msg}")
|
|
128
|
+
if verbose >= 2:
|
|
129
|
+
# print(f"TOOL CALLS:\n{tool_calls}")
|
|
130
|
+
print(f"LLM RESPONSE:\n{json.dumps(response.model_dump(), indent=2)}")
|
|
131
|
+
return msg, extra
|
gjdutils/llms_openai.py
ADDED
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from openai import OpenAI, NOT_GIVEN
|
|
3
|
+
import os
|
|
4
|
+
from pprint import pprint
|
|
5
|
+
from typing import Any, Literal, Optional
|
|
6
|
+
|
|
7
|
+
from .prompt_templates import summarise_list_of_texts_as_one, summarise_text
|
|
8
|
+
from .rand import DEFAULT_RANDOM_SEED
|
|
9
|
+
from .strings import jinja_render
|
|
10
|
+
from gjdutils.llm_utils import contents_for_images
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
# openai.api_key = OPENAI_API_KEY
|
|
14
|
+
OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY")
|
|
15
|
+
MODEL_NAME_GPT4 = "gpt-4"
|
|
16
|
+
MODEL_NAME_GPT35 = "gpt-3.5-turbo"
|
|
17
|
+
MODEL_NAME_GPT4_TURBO = "gpt-4-turbo" # -1106-preview"
|
|
18
|
+
MODEL_NAME_GPT4O = "gpt-4o"
|
|
19
|
+
MODEL_NAME_GPT4O_MINI = "gpt-4o-mini"
|
|
20
|
+
MODEL_NAME_O1 = "o1-preview"
|
|
21
|
+
MODEL_NAME_O1_MINI = "o1-mini"
|
|
22
|
+
DEFAULT_MODEL_NAME = MODEL_NAME_GPT4O
|
|
23
|
+
|
|
24
|
+
MODELS_NO_TOOLS = [MODEL_NAME_O1, MODEL_NAME_O1_MINI]
|
|
25
|
+
|
|
26
|
+
# from https://github.com/openai/openai-python
|
|
27
|
+
ToolChoiceTyps = Literal["auto", "required", "none"]
|
|
28
|
+
|
|
29
|
+
GranularityTyps = Literal[
|
|
30
|
+
"short phrase of just a few words",
|
|
31
|
+
"short title",
|
|
32
|
+
"short sentence",
|
|
33
|
+
"at most a sentence",
|
|
34
|
+
"sentence or two",
|
|
35
|
+
"few sentences",
|
|
36
|
+
"single short paragraph",
|
|
37
|
+
"couple of paragraphs",
|
|
38
|
+
"page",
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def call_openai_gpt(
|
|
43
|
+
prompt: str,
|
|
44
|
+
tools: Optional[list[dict]] = None,
|
|
45
|
+
tool_choice: Optional[ToolChoiceTyps] = None,
|
|
46
|
+
image_filens: str | list[str] | None = None,
|
|
47
|
+
image_resize_target_size_kb: Optional[int] = 100,
|
|
48
|
+
client: Optional[OpenAI] = None,
|
|
49
|
+
model: Optional[str] = None,
|
|
50
|
+
temperature: Optional[float] = 0.001,
|
|
51
|
+
max_tokens: int | None = None,
|
|
52
|
+
# stop: Optional[list[str]] = None,
|
|
53
|
+
response_json: bool = False,
|
|
54
|
+
seed: Optional[int] = DEFAULT_RANDOM_SEED,
|
|
55
|
+
verbose: int = 0,
|
|
56
|
+
):
|
|
57
|
+
"""
|
|
58
|
+
Usage:
|
|
59
|
+
|
|
60
|
+
client = OpenAI(
|
|
61
|
+
api_key=OPENAI_API_KEY,
|
|
62
|
+
)
|
|
63
|
+
msg, tools, extra = call_gpt_uncached(client, "What is the capital of France?")
|
|
64
|
+
|
|
65
|
+
@cachier()
|
|
66
|
+
def call_gpt_cached(
|
|
67
|
+
inp: str, tools: Optional[list[dict]] = None, tool_choice: ToolChoiceTyps = "auto"
|
|
68
|
+
):
|
|
69
|
+
return call_gpt(client=client, inp=inp, tools=tools, tool_choice=tool_choice)
|
|
70
|
+
|
|
71
|
+
https://platform.openai.com/docs/api-reference/chat/create?lang=python
|
|
72
|
+
SAMPLE_TOOLS = [
|
|
73
|
+
{
|
|
74
|
+
"type": "function",
|
|
75
|
+
"function": {
|
|
76
|
+
"name": "get_current_weather",
|
|
77
|
+
"description": "Get the current weather in a given location",
|
|
78
|
+
"parameters": {
|
|
79
|
+
"type": "object",
|
|
80
|
+
"properties": {
|
|
81
|
+
"location": {
|
|
82
|
+
"type": "string",
|
|
83
|
+
"description": "The city and state, e.g. San Francisco, CA",
|
|
84
|
+
},
|
|
85
|
+
"unit": {"type": "string", "enum": ["celsius", "fahrenheit"]},
|
|
86
|
+
},
|
|
87
|
+
"required": ["location"],
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
}
|
|
91
|
+
]
|
|
92
|
+
|
|
93
|
+
The messages for images could look like this:
|
|
94
|
+
{
|
|
95
|
+
"role": "user",
|
|
96
|
+
"content": [
|
|
97
|
+
{
|
|
98
|
+
"type": "text",
|
|
99
|
+
"text": "Filename: cat.jpg"
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
"type": "image_url",
|
|
103
|
+
"image_url": {
|
|
104
|
+
"url": "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEAYABgAAD/2wBDAA..."
|
|
105
|
+
}
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
"type": "text",
|
|
109
|
+
"text": "Filename: dog.png"
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
"type": "image_url",
|
|
113
|
+
"image_url": {
|
|
114
|
+
"url": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJA..."
|
|
115
|
+
}
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
"type": "text",
|
|
119
|
+
"text": "Describe each of these images, referring to them by their filenames:"
|
|
120
|
+
},
|
|
121
|
+
]
|
|
122
|
+
}
|
|
123
|
+
]
|
|
124
|
+
"""
|
|
125
|
+
|
|
126
|
+
extra = locals()
|
|
127
|
+
extra.pop("client") # to avoid caching issues, and because it includes the API key
|
|
128
|
+
if client is None:
|
|
129
|
+
client = OpenAI(api_key=OPENAI_API_KEY)
|
|
130
|
+
if not tools:
|
|
131
|
+
# otherwise you get a 400
|
|
132
|
+
tool_choice = None
|
|
133
|
+
if model is None:
|
|
134
|
+
model = DEFAULT_MODEL_NAME
|
|
135
|
+
if image_filens is None:
|
|
136
|
+
base64_images = None
|
|
137
|
+
image_contents = []
|
|
138
|
+
else:
|
|
139
|
+
if isinstance(image_filens, str):
|
|
140
|
+
image_filens = [image_filens]
|
|
141
|
+
assert (
|
|
142
|
+
image_resize_target_size_kb is not None
|
|
143
|
+
), "You must provide a resize_target_size_kb"
|
|
144
|
+
image_contents, base64_images = contents_for_images(
|
|
145
|
+
image_filens, resize_target_size_kb=image_resize_target_size_kb
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
prompt_content = {"type": "text", "text": prompt}
|
|
149
|
+
contents = image_contents + [prompt_content]
|
|
150
|
+
messages = [{"role": "user", "content": contents}]
|
|
151
|
+
response_format = {"type": "json_object"} if response_json else None
|
|
152
|
+
if model in MODELS_NO_TOOLS:
|
|
153
|
+
assert (
|
|
154
|
+
tools is None
|
|
155
|
+
), "You cannot provide tools for models that don't support them"
|
|
156
|
+
assert (
|
|
157
|
+
tool_choice is None
|
|
158
|
+
), "You cannot provide tool_choice for models that don't support them"
|
|
159
|
+
tools, tool_choice = NOT_GIVEN, NOT_GIVEN # type: ignore
|
|
160
|
+
# assert temperature is None, f"Temperature can't be set for {model}"
|
|
161
|
+
temperature = NOT_GIVEN # type: ignore
|
|
162
|
+
response = client.chat.completions.create(
|
|
163
|
+
model=model,
|
|
164
|
+
messages=messages, # type: ignore
|
|
165
|
+
tools=tools, # type: ignore
|
|
166
|
+
tool_choice=tool_choice, # type: ignore
|
|
167
|
+
temperature=temperature,
|
|
168
|
+
max_tokens=max_tokens if max_tokens is not None else NOT_GIVEN,
|
|
169
|
+
# stop=stop, # for some reason, setting this to None causes an error
|
|
170
|
+
seed=seed,
|
|
171
|
+
response_format=response_format, # type: ignore
|
|
172
|
+
)
|
|
173
|
+
msg = response.choices[0].message.content # could be empty
|
|
174
|
+
if response_json:
|
|
175
|
+
msg = json.loads(msg) # type: ignore
|
|
176
|
+
tool_calls = response.choices[0].message.tool_calls # could be None or a list
|
|
177
|
+
extra.update(
|
|
178
|
+
{
|
|
179
|
+
"response": response.model_dump(),
|
|
180
|
+
"msg": msg,
|
|
181
|
+
"tool_calls": tool_calls,
|
|
182
|
+
"model": model,
|
|
183
|
+
"base64_images": base64_images,
|
|
184
|
+
"contents": contents,
|
|
185
|
+
# "client": client,
|
|
186
|
+
}
|
|
187
|
+
)
|
|
188
|
+
if verbose >= 2:
|
|
189
|
+
print(f"PROMPT:\n{prompt}")
|
|
190
|
+
if verbose >= 1:
|
|
191
|
+
print(f"LLM MESSAGE:\n{msg}")
|
|
192
|
+
if verbose >= 2:
|
|
193
|
+
print(f"TOOL CALLS:\n{tool_calls}")
|
|
194
|
+
print(f"LLM RESPONSE:\n{json.dumps(response.model_dump(), indent=2)}")
|
|
195
|
+
return msg, tool_calls, extra
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def llm_generate_summary(
|
|
199
|
+
txt_or_txts: str | list[str],
|
|
200
|
+
granularity: Optional[GranularityTyps] = None,
|
|
201
|
+
full_txt_or_html: Optional[str] = None,
|
|
202
|
+
n_truncate_words=None,
|
|
203
|
+
model_name: Optional[str] = None,
|
|
204
|
+
max_tokens: Optional[int] = None,
|
|
205
|
+
verbose: int = 0,
|
|
206
|
+
):
|
|
207
|
+
"""
|
|
208
|
+
TXT_OR_TXTS can either be a single string,
|
|
209
|
+
or a list of strings (in which case it tries to find the summary that unifies them).
|
|
210
|
+
|
|
211
|
+
FULL_TXT_OR_HTML is the full text or HTML that the text is a part of.
|
|
212
|
+
It is used to provide context to the summarisation.
|
|
213
|
+
|
|
214
|
+
TODO: I combined summarisation of text and list into one, but I'm not convinced
|
|
215
|
+
it was such a good idea. It has made things unwieldy. I'm mostly focused on the
|
|
216
|
+
summarisation of a single text for now.
|
|
217
|
+
|
|
218
|
+
TODO maybe we don't need both MAX_TOKENS and N_TRUNCATE_WORDS. Maybe we can just
|
|
219
|
+
use MAX_TOKENS and calculate N_TRUNCATE_WORDS from that.
|
|
220
|
+
"""
|
|
221
|
+
|
|
222
|
+
def do_summarise_text(txt: str):
|
|
223
|
+
if n_truncate_words:
|
|
224
|
+
txt = txt[:n_truncate_words]
|
|
225
|
+
context["txt"] = txt
|
|
226
|
+
prompt = jinja_render(summarise_text, context)
|
|
227
|
+
extra.update(
|
|
228
|
+
{
|
|
229
|
+
"txt": txt, # type: ignore
|
|
230
|
+
}
|
|
231
|
+
) # type: ignore
|
|
232
|
+
return prompt
|
|
233
|
+
|
|
234
|
+
def do_summarise_list(txts: list[str]):
|
|
235
|
+
# UNTESTED
|
|
236
|
+
txts = [txt.replace("\n", " ").replace(" ", " ").strip() for txt in txts]
|
|
237
|
+
if max_tokens is not None:
|
|
238
|
+
if n_truncate_words is None: # type: ignore
|
|
239
|
+
# assume a word is <1.5 tokens. so 3500 / 10 / 1.5 = 233
|
|
240
|
+
n_truncate_words = int(max_tokens / len(txts) / 1.5)
|
|
241
|
+
if n_truncate_words is not None: # type: ignore
|
|
242
|
+
txts = [txt[:n_truncate_words] for txt in txts if txt] # type: ignore
|
|
243
|
+
context["txts"] = txts # type: ignore
|
|
244
|
+
prompt = jinja_render(summarise_list_of_texts_as_one, context)
|
|
245
|
+
extra.update(
|
|
246
|
+
{
|
|
247
|
+
"txts": txts,
|
|
248
|
+
"max_tokens": max_tokens,
|
|
249
|
+
"n_truncate_words": n_truncate_words, # type: ignore
|
|
250
|
+
}
|
|
251
|
+
) # type: ignore
|
|
252
|
+
return prompt
|
|
253
|
+
|
|
254
|
+
extra = {"input": locals()}
|
|
255
|
+
if full_txt_or_html is not None:
|
|
256
|
+
raise NotImplementedError("full_txt_or_html is not yet implemented")
|
|
257
|
+
context = {
|
|
258
|
+
"granularity": (
|
|
259
|
+
"Adjust the length of your summary appropriately, based on the length and complexity of the text. For example, if the text is a paragraph, write a sentence or two. If it's a page, write a paragraph or so. If it's a book, write a page."
|
|
260
|
+
if granularity is None
|
|
261
|
+
else f"Write at most a {granularity}."
|
|
262
|
+
)
|
|
263
|
+
}
|
|
264
|
+
assert txt_or_txts, "txt_or_txts must be non-empty"
|
|
265
|
+
if isinstance(txt_or_txts, str):
|
|
266
|
+
prompt = do_summarise_text(txt=txt_or_txts)
|
|
267
|
+
elif isinstance(txt_or_txts, list):
|
|
268
|
+
prompt = do_summarise_list(txts=txt_or_txts)
|
|
269
|
+
else:
|
|
270
|
+
raise TypeError("txt_or_txts must be str or list[str]: %s" % type(txt_or_txts))
|
|
271
|
+
|
|
272
|
+
assert max_tokens is None, "max_tokens is not yet implemented"
|
|
273
|
+
msg, tools, extra = call_openai_gpt(
|
|
274
|
+
prompt=prompt, model=model_name
|
|
275
|
+
) # , max_tokens=max_tokens)
|
|
276
|
+
extra.update(
|
|
277
|
+
{
|
|
278
|
+
"context": context,
|
|
279
|
+
"prompt": prompt,
|
|
280
|
+
"llm_msg": msg,
|
|
281
|
+
"llm_tools": tools,
|
|
282
|
+
"llm_extra": extra,
|
|
283
|
+
} # type: ignore
|
|
284
|
+
)
|
|
285
|
+
if verbose > 0:
|
|
286
|
+
print("Summary:", msg)
|
|
287
|
+
if verbose > 1:
|
|
288
|
+
print(f"PROMPT:\n{prompt}")
|
|
289
|
+
extra = {
|
|
290
|
+
"model_name": model_name,
|
|
291
|
+
"prompt": prompt,
|
|
292
|
+
}
|
|
293
|
+
return msg, extra
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
if __name__ == "__main__":
|
|
297
|
+
# txt = prompt('What is the capital of France?')
|
|
298
|
+
msg, _, _ = call_openai_gpt("What is the capital of France?")
|
|
299
|
+
print(msg)
|
gjdutils/misc.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from .dicts import print_dict
|
|
2
|
+
from .typ import isfunction
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def print_locals(
|
|
6
|
+
d: dict, ignore_functions: bool = True, ignore_underscores: bool = True
|
|
7
|
+
):
|
|
8
|
+
"""
|
|
9
|
+
e.g. print_locals(locals())
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
def del_robust(k):
|
|
13
|
+
if k in d:
|
|
14
|
+
del d[k]
|
|
15
|
+
|
|
16
|
+
assert isinstance(d, dict)
|
|
17
|
+
for k in d.keys():
|
|
18
|
+
if ignore_functions and isfunction(d[k]):
|
|
19
|
+
del_robust(k)
|
|
20
|
+
if ignore_underscores and k.startswith("_"):
|
|
21
|
+
del_robust(k)
|
|
22
|
+
return print_dict(d)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def identity_func(x):
|
|
26
|
+
return x
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def empty_func(*args, **kwargs):
|
|
30
|
+
return None
|