From f7a27c4088a8ac4d65e2efeb897e30e9a2464080 Mon Sep 17 00:00:00 2001 From: lonpatovaadelina Date: Thu, 28 May 2026 15:48:08 +0000 Subject: [PATCH] =?UTF-8?q?=D0=97=D0=B0=D0=B3=D1=80=D1=83=D0=B7=D0=B8?= =?UTF-8?q?=D1=82=D1=8C=20=D1=84=D0=B0=D0=B9=D0=BB=D1=8B=20=D0=B2=20=C2=AB?= =?UTF-8?q?/=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- agent.py | 627 +++++++++++++++++++++++++++++++++++++++++++++++++ fix_env.py | 29 +++ gitea_rest.py | 87 +++++++ langgraph.json | 6 + llm.py | 38 +++ 5 files changed, 787 insertions(+) create mode 100644 agent.py create mode 100644 fix_env.py create mode 100644 gitea_rest.py create mode 100644 langgraph.json create mode 100644 llm.py diff --git a/agent.py b/agent.py new file mode 100644 index 0000000..9751a8e --- /dev/null +++ b/agent.py @@ -0,0 +1,627 @@ +""" +Агент-решатель домашних заданий. +Запуск: langgraph dev +""" + +import os, re, sys, json, argparse + +os.environ.setdefault("PYTHONUTF8", "1") +os.environ.setdefault("PYTHONIOENCODING", "utf-8") + +from dotenv import load_dotenv +from langchain_core.tools import tool +from langchain_core.messages import SystemMessage, HumanMessage +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 + +# ------------------------------------------------------------------ # +# Версии пакетов # +# ------------------------------------------------------------------ # + +KNOWN_VERSIONS = { + "langchain": "1.2.15", + "langchain-core": "1.3.0", + "langchain-openai": "1.1.9", + "langchain-community": "0.3.20", + "langchain-mcp-adapters": "0.2.1", + "langchain-qdrant": "0.2.0", + "langchain-ollama": "0.3.0", + "langgraph": "1.1.8", + "fastmcp": "3.1.0", + "fastapi": "0.115.0", + "uvicorn": "0.30.0", + "gigachat": "0.2.0", + "requests": "2.31.0", + "beautifulsoup4": "4.12.0", + "python-dotenv": "1.0.0", + "pydantic": "2.7.1", + "rich": "14.3.3", + "markdownify": "1.2.2", + "httpx": "0.27.0", + "openai": "1.30.0", + "anthropic": "0.25.0", + "qdrant-client": "1.9.1", + "ollama": "0.2.1", + "faiss-cpu": "1.8.0", + "chromadb": "0.5.3", + "tiktoken": "0.7.0", + "numpy": "1.26.4", + "pandas": "2.2.2", + "pytest": "8.2.0", + "aiohttp": "3.9.5", + "pyyaml": "6.0.1", + "sqlalchemy": "2.0.30", + "mcp": "1.6.0", +} + + +def _pin_versions(requirements_text: str) -> str: + lines = [] + for line in requirements_text.splitlines(): + stripped = line.strip() + if not stripped or stripped.startswith("#"): + lines.append(stripped) + continue + pkg_name = re.split(r"[=>) + → передавай аргумент courseId, найди задание по названию + → taskId — это ПОЛНЫЙ id из поля id/taskId ответа, ровно 24 символа hex (MongoDB ObjectId) + → ПРОВЕРЬ: id должен быть ровно 24 символа, например: 6a02e23da6fe2e4ac16acf65 + → если id короче 24 символов — сначала вызови task_get чтобы получить полный id +Шаг 3. mcp__journal-mcp__task_text(taskId=) + → получи ПОЛНЫЙ текст условия +Шаг 4. plan_and_upload(task_id=taskId, task_title=название, task_description=текст) + → возвращает ссылку на папку +Шаг 5. mcp__journal-mcp__task_update_answer(taskId=, answer=<ссылка из шага 4>) +Шаг 6. submit_solution(task_id=, solution_url=<ссылка>) → отправляет на проверку через REST +Шаг 7. Напиши: "Готово! Ссылка: <ссылка>" + +## Порядок при запросе "переделай задание X" + +Шаг 1. mcp__journal-mcp__courses_list() → courseId +Шаг 2. mcp__journal-mcp__tasks_list(courseId=) → taskId +Шаг 3. mcp__journal-mcp__task_text(taskId=) → текст условия +Шаг 4. mcp__journal-mcp__task_get(taskId=) → комментарий преподавателя +Шаг 5. revise_task(task_id=taskId, task_title=название, task_description=текст, teacher_comment=комментарий) + → возвращает ссылку (автоматически сохраняет ответ) +Шаг 6. submit_solution(task_id=, solution_url=<ссылка>) → отправляет на проверку через REST +Шаг 7. Напиши: "Исправлено! Ссылка: <ссылка>" + +## ЗАПРЕЩЕНО: писать код в чат, пропускать шаги, передавать taskId короче 24 символов + +## Инструменты — ТОЧНЫЕ сигнатуры (не меняй имена аргументов!) + +Журнал: +- mcp__journal-mcp__courses_list() + → список курсов, у каждого есть поле id (courseId) +- mcp__journal-mcp__tasks_list(courseId=) + → аргумент называется courseId, НЕ taskId, НЕ id + → список заданий, у каждого есть поле id (taskId) + → taskId ОБЯЗАН быть ровно 24 символа hex (MongoDB ObjectId) + → пример правильного id: 6a02e23da6fe2e4ac16acf65 +- mcp__journal-mcp__task_text(taskId=) +- mcp__journal-mcp__task_get(taskId=) +- mcp__journal-mcp__task_update_answer(taskId=, answer=<ссылка>) +- mcp__journal-mcp__task_submit(taskId=) +- mcp__journal-mcp__task_submission_status(taskId=) + +Локальные: +- plan_and_upload(task_id, task_title, task_description) +- revise_task(task_id, task_title, task_description, teacher_comment) +- submit_solution(task_id, solution_url)""" + +# ------------------------------------------------------------------ # +# Промпты # +# ------------------------------------------------------------------ # + +ANALYZER_PROMPT = """Ты — аналитик заданий. Прочитай условие и составь список файлов. + +Правила: +1. Если в условии явно названы файлы — включи все. +2. Если нет — один solution.py. +3. Всегда добавляй requirements.txt и README.md последними. +4. Внимательно читай раздел "Стек" — записывай точные библиотеки. + +Верни ТОЛЬКО JSON: +{ + "files": [ + {"filename": "agent.py", "purpose": "агент с инструментами"}, + {"filename": "client.py", "purpose": "интерактивный CLI"}, + {"filename": "requirements.txt", "purpose": "зависимости"}, + {"filename": "README.md", "purpose": "документация"} + ], + "main_file": "agent.py", + "tech_stack": "LangChain, create_agent, @tool, rich", + "forbidden": "НЕ использовать: LangGraph StateGraph, ToolExecutorNode" +}""" + +CODER_PROMPT = """Ты — опытный Python-разработчик. Пиши ОДИН файл строго по условию. + +═══ КРИТИЧЕСКИЕ ПРАВИЛА ═══ + +1. ИСПОЛЬЗУЙ ТОЛЬКО ТОТ СТЕК ЧТО В УСЛОВИИ: + - Если написано LangChain create_agent → from langchain.agents import create_agent + - Если написано Ollama → from langchain_ollama import ChatOllama + - Если написано Qdrant → from langchain_qdrant import QdrantVectorStore + - Если написано FastMCP → from fastmcp import FastMCP + - Если написано @tool → from langchain.tools import tool + - Если написано rich → from rich import print as rprint + - Если написано .stream() → используй agent.stream(), НЕ agent.invoke() + - Если написано MemorySaver → from langgraph.checkpoint.memory import MemorySaver + +2. НИКОГДА НЕ ИСПОЛЬЗУЙ (если не указано явно): + - LangGraph StateGraph / langgraph.graph + - ToolExecutorNode + - OpenAI / ChatOpenAI (если в условии написано Ollama или другой LLM) + +3. Реализуй ВСЁ из раздела "Что нужно сделать" и "Критерии зачёта" + +4. Код запускается без ошибок, без заглушек (pass, TODO) + +5. Комментарии на русском языке + +6. Верни ТОЛЬКО чистый Python-код без markdown""" + +REVISER_CODER_PROMPT = """Ты — опытный Python-разработчик. Исправь файл ТОЧНО по комментарию преподавателя. + +═══ КРИТИЧЕСКИЕ ПРАВИЛА ═══ + +1. Читай комментарий дословно — исправляй ТОЛЬКО то что он написал, не больше. +2. Если сказано "добавь langchain в requirements.txt" — просто добавь строку, не меняй остальное. +3. Если сказано "добавь логирование" — добавь только logging, остальное не трогай. +4. Если сказано "используй LangChain вместо LangGraph" — тогда переписывай архитектуру. +5. НЕ переписывай весь файл ради мелкого изменения — вноси минимально необходимые правки. +6. Сохраняй стек, структуру и логику оригинального кода если комментарий не требует менять. +7. Верни ТОЛЬКО чистый исправленный код без markdown и пояснений.""" + +REQUIREMENTS_PROMPT = """Напиши requirements.txt для этих Python-файлов. + +ПРАВИЛА: +- Только библиотеки которые реально импортируются (import ...) +- Формат: название==версия +- Одна строка — одна библиотека +- Никакого текста кроме содержимого файла + +Примеры правильных строк: +langchain==1.2.15 +langchain-ollama==0.3.0 +rich==14.3.3 +qdrant-client==1.9.1""" + +README_PROMPT = """Напиши README.md. Включи: описание, предварительные требования, установка, запуск, пример. Только markdown.""" + + +def _strip_md(text: str) -> str: + text = text.strip() + text = re.sub(r"^```[\w]*\n?", "", text) + text = re.sub(r"\n?```$", "", text) + return text.strip() + + +def _parse_json(raw: str) -> dict | None: + raw = _strip_md(raw) + try: + return json.loads(raw) + except json.JSONDecodeError: + match = re.search(r'\{[\s\S]*"files"[\s\S]*\}', raw) + if match: + try: + return json.loads(match.group()) + except Exception: + pass + return None + + +def _repo_name(task_title: str) -> str: + """Имя репозитория из названия задания: только латиница/цифры/дефис, max 50 символов.""" + import unicodedata + # Транслитерация кириллицы + translit = { + 'а':'a','б':'b','в':'v','г':'g','д':'d','е':'e','ё':'yo','ж':'zh','з':'z', + 'и':'i','й':'y','к':'k','л':'l','м':'m','н':'n','о':'o','п':'p','р':'r', + 'с':'s','т':'t','у':'u','ф':'f','х':'kh','ц':'ts','ч':'ch','ш':'sh', + 'щ':'sch','ъ':'','ы':'y','ь':'','э':'e','ю':'yu','я':'ya', + } + result = "" + for ch in task_title.lower(): + if ch in translit: + result += translit[ch] + elif ch.isascii() and (ch.isalnum() or ch in '-_ '): + result += ch + else: + result += '-' + # Заменяем пробелы и подчёркивания на дефис, убираем двойные дефисы + result = re.sub(r'[-_\s]+', '-', result).strip('-') + return result[:50] or "task" + + +def _folder_url(repo: str, folder: str = "") -> str: + base = os.getenv("GITEA_URL", "https://git.brojs.ru").rstrip("/") + user = os.getenv("GITEA_USERNAME", "") + # Всегда возвращаем корень репозитория — файлы лежат там + return f"{base}/{user}/{repo}" + + +def _get_existing_files(repo: str, folder: str = "") -> dict[str, str]: + """Читает существующие файлы из репозитория Gitea. При ошибке возвращает {}.""" + import base64 + try: + gitea = GiteaRestClient() + user = os.getenv("GITEA_USERNAME", "") + path = f"{folder}" if folder else "" + resp = gitea.session.get( + f"{gitea.api}/repos/{user}/{repo}/contents/{path}", + params={"ref": "main"}, + verify=False, + timeout=15, + ) + if not resp.ok: + print(f"[GITEA] Репо/папка не найдена ({resp.status_code})") + return {} + existing: dict[str, str] = {} + for item in resp.json(): + if item.get("type") != "file": + continue + try: + file_resp = gitea.session.get(item["url"], verify=False, timeout=15) + if file_resp.ok: + existing[item["name"]] = base64.b64decode( + file_resp.json().get("content", "") + ).decode("utf-8") + except Exception as e: + print(f"[GITEA] Не удалось прочитать {item.get('name')}: {e}") + print(f"[GITEA] Прочитано файлов: {list(existing.keys())}") + return existing + except Exception as e: + print(f"[GITEA] Ошибка чтения: {e} — пишу заново") + return {} + + +def _rest_update_answer(task_id: str, url: str) -> bool: + """Сохраняет ссылку через REST API — создаёт submission если нет.""" + import requests + token = os.getenv("JOURNAL_MCP_PAT") + if not token or not task_id: + return False + try: + # Пробуем оба варианта эндпоинта + for endpoint in [ + f"https://platform.brojs.ru/jrnl-bh/api/tasks/{task_id}/answer", + f"https://platform.brojs.ru/jrnl-bh/api/task/{task_id}/answer", + ]: + r = requests.patch( + endpoint, + json={"answer": url}, + headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"}, + timeout=15, + verify=False, + ) + print(f"[REST] task_update_answer {endpoint}: {r.status_code}") + if r.ok: + return True + return False + except Exception as e: + print(f"[REST] task_update_answer error: {e}") + return False + + +def _write_files(repo: str, files: list[tuple[str, str]], task_title: str) -> str: + """Создаёт репозиторий (если нет) и загружает файлы в корень.""" + gitea = GiteaRestClient() + # Явно устанавливаем имя репо + gitea.repo = repo + ok = gitea.ensure_repo_named(repo, description=f"Решение: {task_title}") + if not ok: + print(f"[GITEA] ❌ Не удалось создать репозиторий: {repo}") + + uploaded = [] + for fname, content in files: + if not content.strip(): + print(f"[GITEA] ⚠️ {fname} пустой, пропускаю") + continue + try: + url = gitea.upload_file( + file_path=fname, + content=content, + commit_message=f"Добавлен {fname}", + ) + uploaded.append(fname) + print(f"[GITEA] ✅ {fname} → {url}") + except Exception as e: + print(f"[GITEA] ❌ {fname} ОШИБКА: {type(e).__name__}: {e}") + + folder_url = _folder_url(repo) + print(f"[GITEA] 📦 Загружено {len(uploaded)}/{len(files)} файлов: {uploaded}") + print(f"[GITEA] 🔗 URL репозитория: {folder_url}") + return folder_url + + +def _extract_stack_and_forbidden(task_description: str) -> tuple[str, str]: + """Извлекает стек и что нельзя использовать.""" + stack_parts = [] + forbidden_parts = [] + + # Раздел Стек + m = re.search(r"(?:Стек|Stack)[:\s]+([\s\S]+?)(?:\n\n|\Z)", task_description, re.IGNORECASE) + if m: + stack_parts.append(m.group(1).strip()[:400]) + + # Явные упоминания технологий + positive = ["Ollama", "Qdrant", "FastMCP", "LangChain", "rich", "FastAPI", + "llama3", "nomic-embed-text", "langchain-qdrant", "langchain-ollama", + "create_agent", "MemorySaver", "HumanInTheLoopMiddleware", + "@tool", ".stream()", ".invoke()"] + found = [t for t in positive if t in task_description] + if found: + stack_parts.append("Использовать: " + ", ".join(found)) + + # Запреты — если есть LangChain но нет LangGraph → запрещаем LangGraph + if "LangChain" in task_description and "LangGraph" not in task_description: + forbidden_parts.append("НЕ использовать LangGraph StateGraph вместо LangChain create_agent") + if ".stream()" in task_description and ".invoke()" in task_description: + forbidden_parts.append("Заменить .invoke() на .stream() — не оставлять invoke") + + return "\n".join(stack_parts), "\n".join(forbidden_parts) + + +def _build_solution( + task_title: str, + task_description: str, + revision_comment: str = "", + existing_files: dict[str, str] | None = None, +) -> list[tuple[str, str]]: + llm = get_llm() + stack_hint, forbidden_hint = _extract_stack_and_forbidden(task_description) + + # ── Анализ ─────────────────────────────────────────────────────── + print("[PLANNER] 📋 Анализирую условие...") + r_plan = llm.invoke([ + SystemMessage(content=ANALYZER_PROMPT), + HumanMessage(content=f"Задание: {task_title}\n\nУсловие:\n{task_description}"), + ]) + plan = _parse_json(r_plan.content) + + if not plan or not plan.get("files"): + plan = { + "files": [ + {"filename": "solution.py", "purpose": "основное решение"}, + {"filename": "requirements.txt", "purpose": "зависимости"}, + {"filename": "README.md", "purpose": "документация"}, + ], + "main_file": "solution.py", + } + + all_files_info = plan.get("files", []) + tech_stack = plan.get("tech_stack") or stack_hint + forbidden = plan.get("forbidden") or forbidden_hint + code_files = [f for f in all_files_info if f["filename"] not in ("requirements.txt", "README.md")] + + if not code_files: + print("[PLANNER] ⚠️ Нет кодовых файлов — создаю solution.py") + code_files = [{"filename": "solution.py", "purpose": "полное решение задания"}] + + print(f"[PLANNER] 📁 Файлы: {[f['filename'] for f in code_files]}") + print(f"[PLANNER] 🔧 Стек: {tech_stack[:120]}") + if forbidden: + print(f"[PLANNER] 🚫 Запрещено: {forbidden[:120]}") + + # ── Пишем каждый файл ──────────────────────────────────────────── + written_code: dict[str, str] = {} + for file_info in code_files: + fname = file_info["filename"] + purpose = file_info.get("purpose", "") + print(f"[PLANNER] ✍️ Пишу {fname}...") + + if revision_comment and existing_files and fname in existing_files: + prompt = REVISER_CODER_PROMPT + user_msg = ( + f"Задание: {task_title}\n\n" + f"Условие:\n{task_description}\n\n" + f"Требуемый стек: {tech_stack}\n" + f"ЗАПРЕЩЕНО: {forbidden}\n\n" + f"Комментарий преподавателя:\n{revision_comment}\n\n" + f"Текущий код {fname}:\n{existing_files[fname]}" + ) + else: + revision_note = ( + f"\n\nКомментарий преподавателя (исправь именно это):\n{revision_comment}" + if revision_comment else "" + ) + prompt = CODER_PROMPT + user_msg = ( + f"Задание: {task_title}\n\n" + f"Условие (читай каждое слово!):\n{task_description}\n\n" + f"═══ ОБЯЗАТЕЛЬНЫЙ СТЕК ═══\n{tech_stack}\n\n" + + (f"═══ ЗАПРЕЩЕНО ═══\n{forbidden}\n\n" if forbidden else "") + + f"Пиши ТОЛЬКО файл: {fname}\n" + f"Что делает этот файл: {purpose}" + + revision_note + ) + + r_code = llm.invoke([SystemMessage(content=prompt), HumanMessage(content=user_msg)]) + written_code[fname] = _strip_md(r_code.content) + print(f"[PLANNER] ✅ {fname}: {len(written_code[fname])} символов") + + # ── requirements.txt ───────────────────────────────────────────── + print("[PLANNER] 📦 Пишу requirements.txt...") + code_preview = "\n\n# ---\n\n".join( + f"# {fn}\n{code[:2000]}" for fn, code in written_code.items() + ) + r_req = llm.invoke([ + SystemMessage(content=REQUIREMENTS_PROMPT), + HumanMessage(content=code_preview), + ]) + requirements = _pin_versions(_strip_md(r_req.content)) + + # ── README.md ───────────────────────────────────────────────────── + print("[PLANNER] 📄 Пишу README.md...") + r_readme = llm.invoke([ + SystemMessage(content=README_PROMPT), + HumanMessage(content=( + f"Проект: {task_title}\nСтек: {tech_stack}\n" + f"Файлы: {', '.join(written_code.keys())}\n\n" + f"Условие:\n{task_description[:500]}\n\n" + f"Код:\n{list(written_code.values())[0][:400] if written_code else ''}" + )), + ]) + readme = _strip_md(r_readme.content) + + return list(written_code.items()) + [("requirements.txt", requirements), ("README.md", readme)] + + +# ------------------------------------------------------------------ # +# Инструменты # +# ------------------------------------------------------------------ # + +@tool +def plan_and_upload(task_id: str, task_title: str, task_description: str) -> str: + """ + Читает условие задания, пишет все файлы строго по указанному стеку и заливает на Gitea. + Возвращает ссылку на ПАПКУ с решением. + + Args: + task_id: ID задания + task_title: Название задания + task_description: Полный текст условия — передавай ВЕСЬ текст без сокращений + """ + # Валидация taskId — должен быть MongoDB ObjectId (24 hex символа) + task_id = task_id.strip() + if len(task_id) != 24 or not all(c in '0123456789abcdef' for c in task_id.lower()): + return f"❌ Неверный taskId '{task_id}' (длина {len(task_id)}). Нужен MongoDB ObjectId — ровно 24 hex символа. Сначала вызови courses_list → tasks_list и скопируй точный id из поля id/_id ответа." + + print(f"\n[PLANNER] 🚀 Решаю: «{task_title}»") + repo = _repo_name(task_title) + print(f"[PLANNER] 📦 Репозиторий: {repo}") + files = _build_solution(task_title, task_description) + url = _write_files(repo, files, task_title) + print(f"[PLANNER] 🎉 {url}") + # Автоматически сохраняем ссылку через REST — создаёт submission + _rest_update_answer(task_id, url) + return url + + +@tool +def revise_task(task_id: str, task_title: str, task_description: str, teacher_comment: str) -> str: + """ + Исправляет решение по комментарию преподавателя. Читает старые файлы и переписывает их. + Возвращает ссылку на папку. + + Args: + task_id: ID задания + task_title: Название задания + task_description: Полный текст условия + teacher_comment: Комментарий преподавателя + """ + # Валидация taskId + task_id = task_id.strip() + if len(task_id) != 24 or not all(c in '0123456789abcdef' for c in task_id.lower()): + return f"❌ Неверный taskId '{task_id}' (длина {len(task_id)}). Нужен MongoDB ObjectId — ровно 24 hex символа. Вызови courses_list → tasks_list и скопируй точный id." + + print(f"\n[REVISER] 🔄 Переделываю: «{task_title}»") + print(f"[REVISER] 💬 {teacher_comment[:200]}") + repo = _repo_name(task_title) + print(f"[REVISER] 📦 Репозиторий: {repo}") + existing = _get_existing_files(repo) + files = _build_solution(task_title, task_description, revision_comment=teacher_comment, existing_files=existing) + url = _write_files(repo, files, task_title) + print(f"[REVISER] ✅ {url}") + # Автоматически сохраняем ссылку через REST — создаёт submission + _rest_update_answer(task_id, url) + return url + + +@tool +def submit_solution(task_id: str, solution_url: str) -> str: + """ + Fallback: сохраняет ссылку и отправляет задание на проверку через REST. + Используй если MCP-инструменты task_update_answer / task_submit не работают. + + Args: + task_id: ID задания (24 символа hex) + solution_url: URL репозитория с решением + """ + import requests + token = os.getenv("JOURNAL_MCP_PAT") + if not token: + return "❌ JOURNAL_MCP_PAT не задан" + headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"} + base = "https://platform.brojs.ru/jrnl-bh/api" + + # Шаг 1: сохраняем ответ (создаёт submission если нет) + r1 = requests.patch( + f"{base}/tasks/{task_id}/answer", + json={"answer": solution_url}, headers=headers, timeout=30, + ) + print(f"[REST] update_answer: {r1.status_code}") + + # Шаг 2: отправляем на проверку + r2 = requests.post( + f"{base}/tasks/{task_id}/submit", + headers=headers, timeout=30, + ) + print(f"[REST] submit: {r2.status_code}") + + if r2.ok: + return f"✅ Отправлено на проверку: {solution_url}" + if r1.ok: + return f"⚠️ Ссылка сохранена, но submit упал ({r2.status_code}): {solution_url}" + return f"❌ update={r1.status_code} {r1.text[:100]}, submit={r2.status_code} {r2.text[:100]}" + + +# ------------------------------------------------------------------ # +# Граф # +# ------------------------------------------------------------------ # + +def _build_graph(): + llm = get_llm() + toolsets = load_mcp_toolsets() + all_tools = [ + *toolsets.journal_tools, + *toolsets.gitea_tools, + plan_and_upload, + revise_task, + submit_solution, + ] + print(f"[AGENT] 🔧 Инструментов: {len(all_tools)}") + return create_agent(model=llm, tools=all_tools, system_prompt=SYSTEM_PROMPT) + + +agent_graph = _build_graph() + +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/fix_env.py b/fix_env.py new file mode 100644 index 0000000..9d38816 --- /dev/null +++ b/fix_env.py @@ -0,0 +1,29 @@ +""" +Пересохраняет .env в UTF-8 без BOM. +Запусти один раз: python fix_env.py +""" +import os + +env_path = os.path.join(os.path.dirname(__file__), ".env") + +# Читаем с автоопределением кодировки +for encoding in ("utf-8-sig", "cp1251", "cp1252", "latin-1"): + try: + with open(env_path, "r", encoding=encoding) as f: + content = f.read() + print(f"[fix_env] Прочитано в кодировке: {encoding}") + break + except (UnicodeDecodeError, FileNotFoundError) as e: + if isinstance(e, FileNotFoundError): + print(f"[fix_env] ❌ Файл .env не найден: {env_path}") + exit(1) + continue +else: + print("[fix_env] ❌ Не удалось определить кодировку .env") + exit(1) + +# Пишем обратно в UTF-8 без BOM +with open(env_path, "w", encoding="utf-8", newline="\n") as f: + f.write(content) + +print("[fix_env] ✅ .env пересохранён в UTF-8. Теперь запускай: langgraph dev") diff --git a/gitea_rest.py b/gitea_rest.py new file mode 100644 index 0000000..893d1b6 --- /dev/null +++ b/gitea_rest.py @@ -0,0 +1,87 @@ +""" +Gitea REST API клиент — fallback когда Gitea MCP недоступен. +Используется напрямую из агента если uvx/gitea-mcp не установлен. +""" + +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", "homework-solutions") + 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", + }) + self.session.verify = False # git.brojs.ru SSL бывает нестабилен + + def ensure_repo(self) -> bool: + return self.ensure_repo_named(self.repo) + + def ensure_repo_named(self, repo_name: str, description: str = "Решение домашнего задания") -> bool: + """Создаёт репозиторий с заданным именем если не существует.""" + resp = self.session.get(f"{self.api}/repos/{self.username}/{repo_name}") + if resp.status_code == 200: + print(f"[GITEA] 📦 Репо существует: {repo_name}") + return True + resp = self.session.post( + f"{self.api}/user/repos", + json={ + "name": repo_name, + "description": description, + "private": False, + "auto_init": True, + "default_branch": "main", + }, + ) + if resp.ok: + print(f"[GITEA] 🆕 Создан репозиторий: {repo_name}") + return True + print(f"[GITEA] ❌ Ошибка создания репо {repo_name}: {resp.status_code} {resp.text[:200]}") + return False + + def upload_file(self, file_path: str, content: str, commit_message: str) -> str: + """ + Загружает файл в репозиторий. + Возвращает URL файла на Gitea. + """ + self.ensure_repo() + + url = f"{self.api}/repos/{self.username}/{self.repo}/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}/{self.repo}/src/branch/main/{file_path}" + print(f"[GITEA] ✅ Загружен: {file_url}") + return file_url + + 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) \ No newline at end of file diff --git a/langgraph.json b/langgraph.json new file mode 100644 index 0000000..c499447 --- /dev/null +++ b/langgraph.json @@ -0,0 +1,6 @@ +{ + "dependencies": ["."], + "graphs": { + "homework_agent": "agent:agent_graph" + } +} diff --git a/llm.py b/llm.py new file mode 100644 index 0000000..38327d4 --- /dev/null +++ b/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, + )