Загрузить файлы в «/»
This commit is contained in:
@@ -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"[=><!~\[\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")
|
||||||
+29
@@ -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")
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"dependencies": ["."],
|
||||||
|
"graphs": {
|
||||||
|
"homework_agent": "agent:agent_graph"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user