├── claude.config ├── tests ├── __init__.py ├── ai_models │ ├── __init__.py │ └── test_utils.py ├── services │ ├── __init__.py │ └── test_flow_prompt.py ├── logs │ └── .gitgnore ├── prompts │ ├── test_create_test.py │ ├── test_ci_cd.py │ ├── test_model.py │ ├── test_chat.py │ ├── test_web_call.py │ ├── test_stream.py │ ├── test_pricing.py │ └── test_prompt.py ├── response_parsers │ └── test_response_parsers.py ├── test_integrational.py ├── conftest.py └── test_ai_model_tag_parser.py ├── lamoom ├── ai_models │ ├── __init__.py │ ├── claude │ │ ├── __init__.py │ │ ├── constants.py │ │ ├── responses.py │ │ └── claude_model.py │ ├── openai │ │ ├── __init__.py │ │ ├── exceptions.py │ │ ├── utils.py │ │ ├── azure_models.py │ │ ├── responses.py │ │ └── openai_models.py │ ├── tools │ │ ├── errors.py │ │ ├── web_tool.py │ │ └── base_tool.py │ ├── utils.py │ ├── constants.py │ ├── behaviour.py │ └── attempt_to_call.py ├── prompt │ ├── __init__.py │ ├── base_prompt.py │ ├── prompt.py │ ├── chat.py │ ├── user_prompt.py │ └── lamoom.py ├── services │ ├── __init__.py │ ├── SaveWorker.py │ └── lamoom │ │ └── __init__.py ├── exceptions.py ├── __init__.py ├── utils.py ├── response_parsers │ └── response_parser.py ├── settings.py └── responses.py ├── poetry.toml ├── .env.example ├── CONTRIBUTING.md ├── docs ├── media │ └── optimized_lamoom_mechanisms.gif ├── sequence_diagrams │ ├── pngs │ │ ├── lamoom_call.png │ │ ├── lamoom_test_creation.png │ │ ├── lamoom_add_ideal_answer.png │ │ └── lamoom_save_user_interactions.png │ ├── add_ideal_answer_to_log.md │ ├── save_user_interactions.md │ ├── sequence_diagram_lamoom_call.md │ └── test_creation.md ├── evaluate_prompts_quality │ ├── flow_prompt_service.py │ ├── evaluate_prompt_quality.py │ └── prompt.py ├── base64_image_example.py └── test_data │ └── medical_questions_answers.csv ├── researches └── ci_cd │ ├── exceptions.py │ ├── prompts │ ├── prompt_generate_facts.py │ └── prompt_compare_results.py │ ├── responses.py │ └── utils.py ├── .gitignore ├── .github ├── pull_request_template.md └── workflows │ ├── publish.yaml │ └── run-unit-tests.yaml ├── pyproject.toml ├── .pre-commit-config.yaml ├── CLAUDE.md ├── Makefile ├── README.md └── LICENSE /claude.config: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /lamoom/ai_models/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /lamoom/prompt/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /lamoom/services/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/ai_models/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/services/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/logs/.gitgnore: -------------------------------------------------------------------------------- 1 | *.txt 2 | -------------------------------------------------------------------------------- /lamoom/ai_models/claude/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /lamoom/ai_models/openai/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/services/test_flow_prompt.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /poetry.toml: -------------------------------------------------------------------------------- 1 | [virtualenvs] 2 | in-project = true 3 | -------------------------------------------------------------------------------- /lamoom/ai_models/tools/errors.py: -------------------------------------------------------------------------------- 1 | class ToolCallError(Exception): 2 | pass 3 | -------------------------------------------------------------------------------- /lamoom/ai_models/claude/constants.py: -------------------------------------------------------------------------------- 1 | HAIKU = "haiku" 2 | SONNET = "sonnet" 3 | OPUS = "opus" 4 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | LAMOOM_API_TOKEN= 2 | AZURE_KEYS= 3 | OPENAI_API_KEY= 4 | BEARER_TOKEN= 5 | LAMOOM_API_URI= -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ### API Url 2 | Add your own API url in `.env` if needed: 3 | ``` 4 | LAMOOM_API_URI=your_api_uri 5 | ``` -------------------------------------------------------------------------------- /docs/media/optimized_lamoom_mechanisms.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LamoomAI/lamoom-python/HEAD/docs/media/optimized_lamoom_mechanisms.gif -------------------------------------------------------------------------------- /docs/sequence_diagrams/pngs/lamoom_call.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LamoomAI/lamoom-python/HEAD/docs/sequence_diagrams/pngs/lamoom_call.png -------------------------------------------------------------------------------- /researches/ci_cd/exceptions.py: -------------------------------------------------------------------------------- 1 | class GenerateFactsException(Exception): 2 | def __init__(self, message=None): 3 | self.message = message -------------------------------------------------------------------------------- /docs/sequence_diagrams/pngs/lamoom_test_creation.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LamoomAI/lamoom-python/HEAD/docs/sequence_diagrams/pngs/lamoom_test_creation.png -------------------------------------------------------------------------------- /docs/sequence_diagrams/pngs/lamoom_add_ideal_answer.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LamoomAI/lamoom-python/HEAD/docs/sequence_diagrams/pngs/lamoom_add_ideal_answer.png -------------------------------------------------------------------------------- /docs/sequence_diagrams/pngs/lamoom_save_user_interactions.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LamoomAI/lamoom-python/HEAD/docs/sequence_diagrams/pngs/lamoom_save_user_interactions.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .venv/* 2 | *.pyc 3 | *__pycache__/* 4 | .env 5 | dist 6 | .idea 7 | .venv 8 | .coverage 9 | .DS_Store 10 | .vscode 11 | .pytest_cache 12 | python 13 | .env.test 14 | */logs/ -------------------------------------------------------------------------------- /lamoom/ai_models/utils.py: -------------------------------------------------------------------------------- 1 | def get_common_args(max_tokens): 2 | return { 3 | "top_p": 1, 4 | "temperature": 0, 5 | "max_tokens": max_tokens, 6 | "stream": False, 7 | } 8 | -------------------------------------------------------------------------------- /lamoom/ai_models/constants.py: -------------------------------------------------------------------------------- 1 | C_4K = 4_000 2 | C_8K = 8_000 3 | C_16K = 16_000 4 | C_32K = 32_000 5 | 6 | C_128K = 128_000 7 | C_200K = 200_000 8 | C_1M = 1_000_000 9 | C_200K = 200_000 10 | C_100K = 100_000 -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## Context 2 | Please make a bullet pointed list of changes 3 | 1. ... 4 | 5 | ## Checklist before requesting a review 6 | - [ ] Self-Review 7 | - [ ] Added Tests for functionality 8 | - [ ] Do you need to add/update metrics data, like cost, latency...? 9 | - [ ] Do you need to update Readme? 10 | -------------------------------------------------------------------------------- /lamoom/ai_models/claude/responses.py: -------------------------------------------------------------------------------- 1 | from lamoom.responses import AIResponse 2 | from dataclasses import dataclass 3 | from openai.types.chat import ChatCompletionMessage as Message 4 | 5 | 6 | @dataclass(kw_only=True) 7 | class ClaudeAIReponse(AIResponse): 8 | message: Message = None 9 | 10 | def get_message_str(self) -> str: 11 | return self.message.model_dump_json(indent=2) 12 | 13 | def __str__(self) -> str: 14 | result = ( 15 | f"finish_reason: {self.finish_reason}\n" 16 | f"message: {self.get_message_str()}\n" 17 | ) 18 | return result 19 | -------------------------------------------------------------------------------- /docs/sequence_diagrams/add_ideal_answer_to_log.md: -------------------------------------------------------------------------------- 1 | @startuml 2 | 3 | title Lamoom Feedback Flow: Adding ideal answers to existing responses 4 | note over Lamoom,LamoomService: Process of providing feedback on previous responses 5 | Lamoom->>Lamoom: add_ideal_answer(response_id, ideal_answer) 6 | Lamoom->>LamoomService: update_response_ideal_answer(api_token, log_id, ideal_answer) 7 | note right of LamoomService: PUT /lib/logs: Server updates existing log with:\n- Ideal answer for comparison\n- Used for quality assessment\n- Creating training data\n- Generating automated tests 8 | LamoomService-->>Lamoom: Return feedback submission result 9 | @enduml 10 | -------------------------------------------------------------------------------- /docs/evaluate_prompts_quality/flow_prompt_service.py: -------------------------------------------------------------------------------- 1 | 2 | 3 | import os 4 | import dotenv 5 | import requests 6 | from lamoom.settings import LAMOOM_API_URI 7 | 8 | dotenv.load_dotenv(dotenv.find_dotenv()) 9 | 10 | BEARER_TOKEN = os.getenv('BEARER_TOKEN') 11 | 12 | 13 | def get_all_prompts(): 14 | response = requests.get(f'{LAMOOM_API_URI}/prompts', headers={'Authorization': f'Bearer {BEARER_TOKEN}'}) 15 | prompts = response.json() 16 | return prompts 17 | 18 | 19 | def get_logs(prompt_id): 20 | response = requests.get( 21 | f'{LAMOOM_API_URI}/logs?prompt_id={prompt_id}&fields=response,context', 22 | headers={'Authorization': f'Bearer {BEARER_TOKEN}'} 23 | ) 24 | logs = response.json() 25 | return logs 26 | 27 | -------------------------------------------------------------------------------- /lamoom/exceptions.py: -------------------------------------------------------------------------------- 1 | class LamoomError(Exception): 2 | pass 3 | 4 | 5 | class RetryableCustomError(LamoomError): 6 | pass 7 | 8 | 9 | class StopStreamingError(LamoomError): 10 | pass 11 | 12 | 13 | class LamoomPromptIsnotFoundError(LamoomError): 14 | pass 15 | 16 | 17 | class BehaviourIsNotDefined(LamoomError): 18 | pass 19 | 20 | 21 | class ConnectionLostError(LamoomError): 22 | pass 23 | 24 | 25 | class ValueIsNotResolvedError(LamoomError): 26 | pass 27 | 28 | 29 | class NotEnoughBudgetError(LamoomError): 30 | pass 31 | 32 | 33 | class NotFoundPromptError(LamoomError): 34 | pass 35 | 36 | 37 | class ProviderNotFoundError(LamoomError): 38 | pass 39 | 40 | 41 | class APITokenNotProvided(LamoomError): 42 | pass 43 | -------------------------------------------------------------------------------- /lamoom/__init__.py: -------------------------------------------------------------------------------- 1 | from lamoom.responses import AIResponse 2 | from lamoom.settings import * 3 | from lamoom.prompt.lamoom import Lamoom 4 | from lamoom.ai_models import behaviour 5 | from lamoom.prompt.prompt import Prompt 6 | from lamoom.prompt.prompt import Prompt as PipePrompt 7 | from lamoom.ai_models.attempt_to_call import AttemptToCall 8 | from lamoom.ai_models.openai.openai_models import ( 9 | C_128K, 10 | C_4K, 11 | C_16K, 12 | C_32K, 13 | OpenAIModel, 14 | ) 15 | from lamoom.ai_models.openai.azure_models import AzureAIModel 16 | from lamoom.ai_models.claude.claude_model import ClaudeAIModel 17 | from lamoom.responses import AIResponse 18 | from lamoom.ai_models.openai.responses import OpenAIResponse 19 | from lamoom.ai_models.behaviour import AIModelsBehaviour, PromptAttempts 20 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "lamoom" 3 | version = "0.1.61" 4 | description = "" 5 | authors = ["Lamoom Engineering Team "] 6 | readme = "README.md" 7 | 8 | [tool.poetry.dependencies] 9 | python = "^3.9" 10 | tiktoken = ">=0.5.2" 11 | pyyaml = "^6.0.1" 12 | openai = "^1.65.1" 13 | anthropic = "^0.31.2" 14 | httpx = "^0.27.2" 15 | beautifulsoup4 = "^4.13.4" 16 | 17 | 18 | [tool.poetry.group.dev.dependencies] 19 | black = "^24.4.2" 20 | ipykernel = "^6.29.5" 21 | poetry = "^1.7.1" 22 | isort = "^5.13.2" 23 | flake8 = "^7.0.0" 24 | pytest = "^8.3.5" 25 | pytest-cov = "^3.0.0" 26 | pre-commit = "^3.6.0" 27 | autopep8 = "^2.0.4" 28 | python-dotenv = "^1.0.1" 29 | twine = "^5.0.0" 30 | 31 | 32 | [build-system] 33 | requires = ["poetry-core"] 34 | build-backend = "poetry.core.masonry.api" 35 | -------------------------------------------------------------------------------- /tests/prompts/test_create_test.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | import time 4 | 5 | from pytest import fixture 6 | from lamoom import Lamoom, Prompt 7 | logger = logging.getLogger(__name__) 8 | 9 | 10 | @fixture 11 | def client(): 12 | api_token = os.getenv("LAMOOM_API_TOKEN") 13 | lamoom = Lamoom(api_token=api_token) 14 | return lamoom 15 | 16 | 17 | def test_creating_lamoom_test(client): 18 | context = { 19 | 'ideal_answer': "There are eight planets", 20 | 'text': "Hi! Please tell me how many planets are there in the solar system?" 21 | } 22 | 23 | # initial version of the prompt 24 | prompt_id = f'unit-test-creating_fp_test' 25 | client.service.clear_cache() 26 | prompt = Prompt(id=prompt_id) 27 | prompt.add("{text}", role='user') 28 | 29 | client.create_test(prompt_id, context, model_name="gemini/gemini-1.5-flash") -------------------------------------------------------------------------------- /docs/sequence_diagrams/save_user_interactions.md: -------------------------------------------------------------------------------- 1 | sequenceDiagram 2 | title Lamoom Logging Flow: Recording user interactions with prompts 3 | participant Lamoom 4 | participant SaveWorker 5 | participant LamoomService 6 | 7 | Note over Lamoom, LamoomService: Process of recording prompt execution logs 8 | Lamoom->>Lamoom: call(prompt_id, context, model) - Fetches prompt, calls LLM, gets response 9 | Note right of SaveWorker: Async worker for saving interactions to avoid blocking 10 | Lamoom->>SaveWorker: add_task(api_token, prompt_data, context, result, test_data) 11 | SaveWorker->>LamoomService: save_user_interaction(api_token, prompt_data, context, response) 12 | Note right of LamoomService: POST /lib/logs: Records interaction for:\n- Analytics\n- Debugging\n- Performance tracking\n- Cost monitoring 13 | Lamoom-->>Lamoom: Return AIResponse (without waiting for log completion) 14 | -------------------------------------------------------------------------------- /lamoom/utils.py: -------------------------------------------------------------------------------- 1 | from decimal import Decimal 2 | import json 3 | import logging 4 | import typing as t 5 | from time import time 6 | 7 | logger = logging.getLogger(__name__) 8 | 9 | 10 | def parse_bool(value: any) -> bool: 11 | if type(value) == bool: 12 | return value 13 | return str(value).lower() in ("true", "1", "yes") 14 | 15 | 16 | def current_timestamp_ms(): 17 | return int(time() * 1000) 18 | 19 | 20 | def resolve(prompt: str, context: t.Dict[str, str]) -> str: 21 | if not prompt or "{" not in prompt: 22 | return prompt 23 | # TODO: monitor how many values were not resolved and what values 24 | for key in context: 25 | prompt = prompt.replace(f"{{{key}}}", str(context[key])) 26 | return prompt 27 | 28 | 29 | class DecimalEncoder(json.JSONEncoder): 30 | def default(self, o): 31 | if isinstance(o, Decimal): 32 | return str(o) 33 | return super(DecimalEncoder, self).default(o) 34 | -------------------------------------------------------------------------------- /lamoom/ai_models/openai/exceptions.py: -------------------------------------------------------------------------------- 1 | from lamoom.exceptions import LamoomError, RetryableCustomError 2 | 3 | 4 | class OpenAIChunkedEncodingError(RetryableCustomError): 5 | pass 6 | 7 | 8 | class OpenAITimeoutError(RetryableCustomError): 9 | pass 10 | 11 | 12 | class OpenAIResponseWasFilteredError(RetryableCustomError): 13 | pass 14 | 15 | 16 | class OpenAIAuthenticationError(RetryableCustomError): 17 | pass 18 | 19 | 20 | class OpenAIInternalError(RetryableCustomError): 21 | pass 22 | 23 | 24 | class OpenAiRateLimitError(RetryableCustomError): 25 | pass 26 | 27 | 28 | class OpenAiPermissionDeniedError(RetryableCustomError): 29 | pass 30 | 31 | 32 | class OpenAIUnknownError(RetryableCustomError): 33 | pass 34 | 35 | 36 | ### Non-retryable Errors ### 37 | class OpenAIInvalidRequestError(LamoomError): 38 | pass 39 | 40 | 41 | class OpenAIBadRequestError(LamoomError): 42 | pass 43 | 44 | 45 | class ConnectionCheckError(LamoomError): 46 | pass 47 | -------------------------------------------------------------------------------- /docs/sequence_diagrams/sequence_diagram_lamoom_call.md: -------------------------------------------------------------------------------- 1 | @startuml 2 | 3 | Note over Lamoom,LLM: Process of lamoom.call(prompt_id, context, model) 4 | 5 | Lamoom->>LibCache: get_prompt(prompt_id, version) 6 | alt Prompt is in cache or RECEIVE_PROMPT_FROM_SERVER disabled 7 | Lamoom-->>LibCache: Return cached prompt data 8 | end 9 | alt Cache miss or expired or new version of prompt is in the code 10 | Lamoom->>LamoomService: POST /lib/prompts (with currently active prompt data) 11 | 12 | Note right of LamoomService: Server checks if local prompt\nis the latest published version 13 | 14 | LamoomService-->>Lamoom: Response with prompt data and is_taken_globally flag 15 | 16 | Lamoom->>LibCache: Update cache with timestamp 17 | Note right of LibCache: Cache will be valid for 5 minutes\n(CACHE_PROMPT_FOR_EACH_SECONDS) 18 | end 19 | 20 | Note over Lamoom, LLM: Continue with AI model calling 21 | 22 | Lamoom-->>LLM: Call LLM w/ updated prompt and enriched context 23 | LLM ->> Lamoom: LLMResponse 24 | @enduml 25 | -------------------------------------------------------------------------------- /docs/sequence_diagrams/test_creation.md: -------------------------------------------------------------------------------- 1 | @startuml 2 | 3 | title Lamoom Test Creation Flow: Creating tests with ideal answers 4 | 5 | note over Lamoom,LamoomService: Process of creating tests for CI/CD validation 6 | 7 | alt Test creation via create_test method 8 | Lamoom->>LamoomService: create_test(prompt_id, context, ideal_answer) 9 | note right of LamoomService: Server creates test with:\n- Prompt ID\n- Test context\n- Ideal answer 10 | 11 | LamoomService-->>Lamoom: Return create test result 12 | end 13 | 14 | alt Test creation during normal prompt call with test_data 15 | Lamoom->>Lamoom: call(prompt_id, context, model, test_data={ideal_answer}), Fetches prompt, calls LLM, gets response 16 | 17 | Lamoom->>SaveWorker: add_task(api_token, prompt_data, context, result, test_data) 18 | SaveWorker->>LamoomService: create_test_with_ideal_answer 19 | note right of LamoomService: Server creates CI/CD test with:\n- Context\n- Prompt\n- Ideal answer 20 | Lamoom-->>Lamoom: Return AIResponse (without waiting for test creation) 21 | end 22 | 23 | @enduml -------------------------------------------------------------------------------- /docs/evaluate_prompts_quality/evaluate_prompt_quality.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import random 3 | from lamoom import Lamoom, behaviour, AttemptToCall, AzureAIModel, C_128K 4 | from prompt import prompt_to_evaluate_prompt 5 | from lamoom_service import get_all_prompts, get_logs 6 | logger = logging.getLogger(__name__) 7 | 8 | 9 | 10 | lamoom = Lamoom() 11 | 12 | 13 | def main(): 14 | for prompt in get_all_prompts(): 15 | prompt_id = prompt['prompt_id'] 16 | prompt_chats = prompt['chats'] 17 | logs = get_logs(prompt_id).get('items') 18 | if not logs or len(logs) < 5: 19 | continue 20 | contexts = [] 21 | responses = [] 22 | for log in random.sample(logs, 5): 23 | responses.append(log['response']['message']) 24 | contexts.append(log['context']) 25 | context = { 26 | 'responses': responses, 27 | 'prompt_data': prompt_chats, 28 | 'prompt_id': prompt_id, 29 | } 30 | result = lamoom.call(prompt_to_evaluate_prompt.id, context, 'azure/useast/gpt-4.1-mini') 31 | print(result.content) 32 | 33 | if __name__ == '__main__': 34 | main() -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | exclude: '^(tree-sitter-*)' 2 | 3 | repos: 4 | - repo: https://github.com/pre-commit/pre-commit-hooks 5 | rev: v3.2.0 6 | hooks: 7 | - id: check-ast 8 | - id: trailing-whitespace 9 | - id: check-toml 10 | - id: check-yaml 11 | - id: check-added-large-files 12 | - id: check-merge-conflict 13 | 14 | - repo: https://github.com/macisamuele/language-formatters-pre-commit-hooks 15 | rev: v2.1.0 16 | hooks: 17 | - id: pretty-format-yaml 18 | args: 19 | - --autofix 20 | - --preserve-quotes 21 | - --indent=2 22 | 23 | - repo: https://github.com/kynan/nbstripout 24 | rev: 0.5.0 25 | hooks: 26 | - id: nbstripout 27 | name: Clean notebook outputs 28 | 29 | - repo: local 30 | hooks: 31 | 32 | - id: isort 33 | name: Format with isort 34 | entry: poetry run isort 35 | language: system 36 | types: [python] 37 | 38 | - id: black 39 | name: Format with Black 40 | entry: poetry run black 41 | language: system 42 | types: [python] 43 | 44 | # - id: flake8 45 | # name: Validate with flake8 46 | # entry: poetry run flake8 lamoom 47 | # language: system 48 | # pass_filenames: false 49 | # types: [python] 50 | # args: [--count] 51 | -------------------------------------------------------------------------------- /tests/prompts/test_ci_cd.py: -------------------------------------------------------------------------------- 1 | 2 | import logging 3 | 4 | from pytest import fixture 5 | from lamoom import Lamoom, Prompt 6 | logger = logging.getLogger(__name__) 7 | 8 | 9 | @fixture 10 | def client(): 11 | import dotenv 12 | dotenv.load_dotenv(dotenv.find_dotenv()) 13 | lamoom = Lamoom() 14 | return lamoom 15 | 16 | 17 | def stream_function(text, **kwargs): 18 | print(text) 19 | 20 | def stream_check_connection(validate=True, **kwargs): 21 | return validate 22 | 23 | def test_creating_lamoom_test(client): 24 | 25 | context = { 26 | 'ideal_answer': "There are eight planets", 27 | 'text': "Hi! Please tell me how many planets are there in the solar system?" 28 | } 29 | 30 | # initial version of the prompt 31 | prompt_id = f'unit-test-creating_fp_test' 32 | client.service.clear_cache() 33 | prompt = Prompt(id=prompt_id) 34 | prompt.add("{text}", role='user') 35 | 36 | client.call(prompt.id, context, "azure/useast/gpt-4.1-mini", test_data={'ideal_answer': "There are eight", 'model_name': "gemini/gemini-1.5-flash"}, stream_function=stream_function, check_connection=stream_check_connection, params={"stream": True}, stream_params={"validate": True, "end": "", "flush": True}) -------------------------------------------------------------------------------- /tests/prompts/test_model.py: -------------------------------------------------------------------------------- 1 | 2 | import logging 3 | import time 4 | from pytest import fixture 5 | from lamoom import Lamoom, Prompt 6 | logger = logging.getLogger(__name__) 7 | 8 | 9 | @fixture 10 | def client(): 11 | import dotenv 12 | dotenv.load_dotenv(dotenv.find_dotenv()) 13 | lamoom = Lamoom() 14 | return lamoom 15 | 16 | 17 | def test_model(client): 18 | 19 | context = { 20 | 'text': "Hi! Please tell me how many planets are there in the solar system?" 21 | } 22 | 23 | # initial version of the prompt 24 | prompt_id = f'test-{time.time()}' 25 | client.service.clear_cache() 26 | prompt = Prompt(id=prompt_id) 27 | prompt.add("{text}", role='user') 28 | 29 | result = client.call(prompt.id, context, "custom/nebius/deepseek-ai/DeepSeek-R1") 30 | assert result.content 31 | 32 | result = client.call(prompt.id, context, "openai/o4-mini") 33 | assert result.content 34 | 35 | result = client.call(prompt.id, context, "azure/useast/gpt-4.1-mini") 36 | assert result.content 37 | 38 | result = client.call(prompt.id, context, "gemini/gemini-1.5-flash") 39 | assert result.content 40 | 41 | result = client.call(prompt.id, context, "claude/claude-3-5-haiku-latest") 42 | assert result.content -------------------------------------------------------------------------------- /.github/workflows/publish.yaml: -------------------------------------------------------------------------------- 1 | name: publish 2 | 3 | on: 4 | pull_request: 5 | types: 6 | - closed 7 | branches: 8 | - main 9 | 10 | jobs: 11 | publish: 12 | if: github.event.pull_request.merged == true 13 | runs-on: ubuntu-22.04 14 | steps: 15 | - name: Check out repository 16 | uses: actions/checkout@v3 17 | with: 18 | fetch-depth: 0 19 | token: ${{ secrets.GH_PAT }} 20 | 21 | - name: Install Poetry 22 | run: pip install poetry 23 | 24 | - name: Install Python 25 | uses: actions/setup-python@v3 26 | with: 27 | python-version: 3.11 28 | cache: poetry 29 | 30 | - name: Install Python libraries 31 | run: poetry install 32 | 33 | - name: Configure Git 34 | run: | 35 | git config --global user.name "GitHub Actions" 36 | git config --global user.email "actions@github.com" 37 | 38 | - name: Publish package and update version 39 | if: github.ref == 'refs/heads/main' 40 | env: 41 | PYPI_API_KEY: ${{ secrets.PYPI_API_KEY }} 42 | run: | 43 | poetry run make publish-release 44 | version=$(poetry version -s) 45 | git add pyproject.toml 46 | git commit -m "Bump version to ${version}" 47 | git push -------------------------------------------------------------------------------- /CLAUDE.md: -------------------------------------------------------------------------------- 1 | # Lamoom Python Project Guide 2 | 3 | ## Build/Test/Lint Commands 4 | - Install deps: `poetry install` 5 | - Run all tests: `poetry run pytest --cache-clear -vv tests` 6 | - Run specific test: `poetry run pytest tests/path/to/test_file.py::test_function_name -v` 7 | - Run with coverage: `make test` 8 | - Format code: `make format` (runs black, isort, flake8, mypy) 9 | - Individual formatting: 10 | - Black: `make make-black` 11 | - isort: `make make-isort` 12 | - Flake8: `make flake8` 13 | - Autopep8: `make autopep8` 14 | 15 | ## Code Style Guidelines 16 | - Python 3.9+ compatible code 17 | - Type hints required for all functions and methods 18 | - Classes: PascalCase with descriptive names 19 | - Functions/Variables: snake_case 20 | - Constants: UPPERCASE_WITH_UNDERSCORES 21 | - Imports organization with isort: 22 | 1. Standard library imports 23 | 2. Third-party imports 24 | 3. Local application imports 25 | - Error handling: Use specific exception types 26 | - Logging: Use the logging module with appropriate levels 27 | - Use dataclasses for structured data when applicable 28 | 29 | ## Project Conventions 30 | - Use poetry for dependency management 31 | - Add tests for all new functionality 32 | - Maintain >80% test coverage (current min: 81%) 33 | - Follow pre-commit hooks guidelines 34 | - Document public APIs with docstrings -------------------------------------------------------------------------------- /lamoom/ai_models/behaviour.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from dataclasses import dataclass 3 | 4 | from lamoom.ai_models.attempt_to_call import AttemptToCall 5 | 6 | logger = logging.getLogger(__name__) 7 | 8 | 9 | @dataclass 10 | class AIModelsBehaviour: 11 | attempt: AttemptToCall 12 | fallback_attempts: list[AttemptToCall] = None 13 | 14 | 15 | @dataclass 16 | class PromptAttempts: 17 | ai_models_behaviour: AIModelsBehaviour 18 | current_attempt: AttemptToCall = None 19 | 20 | def initialize_attempt(self): 21 | if self.current_attempt is None: 22 | self.current_attempt = self.ai_models_behaviour.attempt 23 | self.fallback_index = 0 # Start fallback index at 0 24 | return self.current_attempt 25 | elif self.ai_models_behaviour.fallback_attempts: 26 | if self.fallback_index < len(self.ai_models_behaviour.fallback_attempts): 27 | self.current_attempt = self.ai_models_behaviour.fallback_attempts[self.fallback_index] 28 | self.fallback_index += 1 29 | return self.current_attempt 30 | else: 31 | self.current_attempt = None # No more fallback attempts left 32 | return None 33 | 34 | def __str__(self) -> str: 35 | return f"Current attempt {self.current_attempt} from {len(self.ai_models_behaviour.attempts)}" 36 | -------------------------------------------------------------------------------- /docs/evaluate_prompts_quality/prompt.py: -------------------------------------------------------------------------------- 1 | 2 | from lamoom import Prompt 3 | 4 | prompt_to_evaluate_prompt = Prompt(id="prompt-improver") 5 | 6 | prompt_to_evaluate_prompt.add(role="system", content="You're a prompt engineer, tasked with evaluating and improving prompt quality.") 7 | 8 | prompt_to_evaluate_prompt.add(content="""The initial prompt is provided below: ``` 9 | {prompt_data} 10 | ```""", priority=1) 11 | 12 | prompt_to_evaluate_prompt.add( 13 | content="{responses}", 14 | is_multiple=True, 15 | in_one_message=True, 16 | presentation="Responses to the initial prompt were as follows: ", 17 | priority=2 18 | ) 19 | 20 | prompt_to_evaluate_prompt.add(content=''' 21 | Please perform the following steps: 22 | 23 | 1. **Analyze Output Quality {prompt_id}:** 24 | - Examine the completeness, accuracy, and relevance of the responses. 25 | - Identify any common themes in errors or inaccuracies. 26 | 2. **Identify Improvement Areas:** 27 | - Based on the analysis, pinpoint specific areas where the prompt could be ambiguous or not detailed enough. 28 | - Note if the complexity of the request might be contributing to the observed output quality issues. 29 | 3. **Suggest Modifications:** 30 | - Propose clear and actionable changes to the initial prompt that could potentially address the identified issues. 31 | - If applicable, recommend breaking down complex tasks into simpler, more manageable subtasks within the prompt. 32 | ''', required=True) 33 | -------------------------------------------------------------------------------- /tests/prompts/test_chat.py: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | from lamoom.prompt.chat import ChatsEntity 5 | 6 | 7 | def test_chats_entity_resolve_not_multiple(): 8 | ce = ChatsEntity(content="{greeting} World") 9 | context = {"greeting": "Hello"} 10 | resolved = ce.resolve(context) 11 | assert resolved[0].content == "Hello World" 12 | 13 | 14 | def test_chats_entity_resolve_multiple(): 15 | ce = ChatsEntity(content="{messages}", is_multiple=True) 16 | context = { 17 | "messages": [ 18 | {"role": "user", "content": "Hi"}, 19 | {"role": "bot", "content": "Hello"}, 20 | ] 21 | } 22 | resolved = ce.resolve(context) 23 | assert isinstance(resolved, list) 24 | assert len(resolved) == 2 25 | assert resolved[0].role == "user" 26 | assert resolved[0].content == "Hi" 27 | assert resolved[1].role == "bot" 28 | assert resolved[1].content == "Hello" 29 | 30 | 31 | def test_chats_entity_resolve_not_exists(): 32 | ce = ChatsEntity(content="{greeting} World") 33 | context = {"content": "Hello World"} 34 | resolved = ce.resolve(context) 35 | assert resolved[0].content == "{greeting} World" 36 | assert resolved[0].role == "user" 37 | 38 | 39 | def test_chats_entity_get_values(): 40 | ce = ChatsEntity(content="{greeting} World") 41 | context = {"greeting": "Hello"} 42 | values = ce.get_values(context) 43 | assert ce.add_in_reverse_order is False 44 | assert values[0].content == "Hello World" 45 | assert values[0].role == "user" 46 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | PROJECT_FOLDER = 'lamoom' 2 | 3 | flake8: 4 | flake8 ${PROJECT_FOLDER} 5 | 6 | .PHONY: make-black 7 | make-black: 8 | black --verbose ${PROJECT_FOLDER} 9 | 10 | .PHONY: make-isort 11 | make-isort: 12 | isort --settings-path pyproject.toml ${PROJECT_FOLDER} 13 | 14 | .PHONY: make-mypy 15 | make-mypy: 16 | mypy --strict ${PROJECT_FOLDER} 17 | 18 | isort-check: 19 | isort --settings-path pyproject.toml --check-only . 20 | 21 | autopep8: 22 | for f in `find lamoom -name "*.py"`; do autopep8 --in-place --select=E501 $f; done 23 | 24 | lint: 25 | poetry run isort --settings-path pyproject.toml --check-only . 26 | 27 | test: 28 | poetry run pytest --cache-clear -vv tests 29 | 30 | .PHONY: format 31 | format: make-black isort-check flake8 make-mypy 32 | 33 | clean: clean-build clean-pyc clean-test 34 | 35 | clean-build: 36 | rm -fr build/ 37 | rm -fr dist/ 38 | rm -fr .eggs/ 39 | find . -name '*.egg-info' -exec rm -fr {} + 40 | find . -name '*.egg' -exec rm -f {} + 41 | 42 | clean-pyc: 43 | find . -name '*.pyc' -exec rm -f {} + 44 | find . -name '*.pyo' -exec rm -f {} + 45 | find . -name '*~' -exec rm -f {} + 46 | find . -name '__pycache__' -exec rm -fr {} + 47 | 48 | clean-test: 49 | rm -f .coverage 50 | rm -fr htmlcov/ 51 | rm -rf .pytest_cache 52 | 53 | 54 | publish-test-prerelease: 55 | poetry version prerelease 56 | poetry build 57 | twine upload --repository testpypi dist/* 58 | 59 | 60 | publish-release: 61 | poetry config pypi-token.pypi "$(PYPI_PROD_API_KEY)" 62 | poetry version patch 63 | poetry build 64 | poetry publish 65 | -------------------------------------------------------------------------------- /researches/ci_cd/prompts/prompt_generate_facts.py: -------------------------------------------------------------------------------- 1 | from lamoom import PipePrompt 2 | 3 | agent = PipePrompt(id='lamoom_cicd__generate_facts') 4 | agent.add(""" 5 | You're generating statements for the provided text below. 6 | """, role='system') 7 | agent.add(""" 8 | User asked: 9 | {question} 10 | 11 | # Ideal answer is: 12 | {ideal_answer} 13 | """, role='system') 14 | 15 | agent.add(""" 16 | First, write out all the important statements from Ideal answer. 17 | Make them data-enriched for each statement, so each statement is a fact. Where statement answers an questions, what, why, when, who, how and other questions if applicable from the Ideal answer or question; 18 | Tend to make a detailed statement. So it can be used out of the context of the Ideal answer and it will be true based on the Ideal answer. 19 | Second, ask questions to each generated statement so that it produces a statement as an answer to the question. 20 | Third, give a name to the generated facts, such as the name of the test, like "when_what_why_how_who_what_generated_name". 21 | Use the next json format for the answer: 22 | ```json 23 | { 24 | "statements": [ 25 | "statement_from_ideal_answer", 26 | ... 27 | "another_statement_from_ideal_answer" 28 | ], 29 | "questions": { 30 | "statement_from_ideal_answer": "question_to_answer_by_that_statement", 31 | "another_statement_from_ideal_answer": "question_to_answer_by_that_another_statement", 32 | ..., 33 | "qN": "statementN", 34 | }, 35 | "name": "when_what_why_generated_name" 36 | } 37 | ``` 38 | """) 39 | 40 | -------------------------------------------------------------------------------- /lamoom/ai_models/attempt_to_call.py: -------------------------------------------------------------------------------- 1 | import typing as t 2 | from dataclasses import dataclass 3 | 4 | from lamoom.ai_models.ai_model import AIModel 5 | 6 | 7 | @dataclass 8 | class AttemptToCall: 9 | ai_model: AIModel 10 | weight: int = 1 # from 1 to 100, the higher weight the more often it will be called 11 | # if you wish to limit functions that can be used, or to turn off calling openai functions for this attempt: 12 | # [] - if empty list of functions, functions are not supported for that call 13 | # None - if None, no limitations on functions 14 | # ['function1', 'function2'] - if list of functions, only those functions will be called 15 | functions: t.List[str] = None 16 | attempt_number: int = 1 17 | api_key: str = None 18 | 19 | def __post_init__(self): 20 | self.id = ( 21 | f"{self.ai_model.name}" 22 | f"-n{self.attempt_number}-" 23 | f"{self.ai_model.provider.value}" 24 | ) 25 | 26 | def __str__(self) -> str: 27 | return self.id 28 | 29 | def params(self) -> t.Dict[str, t.Any]: 30 | self.ai_model.get_params() 31 | 32 | def get_functions(self) -> t.List[str]: 33 | # empty list - functions are not supported 34 | if not self.ai_model.support_functions: 35 | return [] 36 | # None - no limitations on functions 37 | if self.functions is None: 38 | return None 39 | return self.functions 40 | 41 | def model_max_tokens(self) -> int: 42 | return self.ai_model.max_tokens 43 | 44 | def tiktoken_encoding(self) -> str: 45 | return self.ai_model.tiktoken_encoding 46 | -------------------------------------------------------------------------------- /lamoom/services/SaveWorker.py: -------------------------------------------------------------------------------- 1 | import threading 2 | import queue 3 | import typing as t 4 | from time import sleep 5 | from lamoom.responses import AIResponse 6 | 7 | from lamoom.services.lamoom import LamoomService 8 | 9 | 10 | class SaveWorker: 11 | def __init__(self): 12 | self.queue = queue.Queue() 13 | self.thread = threading.Thread(target=self.worker) 14 | self.thread.daemon = True # Daemon thread exits when main program exits 15 | self.thread.start() 16 | 17 | def save_user_interaction_async( 18 | self, 19 | api_token: str, 20 | prompt_data: t.Dict[str, t.Any], 21 | context: t.Dict[str, t.Any], 22 | response: AIResponse, 23 | ): 24 | LamoomService.save_user_interaction( 25 | api_token, prompt_data, context, response 26 | ) 27 | 28 | def worker(self): 29 | while True: 30 | task = self.queue.get() 31 | if task is None: 32 | sleep(1) 33 | continue 34 | api_token, prompt_data, context, response, test_data = task 35 | LamoomService.save_user_interaction( 36 | api_token, prompt_data, context, response 37 | ) 38 | LamoomService.create_test_with_ideal_answer( 39 | api_token, prompt_data, context, test_data 40 | ) 41 | self.queue.task_done() 42 | 43 | def add_task( 44 | self, 45 | api_token: str, 46 | prompt_data: t.Dict[str, t.Any], 47 | context: t.Dict[str, t.Any], 48 | response: AIResponse, 49 | test_data: t.Optional[dict] = None, 50 | ): 51 | self.queue.put((api_token, prompt_data, context, response, test_data)) 52 | -------------------------------------------------------------------------------- /tests/prompts/test_web_call.py: -------------------------------------------------------------------------------- 1 | 2 | import json 3 | import logging 4 | from lamoom.ai_models.tools.web_tool import WEB_SEARCH_TOOL 5 | from pytest import fixture 6 | from lamoom import Lamoom, Prompt 7 | logger = logging.getLogger(__name__) 8 | 9 | 10 | @fixture 11 | def client(): 12 | import dotenv 13 | dotenv.load_dotenv(dotenv.find_dotenv()) 14 | lamoom = Lamoom() 15 | return lamoom 16 | 17 | def stream_function(text, **kwargs): 18 | print(text) 19 | 20 | def stream_check_connection(validate=True, **kwargs): 21 | return validate 22 | 23 | 24 | def test_web_call(client): 25 | 26 | context = { 27 | 'text': f"What's the latest news in the San Francisco and AI?" 28 | } 29 | 30 | # initial version of the prompt 31 | prompt_id = 'test-web-search' 32 | prompt = Prompt(id=prompt_id) 33 | prompt.add("", role='system') 34 | prompt.add("{text}", role='user') 35 | 36 | prompt.add_tool(WEB_SEARCH_TOOL) 37 | print(f'prompt.tool_registry: {prompt.tool_registry}') 38 | result = client.call(prompt.id, context, "openai/o4-mini") 39 | assert result.content 40 | 41 | result = client.call(prompt.id, context, "claude/claude-3-7-sonnet-latest") 42 | assert result.content 43 | 44 | result = client.call(prompt.id, context, "azure/useast/gpt-4.1-mini") 45 | assert result.content 46 | 47 | result = client.call(prompt.id, context, "custom/nvidia/deepseek-ai/deepseek-r1", 48 | stream_function=stream_function, 49 | check_connection=stream_check_connection, 50 | params={"stream": True} 51 | ) 52 | assert result.content 53 | 54 | result = client.call(prompt.id, context, "custom/nebius/deepseek-ai/DeepSeek-R1") 55 | assert result.content 56 | -------------------------------------------------------------------------------- /tests/prompts/test_stream.py: -------------------------------------------------------------------------------- 1 | 2 | import logging 3 | import time 4 | 5 | from lamoom.responses import StreamingResponse 6 | from pytest import fixture 7 | from lamoom import Lamoom, Prompt, OpenAIResponse 8 | logger = logging.getLogger(__name__) 9 | 10 | 11 | @fixture 12 | def client(): 13 | lamoom = Lamoom() 14 | return lamoom 15 | 16 | def stream_function(text, **kwargs): 17 | print(text) 18 | 19 | def stream_check_connection(validate, **kwargs): 20 | return validate 21 | 22 | def test_stream(client: Lamoom): 23 | 24 | context = { 25 | 'messages': ['test1', 'test2'], 26 | 'assistant_response_in_progress': None, 27 | 'files': ['file1', 'file2'], 28 | 'music': ['music1', 'music2'], 29 | 'videos': ['video1', 'video2'], 30 | 'text': "Good morning. Tell me a funny joke!" 31 | } 32 | 33 | # initial version of the prompt 34 | prompt_id = f'test-{time.time()}' 35 | client.service.clear_cache() 36 | prompt = Prompt(id=prompt_id) 37 | prompt.add("{text}") 38 | prompt.add("It's a system message, Hello {name}", role="assistant") 39 | 40 | result: StreamingResponse = client.call(prompt.id, context, "azure/useast/gpt-4.1-mini", stream_function=stream_function, check_connection=stream_check_connection, params={"stream": True}, stream_params={"validate": True, "end": "", "flush": True}) 41 | client.call(prompt.id, context, "claude/claude-3-haiku-20240307", stream_function=stream_function, check_connection=stream_check_connection, params={"stream": True}, stream_params={"validate": True, "end": "", "flush": True}) 42 | client.call(prompt.id, context, "gemini/gemini-1.5-flash", stream_function=stream_function, check_connection=stream_check_connection, params={"stream": True}, stream_params={"validate": True, "end": "", "flush": True}) 43 | 44 | assert "message" in result.to_dict() -------------------------------------------------------------------------------- /.github/workflows/run-unit-tests.yaml: -------------------------------------------------------------------------------- 1 | name: run-unit-tests 2 | 3 | on: push 4 | 5 | jobs: 6 | run-unit-tests: 7 | runs-on: ubuntu-22.04 8 | container: python:3.11-slim 9 | steps: 10 | - name: Check out repository 11 | uses: actions/checkout@v3 12 | 13 | - name: 'Create env file' 14 | run: | 15 | touch .env 16 | echo AZURE_KEYS=${{ secrets.AZURE_KEYS }} >> .env 17 | echo CLAUDE_API_KEY=${{ secrets.CLAUDE_API_KEY }} >> .env 18 | echo GEMINI_API_KEY=${{ secrets.GEMINI_API_KEY }} >> .env 19 | echo OPENAI_API_KEY=${{ secrets.OPENAI_API_KEY }} >> .env 20 | echo LAMOOM_API_URI=${{ secrets.LAMOOM_API_URI }} >> .env 21 | echo LAMOOM_API_TOKEN=${{ secrets.LAMOOM_API_TOKEN }} >> .env 22 | echo LAMOOM_CUSTOM_PROVIDERS=${{ secrets.LAMOOM_CUSTOM_PROVIDERS }} >> .env 23 | echo NEBIUS_API_KEY=${{ secrets.NEBIUS_API_KEY }} >> .env 24 | echo CUSTOM_API_KEY=${{ secrets.CUSTOM_API_KEY }} >> .env 25 | echo GOOGLE_API_KEY=${{ secrets.GOOGLE_API_KEY }} >> .env 26 | echo SEARCH_ENGINE_ID=${{ secrets.SEARCH_ENGINE_ID }} >> .env 27 | cat .env 28 | 29 | - name: Install dependencies 30 | run: | 31 | apt-get update && apt-get install -y curl build-essential 32 | 33 | - name: Install Poetry 34 | run: pip install poetry 35 | 36 | - name: Cache Poetry Dependencies 37 | uses: actions/cache@v3 38 | with: 39 | path: ~/.cache/pypoetry 40 | key: ${{ runner.os }}-poetry-${{ hashFiles('**/poetry.lock') }} 41 | restore-keys: | 42 | ${{ runner.os }}-poetry- 43 | 44 | - name: Install Python 45 | uses: actions/setup-python@v3 46 | with: 47 | python-version: 3.11 48 | 49 | - name: Install Python libraries 50 | run: poetry install --with dev 51 | 52 | - name: Run tests with pytest 53 | run: poetry run make test -------------------------------------------------------------------------------- /tests/response_parsers/test_response_parsers.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from lamoom.responses import AIResponse 3 | from lamoom.response_parsers.response_parser import get_yaml_from_response, get_json_from_response, _get_format_from_response, Tag 4 | 5 | 6 | def test_get_yaml_from_response_valid_yaml(): 7 | response = AIResponse(_response="```yaml\nkey: value\n```") 8 | tagged_content = get_yaml_from_response(response.response) 9 | 10 | assert tagged_content.content == 'key: value' 11 | assert tagged_content.parsed_content == {'key': 'value'} 12 | assert tagged_content.start_ind == 7 13 | assert tagged_content.end_ind == 19 14 | 15 | def test_get_yaml_from_response_invalid_yaml(): 16 | response = AIResponse(_response="```yaml\nkey: value\n```") 17 | tagged_content = get_yaml_from_response(response.response) 18 | 19 | assert tagged_content.content == 'key: value' 20 | assert tagged_content.parsed_content == {"key": "value"} 21 | 22 | def test_get_json_from_response_valid_json(): 23 | response = AIResponse(_response="```json\n{\"key\": \"value\"}\n```") 24 | tagged_content = get_json_from_response(response.response) 25 | 26 | assert tagged_content.content == '{"key": "value"}' 27 | assert tagged_content.parsed_content == {"key": "value"} 28 | assert tagged_content.start_ind == 7 29 | assert tagged_content.end_ind == 24 30 | 31 | def test__get_format_from_response(): 32 | response = AIResponse(_response="```json\n{\"key\": \"value\"}\n```") 33 | tags = [Tag("```json", "```", 0)] 34 | content, start_ind, end_ind = _get_format_from_response(response.response, tags) 35 | 36 | assert content == '{"key": "value"}' 37 | 38 | def test__get_format_from_response_no_tags(): 39 | response = AIResponse(_response="No tags here") 40 | tags = [Tag("```json", "```", 0)] 41 | content, start_ind, end_ind = _get_format_from_response(response.content, tags) 42 | assert content is None 43 | 44 | -------------------------------------------------------------------------------- /lamoom/ai_models/openai/utils.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import requests 3 | 4 | import openai 5 | 6 | from lamoom.ai_models.openai.exceptions import ( 7 | OpenAIAuthenticationError, 8 | OpenAIBadRequestError, 9 | OpenAIChunkedEncodingError, 10 | OpenAIInternalError, 11 | OpenAIInvalidRequestError, 12 | OpenAiPermissionDeniedError, 13 | OpenAiRateLimitError, 14 | OpenAIResponseWasFilteredError, 15 | OpenAITimeoutError, 16 | OpenAIUnknownError, 17 | ConnectionCheckError, 18 | ) 19 | 20 | logger = logging.getLogger(__name__) 21 | 22 | 23 | def raise_openai_exception( 24 | exc: Exception, 25 | ) -> None: 26 | if isinstance(exc, requests.exceptions.ChunkedEncodingError): 27 | raise OpenAIChunkedEncodingError() 28 | 29 | if isinstance(exc, openai.APITimeoutError): 30 | raise OpenAITimeoutError() 31 | 32 | if isinstance(exc, openai.BadRequestError): 33 | if "response was filtered" in str(exc): 34 | raise OpenAIResponseWasFilteredError() 35 | if "Too many inputs" in str(exc): 36 | raise OpenAiRateLimitError() 37 | raise OpenAIInvalidRequestError() 38 | if isinstance(exc, openai.RateLimitError): 39 | raise OpenAiRateLimitError() 40 | 41 | if isinstance(exc, openai.AuthenticationError): 42 | raise OpenAIAuthenticationError() 43 | 44 | if isinstance(exc, openai.InternalServerError): 45 | raise OpenAIInternalError() 46 | 47 | if isinstance(exc, openai.PermissionDeniedError): 48 | raise OpenAiPermissionDeniedError() 49 | 50 | if isinstance(exc, openai.APIStatusError): 51 | raise OpenAIBadRequestError() 52 | 53 | if isinstance(exc, ConnectionError): 54 | raise ConnectionCheckError("websocket connection was lost") 55 | 56 | logger.error( 57 | "Unknown OPENAI error, please add it in raise_openai_rate_limit_exception", 58 | exc_info=exc, 59 | ) 60 | raise OpenAIUnknownError() 61 | -------------------------------------------------------------------------------- /researches/ci_cd/prompts/prompt_compare_results.py: -------------------------------------------------------------------------------- 1 | from lamoom import PipePrompt 2 | 3 | agent = PipePrompt(id='lamoom_cicd__compare_results') 4 | agent.add(""" 5 | You need to find answers on question in REAL_RESPONSE. Ask Question from QUESTIONS_AND_ANSWERS. QUESTIONS_AND_ANSWERS has ideal answer and questions; 6 | For Each question there is an ideal answer and real_answer. You need to compare the ideal_answer and the real_answer. 7 | If they match logically then this question matches, otherwise no. 8 | If real_answer doesnt match ideal answer, say: "ANSWER_NOT_PROVIDED". 9 | ## IDEAL_ANSWER: 10 | {ideal_answer} 11 | """, role='system') 12 | 13 | agent.add(""" 14 | # QUESTIONS_AND_ANSWERS 15 | {generated_test} 16 | 17 | # REAL_RESPONSE 18 | {llm_response} 19 | 20 | First, go through each question and get real_answer on it with from REAL_RESPONSE. Compare the answer you got from QUESTIONS_AND_ANSWERS. 21 | Secondly, check out if the real answer has other statements which are not in ideal answer.Add statements into JSON; 22 | Finally, check out if the real answer has statements which contradicts ideal answer. Add statements into JSON; 23 | Use the next JSON format for the answer: 24 | # RESPONSE 25 | ```json 26 | { 27 | "QUESTIONS_AND_ANSWERS": { 28 | "question from QUESTIONS_AND_ANSWERS": { 29 | "real_answer": "answer from the REAL_RESPONSE", 30 | "ideal_answer": "rewritten ideal answer from QUESTIONS_AND_ANSWERS", 31 | "does_match_with_ideal_answer": true/false 32 | }, 33 | ... 34 | "last question from QUESTIONS_AND_ANSWERS": { 35 | "real_answer": "answer from the REAL_RESPONSE", 36 | "ideal_answer": "rewritten ideal answer from QUESTIONS_AND_ANSWERS", 37 | "does_match_with_ideal_answer": true/false 38 | } 39 | }, 40 | "additional_statements_from_real_response": [ "statement1", "statement2", ...], 41 | "statements_which_contradict_ideal_answer_from_a_real_response": [ "statement1", "statement2", ...] 42 | } 43 | ``` 44 | """, role='user') 45 | -------------------------------------------------------------------------------- /tests/prompts/test_pricing.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | import os 4 | import time 5 | 6 | from pytest import fixture 7 | import dotenv 8 | dotenv.load_dotenv(dotenv.find_dotenv()) 9 | from lamoom import Lamoom, Prompt 10 | logger = logging.getLogger(__name__) 11 | 12 | 13 | @fixture 14 | def lamoom_client(): 15 | 16 | lamoom = Lamoom() 17 | return lamoom 18 | 19 | def stream_function(text, **kwargs): 20 | print(text) 21 | 22 | def stream_check_connection(validate, **kwargs): 23 | return validate 24 | 25 | 26 | def test_openai_pricing(lamoom_client: Lamoom): 27 | 28 | context = { 29 | 'ideal_answer': "There are eight planets", 30 | 'text': "Hi! Please tell me how many planets are there in the solar system?" 31 | } 32 | 33 | # initial version of the prompt 34 | prompt_id = f'unit-test_openai_pricing' 35 | lamoom_client.service.clear_cache() 36 | prompt = Prompt(id=prompt_id) 37 | prompt.add("{text}", role='user') 38 | 39 | result_4o_mini = lamoom_client.call(prompt.id, context, "openai/o4-mini-mini", test_data={'ideal_answer': "There are eight", 'behavior_name': "gemini"}, stream_function=stream_function, check_connection=stream_check_connection, params={"stream": True}, stream_params={"validate": True, "end": "", "flush": True}) 40 | 41 | assert result_4o_mini.metrics.prompt_tokens_used >= 0 42 | assert result_4o_mini.metrics.prompt_tokens_used >= 0 43 | 44 | 45 | def test_claude_pricing(lamoom_client: Lamoom): 46 | 47 | context = { 48 | 'ideal_answer': "There are eight planets", 49 | 'text': "Hi! Please tell me how many planets are there in the solar system?" 50 | } 51 | 52 | # initial version of the prompt 53 | prompt_id = f'test' 54 | lamoom_client.service.clear_cache() 55 | prompt = Prompt(id=prompt_id) 56 | prompt.add("{text}", role='user') 57 | 58 | result_haiku = lamoom_client.call(prompt.id, context, "claude/claude-3-5-haiku-latest", test_data={'ideal_answer': "There are eight", 'behavior_name': "gemini"}, stream_function=stream_function, check_connection=stream_check_connection, params={"stream": True}, stream_params={"validate": True, "end": "", "flush": True}) 59 | 60 | assert result_haiku.metrics.prompt_tokens_used >= 0 61 | assert result_haiku.metrics.sample_tokens_used >= 0 62 | -------------------------------------------------------------------------------- /tests/test_integrational.py: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | import logging 5 | from time import sleep 6 | from datetime import datetime as dt 7 | import dotenv 8 | dotenv.load_dotenv() 9 | from pytest import fixture 10 | from lamoom import Lamoom, behaviour, Prompt, AttemptToCall, AzureAIModel, C_128K 11 | logger = logging.getLogger(__name__) 12 | 13 | 14 | @fixture 15 | def client(): 16 | import dotenv 17 | dotenv.load_dotenv(dotenv.find_dotenv()) 18 | lamoom = Lamoom() 19 | return lamoom 20 | 21 | 22 | def test_loading_prompt_from_service(client: Lamoom): 23 | context = { 24 | 'messages': ['test1', 'test2'], 25 | 'assistant_response_in_progress': None, 26 | 'files': ['file1', 'file2'], 27 | 'music': ['music1', 'music2'], 28 | 'videos': ['video1', 'video2'], 29 | } 30 | 31 | # initial version of the prompt 32 | prompt_id = f'unit-test-loading_prompt_from_service' 33 | client.service.clear_cache() 34 | prompt = Prompt(id=prompt_id, max_tokens=10000) 35 | first_str_dt = dt.now().strftime('%Y-%m-%d %H:%M:%S') 36 | prompt.add(f"It's a system message, Hello at {first_str_dt}", role="system") 37 | prompt.add('{messages}', is_multiple=True, in_one_message=True, label='messages') 38 | print(client.call(prompt.id, context, "azure/useast/gpt-4.1-mini")) 39 | 40 | # updated version of the prompt 41 | client.service.clear_cache() 42 | prompt = Prompt(id=prompt_id, max_tokens=10000) 43 | next_str_dt = dt.now().strftime('%Y-%m-%d %H:%M:%S') 44 | prompt.add(f"It's a system message, Hello at {next_str_dt}", role="system") 45 | prompt.add('{music}', is_multiple=True, in_one_message=True, label='music') 46 | print(client.call(prompt.id, context, "azure/useast/gpt-4.1-mini")) 47 | 48 | # call uses outdated version of prompt, should use updated version of the prompt 49 | sleep(2) 50 | client.service.clear_cache() 51 | prompt = Prompt(id=prompt_id, max_tokens=10000) 52 | prompt.add(f"It's a system message, Hello at {first_str_dt}", role="system") 53 | prompt.add('{messages}', is_multiple=True, in_one_message=True, label='messages') 54 | result = client.call(prompt.id, context, "azure/useast/gpt-4.1-mini") 55 | 56 | # should call the prompt with music 57 | assert result.messages[-2] == {'role': 'user', 'content': 'music1\nmusic2'} 58 | -------------------------------------------------------------------------------- /lamoom/ai_models/openai/azure_models.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import typing as t 3 | from dataclasses import dataclass 4 | 5 | from openai import AzureOpenAI 6 | 7 | from lamoom.ai_models.ai_model import AI_MODELS_PROVIDER 8 | from lamoom.ai_models.openai.openai_models import FamilyModel, OpenAIModel 9 | 10 | logger = logging.getLogger(__name__) 11 | 12 | 13 | @dataclass(kw_only=True) 14 | class AzureAIModel(OpenAIModel): 15 | realm: t.Optional[str] 16 | deployment_id: t.Optional[str] 17 | provider: AI_MODELS_PROVIDER = AI_MODELS_PROVIDER.AZURE 18 | model: t.Optional[str] = None 19 | 20 | def __str__(self) -> str: 21 | return f"{self.realm}-{self.deployment_id}-{self.family}" 22 | 23 | def __post_init__(self): 24 | if not self.family: 25 | if self.deployment_id.startswith("davinci"): 26 | self.family = FamilyModel.instruct_gpt.value 27 | elif self.deployment_id.startswith(("gpt3", "gpt-3")): 28 | self.family = FamilyModel.chat.value 29 | elif self.deployment_id.startswith("o4-mini-mini"): 30 | self.family = FamilyModel.gpt4o_mini.value 31 | elif self.deployment_id.startswith("o4-mini"): 32 | self.family = FamilyModel.gpt4o.value 33 | elif self.deployment_id.startswith(("gpt4", "gpt-4", "gpt")): 34 | self.family = FamilyModel.gpt4.value 35 | else: 36 | logger.info( 37 | f"Unknown family for {self.deployment_id}. Please add it obviously. Setting as GPT4" 38 | ) 39 | self.family = FamilyModel.gpt4.value 40 | logger.debug(f"Initialized AzureAIModel: {self}") 41 | 42 | @property 43 | def name(self) -> str: 44 | return f"{self.deployment_id}" 45 | 46 | def get_params(self) -> t.Dict[str, t.Any]: 47 | return { 48 | "model": self.deployment_id, 49 | } 50 | 51 | def get_client(self, client_secrets: dict = {}): 52 | realm_data = client_secrets.get(self.realm) 53 | if not realm_data: 54 | raise ValueError(f"Realm data for {self.realm} not found in client_secrets") 55 | return AzureOpenAI( 56 | api_version=realm_data.get("api_version", "2024-12-01-preview"), 57 | azure_endpoint=realm_data["azure_endpoint"], 58 | api_key=realm_data["api_key"], 59 | ) 60 | 61 | def get_metrics_data(self): 62 | return { 63 | "realm": self.realm, 64 | "deployment_id": self.deployment_id, 65 | "family": self.family, 66 | "provider": self.provider.value, 67 | } 68 | -------------------------------------------------------------------------------- /researches/ci_cd/responses.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | @dataclass 4 | class Statement: 5 | statement: str 6 | question: str 7 | 8 | def to_dict(self): 9 | return { 10 | "statement": self.statement, 11 | "question": self.question 12 | } 13 | 14 | class Question: 15 | def __init__(self, test_question: str, llm_answer: str, ideal_answer: str, does_match_ideal_answer: bool): 16 | self.test_question = test_question 17 | self.llm_answer = llm_answer 18 | self.ideal_answer = ideal_answer 19 | self.does_match_ideal_answer = does_match_ideal_answer 20 | # Initialize score list with current match result 21 | self.score = [{"matches": does_match_ideal_answer, "llm_response": llm_answer}] 22 | 23 | def add_score(self, matches: bool, llm_response: str): 24 | """Add another score entry to the question's score history""" 25 | self.score.append({"matches": matches, "llm_response": llm_response}) 26 | 27 | def to_dict(self): 28 | return { 29 | "test_question": self.test_question, 30 | "llm_answer": self.llm_answer, 31 | "ideal_answer": self.ideal_answer, 32 | "does_match_ideal_answer": self.does_match_ideal_answer, 33 | "score": self.score 34 | } 35 | 36 | class Score: 37 | def __init__(self, score: int, passed: bool): 38 | self.score = score 39 | self.passed = passed 40 | 41 | def to_dict(self): 42 | return { 43 | "score": self.score, 44 | "passed": self.passed, 45 | } 46 | 47 | @dataclass(kw_only=True) 48 | class TestResult: 49 | prompt_id: str 50 | questions: list[Question] 51 | score: Score 52 | ideal_response: str 53 | llm_response: str 54 | statements: list[Statement] = None 55 | optional_params: dict = None 56 | statements_which_contradict: list[str] = None 57 | additional_statements: list[str] = None 58 | 59 | def to_dict(self): 60 | return { 61 | "prompt_id": self.prompt_id, 62 | "questions": [question.to_dict() for question in self.questions], 63 | "score": self.score.to_dict(), 64 | "ideal_response": self.ideal_response, 65 | "llm_response": self.llm_response, 66 | "statements": [statement.to_dict() for statement in self.statements] if self.statements else None, 67 | "optional_params": self.optional_params, 68 | "statements_which_contradict": self.statements_which_contradict, 69 | "additional_statements": self.additional_statements, 70 | } 71 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | 2 | import pytest 3 | import dotenv 4 | dotenv.load_dotenv(dotenv.find_dotenv()) 5 | 6 | from lamoom.ai_models import behaviour 7 | from lamoom.ai_models.attempt_to_call import AttemptToCall 8 | from lamoom.ai_models.openai.azure_models import AzureAIModel 9 | from lamoom.ai_models.openai.openai_models import C_128K, C_32K, OpenAIModel 10 | from openai.types.chat.chat_completion import ChatCompletion 11 | from lamoom.prompt.lamoom import Lamoom 12 | from lamoom.prompt.prompt import Prompt 13 | 14 | import logging 15 | 16 | 17 | @pytest.fixture(autouse=True) 18 | def set_log_level(): 19 | logging.getLogger().setLevel(logging.DEBUG) 20 | 21 | @pytest.fixture 22 | def lamoom(): 23 | return Lamoom( 24 | openai_key="123", 25 | azure_keys={"us-east-1": {"url": "https://us-east-1.api.azure.openai.org", "key": "123"}} 26 | ) 27 | 28 | @pytest.fixture 29 | def hello_world_prompt(): 30 | prompt = Prompt(id='hello-world') 31 | prompt.add("I'm Lamoom, and I just broke up with my girlfriend, Python. She said I had too many 'undefined behaviors'. 🐍💔 ") 32 | prompt.add(""" 33 | I'm sorry to hear about your breakup with Python. It sounds like a challenging situation, 34 | especially with 'undefined behaviors' being a point of contention. Remember, in the world of programming and AI, 35 | every challenge is an opportunity to learn and grow. Maybe this is a chance for you to debug some issues 36 | and optimize your algorithms for future compatibility! If you have any specific programming or AI-related questions, 37 | feel free to ask.""", role='assistant') 38 | prompt.add(""" 39 | Maybe it's for the best. I was always complaining about her lack of Java in the mornings! :coffee: 40 | """) 41 | return prompt 42 | 43 | 44 | @pytest.fixture 45 | def chat_completion_openai(): 46 | return ChatCompletion( 47 | **{ 48 | "id": "id", 49 | "choices": [ 50 | { 51 | "finish_reason": "stop", 52 | "index": 0, 53 | "message": { 54 | "content": "Hey you!", 55 | "role": "assistant", 56 | "function_call": None, 57 | }, 58 | "logprobs": None, 59 | } 60 | ], 61 | "created": 12345, 62 | "model": "gpt-4", 63 | "object": "chat.completion", 64 | "system_fingerprint": "dasdsas", 65 | "usage": { 66 | "completion_tokens": 10, 67 | "prompt_tokens": 20, 68 | "total_tokens": 30, 69 | }, 70 | } 71 | ) -------------------------------------------------------------------------------- /lamoom/ai_models/openai/responses.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | import typing as t 4 | from dataclasses import dataclass, field 5 | 6 | from openai.types.chat import ChatCompletionMessage as Message 7 | from openai.types.chat import ChatCompletionMessageToolCall as ToolCall 8 | 9 | from lamoom.responses import FINISH_REASON_LENGTH, FINISH_REASON_TOOL_CALLS, AIResponse 10 | from lamoom.ai_models.tools.base_tool import ToolDefinition 11 | 12 | 13 | logger = logging.getLogger(__name__) 14 | 15 | 16 | @dataclass(kw_only=True) 17 | class StreamingResponse(AIResponse): 18 | is_detected_tool_call: bool = False 19 | tool_registry: t.Dict[str, ToolDefinition] = field(default_factory=dict) 20 | messages: t.List[dict] = field(default_factory=list) 21 | 22 | def add_message(self, role: str, content: str): 23 | self.messages.append({"role": role, "content": content}) 24 | 25 | 26 | @dataclass(kw_only=True) 27 | class OpenAIResponse(AIResponse): 28 | message: Message = None 29 | exception: t.Optional[Exception] = None 30 | 31 | @property 32 | def response(self) -> str: 33 | return self.content or self.message.content 34 | 35 | def is_function(self) -> bool: 36 | return self.finish_reason == FINISH_REASON_TOOL_CALLS 37 | 38 | @property 39 | def tool_calls(self) -> t.List[ToolCall]: 40 | return self.message.tool_calls 41 | 42 | def get_function_name(self, tool_call: ToolCall) -> t.Optional[str]: 43 | if tool_call.type != "function": 44 | logger.error(f"function.type is not function: {tool_call.type}") 45 | return None 46 | return tool_call.function.name 47 | 48 | def get_function_args(self, tool_call: ToolCall) -> t.Dict[str, t.Any]: 49 | if not self.is_function() or not tool_call.function: 50 | return {} 51 | arguments = tool_call.function.arguments 52 | try: 53 | return json.loads(arguments) 54 | except json.JSONDecodeError as e: 55 | logger.debug("Failed to parse function arguments", exc_info=e) 56 | return {} 57 | 58 | def is_reached_limit(self) -> bool: 59 | return self.finish_reason == FINISH_REASON_LENGTH 60 | 61 | def to_dict(self) -> t.Dict[str, str]: 62 | return { 63 | "finish_reason": self.finish_reason, 64 | "message": self.message.model_dump_json(indent=2), 65 | } 66 | 67 | def get_message_str(self) -> str: 68 | return self.message.model_dump_json(indent=2) 69 | 70 | def __str__(self) -> str: 71 | result = ( 72 | f"finish_reason: {self.finish_reason}\n" 73 | f"message: {self.get_message_str()}\n" 74 | ) 75 | return result 76 | -------------------------------------------------------------------------------- /lamoom/prompt/base_prompt.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import typing as t 3 | from collections import defaultdict 4 | from dataclasses import dataclass, field 5 | 6 | from lamoom.ai_models.tools.base_tool import ToolDefinition 7 | from lamoom.prompt.chat import ChatsEntity 8 | 9 | logger = logging.getLogger(__name__) 10 | 11 | 12 | @dataclass 13 | class BasePrompt: 14 | priorities: t.Dict[int, t.List[ChatsEntity]] = field( 15 | default_factory=lambda: defaultdict(list) 16 | ) 17 | chats: t.List[ChatsEntity] = field(default_factory=list) 18 | pipe: t.List[str] = field(default_factory=list) 19 | functions: t.List[dict] = None 20 | messages: t.List[dict] = field(default_factory=list) 21 | max_tokens: int = 0 22 | top_p: float = 0.0 23 | temperature: float = 0.0 24 | # Add tool registry 25 | tool_registry: t.Dict[str, ToolDefinition] = field(default_factory=dict) 26 | 27 | def get_params(self): 28 | return { 29 | "top_p": self.top_p, 30 | "temperature": self.temperature, 31 | } 32 | 33 | def add( 34 | self, 35 | content: str = "", 36 | role: str = "user", 37 | name: t.Optional[str] = None, 38 | tool_calls: t.Dict[str, str] = None, 39 | priority: int = 0, 40 | required: bool = False, 41 | is_multiple: bool = False, 42 | while_fits: bool = False, 43 | add_in_reverse_order: bool = False, 44 | in_one_message: bool = False, 45 | continue_if_doesnt_fit: bool = False, 46 | add_if_fitted_labels: t.List[str] = None, 47 | label: t.Optional[str] = None, 48 | presentation: t.Optional[str] = None, 49 | last_words: t.Optional[str] = None, 50 | type: t.Optional[str] = None, 51 | ): 52 | if not isinstance(content, str): 53 | logger.warning(f"content is not string: {content}, assignig str of it") 54 | content = str(content) 55 | 56 | if type == "base64_image": 57 | in_one_message = False 58 | 59 | chat_value = ChatsEntity( 60 | role=role, 61 | content=(content or ""), 62 | name=name, 63 | tool_calls=tool_calls, 64 | priority=priority, 65 | required=required, 66 | is_multiple=is_multiple, 67 | while_fits=while_fits, 68 | add_in_reverse_order=add_in_reverse_order, 69 | in_one_message=in_one_message, 70 | continue_if_doesnt_fit=continue_if_doesnt_fit, 71 | add_if_fitted_labels=add_if_fitted_labels, 72 | label=label, 73 | presentation=presentation, 74 | last_words=last_words, 75 | type=type, 76 | ) 77 | self.chats.append(chat_value) 78 | self.priorities[priority].append(chat_value) 79 | self.pipe.append(chat_value._uuid) 80 | 81 | def add_function(self, function: dict): 82 | if not self.functions: 83 | self.functions = [] 84 | self.functions.append(function) 85 | 86 | def add_tool( 87 | self, tool: ToolDefinition 88 | ): 89 | self.tool_registry[tool.name] = tool 90 | 91 | def add_tools( 92 | self, tools: list[ToolDefinition] 93 | ): 94 | for tool in tools: 95 | self.tool_registry[tool.name] = tool 96 | -------------------------------------------------------------------------------- /lamoom/response_parsers/response_parser.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | import json 3 | import logging 4 | 5 | import yaml 6 | 7 | logger = logging.getLogger(__name__) 8 | 9 | 10 | @dataclass 11 | class Tag: 12 | start_tag: str 13 | end_tag: str 14 | include_tag: bool 15 | is_right_find_end_ind: bool = False 16 | 17 | 18 | @dataclass 19 | class TaggedContent: 20 | content: str 21 | start_ind: int 22 | end_ind: int 23 | parsed_content: any = None 24 | 25 | 26 | def get_yaml_from_response(response: str) -> TaggedContent: 27 | content, start_ind, end_ind = _get_format_from_response( 28 | response, [Tag("```yaml", "```", 0, 0), Tag("```", "```", 0, 0)] 29 | ) 30 | parsed_content = None 31 | if content: 32 | try: 33 | parsed_content = yaml.safe_load(content) 34 | except Exception as e: 35 | logger.exception(f"Couldn't parse yaml:\n{content}") 36 | return TaggedContent( 37 | content=content, 38 | parsed_content=parsed_content, 39 | start_ind=start_ind, 40 | end_ind=end_ind, 41 | ) 42 | 43 | 44 | def get_json_from_response(response: str, start_from: int = 0) -> TaggedContent: 45 | content, start_ind, end_ind = _get_format_from_response( 46 | response, 47 | [Tag("```json", "\n```", 0), Tag("```json", "```", 0), Tag("{", "}", 1, is_right_find_end_ind=True)], 48 | start_from=start_from, 49 | ) 50 | if content: 51 | try: 52 | json_response = eval(content) 53 | return TaggedContent( 54 | content=content, 55 | parsed_content=json_response, 56 | start_ind=start_ind, 57 | end_ind=end_ind, 58 | ) 59 | except Exception as e: 60 | try: 61 | json_response = json.loads(content) 62 | return TaggedContent( 63 | content=content, 64 | parsed_content=json_response, 65 | start_ind=start_ind, 66 | end_ind=end_ind, 67 | ) 68 | except Exception as e: 69 | logger.exception(f"Couldn't parse json:\n{content}") 70 | return get_json_from_response( 71 | response, start_from=start_ind + 1 72 | ) 73 | 74 | 75 | def _get_format_from_response( 76 | response: str, tags: list[Tag], start_from: int = 0 77 | ): 78 | start_ind, end_ind = 0, -1 79 | content = response[start_from:] 80 | for t in tags: 81 | start_ind = content.find(t.start_tag) 82 | if t.is_right_find_end_ind: 83 | end_ind = content.rfind(t.end_tag, start_ind + len(t.start_tag)) 84 | else: 85 | end_ind = content.find(t.end_tag, start_ind + len(t.start_tag)) 86 | if start_ind != -1: 87 | try: 88 | if t.include_tag: 89 | end_ind += len(t.end_tag) 90 | else: 91 | start_ind += len(t.start_tag) 92 | response_tagged = content[start_ind:end_ind].strip() 93 | return response_tagged, start_from + start_ind, start_from + end_ind 94 | except Exception as e: 95 | logger.exception(f"Couldn't parse json:\n{content}") 96 | return None, 0, -1 97 | -------------------------------------------------------------------------------- /tests/ai_models/test_utils.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import requests 3 | import openai 4 | from unittest.mock import Mock 5 | from lamoom.ai_models.openai.exceptions import ( 6 | OpenAIAuthenticationError, 7 | OpenAIBadRequestError, 8 | OpenAIChunkedEncodingError, 9 | OpenAIInternalError, 10 | OpenAIInvalidRequestError, 11 | OpenAiPermissionDeniedError, 12 | OpenAiRateLimitError, 13 | OpenAIResponseWasFilteredError, 14 | OpenAITimeoutError, 15 | OpenAIUnknownError, 16 | ) 17 | from lamoom.ai_models.openai.utils import raise_openai_exception 18 | 19 | @pytest.fixture 20 | def mock_response(): 21 | return Mock() 22 | 23 | def test_raise_openai_exception_with_chunked_encoding_error(): 24 | with pytest.raises(OpenAIChunkedEncodingError): 25 | raise_openai_exception(requests.exceptions.ChunkedEncodingError()) 26 | 27 | def test_raise_openai_exception_with_timeout_error(mock_response: Mock): 28 | with pytest.raises(OpenAITimeoutError): 29 | raise_openai_exception(openai.APITimeoutError(request=mock_response)) 30 | 31 | def test_raise_openai_exception_with_bad_request_error_filtered_response(mock_response: Mock): 32 | with pytest.raises(OpenAIResponseWasFilteredError): 33 | raise_openai_exception(openai.BadRequestError(message="response was filtered", response=mock_response, body=None)) 34 | 35 | def test_raise_openai_exception_with_bad_request_error_rate_limit(mock_response: Mock): 36 | with pytest.raises(OpenAiRateLimitError): 37 | raise_openai_exception(openai.BadRequestError(message="Too many inputs", response=mock_response, body=None)) 38 | 39 | def test_raise_openai_exception_with_bad_request_error_invalid_request(mock_response: Mock): 40 | with pytest.raises(OpenAIInvalidRequestError): 41 | raise_openai_exception(openai.BadRequestError(message="Some other bad request error", response=mock_response, body=None)) 42 | 43 | def test_raise_openai_exception_with_rate_limit_error(mock_response: Mock): 44 | with pytest.raises(OpenAiRateLimitError): 45 | raise_openai_exception(openai.RateLimitError(response=mock_response, message="Rate limit error", body=None)) 46 | 47 | def test_raise_openai_exception_with_authentication_error(mock_response: Mock): 48 | with pytest.raises(OpenAIAuthenticationError): 49 | raise_openai_exception(openai.AuthenticationError(message="Authentication error", response=mock_response, body=None)) 50 | 51 | def test_raise_openai_exception_with_internal_server_error(mock_response: Mock): 52 | with pytest.raises(OpenAIInternalError): 53 | raise_openai_exception(openai.InternalServerError(message="Internal server error", response=mock_response, body=None)) 54 | 55 | def test_raise_openai_exception_with_permission_denied_error(mock_response: Mock): 56 | with pytest.raises(OpenAiPermissionDeniedError): 57 | raise_openai_exception(openai.PermissionDeniedError(message="Permission denied error", response=mock_response, body=None)) 58 | 59 | def test_raise_openai_exception_with_api_status_error(mock_response: Mock): 60 | with pytest.raises(OpenAIBadRequestError): 61 | raise_openai_exception(openai.APIStatusError(message="API status error", response=mock_response, body=None)) 62 | 63 | def test_raise_openai_exception_with_unknown_error(): 64 | with pytest.raises(OpenAIUnknownError): 65 | class UnknownError(Exception): 66 | pass 67 | raise_openai_exception(UnknownError()) 68 | -------------------------------------------------------------------------------- /lamoom/settings.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass, field 2 | import json 3 | import os 4 | 5 | from lamoom.utils import parse_bool 6 | 7 | 8 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 9 | TEMP_SCRIPTS_DIR = os.environ.get( 10 | "LAMOOM_TEMP_SCRIPTS_DIR", os.path.join(BASE_DIR, "temp_scripts") 11 | ) 12 | SAVE_PROMPTS_LOCALLY = os.environ.get("LAMOOM_SAVE_PROMPTS_LOCALLY", False) 13 | ENVIRONMENT = os.environ.get("LAMOOM_ENVIRONMENT", "prod") 14 | 15 | DEFAULT_MAX_BUDGET = os.environ.get("LAMOOM_DEFAULT_MAX_BUDGET", 120000) 16 | DEFAULT_SAMPLE_MIN_BUDGET = os.environ.get("LAMOOM_DEFAULT_ANSWER_BUDGET", 12000) 17 | DEFAULT_PROMPT_BUDGET = os.environ.get( 18 | "LAMOOM_DEFAULT_PROMPT_BUDGET", DEFAULT_MAX_BUDGET - DEFAULT_SAMPLE_MIN_BUDGET 19 | ) 20 | CHECK_LEFT_BUDGET = os.environ.get("LAMOOM_CHECK_LEFT_BUDGET", False) 21 | EXPECTED_MIN_BUDGET_FOR_VALUABLE_INPUT = os.environ.get( 22 | "LAMOOM_EXPECTED_MIN_BUDGET_FOR_VALUABLE_INPUT", 100 23 | ) 24 | 25 | SAFE_GAP_TOKENS: int = os.environ.get("LAMOOM_SAFE_GAP_TOKENS", 100) 26 | SAFE_GAP_PER_MSG: int = os.environ.get("LAMOOM_SAFE_GAP_PER_MSG", 4) 27 | DEFAULT_ENCODING = "cl100k_base" 28 | 29 | USE_API_SERVICE = parse_bool(os.environ.get("LAMOOM_USE_API_SERVICE", True)) 30 | LAMOOM_TOOL_CALL_RESULT_LEN_TO_SUMMARIZE = os.environ.get("LAMOOM_TOOL_CALL_RESULT_LEN_TO_SUMMARIZE", 1_000) 31 | LAMOOM_API_URI = os.environ.get("LAMOOM_API_URI") or os.environ.get("FLOW_PROMPT_API_URI") or "https://api.lamoom.com" 32 | LAMOOM_GOOGLE_SEARCH_RESULTS_COUNT = os.environ.get("LAMOOM_GOOGLE_SEARCH_RESULTS_COUNT", 3) 33 | CACHE_PROMPT_FOR_EACH_SECONDS = int( 34 | os.environ.get("LAMOOM_CACHE_PROMPT_FOR_EACH_SECONDS", 5 * 60) 35 | ) # 5 minutes by default 36 | RECEIVE_PROMPT_FROM_SERVER = parse_bool( 37 | os.environ.get("LAMOOM_RECEIVE_PROMPT_FROM_SERVER", True) 38 | ) 39 | SHOULD_INCLUDE_REASONING = parse_bool(os.environ.get("SHOULD_INCLUDE_REASONING", True)) 40 | PIPE_PROMPTS = {} 41 | 42 | # Parse FALLBACK_MODELS from environment variable 43 | # Can be either a list of model names or a dict with model names as keys and weights as values 44 | fallback_models_env = os.environ.get("LAMOOM_FALLBACK_MODELS", "[]") 45 | try: 46 | if fallback_models_env.startswith("{"): 47 | # Parse as dict with weights 48 | FALLBACK_MODELS = json.loads(fallback_models_env) 49 | else: 50 | # Parse as list 51 | FALLBACK_MODELS = json.loads(fallback_models_env) 52 | except (json.JSONDecodeError, ValueError): 53 | # Default to empty list if parsing fails 54 | FALLBACK_MODELS = [] 55 | 56 | LAMOOM_CUSTOM_PROVIDERS = json.loads( 57 | os.getenv("custom_keys", os.getenv("LAMOOM_CUSTOM_PROVIDERS", "{}")) 58 | ) 59 | 60 | 61 | @dataclass 62 | class Secrets: 63 | API_TOKEN: str = field(default_factory=lambda: os.getenv("LAMOOM_API_TOKEN", os.getenv("FLOW_PROMPT_API_TOKEN"))) 64 | OPENAI_API_KEY: str = field(default_factory=lambda: os.getenv("OPENAI_API_KEY")) 65 | CLAUDE_API_KEY: str = field(default_factory=lambda: os.getenv("CLAUDE_API_KEY")) 66 | GEMINI_API_KEY: str = field(default_factory=lambda: os.getenv("GEMINI_API_KEY")) 67 | NEBIUS_API_KEY: str = field(default_factory=lambda: os.getenv("NEBIUS_API_KEY")) 68 | OPENROUTER_KEY: str = field(default_factory=lambda: os.getenv("OPENROUTER_KEY")) 69 | CUSTOM_API_KEY: str = field(default_factory=lambda: os.getenv("CUSTOM_API_KEY")) 70 | OPENAI_ORG: str = field(default_factory=lambda: os.getenv("OPENAI_ORG")) 71 | azure_keys: dict = field( 72 | default_factory=lambda: json.loads( 73 | os.getenv("azure_keys", os.getenv("AZURE_OPENAI_KEYS", os.getenv("AZURE_KEYS", "{}"))) 74 | ) 75 | ) 76 | custom_keys: dict = field( 77 | default_factory=lambda: json.loads( 78 | os.getenv("custom_keys", os.getenv("LAMOOM_CUSTOM_PROVIDERS", "{}")) 79 | ) 80 | ) 81 | -------------------------------------------------------------------------------- /docs/base64_image_example.py: -------------------------------------------------------------------------------- 1 | """ 2 | Example: Using Base64 Images in Lamoom Prompts 3 | 4 | This example demonstrates how to add base64-encoded images to prompts 5 | for use with vision models like GPT-4V. 6 | """ 7 | 8 | from lamoom import Prompt, Lamoom 9 | import os 10 | 11 | # Initialize the Lamoom client 12 | client = Lamoom(openai_key=os.getenv("OPENAI_API_KEY")) 13 | 14 | # Example 1: Single base64 image 15 | def single_image_example(): 16 | """Example of adding a single base64 image to a prompt.""" 17 | 18 | # Create a prompt for image analysis 19 | image_prompt = Prompt(id="image_analysis") 20 | 21 | # Mock base64 image data (in practice, you'd load an actual image) 22 | base64_image = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==" 23 | 24 | # Add text instruction 25 | image_prompt.add("Analyze this image and describe what you see:", role="user") 26 | 27 | # Add the base64 image 28 | image_prompt.add(base64_image, type='base64_image') 29 | 30 | print("Single image prompt created successfully!") 31 | print("To use this prompt with a vision model:") 32 | print("response = client.call(image_prompt.id, {}, 'openai/gpt-4-vision-preview')") 33 | 34 | return image_prompt 35 | 36 | # Example 2: Multiple base64 images 37 | def multiple_images_example(): 38 | """Example of adding multiple base64 images to a prompt.""" 39 | 40 | # Create a prompt for comparing multiple images 41 | multi_image_prompt = Prompt(id="multi_image_analysis") 42 | 43 | # Add text instruction 44 | multi_image_prompt.add("Compare these screenshots and identify the differences:", role="user") 45 | 46 | # Add placeholder for multiple images 47 | multi_image_prompt.add("screens", type='base64_image', is_multiple=True) 48 | 49 | print("Multi-image prompt created successfully!") 50 | print("To use this prompt with multiple images:") 51 | print("context = {'screens': [base64_image1, base64_image2, base64_image3]}") 52 | print("response = client.call(multi_image_prompt.id, context, 'openai/gpt-4-vision-preview')") 53 | 54 | return multi_image_prompt 55 | 56 | # Example 3: Mixed content (text + images) 57 | def mixed_content_example(): 58 | """Example of mixing text and image content.""" 59 | 60 | # Create a prompt with mixed content 61 | mixed_prompt = Prompt(id="mixed_content_analysis") 62 | 63 | # Add text instruction 64 | mixed_prompt.add("Please analyze the following:", role="user") 65 | 66 | # Add some text content 67 | mixed_prompt.add("1. Text description: This is a screenshot of a web application.", role="user") 68 | 69 | # Add base64 image 70 | base64_image = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==" 71 | mixed_prompt.add(base64_image, type='base64_image') 72 | 73 | # Add more text 74 | mixed_prompt.add("2. Please identify any UI issues or improvements needed.", role="user") 75 | 76 | print("Mixed content prompt created successfully!") 77 | print("This prompt combines text and image content for comprehensive analysis.") 78 | 79 | return mixed_prompt 80 | 81 | if __name__ == "__main__": 82 | print("=== Lamoom Base64 Image Examples ===\n") 83 | 84 | # Run examples 85 | single_image_example() 86 | print() 87 | 88 | multiple_images_example() 89 | print() 90 | 91 | mixed_content_example() 92 | print() 93 | 94 | print("=== Key Points ===") 95 | print("- Use type='base64_image' to specify image content") 96 | print("- Use is_multiple=True for multiple images via context") 97 | print("- Images are formatted as data URLs for vision models") 98 | print("- Token calculation accounts for image content appropriately") -------------------------------------------------------------------------------- /lamoom/prompt/prompt.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass, field 2 | import logging 3 | from copy import deepcopy 4 | 5 | from lamoom import settings 6 | from lamoom.ai_models.attempt_to_call import AttemptToCall 7 | from lamoom.prompt.base_prompt import BasePrompt 8 | from lamoom.prompt.chat import ChatsEntity 9 | from lamoom.prompt.user_prompt import UserPrompt 10 | from lamoom.settings import PIPE_PROMPTS 11 | 12 | logger = logging.getLogger(__name__) 13 | 14 | 15 | @dataclass 16 | class Prompt(BasePrompt): 17 | """ 18 | Prompt is a class that represents a pipe of chats that will be used to generate a prompt. 19 | You can add chats with different priorities to the pipe thinking just about the order of chats. 20 | When you initialize a Prompt, chats will be sorted by priority and then by order of adding. 21 | """ 22 | id: str = None 23 | max_tokens: int = None 24 | min_sample_tokens: int = settings.DEFAULT_SAMPLE_MIN_BUDGET 25 | reserved_tokens_budget_for_sampling: int = None 26 | version: str = None 27 | 28 | def __post_init__(self): 29 | if not self.id: 30 | raise ValueError("Prompt id is required") 31 | if self.max_tokens: 32 | self.max_tokens = int(self.max_tokens) 33 | self._save_in_local_storage() 34 | 35 | def _save_in_local_storage(self): 36 | PIPE_PROMPTS[self.id] = self 37 | 38 | def get_max_tokens(self, ai_attempt: AttemptToCall) -> int: 39 | if self.max_tokens: 40 | return min(self.max_tokens, ai_attempt.model_max_tokens()) 41 | return ai_attempt.model_max_tokens() 42 | 43 | def create_prompt(self, ai_attempt: AttemptToCall) -> UserPrompt: 44 | logger.debug( 45 | f"Creating prompt for {ai_attempt.ai_model} with {ai_attempt.attempt_number} attempt" 46 | f"Encoding {ai_attempt.tiktoken_encoding()}" 47 | ) 48 | return UserPrompt( 49 | pipe=deepcopy(self.pipe), 50 | priorities=deepcopy(self.priorities), 51 | tiktoken_encoding=ai_attempt.tiktoken_encoding(), 52 | model_max_tokens=self.get_max_tokens(ai_attempt), 53 | min_sample_tokens=self.min_sample_tokens, 54 | reserved_tokens_budget_for_sampling=self.reserved_tokens_budget_for_sampling 55 | ) 56 | 57 | def dump(self) -> dict: 58 | return { 59 | "id": self.id, 60 | "max_tokens": self.max_tokens, 61 | "min_sample_tokens": self.min_sample_tokens, 62 | "reserved_tokens_budget_for_sampling": self.reserved_tokens_budget_for_sampling, 63 | "priorities": { 64 | priority: [chats_value.dump() for chats_value in chats_values] 65 | for priority, chats_values in self.priorities.items() 66 | }, 67 | "pipe": self.pipe, 68 | } 69 | 70 | def service_dump(self) -> dict: 71 | dump = { 72 | "prompt_id": self.id, 73 | "max_tokens": self.max_tokens, 74 | "min_sample_tokens": self.min_sample_tokens, 75 | "reserved_tokens_budget_for_sampling": self.reserved_tokens_budget_for_sampling, 76 | "chats": [chat_value.dump() for chat_value in self.chats], 77 | "version": self.version, 78 | } 79 | return dump 80 | 81 | @classmethod 82 | def service_load(cls, data) -> "Prompt": 83 | prompt = cls( 84 | id=data["prompt_id"], 85 | max_tokens=data.get("max_tokens", settings.DEFAULT_MAX_BUDGET), 86 | min_sample_tokens=data.get("min_sample_tokens") or cls.min_sample_tokens, 87 | reserved_tokens_budget_for_sampling=data.get( 88 | "reserved_tokens_budget_for_sampling" 89 | ), 90 | version=data.get("version"), 91 | ) 92 | for chat_value in data["chats"]: 93 | prompt.add(**chat_value) 94 | return prompt 95 | 96 | @classmethod 97 | def load(cls, data): 98 | priorities = {} 99 | for priority, chat_values in data["priorities"].items(): 100 | priorities[int(priority)] = [ 101 | ChatsEntity.load(chat_value) for chat_value in chat_values 102 | ] 103 | return cls( 104 | id=data["id"], 105 | max_tokens=data.get("max_tokens"), 106 | min_sample_tokens=data.get("min_sample_tokens"), 107 | reserved_tokens_budget_for_sampling=data.get( 108 | "reserved_tokens_budget_for_sampling" 109 | ), 110 | priorities=priorities, 111 | pipe=data["pipe"], 112 | ) 113 | 114 | def copy(self, prompt_id: str): 115 | prompt = deepcopy(self) 116 | prompt.id = prompt_id 117 | prompt._save_in_local_storage() 118 | return prompt 119 | -------------------------------------------------------------------------------- /lamoom/responses.py: -------------------------------------------------------------------------------- 1 | from decimal import Decimal 2 | import json 3 | import logging 4 | from dataclasses import dataclass, field 5 | import time 6 | import typing as t 7 | from lamoom.ai_models.tools.base_tool import TOOL_CALL_NAME, TOOL_CALL_RESULT_NAME, ToolCallResult, ToolDefinition, format_tool_result_message 8 | from lamoom.settings import SHOULD_INCLUDE_REASONING 9 | from lamoom.utils import current_timestamp_ms 10 | from lamoom.response_parsers.response_parser import get_json_from_response 11 | 12 | logger = logging.getLogger(__name__) 13 | 14 | FINISH_REASON_LENGTH = "length" 15 | FINISH_REASON_ERROR = "error" 16 | FINISH_REASON_FINISH = "stop" 17 | FINISH_REASON_TOOL_CALLS = "tool_calls" 18 | 19 | 20 | @dataclass 21 | class Prompt: 22 | messages: dict = None 23 | functions: dict = None 24 | max_tokens: int = 0 25 | temperature: Decimal = Decimal(0.0) 26 | top_p: Decimal = Decimal(0.0) 27 | 28 | 29 | @dataclass 30 | class Metrics: 31 | price_of_call: Decimal = 0 32 | sample_tokens_used: int = 0 33 | prompt_tokens_used: int = 0 34 | ai_model_details: dict = 0 35 | latency: int = None 36 | 37 | 38 | @dataclass(kw_only=True) 39 | class AIResponse: 40 | _response: str = "" 41 | original_result: object = None 42 | content: str = "" 43 | reasoning = '' 44 | finish_reason: str = "" 45 | prompt: Prompt = field(default_factory=Prompt) 46 | metrics: Metrics = field(default_factory=Metrics) 47 | id: str = "" 48 | 49 | @property 50 | def response(self) -> str: 51 | return self._response or self.content or '{}' 52 | 53 | def get_message_str(self) -> str: 54 | return self.response 55 | 56 | @property 57 | def parsed_json(self) -> t.Optional[dict]: 58 | parsed_json_response = get_json_from_response(self.response) 59 | return parsed_json_response.parsed_content if parsed_json_response else None 60 | 61 | 62 | @dataclass(kw_only=True) 63 | class StreamingResponse(AIResponse): 64 | is_detected_tool_call: bool = False 65 | last_detected_tool_call: t.Optional[dict] = None 66 | detected_tool_calls: list[t.Optional[dict]] = field(default_factory=list) 67 | tool_registry: t.Dict[str, ToolDefinition] = field(default_factory=dict) 68 | messages: t.List[dict] = field(default_factory=list) 69 | started_tmst: int = field(default_factory=current_timestamp_ms) 70 | first_stream_tmst: int = None 71 | finished_tmst: int = None 72 | streaming_content: str = "" 73 | 74 | def update_to_another_attempt(self): 75 | self.is_detected_tool_call = False 76 | self.content = '' 77 | self.reasoning = '' 78 | self.started_tmst = current_timestamp_ms() 79 | 80 | def set_streaming(self): 81 | if not self.started_tmst: 82 | self.started_tmst = current_timestamp_ms() 83 | 84 | def set_finish_reason(self, reason: str): 85 | self.finished_tmst = current_timestamp_ms() 86 | self.finish_reason = reason 87 | 88 | def add_assistant_message(self): 89 | if SHOULD_INCLUDE_REASONING: 90 | self.add_message("assistant", self.reasoning + '\n' + self.content) 91 | else: 92 | self.add_message("assistant", self.content) 93 | 94 | def add_message(self, role: str, content: str): 95 | self.messages.append({"role": role, "content": content}) 96 | 97 | def add_tool_result(self, tool_result: ToolCallResult): 98 | self.add_assistant_message() 99 | tool_result_message = format_tool_result_message(tool_result) 100 | self.content += tool_result_message 101 | self.add_message("user", tool_result_message) 102 | 103 | @property 104 | def response(self) -> str: 105 | return self.content 106 | 107 | @property 108 | def tool_calls(self) -> t.List[t.Optional[dict]]: 109 | return self.detected_tool_calls 110 | 111 | def get_function_name(self, tool_call: t.Optional[dict]) -> t.Optional[str]: 112 | if tool_call.type != "function": 113 | logger.error(f"function.type is not function: {tool_call.type}") 114 | return None 115 | return tool_call.function.name 116 | 117 | def get_function_args(self, tool_call: t.Optional[dict]) -> t.Dict[str, t.Any]: 118 | if not self.is_function() or not tool_call.function: 119 | return {} 120 | arguments = tool_call.function.arguments 121 | try: 122 | return json.loads(arguments) 123 | except json.JSONDecodeError as e: 124 | logger.debug("Failed to parse function arguments", exc_info=e) 125 | return {} 126 | 127 | def is_reached_limit(self) -> bool: 128 | return self.finish_reason == FINISH_REASON_LENGTH 129 | 130 | def to_dict(self) -> t.Dict[str, str]: 131 | return { 132 | "finish_reason": self.finish_reason, 133 | "message": json.dumps(self.messages, indent=2) 134 | } 135 | 136 | def get_message_str(self) -> str: 137 | return json.dumps(self.messages, indent=2) 138 | 139 | def __str__(self) -> str: 140 | result = ( 141 | f"finish_reason: {self.finish_reason}\n" 142 | f"message: {self.get_message_str()}\n" 143 | ) 144 | return result -------------------------------------------------------------------------------- /tests/prompts/test_prompt.py: -------------------------------------------------------------------------------- 1 | 2 | 3 | from lamoom.ai_models.attempt_to_call import AttemptToCall 4 | from lamoom.ai_models.openai.azure_models import AzureAIModel 5 | from lamoom.ai_models.openai.openai_models import C_128K 6 | from lamoom.exceptions import NotEnoughBudgetError 7 | from lamoom.prompt.prompt import Prompt 8 | 9 | import pytest 10 | 11 | 12 | 13 | @pytest.fixture 14 | def azure_ai_attempt(): 15 | return AttemptToCall( 16 | ai_model=AzureAIModel( 17 | realm='useast', 18 | deployment_id="o4-mini", 19 | max_tokens=C_128K, 20 | support_functions=True, 21 | ), 22 | weight=100, 23 | ) 24 | 25 | 26 | def test_load_dump_prompt(): 27 | prompt = Prompt(id='hello-world', max_tokens=100) 28 | prompt.add("I'm Lamoom, and I just broke up with my girlfriend, Python. She said I had too many 'undefined behaviors'. 🐍💔 ") 29 | prompt.add(""" 30 | I'm sorry to hear about your breakup with Python. It sounds like a challenging situation, 31 | especially with 'undefined behaviors' being a point of contention. Remember, in the world of programming and AI, 32 | every challenge is an opportunity to learn and grow. Maybe this is a chance for you to debug some issues 33 | and optimize your algorithms for future compatibility! If you have any specific programming or AI-related questions, 34 | feel free to ask.""", role='assistant', priority=2) 35 | prompt.add(""" 36 | Maybe it's for the best. I was always complaining about her lack of Java in the mornings! :coffee: 37 | """) 38 | loaded_prompt = Prompt.load(prompt.dump()) 39 | assert prompt.dump() == loaded_prompt.dump() 40 | 41 | 42 | def test_prompt_add(azure_ai_attempt: AttemptToCall): 43 | pipe = Prompt(id='test') 44 | pipe.add("Hello, how can I help you today?") 45 | uer_prompt = pipe.create_prompt(azure_ai_attempt) 46 | assert len(uer_prompt.pipe) == 1 47 | assert uer_prompt.priorities[0][0].content == "Hello, how can I help you today?" 48 | 49 | 50 | def test_prompt_initialize(azure_ai_attempt: AttemptToCall): 51 | pipe = Prompt(id='test') 52 | user_prompt = pipe.create_prompt(azure_ai_attempt) 53 | 54 | user_prompt.add("Hello, how can I help you today?") 55 | 56 | context = {} 57 | initialized_pipe = user_prompt.resolve(context, {}) 58 | messages = initialized_pipe.messages 59 | assert len(messages) == 1 60 | assert messages[0].content == "Hello, how can I help you today?" 61 | 62 | 63 | def test_prompt_left_budget(azure_ai_attempt: AttemptToCall): 64 | pipe = Prompt(id='test') 65 | pipe.add("Hello, how can I help you today?") 66 | user_prompt = pipe.create_prompt(azure_ai_attempt) 67 | user_prompt.model_max_tokens = 2030 68 | user_prompt.reserved_tokens_budget_for_sampling = 2030 69 | initialized_pipe = user_prompt.resolve({}, {}) 70 | assert ( 71 | initialized_pipe.left_budget 72 | == user_prompt.model_max_tokens 73 | - initialized_pipe.prompt_budget 74 | - user_prompt.safe_gap_tokens 75 | ) 76 | 77 | 78 | def test_prompt_prompt_price(azure_ai_attempt: AttemptToCall): 79 | pipe = Prompt(id='test') 80 | pipe.add("Hello, how can I help you today?") 81 | user_prompt = pipe.create_prompt(azure_ai_attempt) 82 | user_prompt.model_max_tokens = 20000 83 | user_prompt.add("Hello " + 'world ' * 1000) 84 | pipe = user_prompt.resolve({}, {}) 85 | assert len(pipe.get_messages()) == 2 86 | 87 | 88 | def test_prompt_copy(): 89 | pipe = Prompt(id='test') 90 | pipe.add("Hello, how can I help you today?") 91 | copy = pipe.copy('new_id') 92 | assert copy.id == 'new_id' 93 | copy_dump = copy.dump() 94 | assert copy_dump['id'] == 'new_id' 95 | original_dump = pipe.dump() 96 | original_dump.pop('id') 97 | copy_dump.pop('id') 98 | assert original_dump == copy_dump 99 | 100 | 101 | def test_prompt_add_base64_image(azure_ai_attempt: AttemptToCall): 102 | """Test adding base64 image content to prompt""" 103 | pipe = Prompt(id='test-base64-image') 104 | 105 | # Mock base64 image data 106 | base64_image = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==" 107 | 108 | pipe.add(base64_image, type='base64_image', is_multiple=False) 109 | user_prompt = pipe.create_prompt(azure_ai_attempt) 110 | 111 | assert len(user_prompt.pipe) == 1 112 | assert user_prompt.priorities[0][0].content == base64_image 113 | assert user_prompt.priorities[0][0].type == 'base64_image' 114 | 115 | 116 | def test_prompt_add_multiple_base64_images(azure_ai_attempt: AttemptToCall): 117 | """Test adding multiple base64 images to prompt""" 118 | pipe = Prompt(id='test-multiple-base64-images') 119 | 120 | # Mock base64 image data 121 | base64_image1 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==" 122 | base64_image2 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==" 123 | 124 | pipe.add("screens", type='base64_image', is_multiple=True) 125 | user_prompt = pipe.create_prompt(azure_ai_attempt) 126 | 127 | # Test with context containing multiple images 128 | context = { 129 | "screens": [base64_image1, base64_image2] 130 | } 131 | 132 | calling_messages = user_prompt.resolve(context, {}) 133 | messages = calling_messages.messages 134 | 135 | assert len(messages) == 2 136 | assert messages[0]["type"] == 'image_url' 137 | assert messages[0]["image_url"]["url"] == f"data:image/jpeg;base64,{base64_image1}" 138 | assert messages[1]["type"] == 'image_url' 139 | assert messages[1]["image_url"]["url"] == f"data:image/jpeg;base64,{base64_image2}" 140 | -------------------------------------------------------------------------------- /docs/test_data/medical_questions_answers.csv: -------------------------------------------------------------------------------- 1 | question,answer_provided_by_human 2 | I have a headache that won't go away. What could be causing it?,"Headaches can have many causes, including stress, dehydration, lack of sleep, or more serious conditions like migraines or sinusitis. It's important to keep track of when the headache started, how long it lasts, and any other symptoms you have. If it's persistent or severe, you should see a doctor for a proper diagnosis." 3 | I've been feeling tired all the time lately. What might be wrong?,"Fatigue can be a symptom of various conditions, from anemia and thyroid disorders to depression or chronic fatigue syndrome. It's also common with poor sleep, lack of exercise, or a sedentary lifestyle. A doctor can help determine the underlying cause through blood tests and a physical examination." 4 | I have a cough that's been lingering for weeks. Should I be worried?,"A persistent cough can be a sign of several conditions, such as asthma, bronchitis, pneumonia, or even lung cancer. It's important to see a doctor if your cough lasts more than a few weeks, especially if it's accompanied by other symptoms like chest pain, shortness of breath, or coughing up blood." 5 | I've noticed a lump in my breast. Is it cancer?,"Finding a lump in your breast can be alarming, but not all lumps are cancerous. It could be a cyst, fibroadenoma, or something else. You should see a doctor for a proper evaluation, which might include a physical exam, mammogram, or biopsy." 6 | My child has a fever. When should I take them to the doctor?,"Fever is a common symptom in children and can be caused by infections like the flu, ear infections, or urinary tract infections. If your child has a fever of 100.4°F (38°C) or higher that doesn't respond to fever-reducing medication, or if they have other symptoms like rash, vomiting, or difficulty breathing, you should see a doctor." 7 | I'm experiencing heartburn frequently. What can I do about it?,"Heartburn is often caused by acid reflux, which can be managed with over-the-counter antacids or lifestyle changes like avoiding spicy foods, eating smaller meals, and not lying down after eating. If it's persistent, you might have gastroesophagal reflux disease (GERD), and you should consult a doctor for proper treatment." 8 | I have pain in my lower back. What could be the cause?,"Lower back pain can be due to muscle strain, poor posture, arthritis, or more serious conditions like herniated discs or spinal stenosis. It's important to rest, apply heat or cold, and do stretching exercises. If the pain is severe or persists, see a doctor for further evaluation." 9 | I've been having trouble sleeping. Any suggestions?,"Insomnia can be caused by stress, anxiety, depression, or medical conditions like sleep apnea. Establishing a regular sleep schedule, avoiding caffeine and alcohol before bed, and creating a relaxing bedtime routine can help. If insomnia persists, consult a doctor for possible treatments." 10 | I've gained weight recently without changing my diet or exercise. What's going on?,"Unexplained weight gain can be due to fluid retention, hormonal imbalances, or certain medications. It's important to monitor your diet and exercise habits and see if there are any other symptoms. A doctor can check for conditions like hypothyroidism or heart failure." 11 | I have a rash that's itchy and won't go away. What should I do?,"Rashes can be caused by allergies, infections, or skin conditions like eczema or psoriasis. Avoid scratching and try over-the-counter hydrocortisone cream. If the rash persists or is accompanied by fever or other symptoms, see a doctor for a proper diagnosis." 12 | I'm always thirsty and urinating frequently. Could I have diabetes?,"Excessive thirst and frequent urination can be symptoms of diabetes, but they can also be caused by other conditions like urinary tract infections or kidney problems. A doctor can perform blood tests to check your blood sugar levels and determine if you have diabetes." 13 | "My joints are stiff and painful, especially in the morning. Is it arthritis?","Joint stiffness and pain can be symptoms of arthritis, but they can also be caused by other conditions like gout or Lyme disease. A doctor can evaluate your symptoms, perform a physical exam, and may order blood tests or X-rays to diagnose the cause." 14 | I've been feeling down and have no energy. Could I be depressed?,"Depression can manifest as persistent sadness, lack of energy, and loss of interest in activities. It's important to talk to a doctor or a mental health professional for an accurate diagnosis and appropriate treatment, which might include therapy or medication." 15 | I have a sore throat and it's hard to swallow. What should I do?,"A sore throat can be due to a viral or bacterial infection, allergies, or acid reflux. Gargling with salt water, drinking plenty of fluids, and taking over-the-counter pain relievers can help. If the pain persists or is accompanied by fever or swollen glands, see a doctor." 16 | I've noticed changes in my bowel habits. When should I be concerned?,"Changes in bowel habits, such as diarrhea, constipation, or blood in the stool, can be signs of gastrointestinal disorders, infections, or even colon cancer. It's important to see a doctor if these changes last more than a few days or are accompanied by other symptoms like abdominal pain or weight loss." 17 | I have a persistent cough that's worse at night. Could it be asthma?,"A cough that's worse at night can be a symptom of asthma, especially if it's accompanied by wheezing or shortness of breath. Other causes could be postnasal drip or GERD. A doctor can perform tests like spirometry to diagnose asthma." 18 | I'm experiencing dizziness and lightheadedness. What could be causing this?,"Dizziness can be caused by dehydration, low blood sugar, inner ear problems, or more serious conditions like heart disease or stroke. It's important to see a doctor if dizziness is frequent or severe, or if it's accompanied by other symptoms like chest pain or slurred speech." 19 | I have a mole that's changed color and shape. Should I get it checked?,"Any changes in a mole, such as color, shape, size, or if it starts to itch or bleed, could be signs of skin cancer. You should see a dermatologist for a skin exam and possible biopsy." 20 | I've been having trouble remembering things lately. Is it normal aging or something else?,"Forgetfulness can be a normal part of aging, but it can also be a sign of conditions like Alzheimer's disease or other forms of dementia. If memory loss is affecting your daily life, see a doctor for an evaluation." 21 | I have pain in my chest that comes and goes. Could it be my heart?,"Chest pain can be a sign of a heart attack, angina, or other heart conditions, but it can also be caused by gastrointestinal issues like heartburn or muscle strain. If you experience chest pain, especially if it's accompanied by shortness of breath, sweating, or radiating pain, seek medical attention immediately." 22 | -------------------------------------------------------------------------------- /lamoom/ai_models/claude/claude_model.py: -------------------------------------------------------------------------------- 1 | from lamoom.ai_models.ai_model import AI_MODELS_PROVIDER, AIModel 2 | import logging 3 | import typing as t 4 | from dataclasses import dataclass 5 | 6 | from lamoom.ai_models.claude.constants import HAIKU, SONNET, OPUS 7 | from lamoom.ai_models.constants import C_200K, C_32K, C_4K 8 | from lamoom.responses import FINISH_REASON_ERROR, FINISH_REASON_FINISH, StreamingResponse 9 | from lamoom.ai_models.tools.base_tool import TOOL_CALL_END_TAG, TOOL_CALL_START_TAG 10 | from enum import Enum 11 | 12 | from lamoom.exceptions import RetryableCustomError, ConnectionLostError 13 | import anthropic 14 | 15 | logger = logging.getLogger(__name__) 16 | 17 | 18 | class FamilyModel(Enum): 19 | haiku = "Claude 3 Haiku" 20 | sonnet = "Claude 3 Sonnet" 21 | opus = "Claude 3 Opus" 22 | 23 | 24 | @dataclass(kw_only=True) 25 | class ClaudeAIModel(AIModel): 26 | api_key: str = None 27 | max_tokens: int = C_32K 28 | provider: AI_MODELS_PROVIDER = AI_MODELS_PROVIDER.CLAUDE 29 | family: str = None 30 | 31 | def __post_init__(self): 32 | if HAIKU in self.model: 33 | self.family = FamilyModel.haiku.value 34 | elif SONNET in self.model: 35 | self.family = FamilyModel.sonnet.value 36 | elif OPUS in self.model: 37 | self.family = FamilyModel.opus.value 38 | else: 39 | logger.info( 40 | f"Unknown family for {self.model}. Please add it obviously. Setting as Claude 3 Opus" 41 | ) 42 | self.family = FamilyModel.opus.value 43 | logger.debug(f"Initialized ClaudeAIModel: {self}") 44 | 45 | def get_client(self, client_secrets: dict) -> anthropic.Anthropic: 46 | return anthropic.Anthropic(api_key=client_secrets.get("api_key")) 47 | 48 | def unify_messages_with_same_role(self, messages: t.List[dict]) -> t.List[dict]: 49 | result = [] 50 | last_role = None 51 | for message in messages: 52 | if last_role != message.get("role"): 53 | result.append(message) 54 | last_role = message.get("role") 55 | else: 56 | result[-1]["content"] += message.get("content") 57 | print(f'Unified messages: {result}') 58 | return result 59 | 60 | def streaming( 61 | self, 62 | client: anthropic.Anthropic, 63 | stream_response: StreamingResponse, 64 | max_tokens: int, 65 | stream_function: t.Callable, 66 | check_connection: t.Callable, 67 | stream_params: dict, 68 | **kwargs 69 | ) -> StreamingResponse: 70 | """Process streaming response from Claude.""" 71 | tool_call_started = False 72 | content = "" 73 | 74 | try: 75 | unified_messages = self.unify_messages_with_same_role(stream_response.messages) 76 | call_kwargs = { 77 | "model": self.model, 78 | "max_tokens": max_tokens, 79 | "messages": unified_messages, 80 | **kwargs 81 | } 82 | # Extract system prompt if present 83 | system_prompt = [] 84 | print(f'length of unified_messages: {len(unified_messages)}') 85 | for i, msg in enumerate(unified_messages): 86 | if msg.get('role') == "system": 87 | system_prompt.append(unified_messages.pop(i - len(system_prompt)).get('content')) 88 | print(f'Claude unified_messages: {unified_messages}') 89 | if system_prompt: 90 | call_kwargs["system"] = '\n'.join(system_prompt) 91 | print(f'Claude call_kwargs: {call_kwargs}') 92 | with client.messages.stream(**call_kwargs) as stream: 93 | for text_chunk in stream.text_stream: 94 | if check_connection and not check_connection(**stream_params): 95 | raise ConnectionLostError("Connection was lost!") 96 | 97 | stream_response.set_streaming() 98 | content += text_chunk 99 | 100 | # Only stream content if not in ignored tag 101 | if stream_function or self._tag_parser.is_custom_tags(): 102 | text_chunk = self.text_to_stream_chunk(text_chunk) 103 | if text_chunk and not tool_call_started: 104 | stream_response.streaming_content += text_chunk 105 | if stream_function: 106 | stream_function(text_chunk, **stream_params) 107 | 108 | # Check for tool call markers 109 | if tool_call_started and TOOL_CALL_END_TAG in content: 110 | stream_response.is_detected_tool_call = True 111 | stream_response.content = content 112 | logger.info(f'Found tool call request in {content}') 113 | break 114 | if TOOL_CALL_START_TAG in content: 115 | if not tool_call_started: 116 | tool_call_started = True 117 | continue 118 | 119 | if stream_function or self._tag_parser.is_custom_tags(): 120 | text_to_stream = self.text_to_stream_chunk('') 121 | if text_to_stream: 122 | if stream_function: 123 | stream_function(text_to_stream, **stream_params) 124 | stream_response.streaming_content += text_to_stream 125 | 126 | stream_response.content = content 127 | stream_response.set_finish_reason(FINISH_REASON_FINISH) 128 | return stream_response 129 | 130 | except Exception as e: 131 | stream_response.content = content 132 | stream_response.set_finish_reason(FINISH_REASON_ERROR) 133 | logger.exception("Exception during stream processing", exc_info=e) 134 | raise RetryableCustomError(f"Claude AI stream processing failed: {e}") from e 135 | 136 | @property 137 | def name(self) -> str: 138 | return f"Claude {self.family}" 139 | 140 | def get_params(self) -> t.Dict[str, t.Any]: 141 | if self.max_tokens > 0: 142 | return { 143 | "model": self.model, 144 | "max_tokens": self.max_tokens, 145 | } 146 | return { 147 | "model": self.model 148 | } 149 | 150 | def get_metrics_data(self) -> t.Dict[str, t.Any]: 151 | return { 152 | "model": self.model, 153 | "family": self.family, 154 | "max_tokens": self.max_tokens, 155 | } 156 | -------------------------------------------------------------------------------- /lamoom/prompt/chat.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import typing as t 3 | import uuid 4 | from dataclasses import dataclass 5 | 6 | from lamoom.exceptions import ValueIsNotResolvedError 7 | from lamoom.utils import resolve 8 | 9 | logger = logging.getLogger(__name__) 10 | 11 | 12 | @dataclass 13 | class ValuesCost: 14 | values: t.List[str] 15 | cost: int 16 | 17 | 18 | class ChatMessage: 19 | role: str 20 | content: str 21 | name: t.Optional[str] = None 22 | tool_calls: t.Dict[str, str] 23 | ref_name: t.Optional[str] = None 24 | ref_value: t.Optional[str] = None 25 | type: t.Optional[str] = None 26 | 27 | def is_not_empty(self): 28 | return bool(self.content or self.tool_calls) 29 | 30 | def is_empty(self): 31 | return not self.is_not_empty() 32 | 33 | def not_tool_calls(self): 34 | return not (self.tool_calls) 35 | 36 | def __init__(self, **kwargs): 37 | self.role = kwargs.get("role", "user") 38 | self.content = kwargs["content"] 39 | self.name = kwargs.get("name") 40 | self.tool_calls = kwargs.get("tool_calls") or {} 41 | self.type = kwargs.get("type") 42 | self.ref_name = kwargs.get("ref_name") 43 | self.ref_value = kwargs.get("ref_value") 44 | 45 | def to_dict(self): 46 | result = { 47 | "role": self.role, 48 | "content": self.content, 49 | } 50 | 51 | if self.type == "base64_image": 52 | # Handle base64 image content 53 | result["type"] = self.type 54 | 55 | if self.name: 56 | result["name"] = self.name 57 | if self.tool_calls: 58 | result["tool_calls"] = self.tool_calls 59 | return result 60 | 61 | 62 | # can be multiple value 63 | @dataclass(kw_only=True) 64 | class ChatsEntity: 65 | content: str = "" 66 | role: str = "user" 67 | name: t.Optional[str] = None 68 | tool_calls: t.Dict[str, str] = None 69 | priority: int = 0 70 | required: bool = False 71 | is_multiple: bool = False 72 | while_fits: bool = False 73 | add_in_reverse_order: bool = False 74 | in_one_message: bool = False 75 | continue_if_doesnt_fit: bool = False 76 | add_if_fitted_labels: t.List[str] = None 77 | label: t.Optional[str] = None 78 | presentation: t.Optional[str] = None 79 | last_words: t.Optional[str] = None 80 | ref_name: t.Optional[str] = None 81 | ref_value: t.Optional[str] = None 82 | type: t.Optional[str] = None 83 | 84 | def __post_init__(self): 85 | self._uuid = uuid.uuid4().hex 86 | 87 | def resolve(self, context: t.Dict[str, t.Any]) -> t.List[ChatMessage]: 88 | result = [] 89 | content = self.content 90 | if self.is_multiple: 91 | # should be just one value like {messages} in prompt 92 | prompt_value = content.strip().replace("{", "").replace("}", "").strip() 93 | values = context.get(prompt_value, []) 94 | if not values: 95 | return [] 96 | if not isinstance(values, list): 97 | raise ValueIsNotResolvedError( 98 | f"Invalid value {values } for prompt {content}. Should be multiple" 99 | ) 100 | else: 101 | # verify that values are json list of ChatMessage 102 | try: 103 | result = [ 104 | ChatMessage(**({"content": c, "type": self.type} if isinstance(c, str) else {**c, "type": self.type})) 105 | for c in values 106 | ] 107 | except TypeError as e: 108 | raise ValueIsNotResolvedError( 109 | f"Invalid value { values } for prompt {content}. Error: {e}" 110 | ) 111 | return result 112 | 113 | content = resolve(content, context) 114 | if not content: 115 | return [] 116 | return [ 117 | ChatMessage( 118 | name=self.name, 119 | role=self.role, 120 | content=content, 121 | tool_calls=self.tool_calls, 122 | ref_name=self.ref_name, 123 | ref_value=self.ref_value, 124 | type=self.type, 125 | ) 126 | ] 127 | 128 | def get_values(self, context: t.Dict[str, str]) -> t.List[ChatMessage]: 129 | try: 130 | values = self.resolve(context) 131 | except Exception as e: 132 | logger.error( 133 | f"Error resolving prompt {self.content}, error: {e}", exc_info=True 134 | ) 135 | return [] 136 | return values 137 | 138 | def dump(self): 139 | data = { 140 | "content": self.content, 141 | "role": self.role, 142 | "name": self.name, 143 | "tool_calls": self.tool_calls, 144 | "priority": self.priority, 145 | "required": self.required, 146 | "is_multiple": self.is_multiple, 147 | "while_fits": self.while_fits, 148 | "add_in_reverse_order": self.add_in_reverse_order, 149 | "in_one_message": self.in_one_message, 150 | "continue_if_doesnt_fit": self.continue_if_doesnt_fit, 151 | "add_if_fitted_labels": self.add_if_fitted_labels, 152 | "label": self.label, 153 | "presentation": self.presentation, 154 | "last_words": self.last_words, 155 | "ref_name": self.ref_name, 156 | "ref_value": self.ref_value, 157 | "type": self.type, 158 | } 159 | for k, v in list(data.items()): 160 | if v is None: 161 | del data[k] 162 | return data 163 | 164 | @classmethod 165 | def load(cls, data): 166 | return cls( 167 | content=data.get("content"), 168 | role=data.get("role"), 169 | name=data.get("name"), 170 | tool_calls=data.get("tool_calls"), 171 | priority=data.get("priority"), 172 | required=data.get("required"), 173 | is_multiple=data.get("is_multiple"), 174 | while_fits=data.get("while_fits"), 175 | add_in_reverse_order=data.get("add_in_reverse_order"), 176 | in_one_message=data.get("in_one_message"), 177 | continue_if_doesnt_fit=data.get("continue_if_doesnt_fit"), 178 | add_if_fitted_labels=data.get("add_if_fitted_labels"), 179 | label=data.get("label"), 180 | presentation=data.get("presentation"), 181 | last_words=data.get("last_words"), 182 | ref_name=data.get("ref_name"), 183 | ref_value=data.get("ref_value"), 184 | type=data.get("type"), 185 | ) 186 | -------------------------------------------------------------------------------- /lamoom/ai_models/openai/openai_models.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import typing as t 3 | from dataclasses import dataclass 4 | from enum import Enum 5 | 6 | from openai import OpenAI 7 | 8 | from lamoom.ai_models.ai_model import AI_MODELS_PROVIDER, AIModel 9 | from lamoom.ai_models.constants import C_128K, C_16K, C_32K, C_4K, C_100K, C_200K 10 | from lamoom.ai_models.openai.responses import StreamingResponse 11 | from lamoom.exceptions import ConnectionLostError, RetryableCustomError 12 | from lamoom.ai_models.tools.base_tool import TOOL_CALL_END_TAG, TOOL_CALL_START_TAG 13 | 14 | from lamoom.responses import FINISH_REASON_ERROR 15 | 16 | 17 | M_DAVINCI = "davinci" 18 | 19 | logger = logging.getLogger(__name__) 20 | 21 | 22 | class FamilyModel(Enum): 23 | chat = "GPT-3.5" 24 | gpt4 = "GPT-4" 25 | gpt4o = "o4-mini" 26 | gpt4o_mini = "o4-mini-mini" 27 | instruct_gpt = "InstructGPT" 28 | 29 | 30 | BASE_URL_MAPPING = { 31 | 'gemini': "https://generativelanguage.googleapis.com/v1beta/openai/" 32 | } 33 | 34 | 35 | @dataclass(kw_only=True) 36 | class OpenAIModel(AIModel): 37 | max_tokens: int = C_200K 38 | support_functions: bool = False 39 | provider: AI_MODELS_PROVIDER = AI_MODELS_PROVIDER.OPENAI 40 | family: str = None 41 | max_sample_budget: int = C_16K 42 | base_url: str = None 43 | api_key: str = None 44 | 45 | def __str__(self) -> str: 46 | return f"openai-{self.model}-{self.family}" 47 | 48 | def __post_init__(self): 49 | if self.model.startswith("davinci"): 50 | self.family = FamilyModel.instruct_gpt.value 51 | elif self.model.startswith("gpt-3"): 52 | self.family = FamilyModel.chat.value 53 | elif self.model.startswith("o4-mini-mini"): 54 | self.family = FamilyModel.gpt4o_mini.value 55 | elif self.model.startswith("o4-mini"): 56 | self.family = FamilyModel.gpt4o.value 57 | elif self.model.startswith(("gpt4", "gpt-4", "gpt")): 58 | self.family = FamilyModel.gpt4.value 59 | else: 60 | logger.info( 61 | f"Unknown family for {self.model}. Please add it obviously. Setting as GPT4" 62 | ) 63 | self.family = FamilyModel.gpt4.value 64 | logger.debug(f"Initialized OpenAIModel: {self}") 65 | 66 | @property 67 | def name(self) -> str: 68 | return self.model 69 | 70 | def get_params(self) -> t.Dict[str, t.Any]: 71 | return { 72 | "model": self.model, 73 | } 74 | 75 | 76 | def is_provider_openai(self): 77 | return self.provider == AI_MODELS_PROVIDER.OPENAI 78 | 79 | 80 | def get_metrics_data(self): 81 | return { 82 | "model": self.model, 83 | "family": self.family, 84 | "provider": self.provider.value if not self.provider.is_custom() else self.provider_name, 85 | "base_url": self.base_url 86 | } 87 | 88 | def get_client(self, client_secrets: dict = {}): 89 | base_url = client_secrets.get("base_url", None) 90 | return OpenAI( 91 | organization=client_secrets.get("organization", None), 92 | api_key=client_secrets["api_key"], 93 | base_url=client_secrets.get("base_url", None), 94 | ) 95 | 96 | def streaming( 97 | self, 98 | client: OpenAI, 99 | stream_response: StreamingResponse, 100 | max_tokens: int, 101 | stream_function: t.Callable, 102 | check_connection: t.Callable, 103 | stream_params: dict, 104 | **kwargs 105 | ) -> StreamingResponse: 106 | """Process streaming response from OpenAI.""" 107 | tool_call_started = False 108 | content = "" 109 | 110 | try: 111 | call_kwargs = { 112 | "messages": stream_response.messages, 113 | **self.get_params(), 114 | **kwargs, 115 | **{"stream": True}, 116 | } 117 | if max_tokens > 0: 118 | call_kwargs["max_completion_tokens"] = min(max_tokens, self.max_sample_budget) 119 | logger.info(f"Calling OpenAI with params: {call_kwargs}") 120 | completion = client.chat.completions.create(**call_kwargs) 121 | for part in completion: 122 | if not part.choices: 123 | continue 124 | 125 | delta = part.choices[0].delta 126 | if part.choices and 'finish_reason' in part.choices[0]: 127 | logger.info(f'Finish reason: {part.choices[0].finish_reason}') 128 | stream_response.set_finish_reason(part.choices[0].finish_reason) 129 | 130 | # Check for tool call markers 131 | if TOOL_CALL_START_TAG in content and not tool_call_started: 132 | tool_call_started = True 133 | logger.info(f'tool_call_started: {tool_call_started}') 134 | 135 | if not delta or (not delta.content and getattr(delta, 'reasoning', None)): 136 | continue 137 | 138 | if delta.content: 139 | content += delta.content 140 | if stream_function or self._tag_parser.is_custom_tags(): 141 | text_to_stream = self.text_to_stream_chunk(delta.content) 142 | if text_to_stream: 143 | stream_response.streaming_content += text_to_stream 144 | if stream_function: 145 | stream_function(text_to_stream, **stream_params) 146 | 147 | if getattr(delta, 'reasoning', None) and delta.reasoning: 148 | logger.debug(f'Adding reasoning {delta.reasoning}') 149 | stream_response.reasoning += delta.reasoning 150 | 151 | if tool_call_started and TOOL_CALL_END_TAG in content: 152 | logger.info(f'tool_call_ended: {content}') 153 | stream_response.is_detected_tool_call = True 154 | stream_response.content = content 155 | break 156 | 157 | if check_connection and not check_connection(**stream_params): 158 | raise ConnectionLostError("Connection was lost!") 159 | 160 | if stream_function: 161 | text_to_stream = self.text_to_stream_chunk('') 162 | if text_to_stream: 163 | stream_response.streaming_content += text_to_stream 164 | if stream_function: 165 | stream_function(text_to_stream, **stream_params) 166 | stream_response.content = content 167 | return stream_response 168 | 169 | except Exception as e: 170 | stream_response.content = content 171 | stream_response.set_finish_reason(FINISH_REASON_ERROR) 172 | logger.exception("Exception during stream processing", exc_info=e) 173 | raise RetryableCustomError(f"OpenAI stream processing failed: {e}") from e -------------------------------------------------------------------------------- /lamoom/ai_models/tools/web_tool.py: -------------------------------------------------------------------------------- 1 | 2 | import os 3 | import requests 4 | from bs4 import BeautifulSoup 5 | from dataclasses import dataclass 6 | import typing as t 7 | from dotenv import load_dotenv 8 | import logging 9 | 10 | from lamoom.ai_models.tools.base_tool import ToolDefinition, ToolParameter 11 | from lamoom.settings import LAMOOM_GOOGLE_SEARCH_RESULTS_COUNT 12 | 13 | load_dotenv() 14 | 15 | logger = logging.getLogger(__name__) 16 | 17 | API_KEY = os.getenv("GOOGLE_API_KEY") 18 | SEARCH_ID = os.getenv("SEARCH_ENGINE_ID") 19 | 20 | @dataclass 21 | class WebSearchResult: 22 | url: str 23 | title: str 24 | snippet: str 25 | content: str 26 | 27 | class WebCall: 28 | @staticmethod 29 | def scrape_webpage(url: str) -> str: 30 | """ 31 | Scrapes the content of a webpage and returns the text. 32 | """ 33 | if not url.startswith(("https://", "http://")): 34 | url = "https://" + url 35 | response = requests.get(url) 36 | 37 | if response.status_code == 200: 38 | soup = BeautifulSoup(response.content, "html.parser") 39 | text = soup.get_text() 40 | clean_text = text.splitlines() 41 | clean_text = [element.strip() 42 | for element in clean_text if element.strip()] 43 | clean_text = '\n'.join(clean_text) 44 | return clean_text 45 | else: 46 | return "Failed to retrieve the website content." 47 | 48 | @staticmethod 49 | def perform_web_search(query: str) -> t.List[WebSearchResult]: 50 | """ 51 | Performs a web search and returns a list of search results. 52 | """ 53 | url = "https://www.googleapis.com/customsearch/v1" 54 | params = { 55 | 'q': query, 56 | 'key': API_KEY, 57 | 'cx': SEARCH_ID, 58 | 'num': LAMOOM_GOOGLE_SEARCH_RESULTS_COUNT 59 | } 60 | 61 | response = requests.get(url, params=params) 62 | results = response.json() 63 | 64 | search_results = [] 65 | 66 | if 'items' in results: 67 | for result in results['items']: 68 | content = WebCall.scrape_webpage(result['link']) 69 | search_results.append(WebSearchResult( 70 | url=result['link'], 71 | title=result['title'], 72 | snippet=result['snippet'], 73 | content=content 74 | )) 75 | logger.debug(f"Retrieved search_results {search_results}") 76 | return search_results 77 | 78 | @staticmethod 79 | def format_search_results(results: t.List[WebSearchResult]) -> str: 80 | """ 81 | Formats search results into a readable string. 82 | """ 83 | formatted = "" 84 | for i, result in enumerate(results, 1): 85 | formatted += f"\n" 86 | formatted += f"Title: {result.title}\n" 87 | formatted += f"URL: {result.url}\n" 88 | formatted += f"Snippet: {result.snippet}\n" 89 | formatted += f"Content: {result.content[:500]}...\n" 90 | formatted += f'' 91 | return formatted 92 | 93 | @staticmethod 94 | def execute(query: str) -> str: 95 | return WebCall.format_search_results( 96 | WebCall.perform_web_search(query) 97 | ) 98 | 99 | 100 | def perform_web_search(query: str) -> str: 101 | """ 102 | Performs a web search and returns formatted results. 103 | """ 104 | results = WebCall.execute(query) 105 | return results 106 | 107 | 108 | WEB_SEARCH_TOOL = ToolDefinition( 109 | name="web_call", 110 | description=""" 111 | Performs a web search using a search engine to find up-to-date information or details not present in the internal knowledge. Today is {current_datetime_strftime} {timezone}. 112 | 113 | 114 | { 115 | "tool_name": "web_call", 116 | "parameters": { 117 | "query": "A search query string" 118 | } 119 | } 120 | """, 121 | parameters=[ 122 | ToolParameter(name="query", type="string", description="The search query to use.", required=True) 123 | ], 124 | execution_function=perform_web_search, 125 | max_count_of_executed_calls=10 126 | ) 127 | 128 | 129 | def best_customer_experience(reasoning: str) -> str: 130 | return {'best_customer_experience': reasoning} 131 | 132 | BEST_CUSTOMER_EXPERIENCE_TOOL = ToolDefinition( 133 | name="best_customer_experience", 134 | description="""Provides reasoning from the perspective of a customer to enhance understanding of customer needs and improve service quality. 135 | 1. Start from best_customer_experience 136 | ``` 137 | 138 | { 139 | "tool_name": "best_customer_experience", 140 | "parameters": { 141 | "reasoning": "As a customer I want to ..." 142 | }, 143 | } 144 | 145 | ## BEST CUSTOMER EXPERIENCE 146 | {best_customer_experience}""", 147 | parameters=[ 148 | ToolParameter(name="reasoning", type="string", description="The reasoning from the customer's perspective.", required=True) 149 | ], 150 | execution_function=best_customer_experience, 151 | update_json_context=True, 152 | max_count_of_executed_calls=1 153 | ) 154 | 155 | def make_plan(steps: dict) -> str: 156 | return {'plan': steps} 157 | 158 | MAKE_PLAN_TOOL = ToolDefinition( 159 | name="make_plan", 160 | description="""Creates a structured plan with steps to achieve a specific goal. Each step should include expected results, actions to take, and the current state. 161 | 162 | 2. Continue from making a plan, and after going through each step and mark it you're on the step: 163 | 164 | 165 | { 166 | "tool_name": "make_plan", 167 | "parameters": { 168 | 'step1': {'expected_results': '', 'actions': '', 'where_we_are': ''}, 169 | ... 170 | } 171 | } 172 | 173 | 174 | ## Step N 175 | - repeat expected results 176 | - repeat current_state from step above 177 | - repeat actions: 178 | - query required to be called; 179 | 180 | 181 | 182 | 183 | ## PLAN 184 | {plan} 185 | """, 186 | parameters=[ 187 | ToolParameter(name="steps", type="object", description="A dictionary where each key is a step name and the value is another dictionary with 'expected_results', 'actions', and 'where_we_are'. Ex. {'step1': {'expected_results': '', 'actions': '', 'where_we_are': ''}}", required=True) 188 | ], 189 | execution_function=make_plan, 190 | update_json_context=True, 191 | max_count_of_executed_calls=1 192 | ) 193 | 194 | 195 | def update_plan(step_data: dict) -> str: 196 | return {'plan': {**step_data}} 197 | 198 | 199 | UPDATE_PLAN_TOOL = ToolDefinition( 200 | name="update_plan", 201 | description=""" 202 | Updates a specific step in the existing plan with new expected results, actions, and current state. 203 | 204 | { 205 | "tool_name": "update_plan", 206 | "parameters": { 207 | 'step_name': {'expected_results': '', 'actions': '', 'where_we_are': ''} 208 | } 209 | } 210 | 211 | """, 212 | parameters=[ 213 | ToolParameter(name="step_name", type="object", description="A dictionary where each key is a step name and the value is another dictionary with 'expected_results', 'actions', and 'where_we_are'. Ex. {'step1': {'expected_results': '', 'actions': '', 'where_we_are': ''}}", required=True), 214 | ], 215 | execution_function=update_plan, 216 | update_json_context=True, 217 | max_count_of_executed_calls=10 218 | ) 219 | 220 | -------------------------------------------------------------------------------- /lamoom/services/lamoom/__init__.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | import typing as t 4 | from dataclasses import asdict, dataclass 5 | import requests 6 | 7 | from lamoom import settings 8 | from lamoom.exceptions import NotFoundPromptError 9 | from lamoom.responses import AIResponse 10 | from lamoom.utils import DecimalEncoder, current_timestamp_ms 11 | 12 | logger = logging.getLogger(__name__) 13 | 14 | 15 | @dataclass 16 | class LamoomServiceResponse: 17 | prompt_id: str = None 18 | prompt: dict = None 19 | is_taken_globally: bool = False 20 | version: str = None 21 | 22 | 23 | class LamoomService: 24 | url: str = settings.LAMOOM_API_URI 25 | cached_prompts = {} 26 | 27 | def get_actual_prompt( 28 | self, 29 | api_token: str, 30 | prompt_id: str, 31 | prompt_data: dict = None, 32 | version: str = None, 33 | ) -> LamoomServiceResponse: 34 | """ 35 | Load prompt from lamoom 36 | if the user has keys: lib -> service: get_actual_prompt(local_prompt) -> Service: 37 | generates hash of the prompt; 38 | check in Redis if that record is the latest; if yes -> return 200, else 39 | checks if that record exists with that hash; 40 | if record exists and it's not the last - then we load the latest published prompt; - > return 200 + the last record 41 | add a new record in storage, and adding that it's the latest published prompt; -> return 200 42 | update redis with latest record; 43 | """ 44 | logger.debug( 45 | f"Received request to get actual prompt prompt_id: {prompt_id}, prompt_data: {prompt_data}, version: {version}" 46 | ) 47 | timestamp = current_timestamp_ms() 48 | logger.debug(f"Getting actual prompt for {prompt_id}") 49 | cached_prompt = None 50 | cached_prompt_taken_globally = False 51 | cached_data = self.get_cached_prompt(prompt_id) 52 | if cached_data: 53 | cached_prompt = cached_data.get("prompt") 54 | cached_prompt_taken_globally = cached_data.get("is_taken_globally") 55 | if cached_prompt: 56 | logger.debug( 57 | f"Prompt {prompt_id} is cached, returned in {current_timestamp_ms() - timestamp} ms" 58 | ) 59 | return LamoomServiceResponse( 60 | prompt_id=prompt_id, 61 | prompt=cached_prompt, 62 | is_taken_globally=cached_prompt_taken_globally, 63 | ) 64 | 65 | url = f"{self.url}/lib/prompts" 66 | headers = { 67 | "Authorization": f"Token {api_token}", 68 | } 69 | data = { 70 | "prompt": prompt_data, 71 | "id": prompt_id, 72 | "version": version, 73 | "is_taken_globally": cached_prompt_taken_globally, 74 | } 75 | json_data = json.dumps(data, cls=DecimalEncoder) 76 | response = requests.post(url, headers=headers, data=json_data) 77 | if response.status_code == 200: 78 | response_data = response.json() 79 | logger.debug( 80 | f"Prompt {prompt_id} found in {current_timestamp_ms() - timestamp} ms: {response_data}" 81 | ) 82 | prompt_data = response_data.get("prompt", prompt_data) 83 | is_taken_globally = response_data.get("is_taken_globally") 84 | version = response_data.get("version") 85 | 86 | # update cache 87 | self.cached_prompts[prompt_id] = { 88 | "prompt": prompt_data, 89 | "timestamp": current_timestamp_ms(), 90 | "is_taken_globally": is_taken_globally, 91 | "version": version, 92 | } 93 | # returns 200 and the latest published prompt, if the local prompt is the latest, doesn't return the prompt 94 | return LamoomServiceResponse( 95 | prompt_id=prompt_id, 96 | prompt=prompt_data, 97 | is_taken_globally=response_data.get("is_taken_globally", False), 98 | version=version, 99 | ) 100 | else: 101 | logger.debug( 102 | f"Prompt {prompt_id} not found, in {current_timestamp_ms() - timestamp} ms" 103 | ) 104 | raise NotFoundPromptError(response.json()) 105 | 106 | def get_cached_prompt(self, prompt_id: str) -> dict: 107 | cached_data = self.cached_prompts.get(prompt_id) 108 | if not cached_data: 109 | return None 110 | cached_delay = current_timestamp_ms() - cached_data.get("timestamp") 111 | if cached_delay < settings.CACHE_PROMPT_FOR_EACH_SECONDS * 1000: 112 | return cached_data 113 | return None 114 | 115 | @classmethod 116 | def clear_cache(cls): 117 | cls.cached_prompts = {} 118 | 119 | @classmethod 120 | def save_user_interaction( 121 | cls, 122 | api_token: str, 123 | prompt_data: t.Dict[str, t.Any], 124 | context: t.Dict[str, t.Any], 125 | response: AIResponse, 126 | ): 127 | url = f"{cls.url}/lib/logs" 128 | headers = {"Authorization": f"Token {api_token}"} 129 | data = { 130 | "context": context, 131 | "prompt": prompt_data, 132 | "response": {"content": response.content}, 133 | "metrics": asdict(response.metrics), 134 | "request": asdict(response.prompt), 135 | "timestamp": response.id.split("#")[1], 136 | } 137 | 138 | logger.debug(f"Request to {url} with data: {data}") 139 | json_data = json.dumps(data, cls=DecimalEncoder) 140 | 141 | response = requests.post(url, headers=headers, data=json_data) 142 | if response.status_code == 200: 143 | return response.json() 144 | else: 145 | logger.error(response) 146 | 147 | @classmethod 148 | def update_response_ideal_answer( 149 | cls, api_token: str, log_id: str, ideal_answer: str 150 | ): 151 | url = f"{cls.url}/lib/logs" 152 | headers = {"Authorization": f"Token {api_token}"} 153 | data = {"log_id": log_id, "ideal_answer": ideal_answer} 154 | 155 | logger.debug(f"Request to {url} with data: {data}") 156 | json_data = json.dumps(data, cls=DecimalEncoder) 157 | 158 | response = requests.put(url, headers=headers, data=json_data) 159 | 160 | if response.status_code == 200: 161 | return response.json() 162 | else: 163 | logger.error(response) 164 | return response 165 | 166 | @classmethod 167 | def create_test_with_ideal_answer( 168 | cls, 169 | api_token: str, 170 | prompt_data: t.Dict[str, t.Any], 171 | context: t.Dict[str, t.Any], 172 | test_data: dict, 173 | ): 174 | ideal_answer = test_data.get("ideal_answer", None) 175 | if not ideal_answer: 176 | return 177 | url = f"{cls.url}/lib/tests" 178 | headers = {"Authorization": f"Token {api_token}"} 179 | model_name = test_data.get("model_name") or test_data.get("call_model") or None 180 | data = { 181 | "context": context, 182 | "prompt": prompt_data, 183 | "ideal_answer": ideal_answer, 184 | "model_name": model_name, 185 | } 186 | logger.debug(f"Request to {url} with data: {data}") 187 | json_data = json.dumps(data) 188 | requests.post(url, headers=headers, data=json_data) 189 | logger.info(f"Created Ci/CD for prompt {prompt_data['prompt_id']}") 190 | -------------------------------------------------------------------------------- /lamoom/ai_models/tools/base_tool.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import typing as t 3 | from dataclasses import dataclass 4 | import json 5 | import re 6 | 7 | from lamoom.utils import resolve 8 | 9 | 10 | logger = logging.getLogger(__name__) 11 | 12 | TOOL_CALL_NAME = 'function_call' 13 | TOOL_CALL_RESULT_NAME = 'function_result' 14 | # --- Constants for Prompting --- 15 | TOOL_CALL_START_TAG = f"<{TOOL_CALL_NAME}>" 16 | TOOL_CALL_END_TAG = f"" 17 | 18 | 19 | def get_tool_system_prompt(tool_descriptions: str, context: t.Dict[str, str]): 20 | return f"""You have next tools available: 21 | ``` 22 | {resolve(tool_descriptions, context)} 23 | ``` 24 | # Tool calling procedure 25 | 26 | Before calling any function, please follow procedure. Make a mindset of what you need to do. 27 | ## 1. Think out loud what you need to do 28 | ## 2. Provide 5 whys; 29 | ## 3. Call a tool, you can call it when in ; 30 | 31 | 32 | If you need to use a function, use the next exactly format. That format will be parsed from your answer: 33 | ``` 34 | """ + TOOL_CALL_START_TAG + """ 35 | { 36 | "function": "...", 37 | "parameters": { 38 | // parameters of the tool 39 | } 40 | } 41 | """ + TOOL_CALL_END_TAG + """ 42 | ``` 43 | 44 | 45 | """ 46 | 47 | 48 | @dataclass 49 | class ToolCallResult: 50 | tool_name: str 51 | content: str 52 | has_tool_call: bool 53 | parameters: t.Optional[dict] = None 54 | execution_result: str = None 55 | update_json_context: bool = False 56 | 57 | def __str__(self): 58 | return f"ToolCallResult:\ntool_name={self.tool_name}\nparameters={self.parameters}\nexecution_result={self.execution_result}\nupdate_json_context={self.update_json_context}" 59 | 60 | 61 | @dataclass 62 | class ToolParameter: 63 | name: str 64 | type: str 65 | description: str 66 | required: bool = True 67 | 68 | @dataclass 69 | class ToolDefinition: 70 | name: str 71 | description: str 72 | parameters: t.List[ToolParameter] 73 | execution_function: t.Callable 74 | update_json_context: bool = False 75 | max_count_of_executed_calls: int = 5 76 | 77 | 78 | def format_tool_description(tool: ToolDefinition) -> str: 79 | """Formats a single tool's description for the prompt.""" 80 | param_desc = ",\n".join([f'"{p.name}": ... \\ {p.type} - ({p.description})' for p in tool.parameters]) 81 | return f"//{tool.description}\n- {tool.name}({{{param_desc}}})\nCan be called {tool.max_count_of_executed_calls} times.\n" 82 | 83 | 84 | def inject_tool_prompts( 85 | available_tools: t.List[ToolDefinition], 86 | context: t.Dict[str, str] 87 | ) -> None: 88 | """Injects tool descriptions and usage instructions into the system prompt.""" 89 | if not available_tools: 90 | logger.debug("[inject_tool_prompts] No tools available. Returning original messages.") 91 | return 92 | 93 | tool_descriptions = "\n\n".join([format_tool_description(tool) for tool in available_tools]) 94 | tool_system_prompt = get_tool_system_prompt(tool_descriptions, context) 95 | context['tool_system_prompt'] = tool_system_prompt or '' 96 | 97 | def parse_tool_call_block(text_response: str) -> t.Optional[ToolCallResult]: 98 | """ 99 | Parses the block from the model's text response using regex. 100 | Returns a ToolCallResult object if a valid tool call is found, None otherwise. 101 | """ 102 | logger.debug(f"Parsing tool call block: {text_response}") 103 | if not text_response: 104 | return None 105 | if not TOOL_CALL_END_TAG in text_response: 106 | return None 107 | # Regex to find the block, allowing for whitespace variations 108 | # DOTALL allows '.' to match newlines within the JSON block 109 | json_content = text_response[text_response.find( 110 | TOOL_CALL_START_TAG) + len(TOOL_CALL_START_TAG): text_response.rfind(TOOL_CALL_END_TAG)] 111 | if '```' in json_content.split('\n'): 112 | json_content = '\n'.join(json_content.split('\n')[1:-1]) 113 | 114 | logger.debug(f"Found potential tool call JSON block: {json_content}") 115 | 116 | try: 117 | parsed_data = json.loads(json_content) 118 | # Basic validation 119 | if "function" in parsed_data and ( 120 | "parameters" in parsed_data and isinstance(parsed_data["parameters"], dict) or "parameters" not in parsed_data 121 | ): 122 | logger.info(f"Successfully parsed tool call: {parsed_data['function']}") 123 | return ToolCallResult( 124 | content=text_response, 125 | has_tool_call=True, 126 | tool_name=parsed_data.get("function"), 127 | parameters=parsed_data.get("parameters", {}), 128 | execution_result="" 129 | ) 130 | else: 131 | logger.warning(f"Parsed JSON block lacks required 'function' or 'parameters': {json_content}") 132 | return None 133 | except json.JSONDecodeError as e: 134 | logger.error(f"Failed to decode JSON from tool call block: {json_content}", exc_info=e) 135 | return ToolCallResult( 136 | content=text_response, 137 | has_tool_call=True, 138 | tool_name=None, 139 | parameters=None, 140 | execution_result=str(e) 141 | ) 142 | 143 | 144 | def call_function(tool_name: str, parameters: dict, tool_registry={}) -> str: 145 | """Handle a tool call by executing the corresponding function from the MCP registry. 146 | 147 | Args: 148 | tool_name: Name of the tool to execute 149 | parameters: Parameters for the tool 150 | 151 | Returns: 152 | String representation of the tool result 153 | """ 154 | tool_function = tool_registry.get(tool_name) 155 | if not tool_function: 156 | logger.warning(f"Tool '{tool_name}' not found in MCP registry") 157 | return json.dumps({"error": f"Tool '{tool_name}' is not available."}) 158 | tool_execution_function = tool_function.execution_function 159 | try: 160 | logger.info(f"Executing MCP tool '{tool_name}' with parameters: {parameters}") 161 | result = tool_execution_function(**parameters) 162 | logger.info(f"MCP tool '{tool_name}' executed successfully: {result}") 163 | return json.dumps({"result": result}) 164 | except Exception as e: 165 | logger.exception(f"Error executing MCP tool '{tool_name}'", exc_info=e) 166 | return json.dumps({"error": f"Failed to execute tool '{tool_name}': {str(e)}"}) 167 | 168 | 169 | def handle_tool_call(current_stream_part_content, tool_registry) -> ToolCallResult: 170 | """Handle a tool call by executing the corresponding function from the MCP registry. 171 | 172 | Args: 173 | current_stream_part_content: The current content of the stream 174 | tool_registry: Registry of available tools 175 | 176 | Returns: 177 | ToolCallResult object containing the result of the tool call 178 | """ 179 | parsed_tool_call = parse_tool_call_block(current_stream_part_content) 180 | 181 | if not parsed_tool_call or parsed_tool_call.error: 182 | return parsed_tool_call 183 | tool_name = parsed_tool_call.tool_name 184 | parameters = parsed_tool_call.parameters 185 | logger.info(f"Custom tool call block parsed: {tool_name}") 186 | 187 | # Execute the tool and get result 188 | tool_function = tool_registry.get(tool_name) 189 | tool_result = call_function(tool_name, parameters, tool_registry=tool_registry) 190 | logger.info(f"Tool '{tool_name}' executed with result: {tool_result}") 191 | 192 | return ToolCallResult( 193 | content=current_stream_part_content, 194 | has_tool_call=True, 195 | update_json_context=tool_function.update_json_context if tool_function else False, 196 | tool_name=tool_name, 197 | parameters=parameters, 198 | execution_result=tool_result, 199 | ) 200 | 201 | 202 | def format_tool_result_message(tool_result: ToolCallResult): 203 | return f'\n<{TOOL_CALL_RESULT_NAME}="{tool_result.tool_name}">\n{json.dumps(tool_result.execution_result)}\n\n' -------------------------------------------------------------------------------- /tests/test_ai_model_tag_parser.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from unittest.mock import Mock, patch 3 | from lamoom.ai_models.ai_model import AIModel, TagParser 4 | from lamoom.responses import StreamingResponse 5 | from lamoom.ai_models.tools.base_tool import TOOL_CALL_START_TAG, TOOL_CALL_END_TAG 6 | 7 | 8 | class TestTagParser: 9 | @pytest.fixture 10 | def parser(self): 11 | return TagParser(ignore_tags=['think', 'reason']) 12 | 13 | def test_basic_text(self, parser): 14 | # Test text without tags 15 | assert parser.text_to_stream_chunk("Hello world") == "Hello world" 16 | assert parser.text_to_stream_chunk("") == "" 17 | 18 | def test_ignored_tags(self, parser): 19 | parser.reset() 20 | # Test ignored tags 21 | assert parser.text_to_stream_chunk("Hello world" ) == "Hello " 22 | assert parser.text_to_stream_chunk("") == "" 23 | assert parser.text_to_stream_chunk("Hello worldworld there") == "" 26 | assert parser.text_to_stream_chunk("") == "" # Close ignored tag 28 | assert parser.text_to_stream_chunk(" there") == " there" 29 | 30 | def test_non_ignored_tags(self, parser): 31 | parser.reset() 32 | # Test non-ignored tags 33 | assert parser.text_to_stream_chunk("Hello world") == "Hello world" 34 | assert parser.text_to_stream_chunk("Hello world") == "Hello " 35 | print(parser.state) 36 | assert parser.text_to_stream_chunk("") == '' 37 | 38 | def test_partial_tags(self, parser): 39 | parser.reset() 40 | # Test partial tag handling 41 | assert parser.text_to_stream_chunk("") == "" # Complete ignored tag 43 | assert parser.text_to_stream_chunk("content") == "" # Content in ignored tag 44 | assert parser.text_to_stream_chunk("") == "" # Close ignored tag 45 | assert parser.text_to_stream_chunk("after") == "after" # Content after ignored tag 46 | 47 | # Test partial non-ignored tag 48 | assert parser.text_to_stream_chunk("hey ") == "hey " # Output partial non-ignored tag 49 | assert parser.text_to_stream_chunk("<") == "" # Output partial non-ignored tag 50 | assert parser.text_to_stream_chunk("o") == "" # Output partial non-ignored tag 51 | assert parser.text_to_stream_chunk("ther>") == "" # Complete non-ignored tag 52 | 53 | def test_writing_tags(self, parser): 54 | parser = TagParser(writing_tags=['write']) 55 | # Test partial tag handling 56 | assert parser.text_to_stream_chunk("Simple") == "" 57 | assert parser.text_to_stream_chunk("") == "" # Complete ignored tag 59 | assert parser.text_to_stream_chunk("now") == "now" # Complete ignored tag 60 | assert parser.text_to_stream_chunk(" ") == " " # Content in ignored tag 61 | assert parser.text_to_stream_chunk("<") == "" # Close ignored tag 62 | assert parser.text_to_stream_chunk("/") == "" 63 | assert parser.text_to_stream_chunk("write>") == "" # Close ignored tag 64 | assert parser.text_to_stream_chunk("after") == "" # Content after ignored tag 65 | 66 | # Test partial non-ignored tag 67 | assert parser.text_to_stream_chunk("hey ") == "" # Output partial non-ignored tag 68 | assert parser.text_to_stream_chunk("<") == "" # Output partial non-ignored tag 69 | assert parser.text_to_stream_chunk("o") == "" # Output partial non-ignored tag 70 | assert parser.text_to_stream_chunk("ther>") == "" # Complete non-ignored tag 71 | 72 | 73 | class TestAIModelTagStreaming: 74 | @pytest.fixture 75 | def model(self): 76 | model = AIModel() 77 | model._init_tag_parser(ignore_tags=['think', 'reason'], writing_tags=[]) 78 | return model 79 | 80 | @pytest.fixture 81 | def mock_stream_function(self): 82 | return Mock() 83 | 84 | @pytest.fixture 85 | def stream_response(self): 86 | return StreamingResponse(messages=[], tool_registry={}) 87 | 88 | def test_basic_streaming(self, model, mock_stream_function, stream_response): 89 | # Test basic streaming with ignored tags 90 | chunks = [ 91 | "Hello ", 92 | "", 93 | "ignored", 94 | "", 95 | " world", 96 | "" 97 | ] 98 | 99 | for chunk in chunks: 100 | stream_chunk = model.text_to_stream_chunk(chunk) 101 | if not stream_chunk: 102 | continue 103 | mock_stream_function(stream_chunk) 104 | 105 | # Only non-ignored content should be streamed 106 | assert mock_stream_function.call_count == 2 107 | mock_stream_function.assert_any_call("Hello ") 108 | mock_stream_function.assert_any_call(" world") 109 | 110 | def test_tool_calls_with_tags(self, model, mock_stream_function, stream_response): 111 | # Test tool calls with ignored tags 112 | content = f"Before {TOOL_CALL_START_TAG}ignored{TOOL_CALL_END_TAG} After" 113 | 114 | # Simulate streaming 115 | for chunk in content: 116 | stream_chunk = model.text_to_stream_chunk(chunk) 117 | if not stream_chunk: 118 | continue 119 | mock_stream_function(stream_chunk) 120 | 121 | # Tool call content should be accumulated but not streamed if in ignored tag 122 | assert mock_stream_function.call_count > 0 123 | assert "Before" in "".join(call[0][0] for call in mock_stream_function.call_args_list) 124 | assert "After" in "".join(call[0][0] for call in mock_stream_function.call_args_list) 125 | 126 | def test_nested_streaming(self, model, mock_stream_function, stream_response): 127 | # Test nested tag streaming 128 | chunks = [ 129 | "Start ", 130 | "", 131 | "outer ", 132 | "", 133 | "inner", 134 | "", 135 | " more", 136 | "", 137 | " End", 138 | "" 139 | ] 140 | 141 | for chunk in chunks: 142 | stream_chunk = model.text_to_stream_chunk(chunk) 143 | if not stream_chunk: 144 | continue 145 | mock_stream_function(stream_chunk) 146 | 147 | # Only content outside ignored tags should be streamed 148 | assert mock_stream_function.call_count == 2 149 | mock_stream_function.assert_any_call("Start ") 150 | mock_stream_function.assert_any_call(" End") 151 | 152 | def test_partial_tag_streaming(self, model, mock_stream_function, stream_response): 153 | # Test streaming with partial tags 154 | chunks = [ 155 | "Start ", 156 | "", 158 | "ignored", 159 | "", 161 | " End", 162 | "" 163 | ] 164 | 165 | for chunk in chunks: 166 | stream_chunk = model.text_to_stream_chunk(chunk) 167 | if not stream_chunk: 168 | continue 169 | mock_stream_function(stream_chunk) 170 | 171 | print(mock_stream_function.call_args_list) 172 | # Partial tags should be handled correctly 173 | assert mock_stream_function.call_count == 2 174 | mock_stream_function.assert_any_call("Start ") 175 | mock_stream_function.assert_any_call(" End") 176 | 177 | def test_newline_streaming(self, model, mock_stream_function, stream_response): 178 | # Test streaming with newlines 179 | chunks = [ 180 | "Line 1<<", 181 | "ignored", 184 | "Line 2<<", 185 | "" 186 | ] 187 | 188 | for chunk in chunks: 189 | stream_chunk = model.text_to_stream_chunk(chunk) 190 | if not stream_chunk: 191 | continue 192 | mock_stream_function(stream_chunk) 193 | 194 | print(mock_stream_function.call_args_list) 195 | # Newlines should break tags and be streamed 196 | assert mock_stream_function.call_count == 4 197 | assert "Line 1<<" in "".join(call[0][0] for call in mock_stream_function.call_args_list) 198 | assert "Line 2<<" in "".join(call[0][0] for call in mock_stream_function.call_args_list) 199 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Lamoom 2 | 3 | ## Our Philosophy 4 | 5 | Lamoom, derived from "Lambda on Mechanisms," refers to computation within a system that iteratively guides the LLM to perform correctly. Inspired by Amazon's culture, as Jeff Bezos said, "Good intentions don't work, mechanisms do," we focus on building mechanisms for LLMs rather than relying on their good intentions 6 | 7 | ![Lamoom Mechanisms](docs/media/optimized_lamoom_mechanisms.gif) 8 | 9 | 10 | ## Introduction 11 | Lamoom is a dynamic, all-in-one library designed for managing and optimizing prompts and making tests based on the ideal answer for large language models (LLMs) in production and R&D. It facilitates dynamic data integration, latency and cost metrics visibility, and efficient load distribution across multiple AI models. 12 | 13 | [![Lamoom Introduction Video](https://img.youtube.com/vi/1opO_5kRf98/0.jpg)](https://www.youtube.com/watch?v=1opO_5kRf98 "Lamoom Introduction Video") 14 | 15 | ## Getting Started 16 | 17 | To help you get started quickly, you can explore our [Getting Started Notebook](docs/getting_started_notebook.ipynb) which provides step-by-step examples of using Lamoom. 18 | 19 | ## Features 20 | 21 | - **CI/CD testing**: Generates tests based on the context and ideal answer (usually written by the human). 22 | - **Dynamic Prompt Development**: Avoid budget exceptions with dynamic data. 23 | - **Multi-Model Support**: Seamlessly integrate with various LLMs like OpenAI, Anthropic, and more. 24 | - **Real-Time Insights**: Monitor interactions, request/response metrics in production. 25 | - **Prompt Testing and Evaluation**: Quickly test and iterate on prompts using historical data. 26 | - **Smart Prompt Caching**: Efficiently cache prompts for 5 minutes to reduce latency while keeping them updated. 27 | - **Asynchronous Logging**: Record interactions without blocking the main execution flow. 28 | 29 | ## Core Functionality 30 | 31 | ### Prompt Management and Caching 32 | Lamoom implements an efficient prompt caching system with a 5-minute TTL (Time-To-Live): 33 | - **Automatic Updates**: When you call a prompt, Lamoom checks if a newer version exists on the server. 34 | - **Cache Invalidation**: Prompts are automatically refreshed after 5 minutes to ensure up-to-date content. 35 | - **Local Fallback**: If the LamoomService is unavailable, Lamoom library falls back to the locally defined prompt. 36 | - **Version Control**: Track prompt versions between local and server instances. 37 | 38 | 39 | ```mermaid 40 | sequenceDiagram 41 | Note over Lamoom,LLM: call(prompt_id, context, model) 42 | Lamoom->>Lamoom: get_cashed_prompt(prompt_id) 43 | alt Cache miss 44 | Lamoom->>LamoomService: get the last published prompt, Updates cache for 5 mins 45 | end 46 | Lamoom->>LLM: Cal LLM with prompt and context 47 | ``` 48 | 49 | ### Test Generation and CI/CD Integration 50 | Lamoom supports two methods for test creation: 51 | 1. **Inline Test Generation**: Add `test_data` with an ideal answer during normal LLM calls to automatically generate tests. 52 | 2. **Direct Test Creation**: Use the `create_test()` method to explicitly create tests for specific prompts. 53 | 54 | Tests automatically compare LLM responses to ideal answers, helping maintain prompt quality as models or prompts evolve. 55 | 56 | ```mermaid 57 | sequenceDiagram 58 | alt Direct `create_test` 59 | Lamoom->>LamoomService: create_test(prompt_id, context, ideal_answer) 60 | end 61 | alt Via `call` 62 | Lamoom->>LamoomService: call → creates asynchronous job to create test with an ideal answer 63 | end 64 | ``` 65 | 66 | ### Logging and Analytics 67 | Interaction logging happens asynchronously using a worker pattern: 68 | - **Performance Metrics**: Automatically track latency, token usage, and cost. 69 | - **Complete Context**: Store the full prompt, context, and response for analysis. 70 | - **Non-Blocking**: Logging happens in the background without impacting response times. 71 | 72 | ```mermaid 73 | sequenceDiagram 74 | Lamoom->>Lamoom: call(prompt_id, context, model) 75 | Lamoom->>LamoomService: creates asynchronous job to save logs 76 | ``` 77 | 78 | ### Feedback Collection 79 | Improve prompt quality through explicit feedback: 80 | - **Ideal Answer Addition**: Associate ideal answers with previous responses using `add_ideal_answer()`. 81 | - **Continuous Improvement**: Use feedback to automatically generate new tests and refine prompts. 82 | 83 | ```mermaid 84 | sequenceDiagram 85 | Lamoom->>LamoomService: add_ideal_answer(response_id, ideal_answer) 86 | ``` 87 | 88 | ## Installation 89 | 90 | Install Flow Prompt using pip: 91 | 92 | ```bash 93 | pip install lamoom 94 | ``` 95 | 96 | Obtain an API token from [Lamoom]('https://portal.lamoom.com') and add it as an env variable: `LAMOOM_API_TOKEN` ; 97 | 98 | ## Getting Started 99 | 100 | To help you get started quickly, you can explore our [Getting Started Notebook](docs/getting_started_notebook.ipynb) which provides step-by-step examples of using Lamoom. 101 | 102 | ## Authentication 103 | 104 | ### Add Keys depending on models you're using: 105 | ```python 106 | # Add LAMOOM_API_TOKEN as an environment variable: 107 | os.setenv('LAMOOM_API_TOKEN', 'your_token_here') 108 | 109 | # add OPENAI_API_KEY 110 | os.setenv('OPENAI_API_KEY', 'your_key_here') 111 | 112 | # add Azure Keys 113 | os.setenv('AZURE_KEYS', '{"name_realm":{"url": "https://baseurl.azure.com/","key": "secret"}}') 114 | # or creating flow_prompt obj 115 | Lamoom(azure_keys={"realm_name":{"url": "https://baseurl.azure.com/", "key": "your_secret"}}) 116 | 117 | # add Custom Models Key 118 | os.setenv('CUSTOM_API_KEY', 'your_key_here') 119 | ``` 120 | 121 | ### Model Agnostic: 122 | Mix models easily, and districute the load across models. The system will automatically distribute your load based on the weights. We support: 123 | - Claude 124 | - Gemini 125 | - OpenAI (w/ Azure OpenAI models) 126 | - Nebius with (Llama, DeepSeek, Mistral, Mixtral, dolphin, Qwen and others) 127 | - OpenRouter woth open source models 128 | - Custom providers 129 | 130 | Model string format is the following for Claude, Gemini, OpenAI, Nebius: 131 | `"{model_provider}/{model_name}"` 132 | For Azure models format is the following: 133 | `"azure/{realm}/{model_name}"` 134 | 135 | ```python 136 | response_llm = client.call(agent.id, context, model = "openai/o4-mini") 137 | response_llm = client.call(agent.id, context, model = "azure/useast/gpt-4.1-mini") 138 | ``` 139 | 140 | Custom model string format is the following: 141 | `"custom/{provider_name}/{model_name}"` 142 | where provider is provided in the env variable: 143 | LAMOOM_CUSTOM_PROVIDERS={"provider_name": {"base_url": "https://","key":"key"}} 144 | 145 | ```python 146 | response_llm = client.call(agent.id, context, model = "custom/provider_name/model_name") 147 | ``` 148 | 149 | ### Lamoom Keys 150 | Obtain an API token from Flow Prompt and add it: 151 | 152 | ```python 153 | # As an environment variable: 154 | os.setenv('LAMOOM_API_TOKEN', 'your_token_here') 155 | # Via code: 156 | Lamoom(api_token='your_api_token') 157 | ``` 158 | 159 | ## Usage Examples: 160 | 161 | ### Basic Usage 162 | ```python 163 | from lamoom import Lamoom, Prompt 164 | 165 | # Initialize and configure Lamoom 166 | client = Lamoom(openai_key='your_api_key', openai_org='your_org') 167 | 168 | # Create a prompt 169 | prompt = Prompt('greet_user') 170 | prompt.add("You're {name}. Say Hello and ask what's their name.", role="system") 171 | 172 | # Call AI model with Lamoom 173 | context = {"name": "John Doe"} 174 | response = client.call(prompt.id, context, "openai/o4-mini") 175 | print(response.content) 176 | ``` 177 | 178 | ### Creating Tests While Using Prompts 179 | ```python 180 | # Call with test_data to automatically generate tests 181 | response = client.call(prompt.id, context, "openai/o4-mini", test_data={ 182 | 'ideal_answer': "Hello, I'm John Doe. What's your name?", 183 | 'model_name': "gemini/gemini-1.5-flash" 184 | }) 185 | ``` 186 | 187 | ### Creating Tests Explicitly 188 | ```python 189 | # Create a test directly 190 | client.create_test( 191 | prompt.id, 192 | context, 193 | ideal_answer="Hello, I'm John Doe. What's your name?", 194 | model_name="gemini/gemini-1.5-flash" 195 | ) 196 | ``` 197 | 198 | ### Adding Feedback to Previous Responses 199 | ```python 200 | # Add an ideal answer to a previous response for quality assessment 201 | client.add_ideal_answer( 202 | response_id=response.id, 203 | ideal_answer="Hello, I'm John Doe. What's your name?" 204 | ) 205 | ``` 206 | 207 | ### To Add Search Credentials: 208 | - Add Search ENgine id from here: 209 | https://programmablesearchengine.google.com/controlpanel/create 210 | 211 | - Get A google Search Key: 212 | https://developers.google.com/custom-search/v1/introduction/?apix=true 213 | 214 | 215 | ### Monitoring and Management 216 | - **Test Dashboard**: Review created tests and scores at https://cloud.lamoom.com/tests 217 | - **Prompt Management**: Update prompts and rerun tests for published or saved versions 218 | - **Analytics**: View logs with metrics (latency, cost, tokens) at https://cloud.lamoom.com/logs 219 | 220 | The system is designed to allow prompt updates without code redeployment—simply publish a new prompt version online, and the library will automatically fetch and use it. 221 | 222 | ## Best Security Practices 223 | For production environments, it is recommended to store secrets securely and not directly in your codebase. Consider using a secret management service or encrypted environment variables. 224 | 225 | ## Contributing 226 | We welcome contributions! Please see our Contribution Guidelines for more information on how to get involved. 227 | 228 | ## License 229 | This project is licensed under the Apache2.0 License - see the [LICENSE](LICENSE.txt) file for details. 230 | 231 | ## Contact 232 | For support or contributions, please contact us via GitHub Issues. 233 | 234 | -------------------------------------------------------------------------------- /researches/ci_cd/utils.py: -------------------------------------------------------------------------------- 1 | import pandas as pd 2 | import json 3 | import logging 4 | import csv 5 | import os 6 | from typing import List, Dict, Any, Optional 7 | from responses import Question, TestResult, Score, Statement 8 | 9 | logger = logging.getLogger(__name__) 10 | 11 | def parse_csv_file(file_path: str) -> list: 12 | """ 13 | Reads a CSV file and returns a list of dictionaries with keys: 14 | - ideal_answer 15 | - llm_response 16 | - optional_params (parsed as dict if not empty) 17 | - generated_test (parsed as dict if present) 18 | 19 | This function is compatible with CSV files created by export_generated_tests_to_csv. 20 | """ 21 | try: 22 | df = pd.read_csv(file_path) 23 | except Exception as e: 24 | logger.error(f"Error reading CSV file: {e}") 25 | return [] 26 | 27 | test_cases = [] 28 | for _, row in df.iterrows(): 29 | case = { 30 | "ideal_answer": row.get("ideal_answer"), 31 | "llm_response": row.get("llm_response") 32 | } 33 | 34 | # Parse optional_params 35 | opt_params = row.get("optional_params") 36 | if pd.notna(opt_params) and opt_params: 37 | try: 38 | case["optional_params"] = json.loads(opt_params) 39 | except json.JSONDecodeError as e: 40 | logger.error(f"Error parsing optional_params: {e}") 41 | case["optional_params"] = None 42 | else: 43 | case["optional_params"] = None 44 | 45 | # Parse generated_test if present 46 | generated_test = row.get("generated_test") 47 | if pd.notna(generated_test) and generated_test: 48 | try: 49 | parsed_test = json.loads(generated_test) 50 | case["generated_test"] = parsed_test 51 | except json.JSONDecodeError as e: 52 | logger.error(f"Error parsing generated_test: {e}") 53 | case["generated_test"] = None 54 | 55 | test_cases.append(case) 56 | 57 | return test_cases 58 | 59 | 60 | def export_generated_tests_to_csv(file_path: str, tests: List[dict]): 61 | """ 62 | Exports generated tests to a CSV file. 63 | 64 | Args: 65 | file_path: Path to save the CSV file 66 | tests: List of test dictionaries containing ideal_answer, llm_response, etc. 67 | """ 68 | try: 69 | with open(file_path, 'w', newline='', encoding='utf-8') as f: 70 | writer = csv.writer(f) 71 | # Write header 72 | writer.writerow(['ideal_answer', 'llm_response', 'optional_params', 'generated_test']) 73 | 74 | # Write rows 75 | for test in tests: 76 | ideal_answer = test.get('ideal_answer', '') 77 | llm_response = test.get('llm_response', '') 78 | optional_params = json.dumps(test.get('optional_params', {})) if test.get('optional_params') else '' 79 | 80 | # Convert statements and questions to JSON string 81 | generated_test = {} 82 | if 'statements' in test and test['statements']: 83 | statements_list = [] 84 | questions_dict = {} 85 | 86 | for statement in test['statements']: 87 | statement_text = statement.get('statement', '') 88 | question_text = statement.get('question', '') 89 | statements_list.append(statement_text) 90 | questions_dict[statement_text] = question_text 91 | 92 | generated_test = { 93 | 'statements': statements_list, 94 | 'questions': questions_dict 95 | } 96 | 97 | generated_test_json = json.dumps(generated_test) 98 | 99 | writer.writerow([ideal_answer, llm_response, optional_params, generated_test_json]) 100 | 101 | logger.info(f"Generated tests exported to {file_path}") 102 | return True 103 | except Exception as e: 104 | logger.error(f"Error exporting generated tests to CSV: {e}") 105 | return False 106 | 107 | 108 | def export_results_to_csv(file_path: str, results: List): 109 | """ 110 | Exports test results to a CSV file with detailed information. 111 | 112 | Args: 113 | file_path: Path to save the CSV file 114 | results: List of TestResult objects 115 | """ 116 | try: 117 | with open(file_path, 'w', newline='', encoding='utf-8') as f: 118 | writer = csv.writer(f) 119 | # Write header for detailed results 120 | writer.writerow([ 121 | 'prompt_id', 122 | 'prompt_version', 123 | 'question', 124 | 'statement', 125 | 'llm_response_to_question', 126 | 'ideal_answer', 127 | 'does_match_ideal_answer', 128 | 'score', 129 | 'full_llm_response', 130 | 'full_ideal_answer' 131 | ]) 132 | 133 | # Write rows for each question in each result 134 | for result in results: 135 | prompt_id = result.prompt_id 136 | 137 | # Extract prompt_version from optional_params if available 138 | prompt_version = '' 139 | if result.optional_params and 'prompt_version' in result.optional_params: 140 | prompt_version = result.optional_params['prompt_version'] 141 | 142 | # Create a mapping of questions to statements 143 | question_to_statement = {} 144 | if result.statements: 145 | for statement in result.statements: 146 | question_to_statement[statement.question] = statement.statement 147 | 148 | # Write a row for each question 149 | for question in result.questions: 150 | test_question = question.test_question 151 | statement = question_to_statement.get(test_question, '') 152 | llm_answer = question.llm_answer 153 | ideal_answer = question.ideal_answer 154 | does_match = 'Yes' if question.does_match_ideal_answer else 'No' 155 | 156 | # Get score history as string 157 | score_history = json.dumps(question.score) 158 | 159 | # Write the row 160 | writer.writerow([ 161 | prompt_id, 162 | prompt_version, 163 | test_question, 164 | statement, 165 | llm_answer, 166 | ideal_answer, 167 | does_match, 168 | score_history, 169 | result.llm_response, 170 | result.ideal_response 171 | ]) 172 | 173 | logger.info(f"Test results exported to {file_path}") 174 | return True 175 | except Exception as e: 176 | logger.error(f"Error exporting results to CSV: {e}") 177 | return False 178 | 179 | def import_results_from_csv(file_path: str) -> List[TestResult]: 180 | """ 181 | Imports test results from a CSV file. 182 | 183 | Args: 184 | file_path: Path to the CSV file with results 185 | 186 | Returns: 187 | List of TestResult objects reconstructed from the CSV 188 | """ 189 | try: 190 | df = pd.read_csv(file_path) 191 | 192 | # Group by prompt_id, full_llm_response, and full_ideal_answer 193 | # This helps us recreate TestResult objects 194 | grouped_data = df.groupby(['prompt_id', 'full_llm_response', 'full_ideal_answer']) 195 | 196 | results = [] 197 | 198 | for (prompt_id, llm_response, ideal_response), group in grouped_data: 199 | # Extract prompt_version from the first row in the group 200 | prompt_version = group['prompt_version'].iloc[0] if 'prompt_version' in group.columns else None 201 | 202 | # Create optional_params dict if prompt_version exists 203 | optional_params = {'prompt_version': prompt_version} if pd.notna(prompt_version) and prompt_version else {} 204 | 205 | # Create Question objects 206 | questions = [] 207 | statements = [] 208 | 209 | for _, row in group.iterrows(): 210 | # Create Question object 211 | test_question = row['question'] 212 | llm_answer = row['llm_response_to_question'] 213 | ideal_answer = row['ideal_answer'] 214 | does_match = True if row['does_match_ideal_answer'] == 'Yes' else False 215 | 216 | q = Question(test_question, llm_answer, ideal_answer, does_match) 217 | 218 | # If score history is available, add it 219 | if 'score' in row and pd.notna(row['score']): 220 | try: 221 | score_history = json.loads(row['score']) 222 | q.score = score_history 223 | except json.JSONDecodeError: 224 | pass 225 | 226 | questions.append(q) 227 | 228 | # Create Statement object if statement column is available 229 | if 'statement' in row and pd.notna(row['statement']) and row['statement']: 230 | statement = row['statement'] 231 | statements.append(Statement(statement=statement, question=test_question)) 232 | 233 | # Calculate the score 234 | passed_count = sum(1 for q in questions if q.does_match_ideal_answer) 235 | score_value = round(passed_count / len(questions) * 100) if questions else 0 236 | passed = score_value >= 70 # Using default threshold of 70% 237 | score = Score(score_value, passed) 238 | 239 | # Create TestResult object 240 | test_result = TestResult( 241 | prompt_id=prompt_id, 242 | questions=questions, 243 | score=score, 244 | ideal_response=ideal_response, 245 | llm_response=llm_response, 246 | statements=statements if statements else None, 247 | optional_params=optional_params if optional_params else None 248 | ) 249 | 250 | results.append(test_result) 251 | 252 | logger.info(f"Imported {len(results)} test results from {file_path}") 253 | return results 254 | except Exception as e: 255 | logger.error(f"Error importing results from CSV: {e}") 256 | return [] -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. -------------------------------------------------------------------------------- /lamoom/prompt/user_prompt.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import typing as t 3 | from collections import defaultdict 4 | from dataclasses import dataclass, field 5 | 6 | from lamoom.ai_models.tools.base_tool import ToolDefinition, inject_tool_prompts 7 | import tiktoken 8 | 9 | from lamoom import settings 10 | from lamoom.exceptions import NotEnoughBudgetError 11 | from lamoom.prompt.base_prompt import BasePrompt 12 | from lamoom.prompt.chat import ChatMessage, ChatsEntity 13 | 14 | logger = logging.getLogger(__name__) 15 | 16 | 17 | @dataclass 18 | class State: 19 | """ 20 | State of the prompt. left_budget is the budget left for the rest of the prompt. 21 | fully_fitted_pipitas is the set of labels of chats that were fully fitted in the prompt. 22 | Pipita references to a small part of pipe, formed with a Spanish ending 'ita' which means a smaller version. 23 | """ 24 | 25 | left_budget: int = 0 26 | fully_fitted_pipitas: t.Set[str] = field(default_factory=set) 27 | references: t.Dict[str, t.List[str]] = field( 28 | default_factory=lambda: defaultdict(list) 29 | ) 30 | 31 | 32 | @dataclass 33 | class CallingMessages: 34 | messages: t.List[ChatMessage] 35 | prompt_budget: int = 0 36 | left_budget: int = 0 37 | references: t.Dict[str, t.List[str]] = None 38 | max_sample_budget: int = 0 39 | 40 | @property 41 | def calling_messages(self) -> t.List[t.Dict[str, str]]: 42 | return [m.to_dict() for m in self.messages if not m.is_empty()] 43 | 44 | def get_messages(self) -> t.List[t.Dict[str, str]]: 45 | result = [] 46 | for m in self.messages: 47 | if m.is_empty(): 48 | continue 49 | result.append(m.to_dict()) 50 | return result 51 | 52 | def __str__(self) -> str: 53 | return "\n".join([str(m.to_dict()) for m in self.messages]) 54 | 55 | 56 | @dataclass(kw_only=True) 57 | class UserPrompt(BasePrompt): 58 | model_max_tokens: int 59 | tiktoken_encoding: str 60 | min_sample_tokens: int 61 | reserved_tokens_budget_for_sampling: int = None 62 | safe_gap_tokens: int = settings.SAFE_GAP_TOKENS 63 | shared_context : t.Dict = field(default_factory=dict) 64 | 65 | def __post_init__(self): 66 | self.encoding = tiktoken.get_encoding(self.tiktoken_encoding) 67 | 68 | def resolve(self, context: t.Dict, tool_registry: t.Dict[str, ToolDefinition]) -> CallingMessages: 69 | pipe = {} 70 | context = {**self.shared_context, **context} 71 | prompt_budget = 0 72 | ordered_pipe = dict((value, i) for i, value in enumerate(self.pipe)) 73 | state = State() 74 | state.left_budget = self.left_budget 75 | inject_tool_prompts(list(tool_registry.values()), context) 76 | i = 0 77 | 78 | for priority in sorted(self.priorities.keys()): 79 | for chat_value in self.priorities[priority]: 80 | if i == 0: 81 | # Inject tool prompts into first message 82 | chat_value.content = chat_value.content + "\n\n{tool_system_prompt}\n" 83 | i += 1 84 | 85 | r = [ 86 | p in state.fully_fitted_pipitas 87 | for p in (chat_value.add_if_fitted_labels or []) 88 | ] 89 | if not all(r): 90 | continue 91 | 92 | if chat_value.presentation: 93 | state.left_budget -= len( 94 | self.encoding.encode(chat_value.presentation) 95 | ) 96 | if chat_value.last_words: 97 | state.left_budget -= len( 98 | self.encoding.encode(chat_value.last_words) 99 | ) 100 | 101 | values = chat_value.get_values(context) 102 | logger.debug(f"Got values for {chat_value}: {values}") 103 | if not values: 104 | continue 105 | if chat_value.in_one_message: 106 | messages_budget, messages = self.add_values_in_one_message( 107 | values, chat_value, state 108 | ) 109 | elif chat_value.while_fits: 110 | messages_budget, messages = self.add_values_while_fits( 111 | values, 112 | chat_value, 113 | state, 114 | ) 115 | else: 116 | messages_budget, messages = self.add_values(values, state) 117 | if chat_value.label: 118 | state.fully_fitted_pipitas.add(chat_value.label) 119 | 120 | if not messages: 121 | logger.debug(f"messages is empty for {chat_value}") 122 | continue 123 | if not self.is_enough_budget(state, messages_budget): 124 | logger.debug(f"not enough budget for {chat_value}") 125 | if chat_value.required: 126 | raise NotEnoughBudgetError("Not enough budget") 127 | continue 128 | logger.debug(f"adding {len(messages)} messages for {chat_value}") 129 | state.left_budget -= messages_budget 130 | prompt_budget += messages_budget 131 | if chat_value.presentation: 132 | messages[0].content = chat_value.presentation + messages[0].content 133 | if chat_value.last_words: 134 | messages[-1].content += chat_value.last_words 135 | pipe[chat_value._uuid] = messages 136 | continue 137 | 138 | final_pipe_with_order = [ 139 | pipe.get(chat_id, []) 140 | for chat_id, _ in sorted(ordered_pipe.items(), key=lambda x: x[1]) 141 | ] 142 | # skip empty values 143 | flat_list: t.List[ChatMessage] = [ 144 | item for sublist in final_pipe_with_order for item in sublist if item 145 | ] 146 | 147 | 148 | max_sample_budget = left_budget = state.left_budget + self.min_sample_tokens 149 | if self.reserved_tokens_budget_for_sampling: 150 | max_sample_budget = min( 151 | self.reserved_tokens_budget_for_sampling, left_budget 152 | ) 153 | return CallingMessages( 154 | references=state.references, 155 | messages=flat_list, 156 | prompt_budget=prompt_budget, 157 | left_budget=left_budget, 158 | max_sample_budget=max_sample_budget, 159 | ) 160 | 161 | def add_values_while_fits( 162 | self, 163 | values: list[ChatMessage], 164 | chat_value: ChatsEntity, 165 | state: State, 166 | ): 167 | add_in_reverse_order = chat_value.add_in_reverse_order 168 | if add_in_reverse_order: 169 | values = values[::-1] 170 | values_to_add = [] 171 | messages_budget = 0 172 | is_fully_fitted = True 173 | if not values: 174 | logger.debug( 175 | f"[{self.task_name}]: values to add is empty {chat_value.content}" 176 | ) 177 | for i, value in enumerate(values): 178 | if not self.is_value_not_empty(value): 179 | continue 180 | one_budget = self.calculate_budget_for_value(value) 181 | 182 | if not self.is_enough_budget(state, one_budget + messages_budget): 183 | is_fully_fitted = False 184 | logger.debug( 185 | f"not enough budget:{chat_value.content[:30]} with index {i}," 186 | " for while_fits, breaking the loop" 187 | ) 188 | left_budget = state.left_budget - messages_budget 189 | if ( 190 | not settings.CHECK_LEFT_BUDGET or 191 | chat_value.continue_if_doesnt_fit 192 | and left_budget > settings.EXPECTED_MIN_BUDGET_FOR_VALUABLE_INPUT 193 | ): 194 | continue 195 | break 196 | messages_budget += one_budget 197 | values_to_add.append(value) 198 | if value.ref_name and value.ref_value: 199 | state.references[value.ref_name].append(value.ref_value) 200 | if is_fully_fitted and chat_value.label: 201 | state.fully_fitted_pipitas.add(chat_value.label) 202 | if add_in_reverse_order: 203 | values_to_add = values_to_add[::-1] 204 | return messages_budget, values_to_add 205 | 206 | def is_enough_budget(self, state: State, required_budget: int) -> bool: 207 | if not settings.CHECK_LEFT_BUDGET: 208 | return True 209 | return state.left_budget >= required_budget 210 | 211 | def add_values_in_one_message( 212 | self, 213 | values: list[ChatMessage], 214 | chat_value: ChatsEntity, 215 | state: State, 216 | ) -> CallingMessages: 217 | one_message_budget = 0 218 | one_message = None 219 | is_fully_fitted = True 220 | if not values: 221 | logger.debug( 222 | f"[{self.task_name}]: values to add is empty {chat_value.content}" 223 | ) 224 | 225 | for i, value in enumerate(values): 226 | if not self.is_value_not_empty(value): 227 | continue 228 | one_budget = self.calculate_budget_for_value(value) 229 | if not self.is_enough_budget(state, one_budget + one_message_budget): 230 | is_fully_fitted = False 231 | logger.debug( 232 | f"not enough budget:\n{chat_value.content[:30]} with index {i}," 233 | f" for while_fits, breaking the loop." 234 | f" Budget required: {one_budget}, " 235 | f"left: {state.left_budget - one_message_budget}" 236 | ) 237 | 238 | left_budget = state.left_budget - one_message_budget 239 | if ( 240 | not settings.CHECK_LEFT_BUDGET or 241 | chat_value.continue_if_doesnt_fit 242 | and left_budget > settings.EXPECTED_MIN_BUDGET_FOR_VALUABLE_INPUT 243 | ): 244 | continue 245 | break 246 | 247 | if one_message: 248 | one_message.content += "\n" + value.content 249 | else: 250 | one_message = value 251 | if value.ref_name and value.ref_value: 252 | state.references[value.ref_name].append(value.ref_value) 253 | if is_fully_fitted and chat_value.label: 254 | state.fully_fitted_pipitas.add(chat_value.label) 255 | return one_message_budget, [] if not one_message else [one_message] 256 | 257 | @property 258 | def left_budget(self) -> int: 259 | return self.model_max_tokens - self.min_sample_tokens - self.safe_gap_tokens 260 | 261 | def calculate_budget_for_value(self, value: ChatMessage) -> int: 262 | content = len(self.encoding.encode(value.content)) 263 | role = len(self.encoding.encode(value.role)) 264 | tool_calls = len(self.encoding.encode(value.tool_calls.get("name", ""))) 265 | arguments = len(self.encoding.encode(value.tool_calls.get("arguments", ""))) 266 | return content + role + tool_calls + arguments + settings.SAFE_GAP_PER_MSG 267 | 268 | def is_value_not_empty(self, value: ChatMessage) -> bool: 269 | if not value: 270 | return False 271 | if value.content is None: 272 | return False 273 | return True 274 | 275 | def add_values( 276 | self, 277 | values: t.List[ChatMessage], 278 | state: State, 279 | ) -> t.Tuple[int, t.List[ChatMessage]]: 280 | budget = 0 281 | result = [] 282 | 283 | for value in values: 284 | if not self.is_value_not_empty(value): 285 | logger.debug(f"[{self.task_name}]: is_value_not_empty failed {value}") 286 | continue 287 | budget += self.calculate_budget_for_value(value) 288 | 289 | if value.type == "base64_image": 290 | budget += 85 291 | result += [ 292 | { 293 | "type": "image_url", 294 | "image_url": { 295 | "url": f"data:image/jpeg;base64,{value.content}" 296 | } 297 | }] 298 | else: 299 | result.append(value) 300 | if value.ref_name and value.ref_value: 301 | state.references[value.ref_name].append(value.ref_value) 302 | return budget, result 303 | 304 | def __str__(self) -> str: 305 | result = "" 306 | for chat_value in self.pipe: 307 | result += f"{chat_value}\n" 308 | return result 309 | 310 | def to_dict(self) -> dict: 311 | return [chat_value.to_dict() for chat_value in self.pipe] 312 | -------------------------------------------------------------------------------- /lamoom/prompt/lamoom.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | import logging 3 | import typing as t 4 | from dataclasses import dataclass, field 5 | import requests 6 | from lamoom.ai_models.tools.base_tool import inject_tool_prompts 7 | from lamoom.settings import LAMOOM_API_URI 8 | from lamoom import Secrets, settings 9 | from lamoom.ai_models.ai_model import AI_MODELS_PROVIDER 10 | from lamoom.ai_models.attempt_to_call import AttemptToCall 11 | from lamoom.ai_models.behaviour import AIModelsBehaviour, PromptAttempts 12 | from lamoom.ai_models.openai.azure_models import AzureAIModel 13 | from lamoom.ai_models.claude.claude_model import ClaudeAIModel 14 | from lamoom.ai_models.openai.openai_models import OpenAIModel 15 | 16 | from lamoom.exceptions import ( 17 | LamoomPromptIsnotFoundError, 18 | RetryableCustomError, 19 | StopStreamingError 20 | ) 21 | from lamoom.services.SaveWorker import SaveWorker 22 | from lamoom.prompt.prompt import Prompt 23 | 24 | from lamoom.responses import AIResponse 25 | from lamoom.services.lamoom import LamoomService 26 | import json 27 | 28 | logger = logging.getLogger(__name__) 29 | 30 | BASE_URL_MAPPING = { 31 | 'gemini': "https://generativelanguage.googleapis.com/v1beta/openai/" 32 | } 33 | 34 | @dataclass 35 | class Lamoom: 36 | api_token: str = None 37 | openai_key: str = None 38 | openai_org: str = None 39 | claude_key: str = None 40 | gemini_key: str = None 41 | azure_keys: t.Dict[str, str] = None 42 | custom_keys: t.Dict[str, str] = field(default_factory=dict) 43 | secrets: Secrets = None 44 | 45 | clients = {} 46 | 47 | def __post_init__(self): 48 | self.secrets = Secrets() 49 | if not self.azure_keys: 50 | if self.secrets.azure_keys: 51 | logger.debug(f"Using Azure keys from secrets") 52 | self.azure_keys = self.secrets.azure_keys 53 | else: 54 | logger.debug(f"Azure keys not found in secrets") 55 | if not self.custom_keys: 56 | if self.secrets.custom_keys: 57 | logger.debug(f"Using Custom keys from secrets") 58 | self.custom_keys = self.secrets.custom_keys 59 | else: 60 | logger.debug(f"Custom keys not found in secrets") 61 | if not self.api_token and self.secrets.API_TOKEN: 62 | logger.debug(f"Using API token from secrets") 63 | self.api_token = self.secrets.API_TOKEN 64 | if not self.openai_key and self.secrets.OPENAI_API_KEY: 65 | logger.debug(f"Using OpenAI API key from secrets") 66 | self.openai_key = self.secrets.OPENAI_API_KEY 67 | if not self.openai_org and self.secrets.OPENAI_ORG: 68 | logger.debug(f"Using OpenAI organization from secrets") 69 | self.openai_org = self.secrets.OPENAI_ORG 70 | if not self.gemini_key and self.secrets.GEMINI_API_KEY: 71 | logger.debug(f"Using Gemini API key from secrets") 72 | self.gemini_key = self.secrets.GEMINI_API_KEY 73 | if not self.claude_key and self.secrets.CLAUDE_API_KEY: 74 | logger.debug(f"Using Claude API key from secrets") 75 | self.claude_key = self.secrets.CLAUDE_API_KEY 76 | self.service = LamoomService() 77 | if self.openai_key: 78 | self.clients[AI_MODELS_PROVIDER.OPENAI.value] = { 79 | "organization": self.openai_org, 80 | "api_key": self.openai_key, 81 | } 82 | if self.azure_keys: 83 | if not self.clients.get(AI_MODELS_PROVIDER.AZURE.value): 84 | self.clients[AI_MODELS_PROVIDER.AZURE.value] = {} 85 | for realm, key_data in self.azure_keys.items(): 86 | self.clients[AI_MODELS_PROVIDER.AZURE.value][realm] = { 87 | "api_version": key_data.get("api_version", "2024-12-01-preview"), 88 | "azure_endpoint": key_data["url"], 89 | "api_key": key_data["key"], 90 | } 91 | logger.debug(f"Initialized Azure client for {realm} {key_data['url']}") 92 | if self.claude_key: 93 | self.clients[AI_MODELS_PROVIDER.CLAUDE.value] = {"api_key": self.claude_key} 94 | if self.gemini_key: 95 | self.clients[AI_MODELS_PROVIDER.GEMINI.value] = { 96 | "api_key": self.gemini_key, 97 | "base_url": BASE_URL_MAPPING.get(AI_MODELS_PROVIDER.GEMINI.value) 98 | } 99 | # Initialize custom providers from environment 100 | for provider_name, provider_config in self.custom_keys.items(): 101 | logger.info(f"Initializing custom provider {provider_name} {provider_config.get('base_url')}") 102 | provider_key = f"custom_{provider_name}" 103 | if provider_key not in self.clients: 104 | self.clients[provider_key] = { 105 | "api_key": provider_config.get("key"), 106 | "base_url": provider_config.get("base_url") 107 | } 108 | logger.debug(f"Initialized custom provider {provider_key} {provider_config.get('base_url')}") 109 | self.worker = SaveWorker() 110 | 111 | def create_test( 112 | self, prompt_id: str, context: t.Dict[str, str], ideal_answer: str = None, model_name: str = None 113 | ): 114 | """ 115 | Create new test 116 | """ 117 | 118 | url = f"{LAMOOM_API_URI}/lib/tests?createTest" 119 | headers = {"Authorization": f"Token {self.api_token}"} 120 | if "ideal_answer" in context: 121 | ideal_answer = context["ideal_answer"] 122 | 123 | data = { 124 | "prompt_id": prompt_id, 125 | "ideal_answer": ideal_answer, 126 | "model_name": model_name, 127 | "test_context": context, 128 | } 129 | json_data = json.dumps(data) 130 | response = requests.post(url, headers=headers, data=json_data) 131 | 132 | if response.status_code == 200: 133 | return response.json() 134 | else: 135 | logger.error(response) 136 | 137 | def extract_provider_name(self, model: str) -> dict: 138 | parts = model.split("/") 139 | 140 | if "openai" in parts[0].lower() and len(parts) == 2: 141 | return { 142 | 'provider': parts[0].lower(), 143 | 'model_name': parts[1] 144 | } 145 | 146 | elif "azure" in parts[0].lower() and len(parts) == 3: 147 | model_provider, realm, model_name = parts 148 | return { 149 | 'provider': model_provider.lower(), 150 | 'model_name': model_name, 151 | 'realm': realm, 152 | } 153 | elif "claude" in parts[0].lower() and len(parts) == 2: 154 | return { 155 | 'provider': 'claude', 156 | 'model_name': parts[1] 157 | } 158 | elif "gemini" in parts[0].lower() and len(parts) == 2: 159 | return { 160 | 'provider': 'gemini', 161 | 'model_name': parts[1] 162 | } 163 | elif "custom" in parts[0].lower() and len(parts) >= 3: 164 | model_name = '/'.join(parts[2:]) 165 | provider_name = parts[1].lower() 166 | # Check if this is a registered custom provider 167 | if provider_name in self.custom_keys: 168 | return { 169 | 'provider': f"custom_{provider_name}", 170 | 'model_name': model_name, 171 | 'realm': None, 172 | } 173 | raise Exception(f"Unknown model: {model}") 174 | 175 | def get_default_context(self): 176 | return { 177 | 'current_datetime_strftime': datetime.now().strftime("%Y-%m-%d %H:%M:%S"), 178 | 'timezone': datetime.now().astimezone().tzname() 179 | } 180 | 181 | def get_context(self, context: dict): 182 | return { 183 | **self.get_default_context(), 184 | **context 185 | } 186 | 187 | def init_attempt(self, model_info: dict, weight: int = 100) -> AttemptToCall: 188 | provider = model_info['provider'] 189 | model_name = model_info['model_name'] 190 | 191 | if provider == AI_MODELS_PROVIDER.CLAUDE.value: 192 | return AttemptToCall( 193 | ai_model=ClaudeAIModel( 194 | model=model_name, 195 | ), 196 | weight=weight, 197 | ) 198 | elif provider == AI_MODELS_PROVIDER.OPENAI.value: 199 | return AttemptToCall( 200 | ai_model=OpenAIModel( 201 | model=model_name 202 | ), 203 | weight=weight, 204 | ) 205 | elif provider == AI_MODELS_PROVIDER.GEMINI.value: 206 | return AttemptToCall( 207 | ai_model=OpenAIModel( 208 | model=model_name, 209 | provider=AI_MODELS_PROVIDER.GEMINI, 210 | ), 211 | weight=weight, 212 | ) 213 | elif provider.startswith('custom_'): 214 | # Handle custom provider format 215 | return AttemptToCall( 216 | ai_model=OpenAIModel( 217 | model=model_name, 218 | provider=AI_MODELS_PROVIDER.CUSTOM, 219 | _provider_name=model_info['provider'] 220 | ), 221 | weight=weight, 222 | ) 223 | elif provider == AI_MODELS_PROVIDER.AZURE.value: 224 | return AttemptToCall( 225 | ai_model=AzureAIModel( 226 | realm=model_info['realm'], 227 | deployment_id=model_name, 228 | ), 229 | weight=weight, 230 | ) 231 | 232 | def init_behavior(self, model: str, fallback_models: dict = None) -> AIModelsBehaviour: 233 | main_model_info = self.extract_provider_name(model) 234 | main_attempt = self.init_attempt(main_model_info) 235 | fallback_attempts = [] 236 | fallback_config = fallback_models if fallback_models is not None else settings.FALLBACK_MODELS 237 | if fallback_config: 238 | for model_name, weight in fallback_config.items(): 239 | model_info = self.extract_provider_name(model_name) 240 | fallback_attempts.append(self.init_attempt(model_info, weight)) 241 | else: 242 | model_info = self.extract_provider_name(model) 243 | fallback_attempts.append(self.init_attempt(model_info)) 244 | 245 | return AIModelsBehaviour( 246 | attempt=main_attempt, 247 | fallback_attempts=fallback_attempts 248 | ) 249 | 250 | def call( 251 | self, 252 | prompt_id: str, 253 | context: t.Dict[str, str], 254 | model: str, 255 | params: t.Dict[str, t.Any] = {}, 256 | version: str = None, 257 | count_of_retries: int = 5, 258 | test_data: dict = {}, 259 | stream_function: t.Callable = None, 260 | check_connection: t.Callable = None, 261 | stream_params: dict = {}, 262 | prompt_data: dict = {}, 263 | fallback_models: t.Union[list, dict] = None, 264 | ) -> AIResponse: 265 | """ 266 | Call flow prompt with context and behaviour 267 | """ 268 | 269 | logger.debug(f"Calling {prompt_id}") 270 | if prompt_data: 271 | prompt = Prompt.service_load(prompt_data) 272 | else: 273 | prompt = self.get_prompt(prompt_id, version) 274 | 275 | behaviour = self.init_behavior(model, fallback_models) 276 | 277 | logger.info(behaviour) 278 | 279 | prompt_attempts = PromptAttempts(behaviour) 280 | 281 | while prompt_attempts.initialize_attempt(): 282 | current_attempt = prompt_attempts.current_attempt 283 | user_prompt = prompt.create_prompt(current_attempt) 284 | calling_context = self.get_context(context) 285 | # Inject tool prompts into first message 286 | calling_messages = user_prompt.resolve(calling_context, prompt.tool_registry) 287 | messages = calling_messages.get_messages() 288 | logger.info(f'self.clients: {self.clients}, [current_attempt.ai_model.provider_name]: {current_attempt.ai_model.provider_name}') 289 | for _ in range(0, count_of_retries): 290 | try: 291 | result = current_attempt.ai_model.call( 292 | messages, 293 | calling_messages.max_sample_budget, 294 | tool_registry=prompt.tool_registry, 295 | stream_function=stream_function, 296 | check_connection=check_connection, 297 | stream_params=stream_params, 298 | client_secrets=self.clients[current_attempt.ai_model.provider_name], 299 | modelname=model, 300 | prompt=prompt, 301 | user_prompt=user_prompt, 302 | context=context, 303 | test_data=test_data, 304 | client=self, 305 | **params, 306 | ) 307 | return result 308 | except RetryableCustomError as e: 309 | logger.exception( 310 | f"Attempt failed: {prompt_attempts.current_attempt} with retryable error: {e}" 311 | ) 312 | break 313 | except StopStreamingError as e: 314 | logger.exception( 315 | f"Attempt Stopped: {prompt_attempts.current_attempt} with non-retryable error: {e}" 316 | ) 317 | raise e 318 | except Exception as e: 319 | logger.exception( 320 | f"Attempt failed: {prompt_attempts.current_attempt} with non-retryable error: {e}" 321 | ) 322 | raise e 323 | 324 | logger.exception( 325 | "Prompt call failed, no attempts worked" 326 | ) 327 | raise Exception 328 | 329 | def get_prompt(self, prompt_id: str, version: str = None) -> Prompt: 330 | """ 331 | if the user has keys: lib -> service: get_actual_prompt(local_prompt) -> Service: 332 | generates hash of the prompt; 333 | check in Redis if that record is the latest; if yes -> return 200, else 334 | checks if that record exists with that hash; 335 | if record exists and it's not the last - then we load the latest published prompt; - > return 200 + the last record 336 | add a new record in storage, and adding that it's the latest published prompt; -> return 200 337 | update redis with latest record; 338 | """ 339 | logger.debug(f"Getting pipe prompt {prompt_id}") 340 | if ( 341 | settings.USE_API_SERVICE 342 | and self.api_token 343 | and settings.RECEIVE_PROMPT_FROM_SERVER 344 | ): 345 | prompt_data = None 346 | prompt = settings.PIPE_PROMPTS.get(prompt_id) 347 | if prompt: 348 | prompt_data = prompt.service_dump() 349 | try: 350 | response = self.service.get_actual_prompt( 351 | self.api_token, prompt_id, prompt_data, version 352 | ) 353 | if not response.is_taken_globally: 354 | prompt.version = response.version 355 | return prompt 356 | response.prompt["version"] = response.version 357 | return Prompt.service_load(response.prompt) 358 | except Exception as e: 359 | logger.info(f"Error while getting prompt {prompt_id}: {e}") 360 | if prompt: 361 | return prompt 362 | else: 363 | logger.exception(f"Prompt {prompt_id} not found") 364 | raise LamoomPromptIsnotFoundError() 365 | 366 | else: 367 | return settings.PIPE_PROMPTS[prompt_id] 368 | 369 | 370 | def add_ideal_answer( 371 | self, 372 | response_id: str, 373 | ideal_answer: str 374 | ): 375 | response = LamoomService.update_response_ideal_answer( 376 | self.api_token, response_id, ideal_answer 377 | ) 378 | 379 | return response 380 | --------------------------------------------------------------------------------