Загрузить файлы в «src»

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