diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..06d7405 Binary files /dev/null and b/src/__init__.py differ diff --git a/src/agent.py b/src/agent.py new file mode 100644 index 0000000..fd7c771 --- /dev/null +++ b/src/agent.py @@ -0,0 +1,340 @@ +""" +Агент-решатель домашних заданий. +Запуск: langgraph dev +""" + +import os +import argparse + +os.environ.setdefault("PYTHONUTF8", "1") +os.environ.setdefault("PYTHONIOENCODING", "utf-8") + +import requests +from dotenv import load_dotenv +from langchain_core.tools import tool +from langchain.agents import create_agent + +load_dotenv(encoding="utf-8") + +from .llm import get_llm +from .mcp_clients import load_mcp_toolsets +from .gitea_rest import GiteaRestClient +from .utils import task_repo_name +from .subagents import analyzer, coder, documenter, validator + +# ------------------------------------------------------------------ # +# Системный промпт # +# ------------------------------------------------------------------ # + +SYSTEM_PROMPT = """Ты — агент решения домашних заданий. Отвечаешь только на русском. + +ГЛАВНОЕ ПРАВИЛО: При любом запросе "реши задание" или "переделай задание" ты ОБЯЗАН вызвать инструменты в правильном порядке. Просто ответить текстом — ЗАПРЕЩЕНО. + +═══ РЕШИ ЗАДАНИЕ X ═══ +1. courses_list() → запомни courseId +2. tasks_list(courseId=...) → найди taskId задания X +3. task_text(taskId=...) → получи условие +4. ВЫЗОВИ plan_and_upload(task_id=taskId, task_title=название, task_description=полный_текст) + ← ЭТО ОБЯЗАТЕЛЬНО. Без этого вызова ты ничего не сделал. +5. Напиши только: "✅ Готово! Ссылка: [ссылка из шага 4]" + +═══ ПЕРЕДЕЛАЙ ЗАДАНИЕ X ПО КОММЕНТАРИЮ ═══ +1. courses_list() → courseId +2. tasks_list(courseId=...) → taskId +3. task_text(taskId=...) → условие +4. ВЫЗОВИ handle_revision(task_id=taskId, task_title=название, task_description=условие, reviewer_comment="") + ← ЭТО ОБЯЗАТЕЛЬНО. reviewer_comment можно передать пустым — инструмент сам прочитает комментарий из журнала. +5. Напиши только: "✅ Исправлено! Ссылка: [ссылка из шага 4]" + +═══ ЗАПРЕЩЕНО ═══ +- Придумывать ссылки самому (ссылка приходит ТОЛЬКО из plan_and_upload или handle_revision) +- Пропускать шаг 4/5 (вызов локального инструмента) +- Вызывать task_update_answer или task_submit +- tasks_list без courseId + +═══ ИНСТРУМЕНТЫ ═══ +MCP журнал: courses_list, tasks_list(courseId), task_text(taskId), task_get(taskId) +Локальные: plan_and_upload(task_id, task_title, task_description), handle_revision(task_id, task_title, task_description, reviewer_comment), edit_file(task_id, task_title, filename, edit_instruction), submit_solution(task_id, solution_url)""" + +# ------------------------------------------------------------------ # +# Инструменты агента # +# ------------------------------------------------------------------ # + +_gitea = GiteaRestClient() + + +@tool +def plan_and_upload(task_id: str, task_title: str, task_description: str) -> str: + """ + Анализирует условие задания, пишет ВСЕ нужные файлы и загружает их на Gitea. + Возвращает ссылку на репозиторий — передай её в task_update_answer. + + Args: + task_id: ID задания из журнала + task_title: Название задания + task_description: Полный текст условия задания + """ + print(f"\n[PLANNER] 📋 Начинаю решение: «{task_title}»") + llm = get_llm() + + # 1. Субагент-аналитик: определяем список файлов + code_files = analyzer.analyze_task(llm, task_title, task_description) + + # 2. Субагент-кодер: пишем каждый файл отдельно + written_code: dict[str, str] = {} + for file_info in code_files: + written_code[file_info["filename"]] = coder.write_file( + llm, task_title, task_description, + file_info["filename"], file_info.get("purpose", ""), + ) + + # 3. Субагент-документатор: requirements.txt и README.md + requirements = documenter.write_requirements(llm, written_code) + readme = documenter.write_readme(llm, task_title, task_description, written_code) + + # 4. Загружаем всё в отдельный репозиторий задания + task_repo = task_repo_name(task_id, task_title) + print(f"[PLANNER] 📦 Репозиторий: {task_repo}") + + upload_list = list(written_code.items()) + [ + ("requirements.txt", requirements), + ("README.md", readme), + ] + main_fname = code_files[0]["filename"] + main_url = None + + for fname, content in upload_list: + if not content.strip(): + print(f"[GITEA] ⚠️ {fname} пустой, пропускаю") + continue + try: + url = _gitea.upload_file( + file_path=fname, + content=content, + commit_message=f"{task_title}: {fname}", + repo=task_repo, + ) + if fname == main_fname: + main_url = url + except Exception as e: + print(f"[GITEA] ❌ {fname}: {e}") + + # Всегда возвращаем корень репо, не ссылку на файл + username = os.getenv("GITEA_USERNAME", "") + repo_url = f"https://git.brojs.ru/{username}/{task_repo}" + print(f"[PLANNER] 🎉 Готово! Ссылка: {repo_url}") + return repo_url + + +@tool +def edit_file(task_id: str, task_title: str, filename: str, edit_instruction: str) -> str: + """ + Вносит правки в конкретный файл на Gitea по инструкции пользователя. + + Args: + task_id: ID задания из журнала + task_title: Название задания + filename: Имя файла для редактирования (например solution.py) + edit_instruction: Что именно нужно изменить + """ + task_repo = task_repo_name(task_id, task_title) + + current_content = _gitea.get_file(filename, repo=task_repo) + if current_content is None: + return f"❌ Файл не найден: {task_repo}/{filename}" + + llm = get_llm() + updated_content = coder.edit_file_content(llm, filename, current_content, edit_instruction) + + try: + url = _gitea.upload_file( + file_path=filename, + content=updated_content, + commit_message=f"Правки {filename}: {edit_instruction[:60]}", + repo=task_repo, + ) + return url + except Exception as e: + return f"❌ Ошибка загрузки {filename}: {e}" + + +@tool +def handle_revision( + task_id: str, + task_title: str, + task_description: str, + reviewer_comment: str = "", +) -> str: + """ + Исправляет решение по комментарию преподавателя. + Если reviewer_comment не передан — читает его из журнала автоматически. + """ + import requests as _req + if not reviewer_comment: + token = os.getenv("JOURNAL_MCP_PAT", "") + try: + r = _req.get( + f"https://platform.brojs.ru/jrnl-bh/api/tasks/{task_id}", + headers={"Authorization": f"Bearer {token}"}, + timeout=10, verify=False, + ) + if r.ok: + data = r.json() + for field in ("teacherComment","comment","feedback","reviewComment"): + reviewer_comment = data.get(field, "") or "" + if reviewer_comment: break + if not reviewer_comment: + for key in ("submission","review","lastSubmission"): + sub = data.get(key) or {} + reviewer_comment = sub.get("comment") or sub.get("teacherComment") or "" + if reviewer_comment: break + if reviewer_comment: + print(f"[REVISION] 📝 Комментарий: {reviewer_comment[:150]}") + else: + print(f"[REVISION] ⚠️ Комментарий не найден, читай из задания") + reviewer_comment = "Исправь по замечаниям преподавателя" + except Exception as e: + print(f"[REVISION] ⚠️ Ошибка: {e}") + reviewer_comment = "Исправь по замечаниям преподавателя" + llm = get_llm() + task_repo = task_repo_name(task_id, task_title) + _CODE_EXTS = ('.py', '.js', '.ts', '.sh', '.sql') + + # Читаем все кодовые файлы из репозитория + filenames = _gitea.list_files(repo=task_repo) + code_files: dict[str, str] = {} + for fname in filenames: + if any(fname.endswith(ext) for ext in _CODE_EXTS): + content = _gitea.get_file(fname, repo=task_repo) + if content: + code_files[fname] = content + + if not code_files: + return "❌ Не удалось прочитать файлы из репозитория" + + # Проверяем обоснованность замечания + result = validator.validate_remark(llm, task_description, code_files, reviewer_comment) + + if result["valid"]: + # Замечание обоснованно — исправляем нужные файлы или создаём новые + files_to_fix = result.get("files_to_fix") or list(code_files.keys()) + fix_instruction = result.get("fix_instruction") or reviewer_comment + + username = os.getenv("GITEA_USERNAME", "") + repo_url = f"https://git.brojs.ru/{username}/{task_repo}" + + for fname in files_to_fix: + if fname in code_files: + # Редактируем существующий файл + updated = coder.edit_file_content(llm, fname, code_files[fname], fix_instruction) + else: + # Файла нет — создаём новый по инструкции + print(f"[REVISION] 🆕 Создаю новый файл: {fname}") + updated = coder.write_file(llm, task_title, task_description, fname, fix_instruction) + try: + _gitea.upload_file( + file_path=fname, + content=updated, + commit_message=f"Доработка: {fix_instruction[:60]}", + repo=task_repo, + ) + except Exception as e: + print(f"[REVISION] ❌ Ошибка загрузки {fname}: {e}") + + explanation = result.get("explanation", "") + print(f"[REVISION] ✅ Замечание обоснованно, код исправлен. Ссылка: {repo_url}") + return repo_url + + else: + # Замечание необоснованно — добавляем возражение в README + explanation = result.get("explanation", "Код соответствует заданию") + objection = ( + f"\n\n---\n\n## Ответ на замечание проверяющего\n\n" + f"**Замечание:** {reviewer_comment}\n\n" + f"**Ответ:** {explanation}\n\n" + f"Код полностью соответствует условию задания.\n" + ) + current_readme = _gitea.get_file("README.md", repo=task_repo) or "" + try: + _gitea.upload_file( + file_path="README.md", + content=current_readme + objection, + commit_message="Ответ на замечание проверяющего", + repo=task_repo, + ) + except Exception as e: + print(f"[REVISION] ❌ Ошибка обновления README: {e}") + + username = os.getenv("GITEA_USERNAME", "") + repo_url = f"https://git.brojs.ru/{username}/{task_repo}" + print(f"[REVISION] ❌ Замечание необоснованно, добавлено возражение в README. Ссылка: {repo_url}") + return repo_url + + +@tool +def submit_solution(task_id: str, solution_url: str) -> str: + """ + Fallback: отправляет ссылку через REST если task_submit не работает. + + Args: + task_id: ID задания + solution_url: URL репозитория на Gitea + """ + token = os.getenv("JOURNAL_MCP_PAT") + if not token: + return "❌ JOURNAL_MCP_PAT не задан" + headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"} + r1 = requests.patch( + f"https://platform.brojs.ru/jrnl-bh/api/tasks/{task_id}/answer", + json={"answer": solution_url}, headers=headers, timeout=30, + ) + r2 = requests.post( + f"https://platform.brojs.ru/jrnl-bh/api/tasks/{task_id}/submit", + headers=headers, timeout=30, + ) + if r1.ok or r2.ok: + return f"✅ Отправлено: {solution_url}" + return f"❌ update={r1.status_code}, submit={r2.status_code}" + + +# ------------------------------------------------------------------ # +# Сборка графа # +# ------------------------------------------------------------------ # + +def _build_graph(): + llm = get_llm() + toolsets = load_mcp_toolsets() + all_tools = [ + *toolsets.journal_tools, + *toolsets.gitea_tools, + plan_and_upload, + edit_file, + handle_revision, + submit_solution, + ] + print(f"[AGENT] 🔧 Инструментов: {len(all_tools)}") + return create_agent(model=llm, tools=all_tools, system_prompt=SYSTEM_PROMPT) + + +agent_graph = _build_graph() + +# ------------------------------------------------------------------ # +# CLI # +# ------------------------------------------------------------------ # + +if __name__ == "__main__": + parser = argparse.ArgumentParser() + parser.add_argument("--list", action="store_true") + parser.add_argument("--task-id") + args = parser.parse_args() + + msg = "Покажи список всех курсов и заданий." if args.list else \ + f"Реши задание с id={args.task_id}." if args.task_id else \ + "Покажи список всех курсов и заданий." + + print(f"\n{'='*60}\n📝 {msg}\n{'='*60}\n") + result = agent_graph.invoke( + {"messages": [{"role": "user", "content": msg}]}, + config={"recursion_limit": 50}, + ) + print(f"\n{'='*60}\n🤖 {result['messages'][-1].content}\n{'='*60}\n") \ No newline at end of file diff --git a/src/gitea_rest.py b/src/gitea_rest.py new file mode 100644 index 0000000..0d3ea03 --- /dev/null +++ b/src/gitea_rest.py @@ -0,0 +1,112 @@ +""" +Gitea REST API клиент — fallback когда Gitea MCP недоступен. +Используется напрямую из агента если uvx/gitea-mcp не установлен. + +REST-клиент для Gitea — альтернатива когда uvx gitea-mcp не установлен. Умеет: +ensure_repo(repo) — создать репозиторий если не существует +upload_file(file_path, content, commit_message) — залить файл, при обновлении сам находит SHA +get_file(file_path) — прочитать файл из репозитория +list_files(repo, path) — список файлов в папке +""" + +import base64 +import os +from typing import Optional + +import requests +from dotenv import load_dotenv + +load_dotenv() + + +class GiteaRestClient: + def __init__(self): + self.base_url = os.getenv("GITEA_URL", "https://git.brojs.ru").rstrip("/") + self.token = os.getenv("GITEA_TOKEN") + self.username = os.getenv("GITEA_USERNAME") + self.repo = os.getenv("GITEA_REPO", "dz") + self.api = f"{self.base_url}/api/v1" + self.session = requests.Session() + self.session.headers.update({ + "Authorization": f"token {self.token}", + "Content-Type": "application/json", + }) + + def ensure_repo(self, repo: str | None = None) -> bool: + """Создаёт репозиторий если не существует. Возвращает True при успехе.""" + target = repo or self.repo + resp = self.session.get(f"{self.api}/repos/{self.username}/{target}") + if resp.status_code == 200: + return True + resp = self.session.post( + f"{self.api}/user/repos", + json={ + "name": target, + "description": "Решения домашних заданий", + "private": False, + "auto_init": True, + "default_branch": "main", + }, + ) + if resp.ok: + print(f"[GITEA] 🆕 Создан репозиторий: {target}") + return True + print(f"[GITEA] ❌ Ошибка создания репо: {resp.status_code} {resp.text[:200]}") + return False + + def upload_file(self, file_path: str, content: str, commit_message: str, repo: str | None = None) -> str: + """ + Загружает файл в репозиторий. + Возвращает URL файла на Gitea. + """ + target = repo or self.repo + self.ensure_repo(target) + + url = f"{self.api}/repos/{self.username}/{target}/contents/{file_path}" + content_b64 = base64.b64encode(content.encode("utf-8")).decode("utf-8") + + # Проверяем sha (для обновления) + existing = self.session.get(url, params={"ref": "main"}) + payload = { + "message": commit_message, + "content": content_b64, + "branch": "main", + } + if existing.status_code == 200: + payload["sha"] = existing.json().get("sha") + + resp = self.session.put(url, json=payload) + resp.raise_for_status() + + file_url = f"{self.base_url}/{self.username}/{target}/src/branch/main/{file_path}" + print(f"[GITEA] ✅ Загружен: {file_url}") + return file_url + + def get_file(self, file_path: str, repo: str | None = None) -> str | None: + """Читает содержимое файла из репозитория. Возвращает None если файл не найден.""" + target = repo or self.repo + url = f"{self.api}/repos/{self.username}/{target}/contents/{file_path}" + resp = self.session.get(url, params={"ref": "main"}) + if resp.status_code != 200: + return None + content_b64 = resp.json().get("content", "") + return base64.b64decode(content_b64).decode("utf-8") + + def list_files(self, repo: str | None = None, path: str = "") -> list[str]: + """Возвращает список имён файлов в корне репозитория.""" + target = repo or self.repo + url = f"{self.api}/repos/{self.username}/{target}/contents/{path}" + resp = self.session.get(url, params={"ref": "main"}) + if resp.status_code != 200: + return [] + items = resp.json() + if not isinstance(items, list): + return [] + return [item["name"] for item in items if item.get("type") == "file"] + + def upload_solution(self, assignment_id: str, title: str, code: str, language: str = "py") -> str: + """Удобный метод: залить решение и получить ссылку.""" + safe = "".join(c if c.isalnum() or c in "-_" else "_" for c in title)[:40] + file_path = f"solutions/{assignment_id}_{safe}/solution.{language}" + commit_msg = f"Решение задания: {title}" + return self.upload_file(file_path, code, commit_msg) diff --git a/src/llm.py b/src/llm.py new file mode 100644 index 0000000..edac5ae --- /dev/null +++ b/src/llm.py @@ -0,0 +1,38 @@ +""" +Фабрика LLM — переключение между провайдерами через .env. +LLM_PROVIDER=openai → gpt-oss-20b (пары) +LLM_PROVIDER=gigachat → GigaChat (дома, бесплатные токены Сбера) +""" + +import os +from dotenv import load_dotenv + +load_dotenv() + + +def get_llm(): + provider = os.getenv("LLM_PROVIDER", "openai").lower() + + if provider == "gigachat": + from langchain_community.chat_models import GigaChat + print(f"[LLM] GigaChat ({os.getenv('GIGACHAT_MODEL', 'GigaChat-MAX')})") + return GigaChat( + credentials=os.getenv("GIGACHAT_CREDENTIALS"), + scope=os.getenv("GIGACHAT_SCOPE", "GIGACHAT_API_B2B"), + model=os.getenv("GIGACHAT_MODEL", "GigaChat-MAX"), + verify_ssl_certs=False, + temperature=0.2, + timeout=240, + ) + else: + from langchain_openai import ChatOpenAI + from pydantic import SecretStr + model = os.getenv("OPENAI_MODEL", "openi/gpt-oss-20b") + print(f"[LLM] OpenAI-compatible ({model})") + return ChatOpenAI( + model=model, + base_url=os.getenv("OPENAI_BASE_URL"), + api_key=SecretStr(os.getenv("OPENAI_API_KEY", "fake")), + temperature=0.2, + timeout=360, + ) diff --git a/src/mcp_clients.py b/src/mcp_clients.py new file mode 100644 index 0000000..0d4c366 --- /dev/null +++ b/src/mcp_clients.py @@ -0,0 +1,133 @@ +""" +Загрузка MCP-серверов: +- journal-mcp: https://platform.brojs.ru/jrnl-bh/api/mcp + Инструменты: tasks_list, task_get, pending_submissions, submission_get, submission_teacher_set_status + +- gitea-mcp: uvx gitea-mcp (локальный, через токен) + Инструменты: создание/обновление файлов в репозитории + +Использует langchain_mcp_adapters (как у преподавателя). +""" + +import asyncio +import os +import sys +from concurrent.futures import ThreadPoolExecutor +from dataclasses import dataclass + +from dotenv import load_dotenv +from langchain_mcp_adapters.client import MultiServerMCPClient + +load_dotenv() + +JOURNAL_SERVER = "journal-mcp" +GITEA_SERVER = "gitea-mcp" + + +def _build_servers() -> dict: + """Собирает конфиг MCP-серверов из переменных окружения.""" + servers = {} + + # --- Journal MCP (platform.brojs.ru) --- + journal_pat = os.getenv("JOURNAL_MCP_PAT") + if journal_pat: + servers[JOURNAL_SERVER] = { + "transport": "http", + "url": "https://platform.brojs.ru/jrnl-bh/api/mcp", + "headers": { + "Authorization": f"Bearer {journal_pat}" + }, + } + else: + print("[MCP] ⚠️ JOURNAL_MCP_PAT не задан — журнал недоступен") + + # --- Gitea MCP (git.brojs.ru) --- + gitea_token = os.getenv("GITEA_TOKEN") + gitea_url = os.getenv("GITEA_URL", "https://git.brojs.ru") + if gitea_token: + servers[GITEA_SERVER] = { + "transport": "stdio", + "command": "uvx", + "args": [ + "--refresh", + "--extra-index-url", + "https://nikitatsym.github.io/gitea-mcp/simple", + "gitea-mcp", + ], + "env": { + "GITEA_URL": gitea_url, + "GITEA_TOKEN": gitea_token, + }, + } + else: + print("[MCP] ⚠️ GITEA_TOKEN не задан — Gitea MCP недоступен (будет REST fallback)") + + return servers + + +def _load_tools_sync(servers: dict) -> dict[str, list]: + """Загружает инструменты из всех MCP-серверов. Возвращает dict server_name -> tools.""" + + async def _fetch(): + client = MultiServerMCPClient(servers) + result = {} + for name in servers: + try: + tools = await client.get_tools(server_name=name) + # Префиксируем имена как у преподавателя: mcp__server__tool + for t in tools: + t.name = f"mcp__{name}__{t.name}" + result[name] = tools + print(f"[MCP] ✅ {name}: загружено {len(tools)} инструментов") + for t in tools: + print(f" - {t.name}") + except Exception as e: + print(f"[MCP] ❌ {name}: {type(e).__name__}: {e}") + result[name] = [] + return result + + # Если event loop уже запущен (Jupyter и т.п.) — запускаем в отдельном потоке + try: + asyncio.get_running_loop() + with ThreadPoolExecutor(max_workers=1) as pool: + return pool.submit(lambda: asyncio.run(_fetch())).result() + except RuntimeError: + return asyncio.run(_fetch()) + + +@dataclass +class McpToolsets: + """Набор инструментов из всех MCP-серверов.""" + journal_tools: list # tasks_list, task_get, pending_submissions, submission_get, ... + gitea_tools: list # create_file, update_file, get_repo, ... + all_tools: list # всё вместе + + +_toolsets: McpToolsets | None = None + + +def load_mcp_toolsets() -> McpToolsets: + """Загружает (один раз) все MCP-инструменты.""" + global _toolsets + if _toolsets is not None: + return _toolsets + + print("[MCP] Загружаем инструменты...") + servers = _build_servers() + + if not servers: + print("[MCP] ⚠️ Нет доступных MCP-серверов") + _toolsets = McpToolsets(journal_tools=[], gitea_tools=[], all_tools=[]) + return _toolsets + + tools_by_server = _load_tools_sync(servers) + + journal = tools_by_server.get(JOURNAL_SERVER, []) + gitea = tools_by_server.get(GITEA_SERVER, []) + + _toolsets = McpToolsets( + journal_tools=journal, + gitea_tools=gitea, + all_tools=journal + gitea, + ) + return _toolsets