Загрузить файлы в «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