627 lines
29 KiB
Python
627 lines
29 KiB
Python
"""
|
||
Агент-решатель домашних заданий.
|
||
Запуск: 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"[=><!~\[\s]", stripped)[0].strip().lower().replace("_", "-")
|
||
if "==" in stripped:
|
||
lines.append(stripped)
|
||
elif pkg_name in KNOWN_VERSIONS:
|
||
lines.append(f"{re.split(r'[=><!~]', stripped)[0].strip()}=={KNOWN_VERSIONS[pkg_name]}")
|
||
else:
|
||
lines.append(stripped)
|
||
return "\n".join(lines)
|
||
|
||
# ------------------------------------------------------------------ #
|
||
# Системный промпт #
|
||
# ------------------------------------------------------------------ #
|
||
|
||
SYSTEM_PROMPT = """Ты — ассистент, который решает практические задания по программированию.
|
||
|
||
## Порядок при запросе "реши задание X"
|
||
|
||
Шаг 1. mcp__journal-mcp__courses_list()
|
||
→ получи список курсов, запомни id нужного курса (это courseId)
|
||
Шаг 2. mcp__journal-mcp__tasks_list(courseId=<id из шага 1>)
|
||
→ передавай аргумент courseId, найди задание по названию
|
||
→ taskId — это ПОЛНЫЙ id из поля id/taskId ответа, ровно 24 символа hex (MongoDB ObjectId)
|
||
→ ПРОВЕРЬ: id должен быть ровно 24 символа, например: 6a02e23da6fe2e4ac16acf65
|
||
→ если id короче 24 символов — сначала вызови task_get чтобы получить полный id
|
||
Шаг 3. mcp__journal-mcp__task_text(taskId=<id из шага 2>)
|
||
→ получи ПОЛНЫЙ текст условия
|
||
Шаг 4. plan_and_upload(task_id=taskId, task_title=название, task_description=текст)
|
||
→ возвращает ссылку на папку
|
||
Шаг 5. mcp__journal-mcp__task_update_answer(taskId=<id>, answer=<ссылка из шага 4>)
|
||
Шаг 6. submit_solution(task_id=<id>, solution_url=<ссылка>) → отправляет на проверку через REST
|
||
Шаг 7. Напиши: "Готово! Ссылка: <ссылка>"
|
||
|
||
## Порядок при запросе "переделай задание X"
|
||
|
||
Шаг 1. mcp__journal-mcp__courses_list() → courseId
|
||
Шаг 2. mcp__journal-mcp__tasks_list(courseId=<courseId>) → taskId
|
||
Шаг 3. mcp__journal-mcp__task_text(taskId=<taskId>) → текст условия
|
||
Шаг 4. mcp__journal-mcp__task_get(taskId=<taskId>) → комментарий преподавателя
|
||
Шаг 5. revise_task(task_id=taskId, task_title=название, task_description=текст, teacher_comment=комментарий)
|
||
→ возвращает ссылку (автоматически сохраняет ответ)
|
||
Шаг 6. submit_solution(task_id=<taskId>, solution_url=<ссылка>) → отправляет на проверку через REST
|
||
Шаг 7. Напиши: "Исправлено! Ссылка: <ссылка>"
|
||
|
||
## ЗАПРЕЩЕНО: писать код в чат, пропускать шаги, передавать taskId короче 24 символов
|
||
|
||
## Инструменты — ТОЧНЫЕ сигнатуры (не меняй имена аргументов!)
|
||
|
||
Журнал:
|
||
- mcp__journal-mcp__courses_list()
|
||
→ список курсов, у каждого есть поле id (courseId)
|
||
- mcp__journal-mcp__tasks_list(courseId=<id курса>)
|
||
→ аргумент называется courseId, НЕ taskId, НЕ id
|
||
→ список заданий, у каждого есть поле id (taskId)
|
||
→ taskId ОБЯЗАН быть ровно 24 символа hex (MongoDB ObjectId)
|
||
→ пример правильного id: 6a02e23da6fe2e4ac16acf65
|
||
- mcp__journal-mcp__task_text(taskId=<id задания>)
|
||
- mcp__journal-mcp__task_get(taskId=<id задания>)
|
||
- mcp__journal-mcp__task_update_answer(taskId=<id задания>, answer=<ссылка>)
|
||
- mcp__journal-mcp__task_submit(taskId=<id задания>)
|
||
- mcp__journal-mcp__task_submission_status(taskId=<id задания>)
|
||
|
||
Локальные:
|
||
- 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") |