mojentic 0.4.1__py3-none-any.whl → 0.5.0__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.
- _examples/current_datetime_tool_example.py +28 -0
- _examples/design_analysis.py +42 -0
- _examples/ensures_files_exist.py +58 -0
- _examples/file_deduplication.py +38 -0
- _examples/image_broker.py +14 -0
- _examples/image_broker_splat.py +50 -0
- _examples/list_models.py +9 -1
- _examples/raw.py +21 -0
- mojentic/llm/__init__.py +1 -0
- mojentic/llm/gateways/anthropic.py +52 -0
- mojentic/llm/gateways/anthropic_messages_adapter.py +72 -0
- mojentic/llm/gateways/file_gateway.py +55 -0
- mojentic/llm/gateways/ollama.py +3 -3
- mojentic/llm/gateways/openai.py +10 -2
- mojentic/llm/message_composers.py +276 -0
- mojentic/llm/message_composers_spec.py +298 -0
- mojentic/llm/tools/current_datetime.py +47 -0
- {mojentic-0.4.1.dist-info → mojentic-0.5.0.dist-info}/METADATA +2 -1
- {mojentic-0.4.1.dist-info → mojentic-0.5.0.dist-info}/RECORD +22 -9
- {mojentic-0.4.1.dist-info → mojentic-0.5.0.dist-info}/WHEEL +1 -1
- {mojentic-0.4.1.dist-info → mojentic-0.5.0.dist-info}/licenses/LICENSE.md +0 -0
- {mojentic-0.4.1.dist-info → mojentic-0.5.0.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
from mojentic.llm.gateways.models import LLMMessage
|
|
2
|
+
from mojentic.llm.llm_broker import LLMBroker
|
|
3
|
+
from mojentic.llm.tools.current_datetime import CurrentDateTimeTool
|
|
4
|
+
|
|
5
|
+
# Create an LLM broker with a specified model
|
|
6
|
+
# You can change the model to any supported model
|
|
7
|
+
llm = LLMBroker(model="qwen2.5:7b") # Using the same model as in simple_tool.py
|
|
8
|
+
|
|
9
|
+
# Create our custom tool
|
|
10
|
+
datetime_tool = CurrentDateTimeTool()
|
|
11
|
+
|
|
12
|
+
# Generate a response with tool assistance
|
|
13
|
+
result = llm.generate(
|
|
14
|
+
messages=[LLMMessage(content="What time is it right now? Also, what day of the week is it today?")],
|
|
15
|
+
tools=[datetime_tool]
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
print("LLM Response:")
|
|
19
|
+
print(result)
|
|
20
|
+
|
|
21
|
+
# You can also try with a custom format string
|
|
22
|
+
result = llm.generate(
|
|
23
|
+
messages=[LLMMessage(content="Tell me the current date in a friendly format, like 'Monday, January 1, 2023'")],
|
|
24
|
+
tools=[datetime_tool]
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
print("\nLLM Response with custom format:")
|
|
28
|
+
print(result)
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
from mojentic.llm.gateways import OllamaGateway
|
|
4
|
+
from mojentic.llm.gateways.models import LLMMessage
|
|
5
|
+
|
|
6
|
+
llmg = OllamaGateway()
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def complete(prompt: str):
|
|
10
|
+
response = llmg.complete(
|
|
11
|
+
model="gemma3:27b",
|
|
12
|
+
messages=[
|
|
13
|
+
LLMMessage(
|
|
14
|
+
content=prompt.strip(),
|
|
15
|
+
image_paths=[
|
|
16
|
+
str(Path.cwd() / "images" / "screen_cap.png")
|
|
17
|
+
]
|
|
18
|
+
)
|
|
19
|
+
],
|
|
20
|
+
)
|
|
21
|
+
return response.content
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# It's quite good at extracting text from images
|
|
25
|
+
result = complete("Extract the text from the attached image in markdown format.")
|
|
26
|
+
print(result)
|
|
27
|
+
|
|
28
|
+
# It's not bad at extracting and structuring the text it finds in images
|
|
29
|
+
result = complete("""
|
|
30
|
+
Extract the title and text boxes from the attached image in the form of a JSON object.
|
|
31
|
+
Use descriptive key names like `title` or `bullet` or `footer`. In the values, use html
|
|
32
|
+
but don't attempt to convert all of the visual formatting, just simple formatting like
|
|
33
|
+
paragraphs, bold, or italic.
|
|
34
|
+
""")
|
|
35
|
+
print(result)
|
|
36
|
+
|
|
37
|
+
# Gemma3 is not very good at determining hex colours :)
|
|
38
|
+
result = complete("""
|
|
39
|
+
Extract the colours from the attached image in the form of a JSON object, where the key
|
|
40
|
+
is the description of how the colour is used, and the value is the colour in hex format.
|
|
41
|
+
""")
|
|
42
|
+
print(result)
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
import sys
|
|
3
|
+
import os
|
|
4
|
+
|
|
5
|
+
from mojentic.llm.message_composers import MessageBuilder
|
|
6
|
+
|
|
7
|
+
# Get the project root directory (2 levels up from the current script)
|
|
8
|
+
project_root = Path(os.path.dirname(os.path.abspath(__file__))).parent.parent
|
|
9
|
+
|
|
10
|
+
# Test adding non-existent image
|
|
11
|
+
print("Testing adding non-existent image:")
|
|
12
|
+
non_existent_image = project_root / 'src' / '_examples' / 'images' / 'non_existent_image.jpg'
|
|
13
|
+
|
|
14
|
+
try:
|
|
15
|
+
message_builder = MessageBuilder("Testing non-existent image")
|
|
16
|
+
message_builder.add_image(non_existent_image)
|
|
17
|
+
print("ERROR: Expected FileNotFoundError was not raised")
|
|
18
|
+
sys.exit(1)
|
|
19
|
+
except FileNotFoundError as e:
|
|
20
|
+
print(f"Success: Caught expected exception: {e}")
|
|
21
|
+
|
|
22
|
+
# Test adding non-existent file
|
|
23
|
+
print("\nTesting adding non-existent file:")
|
|
24
|
+
non_existent_file = project_root / 'src' / '_examples' / 'non_existent_file.py'
|
|
25
|
+
|
|
26
|
+
try:
|
|
27
|
+
message_builder = MessageBuilder("Testing non-existent file")
|
|
28
|
+
message_builder.add_file(non_existent_file)
|
|
29
|
+
print("ERROR: Expected FileNotFoundError was not raised")
|
|
30
|
+
sys.exit(1)
|
|
31
|
+
except FileNotFoundError as e:
|
|
32
|
+
print(f"Success: Caught expected exception: {e}")
|
|
33
|
+
|
|
34
|
+
# Test adding existing image
|
|
35
|
+
print("\nTesting adding existing image:")
|
|
36
|
+
existing_image = project_root / 'src' / '_examples' / 'images' / 'xbox-one.jpg'
|
|
37
|
+
|
|
38
|
+
try:
|
|
39
|
+
message_builder = MessageBuilder("Testing existing image")
|
|
40
|
+
message_builder.add_image(existing_image)
|
|
41
|
+
print("Success: Added existing image without exception")
|
|
42
|
+
except FileNotFoundError as e:
|
|
43
|
+
print(f"ERROR: Unexpected exception: {e}")
|
|
44
|
+
sys.exit(1)
|
|
45
|
+
|
|
46
|
+
# Test adding existing file
|
|
47
|
+
print("\nTesting adding existing file:")
|
|
48
|
+
existing_file = Path(__file__) # This file itself (absolute path)
|
|
49
|
+
|
|
50
|
+
try:
|
|
51
|
+
message_builder = MessageBuilder("Testing existing file")
|
|
52
|
+
message_builder.add_file(existing_file)
|
|
53
|
+
print("Success: Added existing file without exception")
|
|
54
|
+
except FileNotFoundError as e:
|
|
55
|
+
print(f"ERROR: Unexpected exception: {e}")
|
|
56
|
+
sys.exit(1)
|
|
57
|
+
|
|
58
|
+
print("\nAll tests passed!")
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
from mojentic.llm.message_composers import MessageBuilder
|
|
4
|
+
|
|
5
|
+
# Test de-duplication of images
|
|
6
|
+
print("Testing image de-duplication:")
|
|
7
|
+
image_path = Path.cwd() / 'src' / '_examples' / 'images' / 'xbox-one.jpg'
|
|
8
|
+
|
|
9
|
+
# Create a message builder and add the same image multiple times
|
|
10
|
+
message_builder = MessageBuilder("Testing image de-duplication")
|
|
11
|
+
message_builder.add_image(image_path)
|
|
12
|
+
message_builder.add_image(image_path) # Adding the same image again
|
|
13
|
+
message_builder.add_images(image_path, image_path) # Adding the same image twice more
|
|
14
|
+
|
|
15
|
+
# Build the message and check the number of images
|
|
16
|
+
message = message_builder.build()
|
|
17
|
+
print(f"Number of images in message: {len(message.image_paths)}")
|
|
18
|
+
print(f"Expected number of images: 1")
|
|
19
|
+
print(f"De-duplication working: {len(message.image_paths) == 1}")
|
|
20
|
+
|
|
21
|
+
# Test de-duplication of files
|
|
22
|
+
print("\nTesting file de-duplication:")
|
|
23
|
+
file_path = Path('file_deduplication.py') # This file itself
|
|
24
|
+
|
|
25
|
+
# Create a message builder and add the same file multiple times
|
|
26
|
+
message_builder = MessageBuilder("Testing file de-duplication")
|
|
27
|
+
message_builder.add_file(file_path)
|
|
28
|
+
message_builder.add_file(file_path) # Adding the same file again
|
|
29
|
+
message_builder.add_files(file_path, file_path) # Adding the same file twice more
|
|
30
|
+
|
|
31
|
+
# Build the message and check the number of files
|
|
32
|
+
message = message_builder.build()
|
|
33
|
+
# Since we're using the file content in the message, we need to check file_paths directly
|
|
34
|
+
print(f"Number of files in message_builder.file_paths: {len(message_builder.file_paths)}")
|
|
35
|
+
print(f"Expected number of files: 1")
|
|
36
|
+
print(f"De-duplication working: {len(message_builder.file_paths) == 1}")
|
|
37
|
+
|
|
38
|
+
print("\nTest completed.")
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
from mojentic.llm import LLMBroker
|
|
4
|
+
from mojentic.llm.message_composers import MessagesBuilder, MessageBuilder
|
|
5
|
+
|
|
6
|
+
llm = LLMBroker(model="gemma3:27b")
|
|
7
|
+
|
|
8
|
+
message = MessageBuilder("What is in this image?") \
|
|
9
|
+
.add_image(Path.cwd() / 'images' / 'xbox-one.jpg') \
|
|
10
|
+
.build()
|
|
11
|
+
|
|
12
|
+
result = llm.generate(messages=[message])
|
|
13
|
+
|
|
14
|
+
print(result)
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
from mojentic.llm import LLMBroker
|
|
4
|
+
from mojentic.llm.message_composers import MessageBuilder
|
|
5
|
+
|
|
6
|
+
# Initialize the LLM broker
|
|
7
|
+
llm = LLMBroker(model="gemma3:27b")
|
|
8
|
+
|
|
9
|
+
# Example 1: Adding multiple specific images using the splat operator
|
|
10
|
+
print("Example 1: Adding multiple specific images")
|
|
11
|
+
message1 = MessageBuilder("What are in these images?") \
|
|
12
|
+
.add_images(
|
|
13
|
+
Path.cwd() / 'images' / 'flash_rom.jpg',
|
|
14
|
+
Path.cwd() / 'images' / 'screen_cap.png' # Assuming this file exists
|
|
15
|
+
) \
|
|
16
|
+
.build()
|
|
17
|
+
|
|
18
|
+
# Example 2: Adding all JPG images from a directory
|
|
19
|
+
print("\nExample 2: Adding all JPG images from a directory")
|
|
20
|
+
images_dir = Path.cwd() / 'images'
|
|
21
|
+
message2 = MessageBuilder("Describe all these images:") \
|
|
22
|
+
.add_images(images_dir) \
|
|
23
|
+
.build()
|
|
24
|
+
|
|
25
|
+
# Example 3: Using a glob pattern to add specific types of images
|
|
26
|
+
print("\nExample 3: Using a glob pattern")
|
|
27
|
+
message3 = MessageBuilder("What can you tell me about these images?") \
|
|
28
|
+
.add_images(Path.cwd() / 'images' / '*.jpg') \
|
|
29
|
+
.build()
|
|
30
|
+
|
|
31
|
+
# Example 4: Combining different ways to specify images
|
|
32
|
+
print("\nExample 4: Combining different ways to specify images")
|
|
33
|
+
message4 = MessageBuilder("Analyze these images:") \
|
|
34
|
+
.add_images(
|
|
35
|
+
Path.cwd() / 'images' / 'xbox-one.jpg', # Specific image
|
|
36
|
+
images_dir, # All JPGs in directory
|
|
37
|
+
Path.cwd() / 'images' / '*.png' # All PNGs using glob pattern
|
|
38
|
+
) \
|
|
39
|
+
.build()
|
|
40
|
+
|
|
41
|
+
# Print the number of images added in each example
|
|
42
|
+
print(f"Example 1: Added {len(message1.image_paths)} image(s)")
|
|
43
|
+
print(f"Example 2: Added {len(message2.image_paths)} image(s)")
|
|
44
|
+
print(f"Example 3: Added {len(message3.image_paths)} image(s)")
|
|
45
|
+
print(f"Example 4: Added {len(message4.image_paths)} image(s)")
|
|
46
|
+
|
|
47
|
+
# Generate a response using one of the messages (e.g., message1)
|
|
48
|
+
print("\nGenerating response for Example 1...")
|
|
49
|
+
result = llm.generate(messages=[message1])
|
|
50
|
+
print(result)
|
_examples/list_models.py
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import os
|
|
2
2
|
|
|
3
3
|
from mojentic.llm.gateways import OllamaGateway, OpenAIGateway
|
|
4
|
+
from mojentic.llm.gateways.anthropic import AnthropicGateway
|
|
4
5
|
|
|
5
6
|
ollama = OllamaGateway()
|
|
6
7
|
print("Ollama Models:")
|
|
@@ -12,4 +13,11 @@ print()
|
|
|
12
13
|
openai = OpenAIGateway(os.environ["OPENAI_API_KEY"])
|
|
13
14
|
print("OpenAI Models:")
|
|
14
15
|
for model in openai.get_available_models():
|
|
15
|
-
print(f"- {model}")
|
|
16
|
+
print(f"- {model}")
|
|
17
|
+
|
|
18
|
+
print()
|
|
19
|
+
|
|
20
|
+
anthropic = AnthropicGateway(os.environ["ANTHROPIC_API_KEY"])
|
|
21
|
+
print("Anthropic Models:")
|
|
22
|
+
for model in anthropic.get_available_models():
|
|
23
|
+
print(f"- {model}")
|
_examples/raw.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import os
|
|
2
|
+
|
|
3
|
+
from anthropic import Anthropic
|
|
4
|
+
|
|
5
|
+
client = Anthropic(api_key=os.environ.get("ANTHROPIC_API_KEY"))
|
|
6
|
+
|
|
7
|
+
anthropic_args = {
|
|
8
|
+
'model': "claude-3-5-haiku-20241022",
|
|
9
|
+
'system': "You are a helpful assistant.",
|
|
10
|
+
'messages': [
|
|
11
|
+
{
|
|
12
|
+
"role": "user",
|
|
13
|
+
"content": "Say hello world",
|
|
14
|
+
}
|
|
15
|
+
],
|
|
16
|
+
'max_tokens': 2000,
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
response = client.messages.create(**anthropic_args)
|
|
20
|
+
|
|
21
|
+
print(response.content[0].text)
|
mojentic/llm/__init__.py
CHANGED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
from typing import List
|
|
2
|
+
|
|
3
|
+
import structlog
|
|
4
|
+
from anthropic import Anthropic
|
|
5
|
+
|
|
6
|
+
from mojentic.llm.gateways import LLMGateway
|
|
7
|
+
from mojentic.llm.gateways.models import LLMGatewayResponse, LLMToolCall, MessageRole
|
|
8
|
+
from mojentic.llm.gateways.anthropic_messages_adapter import adapt_messages_to_anthropic
|
|
9
|
+
|
|
10
|
+
logger = structlog.get_logger()
|
|
11
|
+
|
|
12
|
+
class AnthropicGateway(LLMGateway):
|
|
13
|
+
def __init__(self, api_key: str):
|
|
14
|
+
self.client = Anthropic(api_key=api_key)
|
|
15
|
+
|
|
16
|
+
def complete(self, **args) -> LLMGatewayResponse:
|
|
17
|
+
|
|
18
|
+
messages = args.get('messages')
|
|
19
|
+
|
|
20
|
+
system_messages = [m for m in messages if m.role == MessageRole.System]
|
|
21
|
+
user_messages = [m for m in messages if m.role == MessageRole.User]
|
|
22
|
+
|
|
23
|
+
anthropic_args = {
|
|
24
|
+
'model': args['model'],
|
|
25
|
+
'system': " " . join([m.content for m in system_messages]) if system_messages else None,
|
|
26
|
+
'messages': adapt_messages_to_anthropic(user_messages),
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
response = self.client.messages.create(
|
|
30
|
+
**anthropic_args,
|
|
31
|
+
temperature=args.get('temperature', 1.0),
|
|
32
|
+
max_tokens=args.get('num_predict', 2000),
|
|
33
|
+
# thinking={
|
|
34
|
+
# "type": "enabled",
|
|
35
|
+
# "budget_tokens": 32768,
|
|
36
|
+
# }
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
object = None
|
|
40
|
+
tool_calls: List[LLMToolCall] = []
|
|
41
|
+
|
|
42
|
+
return LLMGatewayResponse(
|
|
43
|
+
content=response.content[0].text,
|
|
44
|
+
object=object,
|
|
45
|
+
tool_calls=tool_calls,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
def get_available_models(self) -> List[str]:
|
|
49
|
+
return sorted([m.id for m in self.client.models.list()])
|
|
50
|
+
|
|
51
|
+
def calculate_embeddings(self, text: str, model: str = "voyage-3-large") -> List[float]:
|
|
52
|
+
raise NotImplementedError("The Anthropic API does not support embedding generation.")
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
from typing import List, Any
|
|
5
|
+
|
|
6
|
+
import structlog
|
|
7
|
+
|
|
8
|
+
from mojentic.llm.gateways.models import LLMMessage, MessageRole
|
|
9
|
+
|
|
10
|
+
logger = structlog.get_logger()
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def adapt_messages_to_anthropic(messages: List[LLMMessage]):
|
|
14
|
+
new_messages: List[dict[str, Any]] = []
|
|
15
|
+
for m in messages:
|
|
16
|
+
if m.role == MessageRole.System:
|
|
17
|
+
# System messages aren't supported by Anthropic
|
|
18
|
+
pass
|
|
19
|
+
elif m.role == MessageRole.User:
|
|
20
|
+
if m.image_paths is not None and len(m.image_paths) > 0:
|
|
21
|
+
# Create a content structure with text and images
|
|
22
|
+
content = []
|
|
23
|
+
if m.content:
|
|
24
|
+
content.append({"type": "text", "text": m.content})
|
|
25
|
+
|
|
26
|
+
# Add each image as a base64-encoded URL
|
|
27
|
+
for image_path in m.image_paths:
|
|
28
|
+
try:
|
|
29
|
+
with open(image_path, "rb") as image_file:
|
|
30
|
+
base64_image = base64.b64encode(image_file.read()).decode('utf-8')
|
|
31
|
+
|
|
32
|
+
# Determine image type from file extension
|
|
33
|
+
_, ext = os.path.splitext(image_path)
|
|
34
|
+
image_type = ext.lstrip('.').lower()
|
|
35
|
+
if image_type not in ['jpeg', 'jpg', 'png', 'gif', 'webp']:
|
|
36
|
+
image_type = 'jpeg' # Default to jpeg if unknown extension
|
|
37
|
+
|
|
38
|
+
content.append({
|
|
39
|
+
"type": "image",
|
|
40
|
+
"source": {
|
|
41
|
+
"type": "base64",
|
|
42
|
+
"media_type": f"image/{image_type}",
|
|
43
|
+
"data": base64_image
|
|
44
|
+
}
|
|
45
|
+
})
|
|
46
|
+
except Exception as e:
|
|
47
|
+
logger.error("Failed to encode image", error=str(e), image_path=image_path)
|
|
48
|
+
|
|
49
|
+
new_messages.append({'role': 'user', 'content': content})
|
|
50
|
+
else:
|
|
51
|
+
new_messages.append({'role': 'user', 'content': m.content})
|
|
52
|
+
elif m.role == MessageRole.Assistant:
|
|
53
|
+
msg = {'role': 'assistant', 'content': m.content or ''}
|
|
54
|
+
# if m.tool_calls is not None:
|
|
55
|
+
# msg['tool_calls'] = [{
|
|
56
|
+
# 'id': m.tool_calls[0].id,
|
|
57
|
+
# 'type': 'function',
|
|
58
|
+
# 'function': {
|
|
59
|
+
# 'name': m.tool_calls[0].name,
|
|
60
|
+
# 'arguments': json.dumps({k: v for k, v in m.tool_calls[0].arguments.items()})
|
|
61
|
+
# }
|
|
62
|
+
# }]
|
|
63
|
+
new_messages.append(msg)
|
|
64
|
+
# elif m.role == MessageRole.Tool:
|
|
65
|
+
# new_messages.append({
|
|
66
|
+
# 'role': 'tool',
|
|
67
|
+
# 'content': m.content,
|
|
68
|
+
# 'tool_call_id': m.tool_calls[0].id
|
|
69
|
+
# })
|
|
70
|
+
else:
|
|
71
|
+
logger.error("Unknown message role", role=m.role)
|
|
72
|
+
return new_messages
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from typing import Union
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class FileGateway:
|
|
6
|
+
|
|
7
|
+
def read(self, path: Union[Path, str]):
|
|
8
|
+
if isinstance(path, str):
|
|
9
|
+
path = Path(path)
|
|
10
|
+
with open(path, 'r') as file:
|
|
11
|
+
return file.read()
|
|
12
|
+
|
|
13
|
+
def exists(self, path: Union[Path, str]) -> bool:
|
|
14
|
+
"""
|
|
15
|
+
Check if a file exists.
|
|
16
|
+
|
|
17
|
+
Parameters
|
|
18
|
+
----------
|
|
19
|
+
path : Union[Path, str]
|
|
20
|
+
Path to the file
|
|
21
|
+
|
|
22
|
+
Returns
|
|
23
|
+
-------
|
|
24
|
+
bool
|
|
25
|
+
True if the file exists, False otherwise
|
|
26
|
+
"""
|
|
27
|
+
if isinstance(path, str):
|
|
28
|
+
path = Path(path)
|
|
29
|
+
return path.exists()
|
|
30
|
+
|
|
31
|
+
def is_binary(self, path: Union[Path, str]) -> bool:
|
|
32
|
+
"""
|
|
33
|
+
Check if a file is binary.
|
|
34
|
+
|
|
35
|
+
Parameters
|
|
36
|
+
----------
|
|
37
|
+
path : Union[Path, str]
|
|
38
|
+
Path to the file
|
|
39
|
+
|
|
40
|
+
Returns
|
|
41
|
+
-------
|
|
42
|
+
bool
|
|
43
|
+
True if the file is binary, False otherwise
|
|
44
|
+
"""
|
|
45
|
+
if isinstance(path, str):
|
|
46
|
+
path = Path(path)
|
|
47
|
+
try:
|
|
48
|
+
with open(path, 'rb') as file:
|
|
49
|
+
# Read the first 1024 bytes
|
|
50
|
+
chunk = file.read(1024)
|
|
51
|
+
# Check for null bytes, which are typically present in binary files
|
|
52
|
+
return b'\0' in chunk
|
|
53
|
+
except Exception:
|
|
54
|
+
# If there's an error reading the file, assume it's binary
|
|
55
|
+
return True
|
mojentic/llm/gateways/ollama.py
CHANGED
|
@@ -163,14 +163,14 @@ class OllamaGateway(LLMGateway):
|
|
|
163
163
|
|
|
164
164
|
def get_available_models(self) -> List[str]:
|
|
165
165
|
"""
|
|
166
|
-
Get the list of available local models.
|
|
166
|
+
Get the list of available local models, sorted alphabetically.
|
|
167
167
|
|
|
168
168
|
Returns
|
|
169
169
|
-------
|
|
170
170
|
List[str]
|
|
171
|
-
The list of available models.
|
|
171
|
+
The list of available models, sorted alphabetically.
|
|
172
172
|
"""
|
|
173
|
-
return [m.model for m in self.client.list().models]
|
|
173
|
+
return sorted([m.model for m in self.client.list().models])
|
|
174
174
|
|
|
175
175
|
def pull_model(self, model: str) -> None:
|
|
176
176
|
"""
|
mojentic/llm/gateways/openai.py
CHANGED
|
@@ -21,7 +21,7 @@ class OpenAIGateway(LLMGateway):
|
|
|
21
21
|
The OpenAI API key to use.
|
|
22
22
|
"""
|
|
23
23
|
|
|
24
|
-
def __init__(self, api_key):
|
|
24
|
+
def __init__(self, api_key: str):
|
|
25
25
|
self.client = OpenAI(api_key=api_key)
|
|
26
26
|
|
|
27
27
|
def complete(self, **args) -> LLMGatewayResponse:
|
|
@@ -94,7 +94,15 @@ class OpenAIGateway(LLMGateway):
|
|
|
94
94
|
)
|
|
95
95
|
|
|
96
96
|
def get_available_models(self) -> list[str]:
|
|
97
|
-
|
|
97
|
+
"""
|
|
98
|
+
Get the list of available OpenAI models, sorted alphabetically.
|
|
99
|
+
|
|
100
|
+
Returns
|
|
101
|
+
-------
|
|
102
|
+
list[str]
|
|
103
|
+
The list of available models, sorted alphabetically.
|
|
104
|
+
"""
|
|
105
|
+
return sorted([m.id for m in self.client.models.list()])
|
|
98
106
|
|
|
99
107
|
def calculate_embeddings(self, text: str, model: str = "text-embedding-3-large") -> List[float]:
|
|
100
108
|
"""
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from typing import Union, Dict, List, Optional
|
|
3
|
+
|
|
4
|
+
from mojentic.llm.gateways.file_gateway import FileGateway
|
|
5
|
+
from mojentic.llm.gateways.models import LLMMessage, MessageRole
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class FileTypeSensor:
|
|
9
|
+
"""Maps file extensions to language declarations for markdown code-fence."""
|
|
10
|
+
|
|
11
|
+
def __init__(self):
|
|
12
|
+
"""
|
|
13
|
+
Initialize the TypeSensor with a default mapping of file extensions to language declarations.
|
|
14
|
+
|
|
15
|
+
The TypeSensor is used to determine the appropriate language syntax highlighting
|
|
16
|
+
for code blocks in markdown based on file extensions.
|
|
17
|
+
"""
|
|
18
|
+
self.extension_map: Dict[str, str] = {
|
|
19
|
+
'c': 'c',
|
|
20
|
+
'cpp': 'c++',
|
|
21
|
+
'c++': 'c++',
|
|
22
|
+
'cxx': 'c++',
|
|
23
|
+
'h': 'c',
|
|
24
|
+
'objc': 'objective-c',
|
|
25
|
+
'swift': 'swift',
|
|
26
|
+
'cs': 'csharp',
|
|
27
|
+
'fs': 'fsharp',
|
|
28
|
+
'clj': 'clojure',
|
|
29
|
+
'py': 'python',
|
|
30
|
+
'java': 'java',
|
|
31
|
+
'kt': 'kotlin',
|
|
32
|
+
'rb': 'ruby',
|
|
33
|
+
'js': 'javascript',
|
|
34
|
+
'ts': 'typescript',
|
|
35
|
+
'rs': 'rust',
|
|
36
|
+
'go': 'go',
|
|
37
|
+
'cbl': 'cobol',
|
|
38
|
+
'html': 'html',
|
|
39
|
+
'css': 'css',
|
|
40
|
+
'json': 'json',
|
|
41
|
+
'csv': 'csv',
|
|
42
|
+
'xml': 'xml',
|
|
43
|
+
'md': 'markdown',
|
|
44
|
+
}
|
|
45
|
+
self.default_language = 'text'
|
|
46
|
+
|
|
47
|
+
def add_language(self, extension: str, language: str):
|
|
48
|
+
"""
|
|
49
|
+
Add or update a mapping from a file extension to a language declaration.
|
|
50
|
+
|
|
51
|
+
Parameters
|
|
52
|
+
----------
|
|
53
|
+
extension : str
|
|
54
|
+
The file extension without the leading dot (e.g., 'py', 'js')
|
|
55
|
+
language : str
|
|
56
|
+
The language identifier for markdown code-fence (e.g., 'python', 'javascript')
|
|
57
|
+
"""
|
|
58
|
+
self.extension_map[extension] = language
|
|
59
|
+
|
|
60
|
+
def get_language(self, file_path: Path) -> str:
|
|
61
|
+
"""
|
|
62
|
+
Determine the language declaration for a file based on its extension.
|
|
63
|
+
|
|
64
|
+
Parameters
|
|
65
|
+
----------
|
|
66
|
+
file_path : Path
|
|
67
|
+
Path to the file
|
|
68
|
+
|
|
69
|
+
Returns
|
|
70
|
+
-------
|
|
71
|
+
str
|
|
72
|
+
Language declaration for markdown code-fence
|
|
73
|
+
"""
|
|
74
|
+
ext = file_path.suffix[1:] # Remove the leading dot
|
|
75
|
+
return self.extension_map.get(ext, self.default_language)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class MessageBuilder():
|
|
79
|
+
"""
|
|
80
|
+
A builder class for creating LLM messages with text content, images, and files.
|
|
81
|
+
|
|
82
|
+
This class provides a fluent interface for constructing messages to be sent to an LLM,
|
|
83
|
+
with support for adding text content, images, and file contents. It handles file reading,
|
|
84
|
+
language detection for syntax highlighting, and proper formatting of the message.
|
|
85
|
+
"""
|
|
86
|
+
role: MessageRole
|
|
87
|
+
content: Optional[str]
|
|
88
|
+
image_paths: List[Path]
|
|
89
|
+
file_paths: List[Path]
|
|
90
|
+
type_sensor: FileTypeSensor
|
|
91
|
+
|
|
92
|
+
def __init__(self, content: str = None):
|
|
93
|
+
"""
|
|
94
|
+
Initialize a new MessageBuilder with optional text content.
|
|
95
|
+
|
|
96
|
+
Parameters
|
|
97
|
+
----------
|
|
98
|
+
content : str, optional
|
|
99
|
+
Optional text content for the message. If None, the message will only
|
|
100
|
+
contain added files and images.
|
|
101
|
+
"""
|
|
102
|
+
self.role = MessageRole.User
|
|
103
|
+
self.content = content
|
|
104
|
+
self.image_paths = []
|
|
105
|
+
self.file_paths = []
|
|
106
|
+
self.type_sensor = FileTypeSensor()
|
|
107
|
+
self.file_gateway = FileGateway()
|
|
108
|
+
|
|
109
|
+
def _file_content_partial(self, file_path: Path) -> str:
|
|
110
|
+
"""
|
|
111
|
+
Format the content of a file for inclusion in the message.
|
|
112
|
+
|
|
113
|
+
This method reads the file content and formats it with appropriate markdown code-fence
|
|
114
|
+
syntax highlighting based on the file extension.
|
|
115
|
+
|
|
116
|
+
Parameters
|
|
117
|
+
----------
|
|
118
|
+
file_path : Path
|
|
119
|
+
Path to the file to be read and formatted
|
|
120
|
+
|
|
121
|
+
Returns
|
|
122
|
+
-------
|
|
123
|
+
str
|
|
124
|
+
Formatted string containing the file path and content with markdown code-fence
|
|
125
|
+
"""
|
|
126
|
+
content = self.file_gateway.read(file_path)
|
|
127
|
+
return (f"File: {file_path}\n"
|
|
128
|
+
f"```{self.type_sensor.get_language(file_path)}\n"
|
|
129
|
+
f"{content}\n"
|
|
130
|
+
f"```\n")
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def add_image(self, image_path: Union[str, Path]) -> "MessageBuilder":
|
|
134
|
+
"""
|
|
135
|
+
Add a single image to the message.
|
|
136
|
+
|
|
137
|
+
Parameters
|
|
138
|
+
----------
|
|
139
|
+
image_path : Union[str, Path]
|
|
140
|
+
Path to the image file. Can be a string or Path object.
|
|
141
|
+
|
|
142
|
+
Returns
|
|
143
|
+
-------
|
|
144
|
+
MessageBuilder
|
|
145
|
+
The MessageBuilder instance for method chaining.
|
|
146
|
+
|
|
147
|
+
Raises
|
|
148
|
+
------
|
|
149
|
+
FileNotFoundError
|
|
150
|
+
If the specified image file does not exist.
|
|
151
|
+
"""
|
|
152
|
+
if isinstance(image_path, str):
|
|
153
|
+
image_path = Path(image_path)
|
|
154
|
+
if not self.file_gateway.exists(image_path):
|
|
155
|
+
raise FileNotFoundError(f"Image file not found: {image_path}")
|
|
156
|
+
if image_path not in self.image_paths:
|
|
157
|
+
self.image_paths.append(image_path)
|
|
158
|
+
return self
|
|
159
|
+
|
|
160
|
+
def add_file(self, file_path: Union[str, Path]) -> "MessageBuilder":
|
|
161
|
+
"""
|
|
162
|
+
Add a single file to the message.
|
|
163
|
+
|
|
164
|
+
Parameters
|
|
165
|
+
----------
|
|
166
|
+
file_path : Union[str, Path]
|
|
167
|
+
Path to the file. Can be a string or Path object.
|
|
168
|
+
|
|
169
|
+
Returns
|
|
170
|
+
-------
|
|
171
|
+
MessageBuilder
|
|
172
|
+
The MessageBuilder instance for method chaining.
|
|
173
|
+
|
|
174
|
+
Raises
|
|
175
|
+
------
|
|
176
|
+
FileNotFoundError
|
|
177
|
+
If the specified file does not exist.
|
|
178
|
+
"""
|
|
179
|
+
if isinstance(file_path, str):
|
|
180
|
+
file_path = Path(file_path)
|
|
181
|
+
if not self.file_gateway.exists(file_path):
|
|
182
|
+
raise FileNotFoundError(f"File not found: {file_path}")
|
|
183
|
+
if file_path not in self.file_paths:
|
|
184
|
+
self.file_paths.append(file_path)
|
|
185
|
+
return self
|
|
186
|
+
|
|
187
|
+
def add_images(self, *image_paths: Union[str, Path]) -> "MessageBuilder":
|
|
188
|
+
"""
|
|
189
|
+
Add multiple images to the message.
|
|
190
|
+
|
|
191
|
+
Parameters
|
|
192
|
+
----------
|
|
193
|
+
*image_paths : Union[str, Path]
|
|
194
|
+
Variable number of image paths. Can be strings or Path objects.
|
|
195
|
+
Can include glob patterns like '*.jpg' to include all JPG and PNG images in a directory.
|
|
196
|
+
|
|
197
|
+
Returns
|
|
198
|
+
-------
|
|
199
|
+
MessageBuilder
|
|
200
|
+
The MessageBuilder instance for method chaining.
|
|
201
|
+
"""
|
|
202
|
+
for path in image_paths:
|
|
203
|
+
path_obj = Path(path) if isinstance(path, str) else path
|
|
204
|
+
|
|
205
|
+
if path_obj.is_dir():
|
|
206
|
+
for ext in ['*.jpg', '*.png']:
|
|
207
|
+
for img_path in path_obj.glob(ext):
|
|
208
|
+
self.add_image(img_path)
|
|
209
|
+
elif '*' in str(path_obj):
|
|
210
|
+
parent_dir = path_obj.parent if path_obj.parent != Path('.') else Path.cwd()
|
|
211
|
+
for img_path in parent_dir.glob(path_obj.name):
|
|
212
|
+
if img_path.is_file():
|
|
213
|
+
self.add_image(img_path)
|
|
214
|
+
else:
|
|
215
|
+
self.add_image(path_obj)
|
|
216
|
+
|
|
217
|
+
return self
|
|
218
|
+
|
|
219
|
+
def add_files(self, *file_paths: Union[str, Path]) -> "MessageBuilder":
|
|
220
|
+
"""
|
|
221
|
+
Add multiple text files to the message, ignoring binary files.
|
|
222
|
+
|
|
223
|
+
Parameters
|
|
224
|
+
----------
|
|
225
|
+
*file_paths : Union[str, Path]
|
|
226
|
+
Variable number of file paths. Can be strings or Path objects.
|
|
227
|
+
Can include glob patterns like '*.txt' to include all text files in a directory.
|
|
228
|
+
If a directory is provided, all text files in the directory will be added.
|
|
229
|
+
|
|
230
|
+
Returns
|
|
231
|
+
-------
|
|
232
|
+
MessageBuilder
|
|
233
|
+
The MessageBuilder instance for method chaining.
|
|
234
|
+
"""
|
|
235
|
+
for path in file_paths:
|
|
236
|
+
path_obj = Path(path) if isinstance(path, str) else path
|
|
237
|
+
|
|
238
|
+
if path_obj.is_dir():
|
|
239
|
+
# If a directory is provided, add all text files in the directory
|
|
240
|
+
for file_path in path_obj.glob('*'):
|
|
241
|
+
if file_path.is_file() and not self.file_gateway.is_binary(file_path):
|
|
242
|
+
self.add_file(file_path)
|
|
243
|
+
elif '*' in str(path_obj):
|
|
244
|
+
# If a glob pattern is provided, add all matching text files
|
|
245
|
+
parent_dir = path_obj.parent if path_obj.parent != Path('.') else Path.cwd()
|
|
246
|
+
for file_path in parent_dir.glob(path_obj.name):
|
|
247
|
+
if file_path.is_file() and not self.file_gateway.is_binary(file_path):
|
|
248
|
+
self.add_file(file_path)
|
|
249
|
+
else:
|
|
250
|
+
# If a single file is provided, add it if it's a text file
|
|
251
|
+
if path_obj.is_file() and not self.file_gateway.is_binary(path_obj):
|
|
252
|
+
self.add_file(path_obj)
|
|
253
|
+
|
|
254
|
+
return self
|
|
255
|
+
|
|
256
|
+
def build(self) -> LLMMessage:
|
|
257
|
+
"""
|
|
258
|
+
Build the final LLMMessage from the accumulated content, images, and files.
|
|
259
|
+
|
|
260
|
+
This method combines all the content, file contents, and image paths into a single
|
|
261
|
+
LLMMessage object that can be sent to an LLM. If files have been added, their contents
|
|
262
|
+
will be formatted and included in the message content.
|
|
263
|
+
|
|
264
|
+
Returns
|
|
265
|
+
-------
|
|
266
|
+
LLMMessage
|
|
267
|
+
An LLMMessage object containing the message content and image paths.
|
|
268
|
+
"""
|
|
269
|
+
if self.file_paths:
|
|
270
|
+
file_contents = [self._file_content_partial(p) for p in self.file_paths]
|
|
271
|
+
self.content = "\n\n".join(file_contents)
|
|
272
|
+
return LLMMessage(
|
|
273
|
+
role=self.role,
|
|
274
|
+
content=self.content,
|
|
275
|
+
image_paths=[str(p) for p in self.image_paths]
|
|
276
|
+
)
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from mojentic.llm.gateways.models import LLMMessage, MessageRole
|
|
6
|
+
from mojentic.llm import MessageBuilder, FileTypeSensor
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@pytest.fixture
|
|
10
|
+
def file_gateway(mocker):
|
|
11
|
+
file_gateway = mocker.MagicMock()
|
|
12
|
+
file_gateway.read.return_value = "test file content"
|
|
13
|
+
file_gateway.exists.return_value = True
|
|
14
|
+
file_gateway.is_binary.return_value = False
|
|
15
|
+
return file_gateway
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@pytest.fixture
|
|
19
|
+
def message_builder(file_gateway):
|
|
20
|
+
builder = MessageBuilder()
|
|
21
|
+
builder.file_gateway = file_gateway
|
|
22
|
+
return builder
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class DescribeMessageBuilder:
|
|
26
|
+
"""
|
|
27
|
+
Specification for the MessageBuilder class which helps build LLM messages.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
class DescribeInitialization:
|
|
31
|
+
"""
|
|
32
|
+
Specifications for MessageBuilder initialization
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def should_initialize_with_default_values(self):
|
|
36
|
+
"""
|
|
37
|
+
Given a new MessageBuilder
|
|
38
|
+
When it is initialized
|
|
39
|
+
Then it should have default values set
|
|
40
|
+
"""
|
|
41
|
+
builder = MessageBuilder()
|
|
42
|
+
|
|
43
|
+
assert builder.role == MessageRole.User
|
|
44
|
+
assert builder.content is None
|
|
45
|
+
assert builder.image_paths == []
|
|
46
|
+
assert builder.file_paths == []
|
|
47
|
+
assert isinstance(builder.type_sensor, FileTypeSensor)
|
|
48
|
+
assert isinstance(builder.file_gateway, object)
|
|
49
|
+
|
|
50
|
+
class DescribeBuildMethod:
|
|
51
|
+
"""
|
|
52
|
+
Specifications for the build method
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
def should_build_message_with_content(self, message_builder):
|
|
56
|
+
"""
|
|
57
|
+
Given a MessageBuilder with content
|
|
58
|
+
When build is called
|
|
59
|
+
Then it should return a message with that content
|
|
60
|
+
"""
|
|
61
|
+
message_builder.content = "Test content"
|
|
62
|
+
|
|
63
|
+
message = message_builder.build()
|
|
64
|
+
|
|
65
|
+
assert isinstance(message, LLMMessage)
|
|
66
|
+
assert message.content == "Test content"
|
|
67
|
+
assert message.role == MessageRole.User
|
|
68
|
+
assert message.image_paths == []
|
|
69
|
+
|
|
70
|
+
def should_build_message_with_role(self, message_builder):
|
|
71
|
+
"""
|
|
72
|
+
Given a MessageBuilder with a specific role
|
|
73
|
+
When build is called
|
|
74
|
+
Then it should return a message with that role
|
|
75
|
+
"""
|
|
76
|
+
message_builder.role = MessageRole.Assistant
|
|
77
|
+
|
|
78
|
+
message = message_builder.build()
|
|
79
|
+
|
|
80
|
+
assert message.role == MessageRole.Assistant
|
|
81
|
+
|
|
82
|
+
def should_build_message_with_image_paths(self, message_builder):
|
|
83
|
+
"""
|
|
84
|
+
Given a MessageBuilder with image paths
|
|
85
|
+
When build is called
|
|
86
|
+
Then it should return a message with those image paths
|
|
87
|
+
"""
|
|
88
|
+
message_builder.image_paths = [Path("/path/to/image1.jpg"), Path("/path/to/image2.jpg")]
|
|
89
|
+
|
|
90
|
+
message = message_builder.build()
|
|
91
|
+
|
|
92
|
+
assert message.image_paths == ["/path/to/image1.jpg", "/path/to/image2.jpg"]
|
|
93
|
+
|
|
94
|
+
def should_build_message_with_file_content(self, message_builder, file_gateway):
|
|
95
|
+
"""
|
|
96
|
+
Given a MessageBuilder with file paths
|
|
97
|
+
When build is called
|
|
98
|
+
Then it should return a message with the file contents
|
|
99
|
+
"""
|
|
100
|
+
file_path = Path("/path/to/file.txt")
|
|
101
|
+
message_builder.file_paths = [file_path]
|
|
102
|
+
|
|
103
|
+
message = message_builder.build()
|
|
104
|
+
|
|
105
|
+
file_gateway.read.assert_called_once_with(file_path)
|
|
106
|
+
assert "test file content" in message.content
|
|
107
|
+
assert "File: /path/to/file.txt" in message.content
|
|
108
|
+
|
|
109
|
+
def should_build_message_with_multiple_file_contents(self, message_builder, file_gateway):
|
|
110
|
+
"""
|
|
111
|
+
Given a MessageBuilder with multiple file paths
|
|
112
|
+
When build is called
|
|
113
|
+
Then it should return a message with all file contents
|
|
114
|
+
"""
|
|
115
|
+
file_path1 = Path("/path/to/file1.txt")
|
|
116
|
+
file_path2 = Path("/path/to/file2.txt")
|
|
117
|
+
message_builder.file_paths = [file_path1, file_path2]
|
|
118
|
+
|
|
119
|
+
message = message_builder.build()
|
|
120
|
+
|
|
121
|
+
assert file_gateway.read.call_count == 2
|
|
122
|
+
assert "File: /path/to/file1.txt" in message.content
|
|
123
|
+
assert "File: /path/to/file2.txt" in message.content
|
|
124
|
+
|
|
125
|
+
class DescribeFileContentPartial:
|
|
126
|
+
"""
|
|
127
|
+
Specifications for the _file_content_partial method
|
|
128
|
+
"""
|
|
129
|
+
|
|
130
|
+
def should_format_file_content_with_language(self, message_builder, file_gateway, mocker):
|
|
131
|
+
"""
|
|
132
|
+
Given a MessageBuilder
|
|
133
|
+
When _file_content_partial is called
|
|
134
|
+
Then it should format the file content with the correct language
|
|
135
|
+
"""
|
|
136
|
+
file_path = Path("/path/to/file.py")
|
|
137
|
+
mocker.patch.object(message_builder.type_sensor, 'get_language', return_value='python')
|
|
138
|
+
|
|
139
|
+
result = message_builder._file_content_partial(file_path)
|
|
140
|
+
|
|
141
|
+
file_gateway.read.assert_called_once_with(file_path)
|
|
142
|
+
assert "File: /path/to/file.py" in result
|
|
143
|
+
assert "```python" in result
|
|
144
|
+
assert "test file content" in result
|
|
145
|
+
assert "```" in result
|
|
146
|
+
|
|
147
|
+
class DescribeAddImageMethod:
|
|
148
|
+
"""
|
|
149
|
+
Specifications for the add_image method
|
|
150
|
+
"""
|
|
151
|
+
|
|
152
|
+
def should_add_image_path_to_list(self, message_builder):
|
|
153
|
+
"""
|
|
154
|
+
Given a MessageBuilder
|
|
155
|
+
When add_image is called with a path
|
|
156
|
+
Then it should add the path to the image_paths list
|
|
157
|
+
"""
|
|
158
|
+
image_path = Path("/path/to/image.jpg")
|
|
159
|
+
|
|
160
|
+
result = message_builder.add_image(image_path)
|
|
161
|
+
|
|
162
|
+
assert image_path in message_builder.image_paths
|
|
163
|
+
assert result is message_builder # Returns self for method chaining
|
|
164
|
+
|
|
165
|
+
def should_convert_string_path_to_path_object(self, message_builder):
|
|
166
|
+
"""
|
|
167
|
+
Given a MessageBuilder
|
|
168
|
+
When add_image is called with a string path
|
|
169
|
+
Then it should convert the string to a Path object
|
|
170
|
+
"""
|
|
171
|
+
image_path_str = "/path/to/image.jpg"
|
|
172
|
+
|
|
173
|
+
message_builder.add_image(image_path_str)
|
|
174
|
+
|
|
175
|
+
assert Path(image_path_str) in message_builder.image_paths
|
|
176
|
+
|
|
177
|
+
class DescribeAddImagesMethod:
|
|
178
|
+
"""
|
|
179
|
+
Specifications for the add_images method
|
|
180
|
+
"""
|
|
181
|
+
|
|
182
|
+
def should_add_multiple_specific_images(self, message_builder):
|
|
183
|
+
"""
|
|
184
|
+
Given a MessageBuilder
|
|
185
|
+
When add_images is called with multiple specific image paths
|
|
186
|
+
Then it should add all paths to the image_paths list
|
|
187
|
+
"""
|
|
188
|
+
image_path1 = Path("/path/to/image1.jpg")
|
|
189
|
+
image_path2 = Path("/path/to/image2.jpg")
|
|
190
|
+
|
|
191
|
+
result = message_builder.add_images(image_path1, image_path2)
|
|
192
|
+
|
|
193
|
+
assert image_path1 in message_builder.image_paths
|
|
194
|
+
assert image_path2 in message_builder.image_paths
|
|
195
|
+
assert result is message_builder # Returns self for method chaining
|
|
196
|
+
|
|
197
|
+
def should_add_all_jpg_images_from_directory(self, message_builder, mocker):
|
|
198
|
+
"""
|
|
199
|
+
Given a MessageBuilder
|
|
200
|
+
When add_images is called with a directory path
|
|
201
|
+
Then it should add all JPG images in that directory
|
|
202
|
+
"""
|
|
203
|
+
dir_path = Path("/path/to/images")
|
|
204
|
+
jpg_files = [Path("/path/to/images/image1.jpg"), Path("/path/to/images/image2.jpg")]
|
|
205
|
+
|
|
206
|
+
# Mock is_dir, glob, and exists methods
|
|
207
|
+
mocker.patch.object(Path, 'is_dir', return_value=True)
|
|
208
|
+
mocker.patch.object(Path, 'glob', return_value=jpg_files)
|
|
209
|
+
mocker.patch.object(Path, 'exists', return_value=True)
|
|
210
|
+
|
|
211
|
+
message_builder.add_images(dir_path)
|
|
212
|
+
|
|
213
|
+
assert jpg_files[0] in message_builder.image_paths
|
|
214
|
+
assert jpg_files[1] in message_builder.image_paths
|
|
215
|
+
|
|
216
|
+
def should_add_images_matching_glob_pattern(self, message_builder, mocker):
|
|
217
|
+
"""
|
|
218
|
+
Given a MessageBuilder
|
|
219
|
+
When add_images is called with a path containing a wildcard
|
|
220
|
+
Then it should add all matching files
|
|
221
|
+
"""
|
|
222
|
+
pattern_path = Path("/path/to/*.jpg")
|
|
223
|
+
matching_files = [Path("/path/to/image1.jpg"), Path("/path/to/image2.jpg")]
|
|
224
|
+
|
|
225
|
+
# Mock methods
|
|
226
|
+
mocker.patch.object(Path, 'is_dir', return_value=False)
|
|
227
|
+
mocker.patch.object(Path, 'glob', return_value=matching_files)
|
|
228
|
+
mocker.patch.object(Path, 'is_file', return_value=True)
|
|
229
|
+
mocker.patch.object(Path, 'exists', return_value=True)
|
|
230
|
+
|
|
231
|
+
# Mock the parent property and its glob method
|
|
232
|
+
parent_mock = mocker.MagicMock()
|
|
233
|
+
parent_mock.glob.return_value = matching_files
|
|
234
|
+
mocker.patch.object(Path, 'parent', parent_mock)
|
|
235
|
+
|
|
236
|
+
message_builder.add_images(pattern_path)
|
|
237
|
+
|
|
238
|
+
assert matching_files[0] in message_builder.image_paths
|
|
239
|
+
assert matching_files[1] in message_builder.image_paths
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
class DescribeTypeSensor:
|
|
243
|
+
"""
|
|
244
|
+
Specification for the TypeSensor class which maps file extensions to language declarations.
|
|
245
|
+
"""
|
|
246
|
+
|
|
247
|
+
def should_initialize_with_default_mappings(self):
|
|
248
|
+
"""
|
|
249
|
+
Given a new TypeSensor
|
|
250
|
+
When it is initialized
|
|
251
|
+
Then it should have default mappings
|
|
252
|
+
"""
|
|
253
|
+
sensor = FileTypeSensor()
|
|
254
|
+
|
|
255
|
+
assert sensor.extension_map['py'] == 'python'
|
|
256
|
+
assert sensor.extension_map['js'] == 'javascript'
|
|
257
|
+
assert sensor.default_language == 'text'
|
|
258
|
+
|
|
259
|
+
def should_get_language_for_known_extension(self):
|
|
260
|
+
"""
|
|
261
|
+
Given a TypeSensor
|
|
262
|
+
When get_language is called with a known extension
|
|
263
|
+
Then it should return the correct language
|
|
264
|
+
"""
|
|
265
|
+
sensor = FileTypeSensor()
|
|
266
|
+
file_path = Path("/path/to/file.py")
|
|
267
|
+
|
|
268
|
+
language = sensor.get_language(file_path)
|
|
269
|
+
|
|
270
|
+
assert language == 'python'
|
|
271
|
+
|
|
272
|
+
def should_get_default_language_for_unknown_extension(self):
|
|
273
|
+
"""
|
|
274
|
+
Given a TypeSensor
|
|
275
|
+
When get_language is called with an unknown extension
|
|
276
|
+
Then it should return the default language
|
|
277
|
+
"""
|
|
278
|
+
sensor = FileTypeSensor()
|
|
279
|
+
file_path = Path("/path/to/file.unknown")
|
|
280
|
+
|
|
281
|
+
language = sensor.get_language(file_path)
|
|
282
|
+
|
|
283
|
+
assert language == 'text'
|
|
284
|
+
|
|
285
|
+
def should_add_new_language_mapping(self):
|
|
286
|
+
"""
|
|
287
|
+
Given a TypeSensor
|
|
288
|
+
When add_language is called
|
|
289
|
+
Then it should add the new mapping
|
|
290
|
+
"""
|
|
291
|
+
sensor = FileTypeSensor()
|
|
292
|
+
sensor.add_language('custom', 'customlang')
|
|
293
|
+
|
|
294
|
+
assert sensor.extension_map['custom'] == 'customlang'
|
|
295
|
+
|
|
296
|
+
file_path = Path("/path/to/file.custom")
|
|
297
|
+
language = sensor.get_language(file_path)
|
|
298
|
+
assert language == 'customlang'
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
from datetime import datetime
|
|
2
|
+
from mojentic.llm.tools.llm_tool import LLMTool
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class CurrentDateTimeTool(LLMTool):
|
|
6
|
+
def run(self, format_string: str = "%Y-%m-%d %H:%M:%S") -> dict:
|
|
7
|
+
"""
|
|
8
|
+
Returns the current date and time.
|
|
9
|
+
|
|
10
|
+
Parameters
|
|
11
|
+
----------
|
|
12
|
+
format_string : str, optional
|
|
13
|
+
The format string for the datetime, by default "%Y-%m-%d %H:%M:%S"
|
|
14
|
+
|
|
15
|
+
Returns
|
|
16
|
+
-------
|
|
17
|
+
dict
|
|
18
|
+
A dictionary containing the current date and time
|
|
19
|
+
"""
|
|
20
|
+
current_time = datetime.now()
|
|
21
|
+
formatted_time = current_time.strftime(format_string)
|
|
22
|
+
|
|
23
|
+
return {
|
|
24
|
+
"current_datetime": formatted_time,
|
|
25
|
+
"timestamp": current_time.timestamp(),
|
|
26
|
+
"timezone": datetime.now().astimezone().tzname()
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def descriptor(self):
|
|
31
|
+
return {
|
|
32
|
+
"type": "function",
|
|
33
|
+
"function": {
|
|
34
|
+
"name": "get_current_datetime",
|
|
35
|
+
"description": "Get the current date and time. Useful when you need to know the current time or date.",
|
|
36
|
+
"parameters": {
|
|
37
|
+
"type": "object",
|
|
38
|
+
"properties": {
|
|
39
|
+
"format_string": {
|
|
40
|
+
"type": "string",
|
|
41
|
+
"description": "Format string for the datetime (e.g., '%Y-%m-%d %H:%M:%S', '%A, %B %d, %Y'). Default is ISO format."
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
"required": []
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: mojentic
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.0
|
|
4
4
|
Summary: Mojentic is an agentic framework that aims to provide a simple and flexible way to assemble teams of agents to solve complex problems.
|
|
5
5
|
Author-email: Stacey Vetzal <stacey@vetzal.com>
|
|
6
6
|
Project-URL: Homepage, https://github.com/mojility/mojentic
|
|
@@ -15,6 +15,7 @@ Requires-Dist: pydantic
|
|
|
15
15
|
Requires-Dist: structlog
|
|
16
16
|
Requires-Dist: ollama
|
|
17
17
|
Requires-Dist: openai
|
|
18
|
+
Requires-Dist: anthropic
|
|
18
19
|
Requires-Dist: tiktoken
|
|
19
20
|
Requires-Dist: parsedatetime
|
|
20
21
|
Requires-Dist: pytz
|
|
@@ -6,11 +6,18 @@ _examples/characterize_openai.py,sha256=JQQNjsHIEegjLAS3uzDmq_Ci_ZqXTqjcoVaC-rS_
|
|
|
6
6
|
_examples/chat_session.py,sha256=7mLpH4IGQUgyyiY-fYbikDEiR5vEH-V5Z1qtB50d_qg,288
|
|
7
7
|
_examples/chat_session_with_tool.py,sha256=vQfwR24ze5LIhn0MQTQr-JMVGi4Df_SgMXi1npKBt-g,376
|
|
8
8
|
_examples/coding_file_tool.py,sha256=P4nCuFJ2UW0B_S7N03ImBNs-ADzBC7gb3ivCE0IJAqw,2570
|
|
9
|
+
_examples/current_datetime_tool_example.py,sha256=Ihjwp9svf2rf4WUGOzs6L_rEqc5QhPfA6E9J4MGoGCQ,919
|
|
10
|
+
_examples/design_analysis.py,sha256=66JxvnA065QV__WXpxJX_eTBDan34nGcKvao75UPLlw,1321
|
|
9
11
|
_examples/embeddings.py,sha256=94DAMsMB35BU98hOfOeOsbqntcequFSdtaStox2hTvk,267
|
|
12
|
+
_examples/ensures_files_exist.py,sha256=LAwX9YtUZGI6NT254PY7oC5yg6q-b9Q_Dez9gGrabQM,2009
|
|
13
|
+
_examples/file_deduplication.py,sha256=jYayx4BCrk1uJwVBkjIXzguLQOWLREi4PBPzoK9LuOU,1665
|
|
10
14
|
_examples/file_tool.py,sha256=F8xU7JdTScNzEXyGYqK6D_xQ28MbUXXm854WJmJkZo8,2214
|
|
11
15
|
_examples/image_analysis.py,sha256=Kj49vLQD1DIpvv5P7rir4BqzsVCTgq-tfppqXcYVJkA,503
|
|
16
|
+
_examples/image_broker.py,sha256=SoIphNj98zk1i7EQc7M2n2O_9ShDHwErmJ6jf67mzX0,355
|
|
17
|
+
_examples/image_broker_splat.py,sha256=O7rzTFUka32if4G4VuXvhu1O-2lRMWfi0r8gjIE8-0Y,1934
|
|
12
18
|
_examples/iterative_solver.py,sha256=ANGdC74ymHosVt6xUBjplkJl_W3ALTGxOkDpPLEDcm8,1331
|
|
13
|
-
_examples/list_models.py,sha256
|
|
19
|
+
_examples/list_models.py,sha256=8noMpGeXOdX5Pf0NXCt_CRurOKEg_5luhWveGntBhe8,578
|
|
20
|
+
_examples/raw.py,sha256=Y2wvgynFuoUs28agE4ijsLYec8VRjiReklqlCH2lERs,442
|
|
14
21
|
_examples/react.py,sha256=VQ-5MmjUXoHzBFPTV_JrocuOkDzZ8oyUUSYLlEToJ_0,939
|
|
15
22
|
_examples/recursive_agent.py,sha256=NHmX8iQc1y43AKh1ZlAdV9OZ18qVaRRmSaKALE_KI6k,2966
|
|
16
23
|
_examples/routed_send_response.py,sha256=FHhTy7y2f7GY_WAWZYqOnFqF1xeJTBuS5EjfJGsJ3S8,4146
|
|
@@ -47,19 +54,24 @@ mojentic/audit/event_store.py,sha256=uw5ploNym9CvI-WDeW3Y4qHJupPWBQaGrfrubxOfmpA
|
|
|
47
54
|
mojentic/audit/event_store_spec.py,sha256=mCryZGWNXP9CmukvD3hUyQGs_7afjwbTK3RdT831Lzc,594
|
|
48
55
|
mojentic/context/__init__.py,sha256=MKMP7ViQg8gMtLFvn9pf47XMc5beA5Wx95K4dEw93z8,55
|
|
49
56
|
mojentic/context/shared_working_memory.py,sha256=Zt9MNGErEkDIUAaHvyhEOiTaEobI9l0MV4Z59lQFBr0,396
|
|
50
|
-
mojentic/llm/__init__.py,sha256=
|
|
57
|
+
mojentic/llm/__init__.py,sha256=mwdPpTRofw7_KSlW6ZQmcM-GSpyISWyI2bxphKpV7A0,180
|
|
51
58
|
mojentic/llm/chat_session.py,sha256=H2gY0mZYVym8jC69VHsmKaRZ9T87Suyw0-TW5r850nA,3992
|
|
52
59
|
mojentic/llm/chat_session_spec.py,sha256=8-jj-EHV2WwWuvo3t8I75kSEAYiG1nR-OEwkkLTi_z0,3872
|
|
53
60
|
mojentic/llm/llm_broker.py,sha256=8dEgqU-cPesPk4-jx36oPnvKxB34bLONbpyAW_2L-es,5884
|
|
54
61
|
mojentic/llm/llm_broker_spec.py,sha256=zxOf3n8lXmUsowzlMcC-fRcOj_6wwDf7xIqLIV278nM,6979
|
|
62
|
+
mojentic/llm/message_composers.py,sha256=zL7OjYNcim1XXARFBxh3ym4fXOv8qSGCd_pSoOkceLQ,9565
|
|
63
|
+
mojentic/llm/message_composers_spec.py,sha256=iEUlNafRVVb5k60BomMpK1JvIHsaCUD6vj_pypVUBaY,10699
|
|
55
64
|
mojentic/llm/gateways/__init__.py,sha256=u7hXzngoRw_qbsJeiCH2NQ8vC2hF5DnqcXsfLVVPSSw,104
|
|
65
|
+
mojentic/llm/gateways/anthropic.py,sha256=SsyNjq9QaXaqiMM43C9fwLp57hpgFtwNPJUnOAYVrtc,1788
|
|
66
|
+
mojentic/llm/gateways/anthropic_messages_adapter.py,sha256=K6kEZeVt7E1endbGMLsh5l9SxC3Y5dnvbcejVqi_qUs,3003
|
|
56
67
|
mojentic/llm/gateways/embeddings_gateway.py,sha256=kcOhiyHzOyQgKgwPDQJD5oVvfwk71GsBgMYJOIDv5NU,1347
|
|
68
|
+
mojentic/llm/gateways/file_gateway.py,sha256=3bZpalSyl_R4016WzCmmjUBDtAgPsmx19eVGv6p1Ufk,1418
|
|
57
69
|
mojentic/llm/gateways/llm_gateway.py,sha256=5BayP6VuMgMHdAzCFaXLRjRCWh-IOYnaq_s4LZ0_3x4,2559
|
|
58
70
|
mojentic/llm/gateways/models.py,sha256=rHoXkaK5FOgWV5X-nkFhqubwk3sUQceJj3rBnxgqB-E,2335
|
|
59
|
-
mojentic/llm/gateways/ollama.py,sha256=
|
|
71
|
+
mojentic/llm/gateways/ollama.py,sha256=629fpZhC0zVCYqj360-PKTT4mQOLec5nzzvfMtS_mLQ,7581
|
|
60
72
|
mojentic/llm/gateways/ollama_messages_adapter.py,sha256=kUN_p2FyN88_trXMcL-Xsn9xPBU7pGKlJwTUEUCf6G4,1404
|
|
61
73
|
mojentic/llm/gateways/ollama_messages_adapter_spec.py,sha256=gVRbWDrHOa1EiZ0CkEWe0pGn-GKRqdGb-x56HBQeYSE,4981
|
|
62
|
-
mojentic/llm/gateways/openai.py,sha256=
|
|
74
|
+
mojentic/llm/gateways/openai.py,sha256=lYD2-DWt2W3fnmO7k8m29PD-wx4UDvcpPLduanj7tzc,4473
|
|
63
75
|
mojentic/llm/gateways/openai_message_adapter_spec.py,sha256=IgXyio15nXn11MiFpUnAxm5fFpPU9m-c4qerd9gbpBA,6473
|
|
64
76
|
mojentic/llm/gateways/openai_messages_adapter.py,sha256=Qwidv7C-wXDOEV8NYNjgIbHnJSlkcoK10FaeLos5zTc,2882
|
|
65
77
|
mojentic/llm/gateways/tokenizer_gateway.py,sha256=ztuqfunlJ6xmyUPPHcC_69-kegiNJD6jdSEde7hDh2w,485
|
|
@@ -69,6 +81,7 @@ mojentic/llm/registry/models.py,sha256=XOZ0ElTL5mEicpcUk9lrVr0GRFgR1uFmDm7GGWm7y
|
|
|
69
81
|
mojentic/llm/registry/populate_registry_from_ollama.py,sha256=YFlnyXW-Htveu8frPEntV-c_84Xoh_WhHGww5IqtPIg,2539
|
|
70
82
|
mojentic/llm/tools/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
71
83
|
mojentic/llm/tools/ask_user_tool.py,sha256=WrI3uTzIEmr_S5Pl-rmvh2f3O2IIFYfnJ5zeel_59ck,972
|
|
84
|
+
mojentic/llm/tools/current_datetime.py,sha256=JkIFlBBnvE4CMcgZizqITVTtNfL7zeeLxD54E7oMeC8,1585
|
|
72
85
|
mojentic/llm/tools/date_resolver.py,sha256=Vs8kmAONKif03YcVMZ3TqkKyOJZiy1yEAOqjTcvJFA8,2048
|
|
73
86
|
mojentic/llm/tools/date_resolver_spec.py,sha256=OaRvJyhSN8sgi75euk4E5ImaqUmvQdgZKY8u_NOiPWE,1185
|
|
74
87
|
mojentic/llm/tools/file_manager.py,sha256=X8Uw4XtdH_Ol2EB3SN11-GYlC1diJ1cAywU9_EuCjCg,3788
|
|
@@ -78,8 +91,8 @@ mojentic/llm/tools/tool_wrapper_spec.py,sha256=LGqtre-g8SzOy3xtpbMdgTnw2EdYutmFO
|
|
|
78
91
|
mojentic/llm/tools/web_search.py,sha256=L1accST2xMhGAkwHCLlIvKihTLiaYxl0NI6IqCJWGCw,1102
|
|
79
92
|
mojentic/utils/__init__.py,sha256=lqECkkoFvHFttDnafRE1vvh0Dmna_lwupMToP5VvX5k,115
|
|
80
93
|
mojentic/utils/formatting.py,sha256=bPrwwdluXdQ8TsFxfWtHNOeMWKNvAfABSoUnnA1g7c8,947
|
|
81
|
-
mojentic-0.
|
|
82
|
-
mojentic-0.
|
|
83
|
-
mojentic-0.
|
|
84
|
-
mojentic-0.
|
|
85
|
-
mojentic-0.
|
|
94
|
+
mojentic-0.5.0.dist-info/licenses/LICENSE.md,sha256=txSgV8n5zY1W3NiF5HHsCwlaW0e8We1cSC6TuJUqxXA,1060
|
|
95
|
+
mojentic-0.5.0.dist-info/METADATA,sha256=BRcX788NWyJ0K357r-xJ6s9slP8aqEXT-83FpN5LQzk,4935
|
|
96
|
+
mojentic-0.5.0.dist-info/WHEEL,sha256=CmyFI0kx5cdEMTLiONQRbGQwjIoR1aIYB7eCAQ4KPJ0,91
|
|
97
|
+
mojentic-0.5.0.dist-info/top_level.txt,sha256=Q-BvPQ8Eu1jnEqK8Xkr6A9C8Xa1z38oPZRHuA5MCTqg,19
|
|
98
|
+
mojentic-0.5.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|