Загрузить файлы в «src»
This commit is contained in:
Binary file not shown.
+340
@@ -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")
|
||||
@@ -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
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user