""" Агент-решатель домашних заданий. Запуск: 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")