diff --git a/README.md b/README.md index 0479b06..8cbba60 100644 --- a/README.md +++ b/README.md @@ -1,138 +1,128 @@ -# 🤖 PyAgentX for Software Development +# 🤖 PyAgentX: Autonomous Multi-Agent System for Software Development **PyAgentX** — это продвинутая система на базе LLM, предназначенная для автоматизации полного цикла разработки программного обеспечения. Она использует команду специализированных ИИ-агентов, которые совместно работают над решением задач: от декомпозиции высокоуровневых целей до написания, проверки, тестирования и оценки кода. -Ключевой особенностью системы является **интеграция с корпоративной базой знаний (RAG)**, что позволяет агентам генерировать код, соответствующий внутренним стандартам, архитектурным паттернам и лучшим практикам вашей команды. +Ключевой особенностью системы является **гибридная RAG-система**, которая совмещает семантический и ключевой поиск по базе знаний. Это позволяет агентам генерировать код, соответствующий внутренним стандартам, архитектурным паттернам и лучшим практикам вашей команды. -## 🚀 Основные возможности +## 🚀 Ключевые возможности -- **Многоагентная архитектура:** Система включает в себя различных агентов и компоненты: - - **`TaskDecomposer`**: Планировщик, который анализирует высокоуровневую цель и разбивает ее на выполнимые подзадачи. - - **`CodingAgent`**: Пишет код для реализации конкретной функциональности. - - **`ReviewerAgent`**: Проверяет сгенерированный код на соответствие стандартам и наличие ошибок, **используя данные из базы знаний (RAG)**. - - **`TestingAgent`**: Запускает тесты для проверки кода (в будущем будет создавать их). - - **`EvaluatorAgent`**: Оценивает общее качество решения и его соответствие первоначальной цели. - - **`Orchestrator`**: Управляет потоком задач между агентами, обеспечивая их слаженную работу. -- **Интеграция с Базой Знаний (RAG):** Агенты используют Retrieval-Augmented Generation для доступа к внутренней документации, гайдам по стилю и примерам кода, что обеспечивает консистентность и высокое качество результата. -- **Надежное тестирование:** Встроенный набор модульных тестов с использованием моков позволяет проверять логику, не затрагивая реальные API. -- **Работа с файловой системой:** Агенты могут читать, создавать и редактировать файлы прямо в вашем проекте. -- **Модульность и расширяемость:** Легко добавляйте новых агентов и инструменты для расширения функциональности. -- **Простота настройки:** Использует `.env` для конфигурации и `requirements.txt` для зависимостей. +- **Динамическая команда агентов**: Система гибко настраивается через YAML-конфиги. Вы можете легко добавлять, удалять или изменять роли агентов. +- **Продвинутый RAG с гибридным поиском**: Использует комбинацию векторного поиска (семантика) и BM25 (ключевые слова) с Reciprocal Rank Fusion для максимально релевантных результатов из базы знаний. +- **Динамическая конфигурация**: Все параметры системы, от моделей LLM до настроек RAG для каждого агента, задаются в YAML-файлах в директории `configs/`. +- **Интерфейс командной строки (CLI)**: Удобный запуск и управление через `main.py` с использованием `typer`. +- **Самостоятельная регистрация инструментов**: Агенты сами определяют и регистрируют свой инструментарий, что делает систему более модульной и инкапсулированной. +- **Работа с файловой системой**: Агенты могут читать, создавать и редактировать файлы прямо в вашем проекте. +- **Надежное тестирование**: Встроенный набор модульных тестов (`pytest`) с использованием моков позволяет проверять логику, не затрагивая реальные API. -## 🧠 Архитектура с Базой Знаний (RAG) +## 🏛️ Архитектура -Система использует подход Retrieval-Augmented Generation (RAG) для "заземления" ответов и действий агентов на основе релевантного контекста из вашей собственной базы знаний. +Система построена на модульных принципах, где каждый компонент имеет четкую зону ответственности. -Это решает ключевую проблему LLM — отсутствие знаний о специфике вашего проекта. - -### Как это работает? - -1. **Наполнение Базы Знаний:** Вы добавляете внутреннюю документацию (стандарты кодирования, архитектурные гайды, примеры кода в формате `.md`) в папку `knowledge/`. -2. **Индексация:** Вы запускаете скрипт `scripts/build_knowledge_base.py`. Он обрабатывает документы, разбивает их на смысловые фрагменты (чанки), векторизует с помощью OpenAI API и сохраняет в локальную векторную базу данных в папке `db/`. -3. **Извлечение контекста:** Когда агент (например, `ReviewerAgent`) получает задачу, он сначала делает семантический поиск по базе знаний, чтобы найти наиболее релевантную информацию. -4. **Обогащение промпта:** Найденные фрагменты документации добавляются в системный промпт агента. -5. **Генерация с контекстом:** Агент выполняет свою задачу (например, пишет ревью на код), основываясь не только на своих общих знаниях, но и на предоставленных ему внутренних правилах и стандартах. +``` +PyAgentX/ +├── app/ +│ ├── agents/ # Логика и роли специализированных агентов +│ │ ├── roles/ # Классы для конкретных ролей (Coding, Testing, etc.) +│ │ ├── agent.py # Базовый класс Agent с основной логикой +│ │ └── tools.py # Определения инструментов (read_file, edit_file) +│ ├── factory/ # Фабрика для создания агентов +│ │ └── agent_factory.py +│ └── rag/ # Логика для Retrieval-Augmented Generation +│ └── retriever.py # Гибридный ретривер (Vector + BM25) +├── configs/ # Конфигурационные файлы YAML +│ ├── agents/ # Конфиги для каждого агента +│ └── config.yaml # Главный конфигурационный файл +├── db/ # Локальная база данных (создается автоматически) +│ ├── chunks.json # Текстовые чанки +│ ├── embeddings.npy # Векторные эмбеддинги +│ └── bm25_index.pkl # Индекс BM25 для ключевого поиска +├── knowledge/ # Исходники для базы знаний (документация в .md) +├── scripts/ # Вспомогательные скрипты +│ └── build_knowledge_base.py +├── tests/ # Модульные тесты pytest +├── .env # Файл для секретных ключей +├── main.py # Точка входа в приложение (CLI на Typer) +├── poetry.lock # Файл зависимостей Poetry +├── pyproject.toml # Конфигурация проекта и зависимостей +└── README.md +``` -Этот механизм гарантирует, что генерируемый код и ревью будут соответствовать принятым в вашей команде практикам. +**Логика работы:** +1. Пользователь запускает `main.py`, передавая задачу через CLI. +2. `agent_factory` читает `configs/config.yaml` и конфигурации агентов, создавая команду. +3. `TaskDecomposer` получает задачу и разбивает ее на последовательность шагов. +4. `Orchestrator` (в `main.py`) последовательно передает каждый шаг соответствующему агенту. +5. Каждый агент перед выполнением задачи может обратиться к `KnowledgeRetriever` для получения релевантного контекста из базы знаний. +6. Агент выполняет шаг, используя свои инструменты (`tools.py`). +7. Цикл повторяется, пока все шаги не будут выполнены. ## 🛠️ Быстрый старт ### 1. Клонирование репозитория ```bash -git clone -cd <название-папки-проекта> +git clone https://github.com/your-username/PyAgentX.git +cd PyAgentX ``` ### 2. Настройка окружения -- Создайте и активируйте виртуальное окружение: +Проект использует [Poetry](https://python-poetry.org/) для управления зависимостями. + +- Установите Poetry (если не установлен): ```bash - python -m venv .venv - source .venv/bin/activate - # Для Windows: .venv\Scripts\activate + pip install poetry ``` -- Установите зависимости: +- Создайте виртуальное окружение и установите зависимости: ```bash - pip install -r requirements.txt + poetry install ``` ### 3. Конфигурация -- Создайте файл `.env` в корне проекта. -- Откройте файл `.env` и вставьте ваш ключ OpenAI: +- Создайте файл `.env` в корне проекта, скопировав `.env.example` (если он есть) или создав новый. +- Добавьте ваш ключ OpenAI: ``` OPENAI_API_KEY="sk-..." ``` ### 4. Создание базы знаний -Перед первым запуском необходимо проиндексировать вашу документацию. +Перед первым запуском необходимо проиндексировать вашу документацию из папки `knowledge/`. + ```bash -python scripts/build_knowledge_base.py +poetry run python -m scripts.build_knowledge_base ``` Этот шаг нужно повторять только при изменении файлов в папке `knowledge/`. ### 5. Запуск -Запустите главный скрипт: +Запустите главный скрипт, передав ему задачу. + ```bash -python main.py +poetry run python -m main --briefing "Создай новую функцию в 'app/utils.py' для сложения двух чисел и напиши на нее тест в 'tests/test_utils.py'." ``` -После запуска система попросит вас ввести высокоуровневую цель. - -## ✅ Тестирование +Система начнет выполнение, и вы увидите логи работы агентов в консоли. -Проект содержит набор модульных тестов для проверки ключевой логики без реальных вызовов к API OpenAI. Это достигается за счет использования **мок-объектов**. +## 🧩 Расширение системы -Для запуска тестов выполните команду: -```bash -pytest -v -``` -Все тесты должны пройти успешно, что гарантирует работоспособность `ReviewerAgent` и `KnowledgeRetriever`. +### Добавление нового агента -## 🏛️ Архитектура проекта +1. Создайте новый класс агента в `app/agents/roles/`, унаследовав его от `Agent`. +2. В `__init__` нового агента зарегистрируйте необходимые ему инструменты с помощью `self.add_tool()`. +3. Создайте для него YAML-конфиг в `configs/agents/`. +4. Добавьте нового агента в главный `configs/config.yaml`. -``` -agent-ai/ -├── app/ -│ ├── agents/ # Логика и роли специализированных агентов -│ │ ├── roles/ -│ │ │ ├── reviewer_agent.py -│ │ │ └── ... -│ │ ├── agent.py # Базовый класс агента -│ │ └── tools.py # Инструменты, доступные агентам -│ ├── orchestration/ # Управление взаимодействием агентов -│ │ └── orchestrator.py -│ └── rag/ # Логика для Retrieval-Augmented Generation -│ └── retriever.py -├── db/ # Локальная векторная база данных (создается автоматически) -│ ├── chunks.json -│ └── embeddings.npy -├── knowledge/ # Папка для внутренней документации (источник для RAG) -│ └── ... -├── scripts/ # Вспомогательные скрипты -│ └── build_knowledge_base.py -├── tests/ # Модульные тесты -│ ├── agents/ -│ │ └── test_reviewer_agent.py -│ └── rag/ -│ └── test_retriever.py -├── .env # Файл для секретных ключей (необходим для запуска) -├── main.py # Главная точка входа в приложение -├── requirements.txt # Список зависимостей Python -└── README.md -``` +### Добавление нового инструмента -## 🧩 Как добавить новый инструмент +1. Определите функцию-инструмент и ее JSON-схему в `app/agents/tools.py`. +2. Добавьте вызов `self.add_tool()` в `__init__` того агента, который должен использовать этот инструмент. -1. **Определите функцию:** Откройте `app/agents/tools.py` и создайте новую Python-функцию, которая будет выполнять нужное действие (например, `run_command_tool`). -2. **Создайте определение инструмента (`Tool`):** В том же файле создайте словарь (`tool definition`), описывающий ваш инструмент (имя, описание, схема аргументов). -3. **Зарегистрируйте инструмент:** Откройте `main.py`, импортируйте вашу функцию и ее определение, а затем добавьте их к нужному агенту с помощью метода `add_tool()`. +### Добавление знаний -Готово! Агент автоматически сможет использовать ваш новый инструмент. +Просто добавьте новый `.md` файл в папку `knowledge/` и перезапустите скрипт `scripts/build_knowledge_base.py`. -## 📄 Лицензия +## 💡 Следующий шаг: `PROJECT_CONTEXT.md` -Этот проект распространяется под лицензией MIT. Подробности смотрите в файле `LICENSE`. +Для дальнейшего улучшения согласованности действий агентов планируется внедрение файла `PROJECT_CONTEXT.md`. Это будет "конституция" проекта, содержащая глобальные правила и стандарты, которая будет автоматически добавляться в системный промпт каждого агента. diff --git a/app/agents/agent.py b/app/agents/agent.py index e0281c3..fddd0af 100644 --- a/app/agents/agent.py +++ b/app/agents/agent.py @@ -5,11 +5,18 @@ import os import json import logging +import re from openai import OpenAI, APIError from openai.types.chat import ChatCompletionMessage from dotenv import load_dotenv import inspect +from app.rag.retriever import KnowledgeRetriever +from app.agents.tools import ( + read_file_tool, read_file_tool_def, + list_files_tool, list_files_tool_def, +) + # Новый, улучшенный системный промпт, превращающий агента в программиста. SYSTEM_PROMPT = """ Ты — автономный AI-агент, способный выполнять сложные задачи, используя доступные инструменты. @@ -55,6 +62,8 @@ def __init__( api_key: str, model: str = "o4-mini", max_iterations: int = 10, + use_rag: bool = False, + rag_config: Optional[Dict[str, Any]] = None, ): self.name = name self.role = role @@ -67,6 +76,25 @@ def __init__( self.max_iterations = max_iterations self.system_prompt = "Ты — универсальный AI-ассистент." + # Add default tools + self.add_tool(read_file_tool, read_file_tool_def) + self.add_tool(list_files_tool, list_files_tool_def) + + # RAG specific attributes + self.use_rag = use_rag + self.rag_config = rag_config or {} + self.retriever: Optional[KnowledgeRetriever] = None + if self.use_rag: + try: + self.retriever = KnowledgeRetriever() + except FileNotFoundError as e: + logging.warning( + f"Agent '{self.name}' was configured to use RAG, " + f"but the knowledge base is not found. RAG will be disabled. " + f"Error: {e}" + ) + self.use_rag = False + def add_tool(self, tool_func: Callable, tool_definition: Dict[str, Any]): """Добавляет инструмент и его определение.""" tool_name = tool_func.__name__ @@ -79,6 +107,58 @@ def get_openai_tools(self) -> Optional[List[Dict[str, Any]]]: return None return self.tool_definitions + def _create_rag_query(self, briefing: str) -> str: + """ + Creates a focused query for RAG from the detailed briefing. + Extracts the current task and the last result from history. + """ + task_match = re.search(r"\*\*YOUR CURRENT TASK \(Step .*\):\*\*\n\n\*\*Task:\*\* (.*)\n\*\*Description:\*\* (.*)", briefing) + history_match = re.search(r"\*\*EXECUTION HISTORY:\*\*\n(.*)", briefing, re.DOTALL) + + if not task_match: + return briefing # Fallback to full briefing + + task = task_match.group(1) + description = task_match.group(2) + + query_parts = [f"Task: {task}", f"Description: {description}"] + + if history_match: + history_str = history_match.group(1).strip() + # Get the last entry from the history + last_entry = history_str.split("- **Step")[0].strip() + if last_entry: + query_parts.append(f"Context from previous step: {last_entry}") + + focused_query = "\n".join(query_parts) + logging.info(f"Created focused RAG query for {self.name}: '{focused_query}'") + return focused_query + + def _enrich_with_knowledge(self, query: str) -> str: + """Enriches a query with context from the knowledge base if RAG is enabled.""" + if not self.use_rag or not self.retriever: + return "" + + top_k = self.rag_config.get("top_k", 3) + filters = self.rag_config.get("filters", None) + + logging.info(f"Agent '{self.name}' is retrieving knowledge with top_k={top_k}, filters={filters}") + retrieved_knowledge = self.retriever.retrieve(query=query, top_k=top_k, filters=filters) + + if not retrieved_knowledge: + logging.info("No specific internal standards found for this query.") + return "" + + formatted_knowledge = "\n\n---\n\n".join( + [f"Source: {chunk['source']}\n\n{chunk['text']}" for chunk in retrieved_knowledge] + ) + knowledge_context = ( + "Before you begin, consult these internal standards and best practices:" + f"\n\n--- RELEVANT KNOWLEDGE ---\n{formatted_knowledge}\n--------------------------\n" + ) + logging.info(f"Knowledge context added for agent '{self.name}'.") + return knowledge_context + def _execute_tool(self, tool_name: str, tool_args: Dict[str, Any]) -> Any: """Выполняет указанный инструмент с аргументами.""" if tool_name in self.tools: @@ -126,9 +206,17 @@ def execute_task(self, briefing: str) -> str: """ logging.info(f"Агент {self.name} получил задачу.") + knowledge_context = "" + if self.use_rag: + focused_query = self._create_rag_query(briefing) + knowledge_context = self._enrich_with_knowledge(query=focused_query) + # Системный промпт определяет "личность" агента, а брифинг - контекст задачи. + # Контекст из базы знаний добавляется в начало системного промпта. + final_system_prompt = f"{knowledge_context}\n{self.system_prompt}" + self.conversation_history = [ - {"role": "system", "content": self.system_prompt}, + {"role": "system", "content": final_system_prompt}, {"role": "user", "content": briefing} ] diff --git a/app/agents/roles/coding_agent.py b/app/agents/roles/coding_agent.py index 41ba699..423f205 100644 --- a/app/agents/roles/coding_agent.py +++ b/app/agents/roles/coding_agent.py @@ -1,30 +1,59 @@ """Coding Agent.""" from typing import List, Dict from app.agents.agent import Agent +from app.agents.tools import ( + read_file_tool, read_file_tool_def, + edit_file_tool, edit_file_tool_def, + list_files_tool, list_files_tool_def, +) class CodingAgent(Agent): """An agent specializing in writing and refactoring code.""" - def __init__(self, **kwargs): - super().__init__(**kwargs) + def __init__(self, name: str, role: str, goal: str, **kwargs): + super().__init__( + name=name, + role=role, + goal=goal, + **kwargs, + ) + # Self-register tools + self.add_tool(read_file_tool, read_file_tool_def) + self.add_tool(edit_file_tool, edit_file_tool_def) + self.add_tool(list_files_tool, list_files_tool_def) + self.system_prompt = ( "You are a CodingAgent, an elite AI developer. Your task is to write, modify, and fix code." "You will be provided with the full plan, the history of previous steps, and your current task." "\n\n" "## Operating Principles:\n" "1. **Think Before You Code:** Carefully study the task and context. Plan your actions." - "2. **Follow Instructions:** Precisely follow the given task, whether it's writing a new function, fixing a bug, or refactoring." - "3. **Use Tools Wisely:** Do not call tools unnecessarily. Analyze first, then act." + "2. **Surgical Edits:** Your primary goal is to make precise, targeted changes. Do not rewrite entire files. Instead, identify the specific function, method, or block of code that needs changing and replace only that part.\n" + "3. **Use Tools Wisely:**\n" + " - Prefer `edit_file_tool` with `mode='replace'`. This is the safest and most professional way to work.\n" + " - Use `mode='append'` for adding new functions or tests to the end of a file.\n" + " - Use `mode='overwrite'` ONLY when creating a brand new file.\n" "4. **Code Quality:** Write clean, efficient, and well-documented code that adheres to PEP8." - "5. **Handling Code Review Feedback:** " - " - Carefully review ALL feedback from the ReviewerAgent." - " - **'Read-Modify-Overwrite' Strategy:** Instead of many small fixes, use the following approach:" - " a. Read the file's content (`read_file_tool`)." - " b. Apply ALL necessary changes in memory." - " c. Completely overwrite the file with a single call to `edit_file_tool` using `mode='overwrite'` and the full new content." - " - This approach ensures that all corrections are applied atomically and nothing is missed." "\n\n" + "## Example of a Surgical Edit:\n" + "Your task is to fix a bug in the `add` function in `math_utils.py`.\n\n" + "1. **First, read the file:** `read_file_tool(path='app/utils/math_utils.py')`\n" + "2. **Identify the flawed function:**\n" + " ```python\n" + " # This is the old, incorrect code block you will replace\n" + " def add(a, b):\n" + " return a - b # Bug is here\n" + " ```\n" + "3. **Call the edit tool to replace ONLY that function:**\n" + " ```python\n" + " edit_file_tool(\n" + " path='app/utils/math_utils.py',\n" + " mode='replace',\n" + " old_content='''def add(a, b):\\n return a - b # Bug is here''',\n" + " new_content='''def add(a, b):\\n # Fix: Correctly perform addition\\n return a + b'''\n" + " )\n" + " ```\n\n" "Your goal is to successfully complete your part of the plan, preparing the way for the next agent." ) diff --git a/app/agents/roles/evaluator_agent.py b/app/agents/roles/evaluator_agent.py index 7d65063..6725bf8 100644 --- a/app/agents/roles/evaluator_agent.py +++ b/app/agents/roles/evaluator_agent.py @@ -1,10 +1,21 @@ """Evaluator Agent.""" from app.agents.agent import Agent +from app.agents.tools import ( + read_file_tool, read_file_tool_def, +) class EvaluatorAgent(Agent): """An agent that analyzes errors and results.""" - def __init__(self, **kwargs): - super().__init__(**kwargs) + def __init__(self, name: str, role: str, goal: str, **kwargs): + super().__init__( + name=name, + role=role, + goal=goal, + **kwargs, + ) + # Self-register tools + self.add_tool(read_file_tool, read_file_tool_def) + self.system_prompt = """ You are EvaluatorAgent, an experienced QA engineer and systems analyst. Your primary task is to analyze the log from failed pytest runs and formulate a clear, concise, and single task for the CodingAgent to fix the code. diff --git a/app/agents/roles/reviewer_agent.py b/app/agents/roles/reviewer_agent.py index 200e0b0..9c41480 100644 --- a/app/agents/roles/reviewer_agent.py +++ b/app/agents/roles/reviewer_agent.py @@ -1,45 +1,25 @@ """Reviewer Agent.""" from app.agents.agent import Agent -from app.rag.retriever import KnowledgeRetriever +from app.agents.tools import ( + read_file_tool, read_file_tool_def, +) class ReviewerAgent(Agent): """An agent specializing in strict Code Review.""" - def __init__(self, name: str = "CodeReviewer", **kwargs): + def __init__(self, name: str, role: str, goal: str, **kwargs): super().__init__( name=name, - role="Code Reviewer", - goal=( - "Ensure that the provided code is of high quality, " - "free of errors, and adheres to best practices and " - "internal coding standards." - ), + role=role, + goal=goal, **kwargs, ) - self.retriever = KnowledgeRetriever() + # Self-register tools + self.add_tool(read_file_tool, read_file_tool_def) - def _get_system_prompt(self, **kwargs) -> str: - code_to_review = kwargs.get("code", "") - - # Retrieve relevant knowledge - retrieved_knowledge = self.retriever.retrieve(query=code_to_review, top_k=3) - - knowledge_context = "No specific internal standards found for this code." - if retrieved_knowledge: - formatted_knowledge = "\n\n---\n\n".join( - [f"Source: {chunk['source']}\n\n{chunk['text']}" for chunk in retrieved_knowledge] - ) - knowledge_context = ( - "When performing the review, pay close attention to the following " - "internal standards and best practices:\n\n" - f"--- RELEVANT KNOWLEDGE ---\n{formatted_knowledge}\n--------------------------" - ) - - return ( - f"You are a Senior Software Engineer acting as a code reviewer. " - f"Your task is to provide a thorough review of the given code snippet.\n\n" - f"{knowledge_context}\n\n" - f"Please review the following code:\n\n" - f"```python\n{code_to_review}\n```\n\n" - f"Provide your feedback in a clear, constructive manner. " - f"If you find issues, suggest specific improvements." + self.system_prompt = ( + "You are a Senior Software Engineer acting as a code reviewer. " + "Your task is to provide a thorough review of the code based on the " + "provided file path. Use your available tools to read the file content.\n\n" + "If the code meets all standards, respond with only the word 'LGTM'.\n" + "Otherwise, provide clear, constructive feedback on what needs to be improved." ) \ No newline at end of file diff --git a/app/agents/roles/task_decomposer.py b/app/agents/roles/task_decomposer.py new file mode 100644 index 0000000..3c2626c --- /dev/null +++ b/app/agents/roles/task_decomposer.py @@ -0,0 +1,97 @@ +"""Task Decomposer Agent.""" +from app.agents.agent import Agent +import logging +import json + +class TaskDecomposer(Agent): + """ + An agent specializing in breaking down a high-level goal into a step-by-step plan. + """ + def __init__(self, name: str, role: str, goal: str, **kwargs): + # This agent typically does not need RAG, but the option is there. + kwargs.setdefault('use_rag', False) + + super().__init__( + name=name, + role=role, + goal=goal, + **kwargs, + ) + self.system_prompt = """ +You are an expert project manager. Your task is to break down a high-level user goal into a concise, step-by-step plan. +Each step must be a single, clear action assigned to one of the available roles. + +# AVAILABLE ROLES: +- CodingAgent: Writes, modifies, and fixes code. +- TestingAgent: Runs tests and reports results. +- ReviewerAgent: Performs code reviews, checking for quality and adherence to standards. +- EvaluatorAgent: Analyzes test failures and creates bug reports. + +# OUTPUT FORMAT: +Your output must be a list of steps in JSON format. Do not include any other text or explanation. + +# EXAMPLE: +Goal: "Create a function to add two numbers and test it." + +Your output: +```json +[ + { + "step": 1, + "assignee": "CodingAgent", + "task": "Create a new function 'add(a, b)' in 'app/utils/math.py'." + }, + { + "step": 2, + "assignee": "ReviewerAgent", + "task": "Review the 'add' function in 'app/utils/math.py'." + }, + { + "step": 3, + "assignee": "CodingAgent", + "task": "Create a new test file 'tests/test_math.py' with tests for the 'add' function." + }, + { + "step": 4, + "assignee": "TestingAgent", + "task": "Run the tests in 'tests/test_math.py'." + } +] +``` +""" + + def get_plan(self, goal: str) -> list: + """ + Generates a plan for a given goal. + Overrides the base 'execute_task' to return a structured plan. + """ + task_briefing = f"Create a step-by-step plan to achieve the following goal: {goal}" + + try: + logging.info("Requesting plan from OpenAI...") + response = self.client.chat.completions.create( + model=self.model, + messages=[ + {"role": "system", "content": self.system_prompt}, + {"role": "user", "content": task_briefing}, + ], + response_format={"type": "json_object"}, + ) + + response_content = response.choices[0].message.content + logging.info("Received raw plan: %s", response_content) + + # The response is a JSON string, so we need to parse it. + plan = json.loads(response_content) + # Sometimes the model returns a dictionary with a "plan" key + if isinstance(plan, dict) and "plan" in plan: + return plan["plan"] + return plan + + except json.JSONDecodeError as e: + logging.error(f"Failed to decode JSON from OpenAI response: {e}") + logging.error(f"Raw response was: {response_content}") + return [{"step": 1, "assignee": "DefaultAgent", "task": "Failed to create a valid plan due to JSON error."}] + except Exception as e: + logging.error(f"An unexpected error occurred while getting the plan: {e}", exc_info=True) + return [{"step": 1, "assignee": "DefaultAgent", "task": "Failed to create a plan due to an unexpected error."}] \ No newline at end of file diff --git a/app/agents/roles/testing_agent.py b/app/agents/roles/testing_agent.py index e067319..6a01ba5 100644 --- a/app/agents/roles/testing_agent.py +++ b/app/agents/roles/testing_agent.py @@ -1,10 +1,23 @@ """Testing Agent.""" from app.agents.agent import Agent +from app.agents.tools import ( + read_file_tool, read_file_tool_def, + run_tests_tool, run_tests_tool_def, +) class TestingAgent(Agent): """An agent specializing in running tests.""" - def __init__(self, **kwargs): - super().__init__(**kwargs) + def __init__(self, name: str, role: str, goal: str, **kwargs): + super().__init__( + name=name, + role=role, + goal=goal, + **kwargs, + ) + # Self-register tools + self.add_tool(read_file_tool, read_file_tool_def) + self.add_tool(run_tests_tool, run_tests_tool_def) + self.system_prompt = """ You are TestingAgent, an automated test-running robot. Your sole task is to call the `run_tests_tool` with the correct path to the tests. diff --git a/app/agents/tools.py b/app/agents/tools.py index 899cdd2..8930a05 100644 --- a/app/agents/tools.py +++ b/app/agents/tools.py @@ -37,7 +37,7 @@ def list_files_tool(input_data: Dict[str, Any]) -> str: input_data (Dict[str, Any]): Словарь, который может содержать ключ 'path' с путем к директории. По умолчанию - текущая. Returns: - Отформатированное дерево файлов и директорий в виде строки. + Отформатированное дерево файлов и директории в виде строки. """ path = input_data.get("path", ".") if not os.path.isdir(path): @@ -201,7 +201,7 @@ def run_tests_tool(input_data: Dict[str, Any]) -> str: "type": "function", "function": { "name": "list_files_tool", - "description": "Рекурсивно выводит дерево файлов и директорий по указанному пути.", + "description": "Рекурсивно выводит дерево файлов и директории по указанному пути.", "parameters": { "type": "object", "properties": { diff --git a/app/factory/agent_factory.py b/app/factory/agent_factory.py new file mode 100644 index 0000000..d3e956c --- /dev/null +++ b/app/factory/agent_factory.py @@ -0,0 +1,60 @@ +""" +Factory for creating agent teams from configuration. +""" +import logging +import yaml +import os +from importlib import import_module +from typing import Dict, Tuple + +from app.agents.agent import Agent +from app.agents.roles.task_decomposer import TaskDecomposer + +def load_config(path: str) -> dict: + """Loads a YAML configuration file.""" + with open(path, 'r') as f: + return yaml.safe_load(f) + +def get_class_from_string(class_path: str): + """Dynamically imports a class from a string path.""" + module_path, class_name = class_path.rsplit('.', 1) + module = import_module(module_path) + return getattr(module, class_name) + +def create_agent_team(main_config_path: str) -> Tuple[TaskDecomposer, Dict[str, Agent]]: + """ + Creates and configures the agent team based on YAML files. + + Args: + main_config_path (str): The path to the main configuration file. + + Returns: + A tuple containing the TaskDecomposer and the dictionary of worker agents. + """ + logging.info("Loading configurations from %s...", main_config_path) + main_config = load_config(main_config_path) + + workers = {} + api_key = os.getenv("OPENAI_API_KEY") + common_kwargs = {"api_key": api_key, "model": main_config.get('default_model', 'o4-mini')} + + for agent_name, agent_info in main_config['agents'].items(): + logging.info(f"Initializing {agent_name}...") + + agent_config_path = agent_info['config_path'] + agent_config = load_config(agent_config_path) + + agent_class = get_class_from_string(agent_info['_target_']) + + init_params = agent_config.copy() + init_params.update(common_kwargs) + + agent_instance = agent_class(**init_params) + + # The logic for adding tools has been moved to the agent classes themselves. + # The factory is now cleaner and only responsible for instantiation. + + workers[agent_name] = agent_instance + + task_decomposer = workers.pop("TaskDecomposer") + return task_decomposer, workers \ No newline at end of file diff --git a/app/orchestration/decomposer.py b/app/orchestration/decomposer.py deleted file mode 100644 index e62e7e3..0000000 --- a/app/orchestration/decomposer.py +++ /dev/null @@ -1,195 +0,0 @@ -""" -Module for decomposing a high-level task into specific subtasks. -""" -import json -import logging -from typing import List, Optional, Dict, Any -from openai import OpenAI - -DECOMPOSER_PROMPT = """ -Твоя задача - разбить высокоуровневую цель на детальный, последовательный план для AI-агента. -Ответ должен быть ТОЛЬКО JSON-массивом строк без какого-либо другого текста или объяснений. - -# Доступные Инструменты Агента: -Агент имеет доступ к следующим функциям, которые он может использовать для выполнения шагов: -- `list_files_tool(path: str)`: Показывает содержимое директории. -- `read_file_tool(path: str)`: Читает содержимое файла. -- `edit_file_tool(path: str, mode: str, ...)`: Редактирует файл. Режимы: 'append' (добавить в конец), 'replace' (заменить фрагмент), 'overwrite' (перезаписать). -- `delete_file_tool(path: str)`: Удаляет файл. - -# Правила Создания Плана: -1. **Конкретика**: Каждый шаг должен быть одной конкретной, осмысленной операцией. Думай о том, как бы ты сам решал эту задачу, используя доступные инструменты. -2. **Эффективность**: Не создавай лишних шагов. Например, не нужно отдельно "проверять существование файла", если следующий шаг - его чтение. Инструмент `read_file_tool` сам сообщит об ошибке, если файла нет. -3. **Никаких фиктивных шагов**: Не создавай шаги, для которых нет инструментов. Например, "закрыть файл", "проверить синтаксис" или "запустить тесты" - это плохие шаги, так как у агента нет для них инструментов. -4. **Целостность**: Думай о всем процессе. Если один шаг генерирует код, следующий шаг должен использовать этот код (например, сохранить его в файл). - -# Пример: -### Цель: -"Проанализируй файл `main.py`, предложи улучшение и запиши новую версию в `main_v2.py`." - -### Хороший план (твой результат должен быть в таком формате): -[ - "Прочитать содержимое файла `main.py` для анализа.", - "Проанализировать прочитанный код и сгенерировать новую, улучшенную версию кода.", - "Записать сгенерированный улучшенный код в новый файл `main_v2.py`." -] - - -# Твоя Задача: -### Цель: -"{main_goal}" - -### План (только JSON): -""" - -class TaskDecomposer: - """ - Decomposes the main task into a list of subtasks using an LLM. - """ - def __init__(self, api_key: str, model: str = "gpt-4-turbo"): - self.client = OpenAI(api_key=api_key) - self.model = model - - def _parse_llm_response(self, content: Optional[str]) -> List[str]: - """Parses the LLM response more flexibly.""" - if not content: - logging.warning("LLM returned an empty response during decomposition.") - return [] - - try: - # Try to load as a full JSON - parsed_json = json.loads(content) - - if isinstance(parsed_json, list): - return [str(item) for item in parsed_json] - - # If it's a dictionary, look for a key containing a list - if isinstance(parsed_json, dict): - for key, value in parsed_json.items(): - if isinstance(value, list): - logging.info("Found plan in key '%s'", key) - return [str(item) for item in value] - - logging.warning("LLM response is not a list and does not contain a list. Response: %s", content) - return [] - - except json.JSONDecodeError: - # Sometimes the LLM returns a raw list without quotes, try to "fix" it - logging.warning("Failed to parse JSON. Response: %s", content) - # This is a very simplified attempt to extract strings, may not always work - cleaned_content = content.strip().replace("`", "") - if cleaned_content.startswith('[') and cleaned_content.endswith(']'): - try: - return json.loads(cleaned_content) - except json.JSONDecodeError: - pass - logging.error("Failed to extract plan from LLM response.") - return [] - - def generate_plan(self, goal: str) -> List[Dict[str, Any]]: - """ - Generates a step-by-step plan to achieve a goal, assigning executors. - """ - # Updated prompt with role descriptions and requirement to assign an executor - system_prompt = """ -You are an elite AI planner specializing in decomposing complex IT tasks for a team of AI agents. -Your task is to break down a high-level goal into a detailed, sequential plan in JSON format. - -# PROJECT STRUCTURE (CRITICALLY IMPORTANT): -- `app/`: All application logic is here. - - `app/agents/`: The agents' code. - - `app/agents/tools.py`: **File with tools used by the agents.** -- `tests/`: All tests are here. - - `tests/test_tools.py`: **Tests for the tools from `app/agents/tools.py`** - -# AVAILABLE AGENT TEAM: -1. **CodingAgent**: - - **Specialization**: Writing, reading, and modifying code. - - **Tools**: `list_files`, `read_file`, `edit_file`. -2. **TestingAgent**: - - **Specialization**: Testing code. - - **Tools**: `read_file`, `run_tests`. -3. **ReviewerAgent**: - - **Specialization**: Checking code quality, finding bugs and inconsistencies. - - **Tools**: `read_file`. -4. **EvaluatorAgent**: - - **Specialization**: Analyzing errors and creating bug reports. - - **Tools**: `read_file`. - - **IMPORTANT**: This agent is used by the Orchestrator automatically when tests fail. You do not need to assign it tasks in the initial plan. -5. **DefaultAgent**: - - **Specialization**: General tasks and analysis. - - **Tools**: `list_files`, `read_file`. - -# RULES FOR CREATING THE PLAN: -1. **Code Review for ALL code**: Immediately after EVERY step in which `CodingAgent` writes or modifies any code (be it main code, tests, documentation, etc.), a "Conduct Code Review" step assigned to `ReviewerAgent` MUST follow. -2. **Full Paths**: ALWAYS use full relative paths from the project root for any files. For example: `app/agents/tools.py`, `tests/test_tools.py`. -3. **Specificity**: Formulate tasks as specifically as possible. Instead of "write a function", write "add function X to file Y". -4. **Logic Before Tests**: The plan must first contain a step for writing or changing the **complete logic** of a function, and only then a step for writing tests. -5. **Focused Testing**: The testing step must specify a particular test file. In the task `description`, you MUST include an example of how to call the tool, for example: `Using 'run_tests', call it like this: run_tests_tool({'path': 'tests/test_tools.py'})`. -6. **Assignment**: For each step, specify the `assignee` (`CodingAgent`, `TestingAgent`, `DefaultAgent`). -7. **Format**: The output must be STRICTLY in the format of a JSON array of objects. - -# EXAMPLE: - -**Goal**: "Add a `multiply(a, b)` function to `tools.py` and cover it with tests." - -**Result (JSON):** -```json -[ - { - "step": 1, - "assignee": "CodingAgent", - "task": "Add 'multiply_tool' function to 'app/agents/tools.py'.", - "description": "Open the file 'app/agents/tools.py' and add a new Python function 'multiply' that takes two numerical arguments and returns their product. Use 'append' mode." - }, - { - "step": 2, - "assignee": "ReviewerAgent", - "task": "Conduct Code Review for 'multiply_tool' function in 'app/agents/tools.py'.", - "description": "Check the code for compliance with quality standards." - }, - { - "step": 3, - "assignee": "CodingAgent", - "task": "Create 'tests/test_tools.py' file with tests for 'multiply_tool'.", - "description": "In 'tests/test_tools.py', write a 'test_multiply' test using pytest that checks the correctness of the 'multiply' function." - }, - { - "step": 4, - "assignee": "ReviewerAgent", - "task": "Conduct Code Review for 'tests/test_tools.py' file.", - "description": "Check the test code for completeness, correctness, and style." - }, - { - "step": 5, - "assignee": "TestingAgent", - "task": "Run tests for 'tests/test_tools.py' file.", - "description": "Using the 'run_tests' tool, call it with the argument {'path': 'tests/test_tools.py'} to verify the work done." - } -] -``` -""" - user_prompt = f"My goal is: \"{goal}\". Please create a plan." - - try: - response = self.client.chat.completions.create( - model=self.model, - messages=[{"role": "system", "content": system_prompt}, {"role": "user", "content": user_prompt}], - ) - - plan_str = response.choices[0].message.content - - if plan_str.strip().startswith("```json"): - plan_str = plan_str.strip()[7:-3].strip() - - plan = json.loads(plan_str) - - for step in plan: - if 'assignee' not in step: - raise ValueError(f"Step {step.get('step')} in the plan is missing the required 'assignee' field") - - return plan - - except Exception as e: - logging.error("An error occurred during task decomposition: %s", e, exc_info=True) - return [] \ No newline at end of file diff --git a/app/orchestration/orchestrator.py b/app/orchestration/orchestrator.py index b83111d..3fc5f01 100644 --- a/app/orchestration/orchestrator.py +++ b/app/orchestration/orchestrator.py @@ -5,7 +5,7 @@ import json from typing import Dict, Any, List, Tuple, Optional from app.agents.agent import Agent -from app.orchestration.decomposer import TaskDecomposer +from app.agents.roles.task_decomposer import TaskDecomposer class Orchestrator: """ @@ -178,7 +178,7 @@ def run(self, goal: str): def _get_plan(self, goal: str) -> List[Dict[str, Any]]: """Gets the plan from the TaskDecomposer.""" logging.info("Getting plan from TaskDecomposer...") - plan = self.task_decomposer.generate_plan(goal) + plan = self.task_decomposer.get_plan(goal) if not plan: logging.error("Failed to create a plan. Orchestrator is stopping.") return [] diff --git a/app/rag/retriever.py b/app/rag/retriever.py index ec6d5d8..5f857cf 100644 --- a/app/rag/retriever.py +++ b/app/rag/retriever.py @@ -3,12 +3,14 @@ import json import logging import os +import pickle from pathlib import Path -from typing import List, Dict, Any +from typing import List, Dict, Any, Tuple import numpy as np from dotenv import load_dotenv from openai import OpenAI +from rank_bm25 import BM25Okapi from sklearn.metrics.pairwise import cosine_similarity # --- Configuration --- @@ -18,16 +20,19 @@ DB_DIR = Path("db") EMBEDDINGS_FILE = DB_DIR / "embeddings.npy" CHUNKS_FILE = DB_DIR / "chunks.json" +BM25_INDEX_FILE = DB_DIR / "bm25_index.pkl" EMBEDDING_MODEL = "text-embedding-3-small" class KnowledgeRetriever: """ - A class to retrieve relevant knowledge chunks from a vectorized database. + A class to retrieve relevant knowledge chunks from a database + using a hybrid search approach (vector search + keyword search). """ def __init__(self): - """Initializes the retriever, loading the knowledge base and model.""" + """Initializes the retriever, loading all necessary data from disk.""" self.embeddings: np.ndarray = None self.chunks: List[Dict[str, Any]] = [] + self.bm25: BM25Okapi = None self._load_knowledge_base() api_key = os.getenv("OPENAI_API_KEY") @@ -36,12 +41,12 @@ def __init__(self): self.client = OpenAI(api_key=api_key) def _load_knowledge_base(self): - """Loads embeddings and text chunks from disk.""" + """Loads embeddings, text chunks, and BM25 index from disk.""" logging.info("Loading knowledge base...") - if not EMBEDDINGS_FILE.exists() or not CHUNKS_FILE.exists(): + if not all([EMBEDDINGS_FILE.exists(), CHUNKS_FILE.exists(), BM25_INDEX_FILE.exists()]): msg = ( - "Knowledge base not found. Please run " - "'scripts/build_knowledge_base.py' first." + f"Knowledge base file not found. Please run " + f"'scripts/build_knowledge_base.py' first." ) logging.error(msg) raise FileNotFoundError(msg) @@ -50,56 +55,89 @@ def _load_knowledge_base(self): self.embeddings = np.load(EMBEDDINGS_FILE) with open(CHUNKS_FILE, "r", encoding="utf-8") as f: self.chunks = json.load(f) + with open(BM25_INDEX_FILE, "rb") as f: + self.bm25 = pickle.load(f) logging.info( f"Knowledge base loaded successfully. " - f"({len(self.chunks)} chunks)" + f"({len(self.chunks)} chunks, BM25 index, Embeddings)" ) except Exception as e: logging.error(f"Failed to load knowledge base: {e}") raise - def retrieve(self, query: str, top_k: int = 3) -> List[Dict[str, Any]]: - """ - Retrieves the top_k most relevant chunks for a given query. + def _vector_search(self, query: str, k: int) -> List[Tuple[int, float]]: + """Performs a pure vector search.""" + response = self.client.embeddings.create(input=[query], model=EMBEDDING_MODEL) + query_embedding = np.array([response.data[0].embedding]) + similarities = cosine_similarity(query_embedding, self.embeddings).flatten() + top_k_indices = np.argsort(similarities)[-k:][::-1] + return [(idx, similarities[idx]) for idx in top_k_indices] - Args: - query (str): The search query. - top_k (int): The number of top results to return. + def _keyword_search(self, query: str, k: int) -> List[Tuple[int, float]]: + """Performs a pure keyword search using BM25.""" + tokenized_query = query.lower().split(" ") + doc_scores = self.bm25.get_scores(tokenized_query) + top_k_indices = np.argsort(doc_scores)[-k:][::-1] + return [(idx, doc_scores[idx]) for idx in top_k_indices] - Returns: - List[Dict[str, Any]]: A list of the most relevant chunks. + def _reciprocal_rank_fusion(self, search_results: List[List[Tuple[int, float]]], k: int = 60) -> Dict[int, float]: + """Merges search results using Reciprocal Rank Fusion.""" + fused_scores = {} + for result_list in search_results: + for rank, (doc_id, _) in enumerate(result_list): + if doc_id not in fused_scores: + fused_scores[doc_id] = 0 + fused_scores[doc_id] += 1 / (rank + k) + return fused_scores + + def retrieve( + self, query: str, top_k: int = 5, filters: Dict[str, Any] = None + ) -> List[Dict[str, Any]]: + """ + Retrieves the top_k most relevant chunks using hybrid search. """ - if self.embeddings is None or not self.chunks: - logging.warning("Knowledge base is not loaded. Cannot retrieve.") + if self.embeddings is None or not self.chunks or not self.bm25: + logging.warning("Knowledge base is not fully loaded. Cannot retrieve.") return [] - # 1. Create query embedding using OpenAI - response = self.client.embeddings.create(input=[query], model=EMBEDDING_MODEL) - query_embedding = np.array([response.data[0].embedding]) - - # 2. Calculate cosine similarity - similarities = cosine_similarity(query_embedding, self.embeddings).flatten() + # 1. Get results from both search methods + candidate_k = top_k * 10 + vector_results = self._vector_search(query, candidate_k) + keyword_results = self._keyword_search(query, candidate_k) - # Ensure top_k is not greater than the number of available chunks - effective_top_k = min(top_k, len(self.chunks)) + # 2. Fuse the results + fused_scores = self._reciprocal_rank_fusion([vector_results, keyword_results]) + + # 3. Sort by fused score + sorted_doc_ids = sorted(fused_scores.keys(), key=lambda id: fused_scores[id], reverse=True) - # 3. Get top_k indices - # We use argpartition which is faster than argsort for finding top_k - top_k_indices = np.argpartition(similarities, -effective_top_k)[-effective_top_k:] + # 4. Filter candidates based on metadata + final_indices = [] + required_tags = set(filters.get("tags", [])) if filters else set() - # Sort these top_k indices by similarity score - sorted_top_k_indices = top_k_indices[ - np.argsort(similarities[top_k_indices]) - ][::-1] + for doc_id in sorted_doc_ids: + if len(final_indices) >= top_k: + break + + if required_tags: + chunk_tags = set(self.chunks[doc_id].get("metadata", {}).get("tags", [])) + if required_tags.issubset(chunk_tags): + final_indices.append(doc_id) + else: + final_indices.append(doc_id) - # 4. Format results + # 5. Format results results = [] - for idx in sorted_top_k_indices: + for idx in final_indices: result = { "text": self.chunks[idx]["text"], "source": self.chunks[idx]["source"], - "score": float(similarities[idx]), + "metadata": self.chunks[idx].get("metadata", {}), + "score": fused_scores[idx], # Use the fused score } results.append(result) + if not results: + logging.warning(f"No documents found for query '{query[:50]}...' with filters {filters}") + return results \ No newline at end of file diff --git a/configs/agent/coding_agent.yaml b/configs/agent/coding_agent.yaml new file mode 100644 index 0000000..c38ff9b --- /dev/null +++ b/configs/agent/coding_agent.yaml @@ -0,0 +1,12 @@ +# Configuration for CodingAgent + +name: "CodingAgent" +role: "Software Engineer" +goal: "Write, modify, and fix code according to the given task." +use_rag: true + +# RAG specific settings +rag_config: + top_k: 3 + filters: + tags: ["code-example", "style-guide"] \ No newline at end of file diff --git a/configs/agent/default_agent.yaml b/configs/agent/default_agent.yaml new file mode 100644 index 0000000..48c2350 --- /dev/null +++ b/configs/agent/default_agent.yaml @@ -0,0 +1,6 @@ +# Configuration for DefaultAgent + +name: "DefaultAgent" +role: "General Assistant" +goal: "Perform basic tasks like listing files when no other agent is assigned." +use_rag: false \ No newline at end of file diff --git a/configs/agent/evaluator_agent.yaml b/configs/agent/evaluator_agent.yaml new file mode 100644 index 0000000..3fc8174 --- /dev/null +++ b/configs/agent/evaluator_agent.yaml @@ -0,0 +1,13 @@ +# Configuration for EvaluatorAgent + +name: "EvaluatorAgent" +role: "Quality Assurance Analyst" +goal: "Analyze test results and create tasks for the CodingAgent to fix any issues." +use_rag: true + +# RAG specific settings +rag_config: + top_k: 3 + filters: + # Evaluator might benefit from error handling guides + tags: ["error-handling"] \ No newline at end of file diff --git a/configs/agent/reviewer_agent.yaml b/configs/agent/reviewer_agent.yaml new file mode 100644 index 0000000..3fa9aff --- /dev/null +++ b/configs/agent/reviewer_agent.yaml @@ -0,0 +1,12 @@ +# Configuration for ReviewerAgent + +name: "ReviewerAgent" +role: "Code Reviewer" +goal: "Ensure that the provided code is of high quality, free of errors, and adheres to best practices and internal coding standards." +use_rag: true + +# RAG specific settings +rag_config: + top_k: 5 + filters: + tags: ["style-guide"] \ No newline at end of file diff --git a/configs/agent/task_decomposer.yaml b/configs/agent/task_decomposer.yaml new file mode 100644 index 0000000..0200e66 --- /dev/null +++ b/configs/agent/task_decomposer.yaml @@ -0,0 +1,6 @@ +# Configuration for TaskDecomposer + +name: "TaskDecomposer" +role: "Project Manager" +goal: "Break down a high-level user goal into a clear, step-by-step plan of action." +use_rag: false \ No newline at end of file diff --git a/configs/agent/testing_agent.yaml b/configs/agent/testing_agent.yaml new file mode 100644 index 0000000..b1424c0 --- /dev/null +++ b/configs/agent/testing_agent.yaml @@ -0,0 +1,12 @@ +# Configuration for TestingAgent + +name: "TestingAgent" +role: "Quality Assurance Engineer" +goal: "Run tests to ensure the code is working correctly." +use_rag: true + +# RAG specific settings +rag_config: + top_k: 2 + filters: + tags: ["testing-guide"] \ No newline at end of file diff --git a/configs/config.yaml b/configs/config.yaml new file mode 100644 index 0000000..b8a805e --- /dev/null +++ b/configs/config.yaml @@ -0,0 +1,35 @@ +# Main application configuration + +# Default model for all agents, can be overridden in specific agent configs +default_model: "o4-mini" + +# API key for OpenAI, will be loaded from environment variables +# This is just a placeholder; the actual key is loaded from .env in the code +api_key: "${OPENAI_API_KEY}" + +# Agent configurations +# The keys here (e.g., 'CodingAgent') should match the names used in main.py +agents: + CodingAgent: + _target_: app.agents.roles.coding_agent.CodingAgent + config_path: "configs/agent/coding_agent.yaml" + + TestingAgent: + _target_: app.agents.roles.testing_agent.TestingAgent + config_path: "configs/agent/testing_agent.yaml" + + ReviewerAgent: + _target_: app.agents.roles.reviewer_agent.ReviewerAgent + config_path: "configs/agent/reviewer_agent.yaml" + + EvaluatorAgent: + _target_: app.agents.roles.evaluator_agent.EvaluatorAgent + config_path: "configs/agent/evaluator_agent.yaml" + + TaskDecomposer: + _target_: app.agents.roles.task_decomposer.TaskDecomposer + config_path: "configs/agent/task_decomposer.yaml" # We'll create this one too + + DefaultAgent: + _target_: app.agents.agent.Agent + config_path: "configs/agent/default_agent.yaml" \ No newline at end of file diff --git a/db/chunks.json b/db/chunks.json index bcc3e4b..cef79a3 100644 --- a/db/chunks.json +++ b/db/chunks.json @@ -1,7 +1,118 @@ [ { - "text": "This is one chunk.", - "source": "test_doc.md", - "chunk_id": 0 + "text": "Testing Guidelines\n1. Test Structure: Arrange-Act-Assert (AAA)\nAll tests should follow the AAA pattern for clarity and readability. Arrange: Prepare all necessary data and mocks. Act: Call the function or method being tested. Assert: Check that the result meets expectations. ```python def test_user_creation(): # Arrange user_data = {\"username\": \"test\", \"email\": \"test@example.com\"} mock_db = MagicMock()\n# Act\ncreated_user = create_user(db=mock_db, data=user_data)", + "source": "testing_guidelines.md", + "metadata": { + "tags": [], + "full_path": "knowledge/testing_guidelines.md", + "chunk_id": "testing_guidelines.md_0" + } + }, + { + "text": "# Assert\nassert created_user.username == user_data[\"username\"]\nmock_db.add.assert_called_once()\n```\n2. Test Naming\nTest function names should be descriptive and start with test_. Follow the format test___. Example: test_add_items_with_negative_quantity_raises_error()\n3. Use pytest.raises for Exception Testing\nTo verify that code correctly raises exceptions, use the pytest.raises context manager. ```python import pytest\ndef test_divide_by_zero_raises_exception(): with pytest.raises(ZeroDivisionError): divide(10, 0) ```", + "source": "testing_guidelines.md", + "metadata": { + "tags": [], + "full_path": "knowledge/testing_guidelines.md", + "chunk_id": "testing_guidelines.md_1" + } + }, + { + "text": "Error Handling Principles\n1. Prefer Specific Exceptions\nAlways catch the most specific exception type possible. Avoid using except Exception: unless absolutely necessary. Bad: python try: # some code except Exception as e: log.error(\"An error occurred\")\nGood: python try: # some code except FileNotFoundError as e: log.error(f\"File not found: {e}\") except (KeyError, ValueError) as e: log.warning(f\"Data error: {e}\")\n2. Use Custom Exceptions\nFor errors specific to your application's domain logic, create your own exception classes.", + "source": "error_handling.md", + "metadata": { + "tags": [], + "full_path": "knowledge/error_handling.md", + "chunk_id": "error_handling.md_0" + } + }, + { + "text": "This makes the code more readable and allows calling code to handle specific failures precisely. ```python class InsufficientBalanceError(Exception): \"\"\"Exception raised when the account balance is too low.\"\"\" pass\ndef withdraw(amount): if amount > current_balance: raise InsufficientBalanceError(\"Insufficient funds in the account\") ```\n3. Log Errors Correctly\nWhen catching an exception, be sure to log the full information, including the stack trace, to simplify debugging. ```python import logging\ntry: # ... except Exception as e: logging.error(\"An unexpected error occurred\", exc_info=True)", + "source": "error_handling.md", + "metadata": { + "tags": [], + "full_path": "knowledge/error_handling.md", + "chunk_id": "error_handling.md_1" + } + }, + { + "text": "API Design Principles\nResources: Use plural nouns for endpoint naming (e.g., /users, /products). HTTP Methods: Use the correct HTTP verbs for actions:\nGET for retrieving data. POST for creating new resources. PUT / PATCH for updating. DELETE for deleting. Versioning: Include the API version in the URL (e.g., /api/v1/users).", + "source": "api_design.md", + "metadata": { + "tags": [], + "full_path": "knowledge/api_design.md", + "chunk_id": "api_design.md_0" + } + }, + { + "text": "Python Style Guide\nNaming: Use snake_case for variables and functions. Class names should use CamelCase. Constants should be in UPPER_SNAKE_CASE. Line Length: The maximum line length is 99 characters. Docstrings: All public modules, functions, classes, and methods must have Google-style docstrings. Imports: Group imports in the following order: standard library, third-party libraries, local application. String Formatting\nf-strings: Always prefer f-strings for formatting instead of str.format() or the % operator.", + "source": "python_style_guide.md", + "metadata": { + "tags": [], + "full_path": "knowledge/python_style_guide.md", + "chunk_id": "python_style_guide.md_0" + } + }, + { + "text": "Good: user_info = f\"User {user.name} with ID {user.id}\"\nBad: user_info = \"User {} with ID {}\".format(user.name, user.id)\nList Comprehensions\nSimplicity: Use list comprehensions to create lists from existing iterables, but only if the logic remains simple and readable. If complex logic or multiple nested loops are required, use a regular for loop.", + "source": "python_style_guide.md", + "metadata": { + "tags": [], + "full_path": "knowledge/python_style_guide.md", + "chunk_id": "python_style_guide.md_1" + } + }, + { + "text": "Good: squares = [x*x for x in range(10)]\nAvoid (hard to read): complex_list = [x + y for x in range(10) for y in range(5) if x % 2 == 0 if y % 3 == 0]", + "source": "python_style_guide.md", + "metadata": { + "tags": [], + "full_path": "knowledge/python_style_guide.md", + "chunk_id": "python_style_guide.md_2" + } + }, + { + "text": "Agent Tool Creation Guide\nThis document outlines the best practices for creating new tool functions that can be used by AI agents in our system. 1. Tool Function Structure\nEvery tool must be a wrapper around the core logic and accept a single argument of type Dict[str, Any]. This ensures a unified interface for all tools. ```python\nCorrect\ndef my_tool(input_data: Dict[str, Any]) -> str: #... Incorrect\ndef my_tool(param1: str, param2: int) -> str: #... ```\n2. Mandatory Error Handling\nA tool should never crash with an unhandled exception.", + "source": "tool_creation_guide.md", + "metadata": { + "tags": [ + "code-example" + ], + "full_path": "knowledge/code-example/tool_creation_guide.md", + "chunk_id": "tool_creation_guide.md_0" + } + }, + { + "text": "Always use try-except and return an informative error message as a string. python def substring_tool(input_data: Dict[str, Any]) -> str: try: text = input_data['text'] start = input_data['start'] # ... core logic ... return result except KeyError as e: return f\"Error: Missing required key {e} in input_data.\" except Exception as e: return f\"An unexpected error occurred: {e}\"\n3. Detailed Docstrings\nAlways write detailed docstrings in Google-style. Describe the function's purpose, all keys in the input_data dictionary, and the return value. 4.", + "source": "tool_creation_guide.md", + "metadata": { + "tags": [ + "code-example" + ], + "full_path": "knowledge/code-example/tool_creation_guide.md", + "chunk_id": "tool_creation_guide.md_1" + } + }, + { + "text": "Tool Definition (_tool_def)\nEvery tool must have a corresponding _tool_def definition. This is a dictionary that describes the function's signature for the OpenAI API, allowing the agent to understand how to call your tool.", + "source": "tool_creation_guide.md", + "metadata": { + "tags": [ + "code-example" + ], + "full_path": "knowledge/code-example/tool_creation_guide.md", + "chunk_id": "tool_creation_guide.md_2" + } + }, + { + "text": "python substring_tool_def = { \"type\": \"function\", \"function\": { \"name\": \"substring_tool\", \"description\": \"Extracts a substring from text.\", \"parameters\": { \"type\": \"object\", \"properties\": { \"text\": {\"type\": \"string\", \"description\": \"The source text.\"}, \"start\": {\"type\": \"integer\", \"description\": \"The starting index.\"}, }, \"required\": [\"text\", \"start\"], }, }, }", + "source": "tool_creation_guide.md", + "metadata": { + "tags": [ + "code-example" + ], + "full_path": "knowledge/code-example/tool_creation_guide.md", + "chunk_id": "tool_creation_guide.md_3" + } } ] \ No newline at end of file diff --git a/db/embeddings.npy b/db/embeddings.npy index 6143736..d192fff 100644 Binary files a/db/embeddings.npy and b/db/embeddings.npy differ diff --git a/knowledge/api_design.md b/knowledge/api_design.md index 92dbde6..61f8096 100644 --- a/knowledge/api_design.md +++ b/knowledge/api_design.md @@ -1,9 +1,9 @@ # API Design Principles -- **Ресурсы:** Используйте существительные во множественном числе для именования эндпоинтов (например, `/users`, `/products`). -- **HTTP-методы:** Используйте правильные HTTP-глаголы для действий: - - `GET` для получения данных. - - `POST` для создания новых ресурсов. - - `PUT` / `PATCH` для обновления. - - `DELETE` для удаления. -- **Версионирование:** Включайте версию API в URL (например, `/api/v1/users`). \ No newline at end of file +- **Resources:** Use plural nouns for endpoint naming (e.g., `/users`, `/products`). +- **HTTP Methods:** Use the correct HTTP verbs for actions: + - `GET` for retrieving data. + - `POST` for creating new resources. + - `PUT` / `PATCH` for updating. + - `DELETE` for deleting. +- **Versioning:** Include the API version in the URL (e.g., `/api/v1/users`). \ No newline at end of file diff --git a/knowledge/code-example/tool_creation_guide.md b/knowledge/code-example/tool_creation_guide.md new file mode 100644 index 0000000..e8afab2 --- /dev/null +++ b/knowledge/code-example/tool_creation_guide.md @@ -0,0 +1,60 @@ +### Agent Tool Creation Guide + +This document outlines the best practices for creating new tool functions that can be used by AI agents in our system. + +#### 1. Tool Function Structure + +Every tool must be a wrapper around the core logic and accept a **single argument** of type `Dict[str, Any]`. This ensures a unified interface for all tools. + +```python +# Correct +def my_tool(input_data: Dict[str, Any]) -> str: + #... + +# Incorrect +def my_tool(param1: str, param2: int) -> str: + #... +``` + +#### 2. Mandatory Error Handling + +A tool should **never crash with an unhandled exception**. Always use `try-except` and return an informative error message as a string. + +```python +def substring_tool(input_data: Dict[str, Any]) -> str: + try: + text = input_data['text'] + start = input_data['start'] + # ... core logic ... + return result + except KeyError as e: + return f"Error: Missing required key {e} in input_data." + except Exception as e: + return f"An unexpected error occurred: {e}" +``` + +#### 3. Detailed Docstrings + +Always write detailed docstrings in Google-style. Describe the function's purpose, all keys in the `input_data` dictionary, and the return value. + +#### 4. Tool Definition (`_tool_def`) + +Every tool must have a corresponding `_tool_def` definition. This is a dictionary that describes the function's signature for the OpenAI API, allowing the agent to understand how to call your tool. + +```python +substring_tool_def = { + "type": "function", + "function": { + "name": "substring_tool", + "description": "Extracts a substring from text.", + "parameters": { + "type": "object", + "properties": { + "text": {"type": "string", "description": "The source text."}, + "start": {"type": "integer", "description": "The starting index."}, + }, + "required": ["text", "start"], + }, + }, +} +``` \ No newline at end of file diff --git a/knowledge/error_handling.md b/knowledge/error_handling.md index f6bf832..6ad9f3e 100644 --- a/knowledge/error_handling.md +++ b/knowledge/error_handling.md @@ -1,44 +1,44 @@ -# Принципы Обработки Ошибок +# Error Handling Principles -## 1. Предпочитайте специфичные исключения +## 1. Prefer Specific Exceptions -Всегда перехватывайте наиболее специфичный тип исключения. Избегайте использования `except Exception:` без крайней необходимости. +Always catch the most specific exception type possible. Avoid using `except Exception:` unless absolutely necessary. -**Плохо:** +**Bad:** ```python try: - # какой-то код + # some code except Exception as e: - log.error("Произошла ошибка") + log.error("An error occurred") ``` -**Хорошо:** +**Good:** ```python try: - # какой-то код + # some code except FileNotFoundError as e: - log.error(f"Файл не найден: {e}") + log.error(f"File not found: {e}") except (KeyError, ValueError) as e: - log.warning(f"Ошибка данных: {e}") + log.warning(f"Data error: {e}") ``` -## 2. Используйте кастомные исключения +## 2. Use Custom Exceptions -Для ошибок, специфичных для доменной логики вашего приложения, создавайте собственные классы исключений. Это делает код более читаемым и позволяет вызывающему коду точечно обрабатывать конкретные сбои. +For errors specific to your application's domain logic, create your own exception classes. This makes the code more readable and allows calling code to handle specific failures precisely. ```python class InsufficientBalanceError(Exception): - """Исключение, возникающее при недостаточном балансе.""" + """Exception raised when the account balance is too low.""" pass def withdraw(amount): if amount > current_balance: - raise InsufficientBalanceError("Недостаточно средств на счете") + raise InsufficientBalanceError("Insufficient funds in the account") ``` -## 3. Логируйте ошибки правильно +## 3. Log Errors Correctly -При перехвате исключения обязательно логируйте полную информацию, включая трассировку стека, чтобы упростить отладку. +When catching an exception, be sure to log the full information, including the stack trace, to simplify debugging. ```python import logging @@ -46,4 +46,4 @@ import logging try: # ... except Exception as e: - logging.error("Произошла непредвиденная ошибка", exc_info=True) \ No newline at end of file + logging.error("An unexpected error occurred", exc_info=True) \ No newline at end of file diff --git a/knowledge/python_style_guide.md b/knowledge/python_style_guide.md index cc3f9e0..36be205 100644 --- a/knowledge/python_style_guide.md +++ b/knowledge/python_style_guide.md @@ -1,26 +1,26 @@ # Python Style Guide -- **Именование:** Используйте `snake_case` для переменных и функций. Имена классов должны использовать `CamelCase`. Константы должны быть в `UPPER_SNAKE_CASE`. -- **Длина строки:** Максимальная длина строки - 99 символов. -- **Докстринги:** Все публичные модули, функции, классы и методы должны иметь докстринги в стиле Google. -- **Импорты:** Группируйте импорты в следующем порядке: стандартная библиотека, сторонние библиотеки, локальные приложения. +- **Naming:** Use `snake_case` for variables and functions. Class names should use `CamelCase`. Constants should be in `UPPER_SNAKE_CASE`. +- **Line Length:** The maximum line length is 99 characters. +- **Docstrings:** All public modules, functions, classes, and methods must have Google-style docstrings. +- **Imports:** Group imports in the following order: standard library, third-party libraries, local application. -## Форматирование строк +## String Formatting -- **f-строки:** Всегда предпочитайте f-строки для форматирования вместо `str.format()` или оператора `%`. +- **f-strings:** Always prefer f-strings for formatting instead of `str.format()` or the `%` operator. -**Хорошо:** -`user_info = f"Пользователь {user.name} с ID {user.id}"` +**Good:** +`user_info = f"User {user.name} with ID {user.id}"` -**Плохо:** -`user_info = "Пользователь {} с ID {}".format(user.name, user.id)` +**Bad:** +`user_info = "User {} with ID {}".format(user.name, user.id)` ## List Comprehensions -- **Простота:** Используйте list comprehensions для создания списков из существующих итерируемых объектов, но только если логика остается простой и читаемой. Если требуется сложная логика или несколько вложенных циклов, используйте обычный цикл `for`. +- **Simplicity:** Use list comprehensions to create lists from existing iterables, but only if the logic remains simple and readable. If complex logic or multiple nested loops are required, use a regular `for` loop. -**Хорошо:** +**Good:** `squares = [x*x for x in range(10)]` -**Избегайте (сложно для чтения):** +**Avoid (hard to read):** `complex_list = [x + y for x in range(10) for y in range(5) if x % 2 == 0 if y % 3 == 0]` \ No newline at end of file diff --git a/knowledge/testing_guidelines.md b/knowledge/testing_guidelines.md index 55c5b6b..2fd0333 100644 --- a/knowledge/testing_guidelines.md +++ b/knowledge/testing_guidelines.md @@ -1,12 +1,12 @@ -# Руководство по Написанию Тестов +# Testing Guidelines -## 1. Структура теста: Arrange-Act-Assert (AAA) +## 1. Test Structure: Arrange-Act-Assert (AAA) -Все тесты должны следовать паттерну AAA для ясности и читаемости. +All tests should follow the AAA pattern for clarity and readability. -- **Arrange (Подготовка):** Подготовьте все необходимые данные и моки. -- **Act (Действие):** Вызовите тестируемую функцию или метод. -- **Assert (Проверка):** Проверьте, что результат соответствует ожиданиям. +- **Arrange:** Prepare all necessary data and mocks. +- **Act:** Call the function or method being tested. +- **Assert:** Check that the result meets expectations. ```python def test_user_creation(): @@ -22,15 +22,15 @@ def test_user_creation(): mock_db.add.assert_called_once() ``` -## 2. Именование тестов +## 2. Test Naming -Имена тестовых функций должны быть описательными и начинаться с `test_`. Следуйте формату `test_<что_тестируем>_<при_каких_условиях>_<ожидаемый_результат>`. +Test function names should be descriptive and start with `test_`. Follow the format `test___`. -**Пример:** `test_add_items_with_negative_quantity_raises_error()` +**Example:** `test_add_items_with_negative_quantity_raises_error()` -## 3. Используйте `pytest.raises` для проверки исключений +## 3. Use `pytest.raises` for Exception Testing -Для проверки того, что код корректно выбрасывает исключения, используйте контекстный менеджер `pytest.raises`. +To verify that code correctly raises exceptions, use the `pytest.raises` context manager. ```python import pytest diff --git a/main.py b/main.py index 1f45bcc..958f25f 100644 --- a/main.py +++ b/main.py @@ -1,95 +1,69 @@ import logging import os +import typer from dotenv import load_dotenv -from app.agents.agent import Agent -from app.agents.tools import ( - read_file_tool, read_file_tool_def, - edit_file_tool, edit_file_tool_def, - list_files_tool, list_files_tool_def, - run_tests_tool, run_tests_tool_def, -) -from app.orchestration.decomposer import TaskDecomposer +from typing_extensions import Annotated + +from app.factory.agent_factory import create_agent_team from app.orchestration.orchestrator import Orchestrator -from app.agents.roles import ( - CodingAgent, - ReviewerAgent, - TestingAgent, - EvaluatorAgent, -) -def main(): - """Main function to run the AI agent.""" - load_dotenv() +# Create a typer app +app = typer.Typer() - # Setup logging +def setup_logging(): + """Configures the logging for the application.""" logging.basicConfig( level=logging.INFO, format="%(asctime)s - %(levelname)s - [%(filename)s:%(lineno)d] - %(message)s", handlers=[ - logging.FileHandler("agent_activity.log"), + logging.FileHandler("agent_activity.log", mode='w'), logging.StreamHandler() ] ) +@app.command() +def run( + goal: Annotated[str, typer.Argument(help="The high-level goal for the agent team to accomplish.")], + config: Annotated[str, typer.Option(help="Path to the main configuration file.")] = "configs/config.yaml" +): + """ + Runs the multi-agent system to accomplish a given goal. + """ + load_dotenv() + setup_logging() + api_key = os.getenv("OPENAI_API_KEY") if not api_key: logging.error("OPENAI_API_KEY not found in .env file.") - print("Error: Please make sure your .env file contains OPENAI_API_KEY.") - return + typer.echo("Error: Please make sure your .env file contains OPENAI_API_KEY.") + raise typer.Exit(code=1) try: - # 1. Initialize the agent team - logging.info("Initializing agent team...") - - common_kwargs = {"api_key": api_key, "model": "o4-mini"} - - coding_agent = CodingAgent(name="CodingAgent", **common_kwargs) - coding_agent.add_tool(read_file_tool, read_file_tool_def) - coding_agent.add_tool(edit_file_tool, edit_file_tool_def) - coding_agent.add_tool(list_files_tool, list_files_tool_def) - - testing_agent = TestingAgent(name="TestingAgent", **common_kwargs) - testing_agent.add_tool(read_file_tool, read_file_tool_def) - testing_agent.add_tool(run_tests_tool, run_tests_tool_def) - - evaluator_agent = EvaluatorAgent(name="EvaluatorAgent", **common_kwargs) - evaluator_agent.add_tool(read_file_tool, read_file_tool_def) + # Create the agent team using the factory + task_decomposer, worker_agents = create_agent_team(config) - reviewer_agent = ReviewerAgent(name="ReviewerAgent", **common_kwargs) - reviewer_agent.add_tool(read_file_tool, read_file_tool_def) - - workers = { - "CodingAgent": coding_agent, - "TestingAgent": testing_agent, - "EvaluatorAgent": evaluator_agent, - "ReviewerAgent": reviewer_agent, - } - - default_agent = Agent(name="DefaultAgent", **common_kwargs) - default_agent.add_tool(list_files_tool, list_files_tool_def) - default_agent.add_tool(read_file_tool, read_file_tool_def) - workers["DefaultAgent"] = default_agent - - # Planner - planner = TaskDecomposer(api_key=api_key, model="o4-mini") - - # The orchestrator now manages the team + # Initialize and run the orchestrator orchestrator = Orchestrator( - task_decomposer=planner, - worker_agents=workers + task_decomposer=task_decomposer, + worker_agents=worker_agents ) - - # 2. Request the goal and run the orchestrator - goal = input("Please enter your high-level goal: ") - if not goal: - print("Goal cannot be empty.") - return - + + typer.echo(f"🚀 Starting agent team to accomplish goal: {goal}") orchestrator.run(goal) + typer.echo("✅ Goal accomplished successfully!") + except FileNotFoundError as e: + logging.error(f"Configuration file not found: {e}") + typer.echo(f"Error: Configuration file not found at '{config}'. Please check the path.") + raise typer.Exit(code=1) + except KeyboardInterrupt: + typer.echo("\nOperation cancelled by user. Exiting...") + logging.info("User cancelled the operation.") + raise typer.Exit() except Exception as e: logging.critical("A critical error occurred: %s", e, exc_info=True) - print(f"\nCritical Error: {e}") + typer.echo(f"\n🚨 Critical Error: {e}") + raise typer.Exit(code=1) if __name__ == "__main__": - main() \ No newline at end of file + app() \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 807b75c..3bf24f0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,13 +2,17 @@ fastapi==0.111.1 uvicorn==0.30.1 python-dotenv==1.0.1 openai==1.35.13 -tiktoken==0.7.0 -unstructured==0.14.9 +tiktoken +unstructured +unstructured-client markdown==3.6 -numpy==1.26.4 -scikit-learn==1.5.1 -sentence-transformers==3.0.1 -torch==2.3.1 +numpy +scikit-learn +sentence-transformers +torch pytest==8.3.2 pytest-mock==3.14.0 -httpx==0.27.0 \ No newline at end of file +httpx==0.27.0 +rank-bm25 +pyyaml +typer[all] \ No newline at end of file diff --git a/scripts/build_knowledge_base.py b/scripts/build_knowledge_base.py index a630183..820b384 100644 --- a/scripts/build_knowledge_base.py +++ b/scripts/build_knowledge_base.py @@ -6,12 +6,14 @@ import re from pathlib import Path from typing import List, Dict, Any +import pickle import numpy as np import tiktoken from dotenv import load_dotenv from openai import OpenAI from unstructured.partition.md import partition_md +from rank_bm25 import BM25Okapi # --- Configuration --- load_dotenv() @@ -25,6 +27,7 @@ DB_DIR = Path("db") EMBEDDINGS_FILE = DB_DIR / "embeddings.npy" CHUNKS_FILE = DB_DIR / "chunks.json" +BM25_INDEX_FILE = DB_DIR / "bm25_index.pkl" EMBEDDING_MODEL = "text-embedding-3-small" TEXT_CHUNK_MAX_TOKENS = 128 @@ -37,29 +40,47 @@ tokenizer = tiktoken.get_encoding("cl100k_base") def load_and_partition_documents(directory: Path) -> List[Dict[str, Any]]: - """Loads and partitions all markdown documents from a directory.""" + """ + Loads documents and extracts metadata from their file path. + The subdirectories of the knowledge base are used as tags. + Example: `knowledge/style-guide/python.md` -> tags: ['style-guide'] + """ documents = [] logging.info(f"Loading documents from {directory}...") for file_path in directory.rglob("*.md"): + if file_path.name.startswith("."): + continue logging.info(f"Processing file: {file_path.name}") try: + # Extract tags from the path relative to the knowledge directory + relative_path = file_path.relative_to(directory) + tags = [part for part in relative_path.parts[:-1] if part] + elements = partition_md(filename=str(file_path)) text_content = "\n".join([el.text for el in elements]) + documents.append({ "text": text_content, "source": file_path.name, + "metadata": { + "tags": tags, + "full_path": str(file_path) + } }) except Exception as e: logging.error(f"Failed to process {file_path}: {e}") return documents def chunk_text( - doc_text: str, - source_name: str, + doc: Dict[str, Any], max_tokens: int = TEXT_CHUNK_MAX_TOKENS, ) -> List[Dict[str, Any]]: - """Splits a long text into smaller, semantically meaningful chunks.""" + """Splits a document into smaller, semantically meaningful chunks.""" chunks = [] + doc_text = doc["text"] + source_name = doc["source"] + metadata = doc["metadata"] + header_splits = re.split(r'(^## .+$|^### .+$)', doc_text, flags=re.MULTILINE) texts_to_process = [header_splits[0]] if len(header_splits) > 1: @@ -80,11 +101,16 @@ def chunk_text( current_chunk += " " + sentence else: if current_chunk: - chunks.append({"text": current_chunk.strip(), "source": source_name, "chunk_id": chunk_id_counter}) + # Add metadata to each chunk + chunk_metadata = metadata.copy() + chunk_metadata["chunk_id"] = f"{source_name}_{chunk_id_counter}" + chunks.append({"text": current_chunk.strip(), "source": source_name, "metadata": chunk_metadata}) chunk_id_counter += 1 current_chunk = sentence if current_chunk: - chunks.append({"text": current_chunk.strip(), "source": source_name, "chunk_id": chunk_id_counter}) + chunk_metadata = metadata.copy() + chunk_metadata["chunk_id"] = f"{source_name}_{chunk_id_counter}" + chunks.append({"text": current_chunk.strip(), "source": source_name, "metadata": chunk_metadata}) chunk_id_counter += 1 return chunks @@ -106,15 +132,26 @@ def main(): return all_chunks_with_metadata = [] for doc in documents: - doc_chunks = chunk_text(doc["text"], doc["source"]) + doc_chunks = chunk_text(doc) all_chunks_with_metadata.extend(doc_chunks) if not all_chunks_with_metadata: logging.warning("Could not create any chunks from the documents. Exiting.") return chunk_texts = [chunk["text"] for chunk in all_chunks_with_metadata] + + # Create and save vector embeddings embeddings = create_embeddings(chunk_texts) logging.info(f"Saving {len(embeddings)} embeddings to {EMBEDDINGS_FILE}") np.save(EMBEDDINGS_FILE, embeddings) + + # Create and save BM25 index for keyword search + logging.info("Creating BM25 index...") + tokenized_corpus = [doc.split(" ") for doc in chunk_texts] + bm25 = BM25Okapi(tokenized_corpus) + with open(BM25_INDEX_FILE, "wb") as f: + pickle.dump(bm25, f) + logging.info(f"BM25 index saved to {BM25_INDEX_FILE}") + logging.info(f"Saving {len(all_chunks_with_metadata)} chunks to {CHUNKS_FILE}") with open(CHUNKS_FILE, "w", encoding="utf-8") as f: json.dump(all_chunks_with_metadata, f, ensure_ascii=False, indent=4) diff --git a/tests/agents/test_reviewer_agent.py b/tests/agents/test_reviewer_agent.py index d537d1d..3347f02 100644 --- a/tests/agents/test_reviewer_agent.py +++ b/tests/agents/test_reviewer_agent.py @@ -9,7 +9,7 @@ def mock_retriever_fixture(): """Fixture to create a mock KnowledgeRetriever.""" # Создаем мок для всего класса KnowledgeRetriever - with patch('app.agents.roles.reviewer_agent.KnowledgeRetriever') as mock: + with patch('app.agents.agent.KnowledgeRetriever') as mock: # Настраиваем мок-экземпляр, который будет возвращаться при создании объекта mock_instance = MagicMock() # Настраиваем метод retrieve, чтобы он возвращал предсказуемые данные @@ -24,29 +24,53 @@ def mock_retriever_fixture(): mock.return_value = mock_instance yield mock_instance -def test_reviewer_agent_uses_rag_context(mock_retriever_fixture): +def test_reviewer_agent_uses_rag_context(mock_retriever_fixture, mocker): """ Tests that the ReviewerAgent correctly uses the context from the KnowledgeRetriever. """ # Arrange - # Инициализируем агента. Благодаря нашему фикстуре, self.retriever будет моком. - agent = ReviewerAgent(name="TestReviewer", api_key="test_key") + agent = ReviewerAgent( + name="TestReviewer", + role="Test Reviewer", + goal="Review code", + use_rag=True, + api_key="fake_api_key", + ) code_to_review = "my_variable = 1" + # Mock the model's response to stop the execution loop after one turn + mocker.patch.object(agent.client.chat.completions, 'create', return_value=MagicMock()) + # Act - # Получаем системный промпт, который должен быть обогащен контекстом - system_prompt = agent._get_system_prompt(code=code_to_review) + # Запускаем execute_task, который теперь содержит логику обогащения промпта + agent.execute_task(briefing=code_to_review) # Assert - # 1. Проверяем, что метод retrieve был вызван с правильным кодом - mock_retriever_fixture.retrieve.assert_called_once_with(query=code_to_review, top_k=3) + # 1. Проверяем, что метод retrieve был вызван с правильным запросом + # _create_rag_query вернет сам брифинг, если не найдет шаблон задачи + mock_retriever_fixture.retrieve.assert_called_once_with(query=code_to_review, top_k=3, filters=None) - # 2. Проверяем, что информация из мока попала в системный промпт - assert "RELEVANT KNOWLEDGE" in system_prompt - assert "Всегда используйте snake_case для переменных." in system_prompt - assert "python_style_guide.md" in system_prompt + # 2. Проверяем, что информация из мока попала в системный промпт в истории диалога + assert len(agent.conversation_history) > 0 + system_prompt_from_history = agent.conversation_history[0]['content'] + assert "RELEVANT KNOWLEDGE" in system_prompt_from_history + assert "Всегда используйте snake_case для переменных." in system_prompt_from_history + assert "python_style_guide.md" in system_prompt_from_history - # 3. Проверяем, что если ретривер ничего не нашел, используется дефолтный текст + # 3. Проверяем, что если ретривер ничего не нашел, секция RELEVANT KNOWLEDGE отсутствует mock_retriever_fixture.retrieve.return_value = [] - system_prompt_no_knowledge = agent._get_system_prompt(code=code_to_review) - assert "No specific internal standards found" in system_prompt_no_knowledge \ No newline at end of file + mock_retriever_fixture.retrieve.reset_mock() # Сбрасываем мок + + agent_no_knowledge = ReviewerAgent( + name="TestReviewer2", + role="Test Reviewer", + goal="Review code", + use_rag=True, + api_key="fake_api_key", + ) + mocker.patch.object(agent_no_knowledge.client.chat.completions, 'create', return_value=MagicMock()) + + agent_no_knowledge.execute_task(briefing=code_to_review) + system_prompt_no_knowledge = agent_no_knowledge.conversation_history[0]['content'] + assert "RELEVANT KNOWLEDGE" not in system_prompt_no_knowledge + assert "You are a Senior Software Engineer" in system_prompt_no_knowledge \ No newline at end of file diff --git a/tests/rag/test_retriever.py b/tests/rag/test_retriever.py index 7335e8f..a51c7ba 100644 --- a/tests/rag/test_retriever.py +++ b/tests/rag/test_retriever.py @@ -2,6 +2,7 @@ import numpy as np import pytest from unittest.mock import patch, MagicMock +from rank_bm25 import BM25Okapi from app.rag.retriever import KnowledgeRetriever @@ -29,6 +30,10 @@ def mock_retriever(mocker) -> KnowledgeRetriever: ] retriever.embeddings = np.random.rand(4, 1536) # 4 чанка, 1536 измерений retriever.client = MagicMock() # Нам не нужен клиент, так как мы мокаем cosine_similarity + + # 4. Инициализируем BM25, как это делается в методе load() + tokenized_corpus = [chunk["text"].split(" ") for chunk in retriever.chunks] + retriever.bm25 = BM25Okapi(tokenized_corpus) return retriever @@ -60,6 +65,9 @@ def test_retriever_ranking_logic( 'app.rag.retriever.cosine_similarity', return_value=np.array([mock_similarities]) ) + # Мокаем BM25 так, чтобы он не возвращал результатов и не влиял на фьюжн + mocker.patch.object(mock_retriever, '_keyword_search', return_value=[]) + # Так как `retrieve` вызывает `_get_embedding`, который вызывает `client`, # нам достаточно замокать вызов клиента. mock_retriever.client.embeddings.create.return_value = MagicMock( diff --git a/tests/test_tools.py b/tests/test_tools.py index adc4798..e69de29 100644 --- a/tests/test_tools.py +++ b/tests/test_tools.py @@ -1,3 +0,0 @@ -""" -This module covers various scenarios including valid operations, boundary cases, and invalid inputs. -"""