IssueTriage can now interact with task in YouTrack and it is idempotent
This commit is contained in:
@@ -0,0 +1,511 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Multi-Agent Development Workflow
|
||||
- Planner: анализирует проект и создаёт план подзадач
|
||||
- Coder: выполняет подзадачи, пишет код
|
||||
- Tester: открывает браузер, проверяет ошибки в консоли
|
||||
Поддерживает несколько проектов, состояние хранится в папке проекта.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Any
|
||||
import requests
|
||||
from playwright.sync_api import sync_playwright
|
||||
from agents.architect_ts.analyzer import CodeAnalyzer
|
||||
|
||||
# ---------- Конфигурация ----------
|
||||
CONFIG_FILE = "config.json"
|
||||
|
||||
|
||||
def load_config():
|
||||
"""Загружает конфигурацию из файла."""
|
||||
if not os.path.exists(CONFIG_FILE):
|
||||
default_config = {
|
||||
"ollama_base_url": "http://10.6.3.2:11434",
|
||||
"default_model": "qwen3:4b-instruct-2507-q4_K_M",
|
||||
"projects": [
|
||||
{
|
||||
"name": "example_project",
|
||||
"path": "./projects/example",
|
||||
"url": "http://localhost:3000",
|
||||
"model": "qwen3:4b-instruct-2507-q4_K_M"
|
||||
}
|
||||
]
|
||||
}
|
||||
with open(CONFIG_FILE, "w") as f:
|
||||
json.dump(default_config, f, indent=2)
|
||||
print(f"Создан файл {CONFIG_FILE}. Отредактируйте его и запустите снова.")
|
||||
sys.exit(0)
|
||||
with open(CONFIG_FILE, "r") as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
# ---------- Клиент для Ollama ----------
|
||||
class OllamaClient:
|
||||
def __init__(self, base_url: str, model: str):
|
||||
self.base_url = base_url.rstrip('/')
|
||||
self.model = model
|
||||
self.api_url = f"{self.base_url}/api/chat"
|
||||
|
||||
def chat(self, messages: List[Dict[str, str]], temperature: float = 0.2) -> str:
|
||||
"""Отправляет запрос к Ollama и возвращает ответ."""
|
||||
payload = {
|
||||
"model": self.model,
|
||||
"messages": messages,
|
||||
"stream": False,
|
||||
"temperature": temperature,
|
||||
"options": {
|
||||
"num_ctx": 16000
|
||||
}
|
||||
}
|
||||
try:
|
||||
response = requests.post(self.api_url, json=payload, timeout=600)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
return data.get("message", {}).get("content", "")
|
||||
except Exception as e:
|
||||
print(f"Ошибка при запросе к Ollama: {e}")
|
||||
return ""
|
||||
|
||||
|
||||
# ---------- Управление проектами ----------
|
||||
class Project:
|
||||
def __init__(self, name: str, path: str, url: str, model: str, file_masks, ollama_base: str):
|
||||
self.name = name
|
||||
self.path = Path(path)
|
||||
self.url = url
|
||||
self.model = model
|
||||
self.file_masks = file_masks
|
||||
self.ollama_client = OllamaClient(ollama_base, model)
|
||||
self.state_file = self.path / ".workflow_state.json"
|
||||
self.state = self.load_state()
|
||||
|
||||
self.tokenizer = tiktoken.get_encoding("gpt2")
|
||||
self.max_summary_tokens = 3500
|
||||
self.analyzer = CodeAnalyzer()
|
||||
|
||||
def load_state(self) -> Dict[str, Any]:
|
||||
"""Загружает состояние проекта (план, выполненные подзадачи, история ошибок)."""
|
||||
if self.state_file.exists():
|
||||
with open(self.state_file, "r") as f:
|
||||
return json.load(f)
|
||||
return {
|
||||
"plan": [],
|
||||
"current_step": 0,
|
||||
"completed": [],
|
||||
"errors": [],
|
||||
"history": []
|
||||
}
|
||||
|
||||
def save_state(self):
|
||||
"""Сохраняет состояние проекта."""
|
||||
self.state_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(self.state_file, "w") as f:
|
||||
json.dump(self.state, f, indent=2)
|
||||
|
||||
def get_project_files(self) -> List[Path]:
|
||||
"""Возвращает список файлов проекта (рекурсивно все файлы, кроме системных)."""
|
||||
exclude = {".git", "__pycache__", "node_modules", ".idea", ".vscode", "venv"}
|
||||
files = []
|
||||
for root, dirs, filenames in os.walk(self.path):
|
||||
dirs[:] = [d for d in dirs if d not in exclude]
|
||||
for fname in filenames:
|
||||
if fname.endswith(('.py', '.js', '.html', '.css', '.json', '.txt')):
|
||||
files.append(Path(root) / fname)
|
||||
return files
|
||||
|
||||
def read_file_content(self, file_path: Path) -> str:
|
||||
"""Читает содержимое файла (с обработкой кодировок)."""
|
||||
try:
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
return f.read()
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
def write_file(self, file_path: Path, content: str):
|
||||
"""Записывает содержимое в файл (создавая папки при необходимости)."""
|
||||
file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(file_path, 'w', encoding='utf-8') as f:
|
||||
f.write(content)
|
||||
|
||||
def get_project_structure(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Возвращает дерево папок и файлов, отфильтрованных по маскам.
|
||||
Возвращает список путей и текстовое представление дерева.
|
||||
"""
|
||||
masks = self.file_masks
|
||||
include_masks = masks.get("include", [])
|
||||
exclude_masks = masks.get("exclude", [])
|
||||
|
||||
"""Возвращает дерево папок и файлов, отфильтрованных по маскам."""
|
||||
base = self.path.resolve()
|
||||
all_paths = []
|
||||
for root, dirs, files in os.walk(base):
|
||||
rel_root = Path(root).relative_to(base)
|
||||
for f in files:
|
||||
rel_path = str(rel_root / f).replace('\\', '/')
|
||||
# Пропускаем файлы, которые не проходят фильтр
|
||||
if not self._matches_masks(rel_path, include_masks, exclude_masks):
|
||||
continue
|
||||
all_paths.append(rel_path)
|
||||
all_paths.sort()
|
||||
|
||||
tree_lines = self._build_tree(all_paths)
|
||||
|
||||
return {
|
||||
"paths": all_paths,
|
||||
"tree": "\n".join(tree_lines)
|
||||
}
|
||||
|
||||
def _build_tree(self, paths: List[str]) -> List[str]:
|
||||
"""Строит ASCII-дерево из списка путей."""
|
||||
if not paths:
|
||||
return ["(пусто)"]
|
||||
# Простая реализация: разбиваем по "/" и строим вложенный словарь
|
||||
tree = {}
|
||||
for p in paths:
|
||||
parts = p.split('/')
|
||||
node = tree
|
||||
for part in parts:
|
||||
if part not in node:
|
||||
node[part] = {}
|
||||
node = node[part]
|
||||
|
||||
lines = []
|
||||
|
||||
def walk(node, prefix=""):
|
||||
items = sorted(node.items())
|
||||
for i, (name, subnode) in enumerate(items):
|
||||
is_last = (i == len(items) - 1)
|
||||
lines.append(f"{prefix}{'└── ' if is_last else '├── '}{name}")
|
||||
if subnode:
|
||||
walk(subnode, prefix + (" " if is_last else "│ "))
|
||||
|
||||
walk(tree)
|
||||
return lines
|
||||
|
||||
@staticmethod
|
||||
def _mask_to_regex(mask: str) -> str:
|
||||
"""
|
||||
Преобразует glob-подобную маску в регулярное выражение.
|
||||
Поддерживает:
|
||||
- ** : любое количество любых символов (включая /)
|
||||
- * : любое количество любых символов, кроме /
|
||||
- {a,b,c} : выбор из нескольких вариантов
|
||||
"""
|
||||
# Убираем ведущий /, если он есть (пути передаются без него)
|
||||
if mask.startswith('/'):
|
||||
mask = mask[1:]
|
||||
|
||||
parts = []
|
||||
i = 0
|
||||
n = len(mask)
|
||||
while i < n:
|
||||
ch = mask[i]
|
||||
if ch == '*':
|
||||
if i + 1 < n and mask[i + 1] == '*':
|
||||
# **
|
||||
parts.append('.*')
|
||||
i += 2
|
||||
else:
|
||||
# *
|
||||
parts.append('[^/]*')
|
||||
i += 1
|
||||
elif ch == '{':
|
||||
# Ищем закрывающую }
|
||||
j = mask.find('}', i + 1)
|
||||
if j == -1:
|
||||
# Нет закрывающей – экранируем как обычный символ
|
||||
parts.append(re.escape(ch))
|
||||
i += 1
|
||||
else:
|
||||
inner = mask[i + 1:j]
|
||||
# Разбиваем по запятым, экранируем каждую опцию
|
||||
options = [re.escape(opt.strip()) for opt in inner.split(',')]
|
||||
parts.append('(?:' + '|'.join(options) + ')')
|
||||
i = j + 1
|
||||
else:
|
||||
# Экранируем все остальные спецсимволы
|
||||
parts.append(re.escape(ch))
|
||||
i += 1
|
||||
|
||||
regex = ''.join(parts)
|
||||
return '^' + regex + '$'
|
||||
|
||||
def _matches_masks(self, rel_path: str, include_masks: List[str], exclude_masks: List[str]) -> bool:
|
||||
"""Проверяет, подходит ли файл под маски включения/исключения."""
|
||||
# Если нет include – считаем, что включено
|
||||
included = False
|
||||
if not include_masks:
|
||||
included = True
|
||||
else:
|
||||
for mask in include_masks:
|
||||
regex = self._mask_to_regex(mask)
|
||||
if re.match(regex, rel_path):
|
||||
included = True
|
||||
break
|
||||
if not included:
|
||||
return False
|
||||
|
||||
# Проверяем исключения
|
||||
for mask in exclude_masks:
|
||||
regex = self._mask_to_regex(mask)
|
||||
if re.match(regex, rel_path):
|
||||
return False
|
||||
return True
|
||||
|
||||
def _count_tokens(self, text: str) -> int:
|
||||
return len(self.tokenizer.encode(text))
|
||||
|
||||
def generate_summaries(self, file_paths: List[str]) -> Dict[str, str]:
|
||||
"""
|
||||
Генерирует описания для файлов, пока общее количество токенов не превысит лимит.
|
||||
Сохраняет результат в state и возвращает словарь {путь: описание}.
|
||||
"""
|
||||
# Если уже есть в состоянии, возвращаем его (можно добавить проверку актуальности позже)
|
||||
if "file_summaries" in self.state and self.state["file_summaries"]:
|
||||
return self.state["file_summaries"]
|
||||
|
||||
file_paths = sorted(file_paths, key=lambda p: (self.path / p).stat().st_size, reverse=True)
|
||||
|
||||
summaries = {}
|
||||
total_tokens = 0
|
||||
|
||||
for rel_path in file_paths:
|
||||
full_path = self.path / rel_path
|
||||
content = self.read_file_content(full_path)
|
||||
if not content:
|
||||
continue
|
||||
|
||||
# Получаем структуру через tree-sitter (как раньше)
|
||||
ext = full_path.suffix
|
||||
signature = self.analyzer.analyze_file(content, ext)
|
||||
signature_str = json.dumps(signature, ensure_ascii=False, indent=2)
|
||||
|
||||
# Генерируем описание
|
||||
prompt = f"""
|
||||
Файл: {rel_path}
|
||||
Структура:
|
||||
{signature_str}
|
||||
|
||||
Опиши кратко (1–2 предложения), что делает этот файл, перечисли все компоненты (классы, функции).
|
||||
Не пиши код, только текст.
|
||||
"""
|
||||
response = self.ollama_client.chat([{"role": "user", "content": prompt}])
|
||||
if not response:
|
||||
response = "(описание не получено)"
|
||||
|
||||
# Считаем токены для этого описания (плюс небольшой запас на разделители)
|
||||
desc_tokens = self._count_tokens(response)
|
||||
|
||||
summaries[rel_path] = response
|
||||
total_tokens += desc_tokens
|
||||
print(f" ✅ {rel_path} – {desc_tokens} токенов, всего {total_tokens}")
|
||||
|
||||
# Сохраняем в состоянии
|
||||
self.state["file_summaries"] = summaries
|
||||
self.save_state()
|
||||
return summaries
|
||||
|
||||
|
||||
# ---------- Основной цикл ----------
|
||||
|
||||
def run_workflow(project: Project, user_task: str, max_iterations: int = 5):
|
||||
print(f"\n🚀 Начинаем работу над проектом '{project.name}'")
|
||||
|
||||
# Получаем структуру и суммари
|
||||
file_paths = project.state.get('paths')
|
||||
print(f"📁 Найдено {len(file_paths)} файлов по маскам.")
|
||||
|
||||
summaries = project.generate_summaries(file_paths)
|
||||
if not summaries:
|
||||
print("❌ Не удалось получить описания файлов.")
|
||||
return
|
||||
|
||||
# Инициализация состояния (если нет)
|
||||
if "completed" not in project.state:
|
||||
project.state["completed"] = []
|
||||
if "errors_history" not in project.state:
|
||||
project.state["errors_history"] = []
|
||||
if "last_errors" not in project.state:
|
||||
project.state["last_errors"] = []
|
||||
|
||||
iteration = 0
|
||||
while iteration < max_iterations:
|
||||
iteration += 1
|
||||
print(f"\n--- Итерация {iteration} ---")
|
||||
|
||||
# Планировщик решает, что делать дальше
|
||||
task = PlannerAgent.next_task(project, user_task)
|
||||
print(json.dumps(task))
|
||||
if not task or task.get("type") == "finish":
|
||||
print("✅ Планировщик завершил работу.")
|
||||
break
|
||||
|
||||
task_type = task["type"]
|
||||
description = task["description"]
|
||||
files = task.get("files", [])
|
||||
print(f"📌 Следующая задача: {task_type.upper()} — {description}")
|
||||
if files:
|
||||
print(f" Файлы: {', '.join(files)}")
|
||||
|
||||
if task_type == "test":
|
||||
errors = TesterAgent.test(project)
|
||||
if errors:
|
||||
print(f" ❌ Обнаружены ошибки ({len(errors)}):")
|
||||
for err in errors[:3]:
|
||||
print(f" {err}")
|
||||
project.state["last_errors"] = errors
|
||||
project.state["errors_history"].extend(errors)
|
||||
else:
|
||||
print(" ✅ Ошибок не обнаружено.")
|
||||
project.state["last_errors"] = []
|
||||
project.save_state()
|
||||
|
||||
elif task_type == "code":
|
||||
error_feedback = ""
|
||||
if project.state.get("last_errors"):
|
||||
# Преобразуем структурированные ошибки в текст
|
||||
error_lines = []
|
||||
for err in project.state["last_errors"]:
|
||||
msg = err.get("message", "")
|
||||
loc = err.get("location")
|
||||
if loc:
|
||||
file_name = loc.get("url", "неизвестный файл")
|
||||
line = loc.get("line", "?")
|
||||
col = loc.get("column", "?")
|
||||
error_lines.append(f"{msg} (в {file_name}:{line}:{col})")
|
||||
else:
|
||||
error_lines.append(msg)
|
||||
error_feedback = "\n".join(error_lines)
|
||||
success = CoderAgent.execute(project, description, files, error_feedback)
|
||||
if success:
|
||||
print(" ✅ Код применён.")
|
||||
# После кода сбрасываем last_errors, так как они могут быть исправлены
|
||||
project.state["last_errors"] = []
|
||||
else:
|
||||
print(" ⚠️ Кодер не смог применить изменения.")
|
||||
project.save_state()
|
||||
|
||||
elif task_type == "browser":
|
||||
print(" 🌐 Открываем браузер для визуальной проверки...")
|
||||
# Можно реализовать отдельный метод
|
||||
else:
|
||||
print(f" ⚠️ Неизвестный тип задачи: {task_type}")
|
||||
|
||||
# Запоминаем выполненную задачу
|
||||
project.state["completed"].append(task)
|
||||
project.save_state()
|
||||
|
||||
if iteration >= max_iterations:
|
||||
print(f"⛔ Достигнут лимит итераций ({max_iterations}).")
|
||||
else:
|
||||
print("\n✅ Работа завершена успешно.")
|
||||
|
||||
def replan(project: Project, user_task: str, errors: List[str]) -> bool:
|
||||
"""Перепланирование на основе ошибок."""
|
||||
print("🔄 Перепланирование с учетом ошибок...")
|
||||
new_plan = PlannerAgent.plan(project, user_task)
|
||||
if not new_plan or (len(new_plan) == 1 and new_plan[0].get("type") == "error"):
|
||||
print("❌ Не удалось создать новый план.")
|
||||
return False
|
||||
project.state["plan"] = new_plan
|
||||
project.state["current_step"] = 0
|
||||
project.state["completed"] = []
|
||||
project.state["errors_history"] = []
|
||||
project.save_state()
|
||||
print("📋 Новый план:")
|
||||
for i, task in enumerate(new_plan, 1):
|
||||
files_str = f" files: {task.get('files', [])}" if task.get("type") == "code" else ""
|
||||
print(f"{i}. [{task['type']}] {task['description']}{files_str}")
|
||||
return True
|
||||
# ---------- Точка входа - ---------
|
||||
def main():
|
||||
import argparse
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--auto", action="store_true",
|
||||
help="Автоматический режим без вопросов (выбирает первый проект)")
|
||||
parser.add_argument("--task", type=str,
|
||||
help="Задача для выполнения (в автоматическом режиме или при повторном запуске)")
|
||||
parser.add_argument("--repeat", action="store_true",
|
||||
help="Перезапустить выполнение последней задачи (сброс состояния)")
|
||||
args = parser.parse_args()
|
||||
|
||||
config = load_config()
|
||||
ollama_base = config.get("ollama_base_url", "http://10.6.3.2:11434")
|
||||
projects = config.get("projects", [])
|
||||
if not projects:
|
||||
print("Нет проектов в конфигурации. Добавьте их в config.json.")
|
||||
sys.exit(1)
|
||||
|
||||
if args.auto:
|
||||
# Берём первый проект
|
||||
selected = projects[0]
|
||||
else:
|
||||
print("Доступные проекты:")
|
||||
for idx, proj in enumerate(projects, 1):
|
||||
print(f" {idx}. {proj['name']} (путь: {proj['path']})")
|
||||
|
||||
try:
|
||||
choice = int(input("Выберите номер проекта: ")) - 1
|
||||
if choice < 0 or choice >= len(projects):
|
||||
raise ValueError
|
||||
except ValueError:
|
||||
print("Некорректный ввод.")
|
||||
sys.exit(1)
|
||||
|
||||
selected = projects[choice]
|
||||
|
||||
project = Project(
|
||||
name=selected["name"],
|
||||
path=selected["path"],
|
||||
url=selected.get("url", "http://localhost:3000"),
|
||||
model=selected.get("model", config.get("default_model", "llama3.1")),
|
||||
file_masks=selected['file_masks'],
|
||||
ollama_base=ollama_base,
|
||||
)
|
||||
|
||||
if not project.path.exists():
|
||||
print(f"Папка проекта {project.path} не существует. Создаю.")
|
||||
project.path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Задача из аргументов или дефолтная
|
||||
task_arg = args.task
|
||||
if not task_arg:
|
||||
# Если не задана, пробуем взять из состояния проекта (последняя задача)
|
||||
# Но у нас нет такого поля, поэтому используем заглушку
|
||||
task_arg = input("Введите описание задачи для этого проекта: ").strip()
|
||||
if not task_arg and not project.state['plan']:
|
||||
print("Задача не введена. Завершение.")
|
||||
sys.exit(1)
|
||||
|
||||
if not args.auto:
|
||||
structure = project.get_project_structure()
|
||||
print("\n📁 Структура проекта (используемые файлы):")
|
||||
print(structure["tree"])
|
||||
if input("Ok? [n/Y]: ").strip() == "n":
|
||||
sys.exit(1)
|
||||
|
||||
project.state['tree'] = structure["tree"]
|
||||
project.state['paths'] = structure["paths"]
|
||||
project.save_state()
|
||||
|
||||
# Если repeat, сбрасываем состояние выполнения
|
||||
if args.repeat:
|
||||
print("🔄 Состояние сброшено для перезапуска задачи.")
|
||||
|
||||
try:
|
||||
run_workflow(project, task_arg)
|
||||
except KeyboardInterrupt:
|
||||
print("\nПрервано пользователем.")
|
||||
except Exception as e:
|
||||
print(f"Критическая ошибка: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user