feat: initial brojs-agent implementation
AI-агент для автоматического выполнения заданий курса KFU-26-1. Стек: deepagents + LangGraph + LangChain + OpenRouter (gpt-oss-20b:free). - Главный агент-оркестратор с субагентами - homework_direct_agent и rework_agent для сдачи/пересдачи - Gitea REST API инструменты (git.brojs.ru/glevelll) - BroJS Journal MCP клиент (platform.brojs.ru) - LangGraph pipeline: последовательное выполнение всех заданий - Middleware: SanitizeToolCalls + ValidateJournalWorkflow Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,19 @@
|
|||||||
|
# ============================================================
|
||||||
|
# ЗАГЛУШКИ — замени значения на реальные перед запуском
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
# OpenRouter API ключ (https://openrouter.ai/settings/keys)
|
||||||
|
# ⚠️ ЗАГЛУШКА #1
|
||||||
|
OPENAI_API_KEY=YOUR_OPENROUTER_KEY_HERE
|
||||||
|
|
||||||
|
# BroJS Journal токен (генерируется на platform.brojs.ru, протухает)
|
||||||
|
# ⚠️ ЗАГЛУШКА #2
|
||||||
|
JOURNAL_TOKEN=YOUR_JOURNAL_TOKEN_HERE
|
||||||
|
|
||||||
|
# Gitea токен (git.brojs.ru → Settings → Applications → Access Tokens)
|
||||||
|
# ⚠️ ЗАГЛУШКА #3
|
||||||
|
GITEA_TOKEN=YOUR_GITEA_TOKEN_HERE
|
||||||
|
|
||||||
|
# Tavily API ключ для web_search (https://tavily.com, опционально)
|
||||||
|
# ⚠️ ЗАГЛУШКА #4 (можно оставить пустым — web_search будет недоступен)
|
||||||
|
TAVILY_API_KEY=
|
||||||
+31
@@ -0,0 +1,31 @@
|
|||||||
|
# Секреты — НИКОГДА не в репо
|
||||||
|
.env
|
||||||
|
|
||||||
|
# Рабочая директория агента (клонированные репо, черновики)
|
||||||
|
src/agent/agent_workspace/*
|
||||||
|
!src/agent/agent_workspace/.gitkeep
|
||||||
|
|
||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*.pyo
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
env/
|
||||||
|
*.egg-info/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
.eggs/
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# LangGraph / LangSmith
|
||||||
|
.langgraph_api/
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
# brojs-agent
|
||||||
|
|
||||||
|
AI-агент для автоматического выполнения заданий курса KFU-26-1 на platform.brojs.ru.
|
||||||
|
|
||||||
|
## Что делает агент
|
||||||
|
|
||||||
|
1. Читает незакрытые задания из журнала через BroJS MCP
|
||||||
|
2. Пишет Python-решение для каждого задания
|
||||||
|
3. Создаёт репозиторий на `git.brojs.ru/glevelll/task-<id>`
|
||||||
|
4. Коммитит решение через Gitea API
|
||||||
|
5. Отправляет ссылку на репозиторий как ответ
|
||||||
|
6. Сдаёт задание на проверку
|
||||||
|
7. При пересдаче — клонирует, исправляет, пушит снова
|
||||||
|
|
||||||
|
## Стек
|
||||||
|
|
||||||
|
- Python 3.11+
|
||||||
|
- [deepagents](https://github.com/langchain-ai/deepagents) — мультиагентный фреймворк
|
||||||
|
- [LangGraph](https://github.com/langchain-ai/langgraph) — граф пайплайна
|
||||||
|
- [LangChain](https://python.langchain.com/) — инструменты и MCP-адаптеры
|
||||||
|
- OpenRouter → `gpt-oss-20b:free`
|
||||||
|
- Gitea REST API (git.brojs.ru)
|
||||||
|
- BroJS Journal MCP (platform.brojs.ru)
|
||||||
|
|
||||||
|
## Установка
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Клонировать репозиторий
|
||||||
|
git clone https://github.com/Glevelll/brojs-agent.git
|
||||||
|
cd brojs-agent
|
||||||
|
|
||||||
|
# Установить зависимости
|
||||||
|
pip install -r requirements.txt
|
||||||
|
# или через uv:
|
||||||
|
# uv sync
|
||||||
|
|
||||||
|
# Создать .env из шаблона
|
||||||
|
cp .env.example .env
|
||||||
|
# Отредактировать .env — заменить заглушки на реальные ключи
|
||||||
|
```
|
||||||
|
|
||||||
|
## Настройка `.env`
|
||||||
|
|
||||||
|
```env
|
||||||
|
OPENAI_API_KEY=sk-or-... # OpenRouter ключ
|
||||||
|
JOURNAL_TOKEN=jrnl_... # BroJS токен (генерируется на platform.brojs.ru)
|
||||||
|
GITEA_TOKEN=... # Токен на git.brojs.ru
|
||||||
|
TAVILY_API_KEY=... # Опционально, для web_search
|
||||||
|
```
|
||||||
|
|
||||||
|
### Где взять токены
|
||||||
|
|
||||||
|
| Токен | Где получить |
|
||||||
|
|-------|-------------|
|
||||||
|
| `OPENAI_API_KEY` | https://openrouter.ai/settings/keys |
|
||||||
|
| `JOURNAL_TOKEN` | platform.brojs.ru → профиль → API токены |
|
||||||
|
| `GITEA_TOKEN` | git.brojs.ru → Settings → Applications → Access Tokens |
|
||||||
|
| `TAVILY_API_KEY` | https://tavily.com (опционально) |
|
||||||
|
|
||||||
|
> ⚠️ **ВАЖНО**: Никогда не коммить `.env` в репозиторий! Он добавлен в `.gitignore`.
|
||||||
|
|
||||||
|
## Запуск
|
||||||
|
|
||||||
|
### Запуск пайплайна (выполнить все незакрытые задания)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -c "
|
||||||
|
import asyncio
|
||||||
|
from src.agent.graph.pipeline import pipeline
|
||||||
|
from langchain_core.messages import HumanMessage
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
result = await pipeline.ainvoke(
|
||||||
|
{'tasks': [], 'current_index': 0, 'results': [], 'errors': []},
|
||||||
|
{'configurable': {'thread_id': 'pipeline-run-1'}}
|
||||||
|
)
|
||||||
|
print(f'Выполнено: {len(result[\"results\"])} заданий')
|
||||||
|
print(f'Ошибки: {result[\"errors\"]}')
|
||||||
|
|
||||||
|
asyncio.run(main())
|
||||||
|
"
|
||||||
|
```
|
||||||
|
|
||||||
|
### LangGraph dev server (UI в браузере)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Установить langgraph-cli
|
||||||
|
pip install "langgraph-cli[inmem]"
|
||||||
|
|
||||||
|
# Запустить dev server
|
||||||
|
langgraph dev --allow-blocking --port 2024
|
||||||
|
# Studio UI: https://smith.langchain.com/studio/?baseUrl=http://127.0.0.1:2024
|
||||||
|
```
|
||||||
|
|
||||||
|
### Интерактивный чат с главным агентом
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -c "
|
||||||
|
import asyncio
|
||||||
|
from src.agent.agent import agent
|
||||||
|
from langchain_core.messages import HumanMessage
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
config = {'configurable': {'thread_id': 'chat-1'}}
|
||||||
|
while True:
|
||||||
|
user = input('Вы: ')
|
||||||
|
if user == 'exit': break
|
||||||
|
result = await agent.ainvoke(
|
||||||
|
{'messages': [HumanMessage(content=user)]}, config
|
||||||
|
)
|
||||||
|
print('Агент:', result['messages'][-1].content)
|
||||||
|
|
||||||
|
asyncio.run(main())
|
||||||
|
"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Структура проекта
|
||||||
|
|
||||||
|
```
|
||||||
|
brojs-agent/
|
||||||
|
├── .env.example # шаблон переменных окружения
|
||||||
|
├── .gitignore
|
||||||
|
├── requirements.txt
|
||||||
|
├── pyproject.toml
|
||||||
|
├── langgraph.json
|
||||||
|
├── agent.py # точка входа для langgraph dev
|
||||||
|
└── src/
|
||||||
|
└── agent/
|
||||||
|
├── agent.py # создание агентов (main, homework, rework)
|
||||||
|
├── constants.py # COURSE_ID, GITEA_OWNER, пути
|
||||||
|
├── llm.py # LLM через OpenRouter
|
||||||
|
├── gitea_tools.py # Gitea REST API инструменты
|
||||||
|
├── mcp_client.py # BroJS Journal MCP клиент
|
||||||
|
├── tools.py # git + web инструменты
|
||||||
|
├── prompts.py # системные промпты
|
||||||
|
├── subagents.py # спецификации субагентов
|
||||||
|
├── middlewares/
|
||||||
|
│ ├── sanitize_tool_calls.py # блокировка несуществующих инструментов
|
||||||
|
│ └── validate_journal_workflow.py # порядок task_update_answer → task_submit
|
||||||
|
├── graph/
|
||||||
|
│ └── pipeline.py # LangGraph пайплайн по всем заданиям
|
||||||
|
└── agent_workspace/ # рабочая директория агента (клонированные репо)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Заглушки в коде
|
||||||
|
|
||||||
|
| Файл | Переменная | Что заменить |
|
||||||
|
|------|------------|-------------|
|
||||||
|
| `src/agent/llm.py` | `OPENAI_API_KEY` | OpenRouter ключ в `.env` |
|
||||||
|
| `src/agent/mcp_client.py` | `JOURNAL_TOKEN` | BroJS токен в `.env` |
|
||||||
|
| `src/agent/gitea_tools.py` | `GITEA_TOKEN` | Gitea токен в `.env` |
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
"""Точки входа для langgraph.json и langgraph dev."""
|
||||||
|
from src.agent.agent import agent, homework_direct_agent, rework_agent
|
||||||
|
from src.agent.graph.pipeline import pipeline
|
||||||
|
|
||||||
|
__all__ = ["agent", "pipeline", "homework_direct_agent", "rework_agent"]
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"dependencies": ["."],
|
||||||
|
"graphs": {
|
||||||
|
"agent": "./agent.py:agent",
|
||||||
|
"pipeline": "./agent.py:pipeline"
|
||||||
|
},
|
||||||
|
"env": ".env"
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
[project]
|
||||||
|
name = "brojs-agent"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "AI-агент для автоматического выполнения заданий курса BroJS"
|
||||||
|
requires-python = ">=3.11"
|
||||||
|
dependencies = [
|
||||||
|
"deepagents>=0.6.3",
|
||||||
|
"langchain>=1.3.0",
|
||||||
|
"langchain-openai>=0.3.0",
|
||||||
|
"langchain-mcp-adapters>=0.1.0",
|
||||||
|
"langgraph>=1.0.8",
|
||||||
|
"pydantic-settings>=2.0.0",
|
||||||
|
"python-dotenv>=1.0.0",
|
||||||
|
"httpx>=0.27.0",
|
||||||
|
"markdownify>=0.13.0",
|
||||||
|
"tavily-python>=0.3.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = ["hatchling"]
|
||||||
|
build-backend = "hatchling.build"
|
||||||
|
|
||||||
|
[tool.hatch.build.targets.wheel]
|
||||||
|
packages = ["src"]
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
deepagents>=0.6.3
|
||||||
|
langchain>=1.3.0
|
||||||
|
langchain-openai>=0.3.0
|
||||||
|
langchain-mcp-adapters>=0.1.0
|
||||||
|
langgraph>=1.0.8
|
||||||
|
pydantic-settings>=2.0.0
|
||||||
|
python-dotenv>=1.0.0
|
||||||
|
httpx>=0.27.0
|
||||||
|
markdownify>=0.13.0
|
||||||
|
tavily-python>=0.3.0
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
from src.agent.agent import agent, homework_direct_agent, rework_agent
|
||||||
|
|
||||||
|
__all__ = ["agent", "homework_direct_agent", "rework_agent"]
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
"""Создание агентов: главный оркестратор, исполнитель ДЗ, агент пересдачи."""
|
||||||
|
from deepagents import create_deep_agent
|
||||||
|
from deepagents.backends import CompositeBackend, FilesystemBackend, LocalShellBackend
|
||||||
|
|
||||||
|
from src.agent.constants import (
|
||||||
|
AGENT_WORKSPACE_DIR,
|
||||||
|
AGENTS_MD_VFS_PATH,
|
||||||
|
BUNDLED_SKILLS_DIR,
|
||||||
|
SKILLS_VFS_MOUNT,
|
||||||
|
ensure_agents_md_file,
|
||||||
|
)
|
||||||
|
from src.agent.gitea_tools import GITEA_TOOLS
|
||||||
|
from src.agent.llm import llm
|
||||||
|
from src.agent.mcp_client import load_journal_toolsets
|
||||||
|
from src.agent.middlewares import SanitizeToolCallsMiddleware, ValidateJournalWorkflowMiddleware
|
||||||
|
from src.agent.prompts import (
|
||||||
|
homework_doing_instructions,
|
||||||
|
main_agent_instructions,
|
||||||
|
rework_instructions,
|
||||||
|
)
|
||||||
|
from src.agent.subagents import subagent_specs_without_tools
|
||||||
|
from src.agent.tools import GIT_TOOLS, WEB_TOOLS
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Инициализация
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
ensure_agents_md_file()
|
||||||
|
|
||||||
|
journal = load_journal_toolsets()
|
||||||
|
_journal_tools = list(journal.courses_lessons_tools) + list(journal.tasks_submissions_tools)
|
||||||
|
|
||||||
|
print(f"=== Загружено: journal={len(_journal_tools)}, gitea={len(GITEA_TOOLS)}, git={len(GIT_TOOLS)} ===")
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Бэкенды (виртуальная файловая система агента)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_workspace_backend = LocalShellBackend(
|
||||||
|
root_dir=str(AGENT_WORKSPACE_DIR),
|
||||||
|
virtual_mode=True,
|
||||||
|
inherit_env=True,
|
||||||
|
)
|
||||||
|
_skills_backend = FilesystemBackend(
|
||||||
|
root_dir=str(BUNDLED_SKILLS_DIR),
|
||||||
|
virtual_mode=True,
|
||||||
|
)
|
||||||
|
_composite_backend = CompositeBackend(
|
||||||
|
default=_workspace_backend,
|
||||||
|
routes={SKILLS_VFS_MOUNT: _skills_backend},
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Наборы инструментов
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_homework_tools = [*GIT_TOOLS, *GITEA_TOOLS, *WEB_TOOLS, *_journal_tools]
|
||||||
|
_web_tools = WEB_TOOLS
|
||||||
|
|
||||||
|
_subagent_tool_map = {
|
||||||
|
"web_search": _web_tools,
|
||||||
|
"homework_doing": _homework_tools,
|
||||||
|
"journal_bh_tasks_submissions": _journal_tools,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Имена всех инструментов для SanitizeToolCallsMiddleware
|
||||||
|
_BUILTIN = {
|
||||||
|
"write_todos", "ls", "read_file", "write_file", "edit_file",
|
||||||
|
"glob", "grep", "execute", "task",
|
||||||
|
}
|
||||||
|
_gitea_names = {t.name for t in GITEA_TOOLS}
|
||||||
|
_journal_names = {t.name for t in _journal_tools}
|
||||||
|
_git_names = {t.name for t in GIT_TOOLS}
|
||||||
|
_web_names = {t.name for t in WEB_TOOLS}
|
||||||
|
|
||||||
|
_main_tool_names = _BUILTIN | _gitea_names
|
||||||
|
|
||||||
|
_subagent_tool_names: dict[str, set[str]] = {
|
||||||
|
"web_search": _BUILTIN | _web_names,
|
||||||
|
"homework_doing": _BUILTIN | _gitea_names | _journal_names | _git_names | _web_names,
|
||||||
|
"journal_bh_tasks_submissions": _BUILTIN | _journal_names,
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Middleware
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _make_subagent_middleware(name: str) -> list:
|
||||||
|
mw = [SanitizeToolCallsMiddleware(known_tools=_subagent_tool_names[name])]
|
||||||
|
if name in ("homework_doing", "journal_bh_tasks_submissions"):
|
||||||
|
mw.append(ValidateJournalWorkflowMiddleware())
|
||||||
|
return mw
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Субагенты с инструментами и middleware
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
subagents = [
|
||||||
|
{
|
||||||
|
**spec,
|
||||||
|
"tools": _subagent_tool_map[spec["name"]],
|
||||||
|
"middleware": _make_subagent_middleware(spec["name"]),
|
||||||
|
}
|
||||||
|
for spec in subagent_specs_without_tools
|
||||||
|
]
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Главный агент
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
agent = create_deep_agent(
|
||||||
|
model=llm,
|
||||||
|
tools=list(GITEA_TOOLS),
|
||||||
|
system_prompt=main_agent_instructions,
|
||||||
|
backend=_composite_backend,
|
||||||
|
memory=[AGENTS_MD_VFS_PATH],
|
||||||
|
subagents=subagents,
|
||||||
|
middleware=[SanitizeToolCallsMiddleware(known_tools=_main_tool_names)],
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Агент прямого выполнения ДЗ (без субагентов, все инструменты сразу)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
homework_direct_agent = create_deep_agent(
|
||||||
|
model=llm,
|
||||||
|
tools=_homework_tools,
|
||||||
|
system_prompt=homework_doing_instructions,
|
||||||
|
backend=_composite_backend,
|
||||||
|
middleware=[
|
||||||
|
SanitizeToolCallsMiddleware(known_tools=_subagent_tool_names["homework_doing"]),
|
||||||
|
ValidateJournalWorkflowMiddleware(),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Агент пересдачи
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
rework_agent = create_deep_agent(
|
||||||
|
model=llm,
|
||||||
|
tools=_homework_tools,
|
||||||
|
system_prompt=rework_instructions,
|
||||||
|
backend=_composite_backend,
|
||||||
|
middleware=[
|
||||||
|
SanitizeToolCallsMiddleware(known_tools=_subagent_tool_names["homework_doing"]),
|
||||||
|
ValidateJournalWorkflowMiddleware(),
|
||||||
|
],
|
||||||
|
)
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
"""Константы проекта: пути, ID курса, Gitea-настройки."""
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
PACKAGE_DIR = Path(__file__).resolve().parent
|
||||||
|
|
||||||
|
AGENT_WORKSPACE_DIR = PACKAGE_DIR / "agent_workspace"
|
||||||
|
BUNDLED_SKILLS_DIR = PACKAGE_DIR / "skills"
|
||||||
|
|
||||||
|
AGENTS_MD_FILENAME = "AGENTS.md"
|
||||||
|
AGENTS_MD_VFS_PATH = "/AGENTS.md"
|
||||||
|
SKILLS_VFS_MOUNT = "/skills/"
|
||||||
|
|
||||||
|
# ID курса KFU-26-1 на platform.brojs.ru
|
||||||
|
COURSE_ID = "698b49da77cb6d4d2e43ce78"
|
||||||
|
|
||||||
|
# Gitea
|
||||||
|
GITEA_OWNER = "glevelll"
|
||||||
|
GITEA_BASE_URL = "https://git.brojs.ru"
|
||||||
|
|
||||||
|
AGENTS_MD_SEED = """\
|
||||||
|
# Память агента
|
||||||
|
|
||||||
|
Здесь хранятся заметки, которые стоит помнить между сообщениями
|
||||||
|
(предпочтения, договорённости, факты о задании).
|
||||||
|
Обновляй этот файл через `edit_file`, когда нужно что-то зафиксировать.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_agents_md_file() -> None:
|
||||||
|
"""Создаёт workspace и начальный AGENTS.md, если файла ещё нет."""
|
||||||
|
AGENT_WORKSPACE_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
host_path = AGENT_WORKSPACE_DIR / AGENTS_MD_FILENAME
|
||||||
|
if not host_path.exists():
|
||||||
|
host_path.write_text(AGENTS_MD_SEED, encoding="utf-8")
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
"""Инструменты для работы с Gitea (git.brojs.ru) через REST API."""
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
from langchain.tools import tool
|
||||||
|
|
||||||
|
from src.agent.constants import GITEA_BASE_URL, GITEA_OWNER
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
# ⚠️ ЗАГЛУШКА #3 — замени в .env: GITEA_TOKEN=ваш_токен_на_git.brojs.ru
|
||||||
|
_GITEA_TOKEN = os.getenv("GITEA_TOKEN", "YOUR_GITEA_TOKEN_HERE")
|
||||||
|
|
||||||
|
|
||||||
|
def _headers() -> dict:
|
||||||
|
return {
|
||||||
|
"Authorization": f"token {_GITEA_TOKEN}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Accept": "application/json",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _get(path: str, **params) -> dict | list:
|
||||||
|
resp = httpx.get(
|
||||||
|
f"{GITEA_BASE_URL}{path}",
|
||||||
|
headers=_headers(),
|
||||||
|
params=params,
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
def _post(path: str, data: dict) -> dict:
|
||||||
|
resp = httpx.post(
|
||||||
|
f"{GITEA_BASE_URL}{path}",
|
||||||
|
headers=_headers(),
|
||||||
|
json=data,
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
def _put(path: str, data: dict) -> dict:
|
||||||
|
resp = httpx.put(
|
||||||
|
f"{GITEA_BASE_URL}{path}",
|
||||||
|
headers=_headers(),
|
||||||
|
json=data,
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
@tool()
|
||||||
|
def gitea_list_repos() -> str:
|
||||||
|
"""Получить список репозиториев текущего пользователя на git.brojs.ru.
|
||||||
|
Также возвращает username — используй его как owner во всех операциях.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
result = _get("/api/v1/repos/search", limit=50, token=_GITEA_TOKEN)
|
||||||
|
repos = result.get("data", result) if isinstance(result, dict) else result
|
||||||
|
names = [r.get("name", "") for r in repos if isinstance(r, dict)]
|
||||||
|
return (
|
||||||
|
f"username (owner): {GITEA_OWNER}\n"
|
||||||
|
f"Репозитории ({len(names)}): {', '.join(names) or 'нет'}"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
return f"Ошибка получения репозиториев: {e}"
|
||||||
|
|
||||||
|
|
||||||
|
@tool()
|
||||||
|
def gitea_create_repo(name: str, private: bool = False, description: str = "") -> str:
|
||||||
|
"""Создать новый репозиторий на git.brojs.ru.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: имя репозитория (например task-abc123)
|
||||||
|
private: сделать приватным (по умолчанию False)
|
||||||
|
description: описание репозитория
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
result = _post(
|
||||||
|
"/api/v1/user/repos",
|
||||||
|
{
|
||||||
|
"name": name,
|
||||||
|
"private": private,
|
||||||
|
"description": description,
|
||||||
|
"auto_init": True,
|
||||||
|
"default_branch": "main",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
url = result.get("html_url", f"{GITEA_BASE_URL}/{GITEA_OWNER}/{name}")
|
||||||
|
return f"Репозиторий создан: {url}"
|
||||||
|
except httpx.HTTPStatusError as e:
|
||||||
|
if e.response.status_code == 409:
|
||||||
|
return f"Репозиторий уже существует: {GITEA_BASE_URL}/{GITEA_OWNER}/{name}"
|
||||||
|
return f"Ошибка создания репозитория: {e.response.text}"
|
||||||
|
except Exception as e:
|
||||||
|
return f"Ошибка: {e}"
|
||||||
|
|
||||||
|
|
||||||
|
@tool()
|
||||||
|
def gitea_write_file(
|
||||||
|
repo: str,
|
||||||
|
path: str,
|
||||||
|
content: str,
|
||||||
|
message: str,
|
||||||
|
owner: str = GITEA_OWNER,
|
||||||
|
) -> str:
|
||||||
|
"""Создать или обновить файл в репозитории на git.brojs.ru.
|
||||||
|
Автоматически коммитит изменения на сервере — git push НЕ нужен.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
repo: имя репозитория (например task-abc123)
|
||||||
|
path: путь к файлу (например main.py или src/agent.py)
|
||||||
|
content: содержимое файла в виде plain text (НЕ base64)
|
||||||
|
message: сообщение коммита
|
||||||
|
owner: владелец репозитория (по умолчанию glevelll)
|
||||||
|
"""
|
||||||
|
encoded = base64.b64encode(content.encode("utf-8")).decode("ascii")
|
||||||
|
endpoint = f"/api/v1/repos/{owner}/{repo}/contents/{path}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Проверяем существование файла для получения sha
|
||||||
|
existing = _get(endpoint)
|
||||||
|
sha = existing.get("sha", "")
|
||||||
|
result = _put(endpoint, {"message": message, "content": encoded, "sha": sha})
|
||||||
|
action = "обновлён"
|
||||||
|
except httpx.HTTPStatusError as e:
|
||||||
|
if e.response.status_code == 404:
|
||||||
|
# Файл не существует — создаём
|
||||||
|
try:
|
||||||
|
result = _post(endpoint, {"message": message, "content": encoded})
|
||||||
|
action = "создан"
|
||||||
|
except Exception as create_err:
|
||||||
|
return f"Ошибка создания файла {path}: {create_err}"
|
||||||
|
else:
|
||||||
|
return f"Ошибка записи файла {path}: {e.response.text}"
|
||||||
|
except Exception as e:
|
||||||
|
return f"Ошибка: {e}"
|
||||||
|
|
||||||
|
commit_sha = (result.get("commit") or {}).get("sha", "")[:8]
|
||||||
|
return f"Файл {path} {action} в {owner}/{repo} (commit: {commit_sha})\nURL: {GITEA_BASE_URL}/{owner}/{repo}/src/branch/main/{path}"
|
||||||
|
|
||||||
|
|
||||||
|
@tool()
|
||||||
|
def gitea_get_file(repo: str, path: str, owner: str = GITEA_OWNER) -> str:
|
||||||
|
"""Получить содержимое файла из репозитория на git.brojs.ru.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
repo: имя репозитория
|
||||||
|
path: путь к файлу
|
||||||
|
owner: владелец репозитория (по умолчанию glevelll)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON-строка с полями content (текст), sha, path
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
result = _get(f"/api/v1/repos/{owner}/{repo}/contents/{path}")
|
||||||
|
raw = result.get("content", "")
|
||||||
|
# Gitea возвращает base64 с переносами строк
|
||||||
|
content = base64.b64decode(raw.replace("\n", "")).decode("utf-8")
|
||||||
|
sha = result.get("sha", "")
|
||||||
|
return json.dumps({"content": content, "sha": sha, "path": path}, ensure_ascii=False)
|
||||||
|
except httpx.HTTPStatusError as e:
|
||||||
|
return f"Ошибка получения файла {path}: {e.response.text}"
|
||||||
|
except Exception as e:
|
||||||
|
return f"Ошибка: {e}"
|
||||||
|
|
||||||
|
|
||||||
|
# Список всех gitea-инструментов для удобного импорта
|
||||||
|
GITEA_TOOLS = [
|
||||||
|
gitea_list_repos,
|
||||||
|
gitea_create_repo,
|
||||||
|
gitea_write_file,
|
||||||
|
gitea_get_file,
|
||||||
|
]
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
from src.agent.graph.pipeline import pipeline
|
||||||
|
|
||||||
|
__all__ = ["pipeline"]
|
||||||
@@ -0,0 +1,289 @@
|
|||||||
|
"""LangGraph pipeline: последовательно выполняет все незакрытые задания курса."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
from typing import TypedDict
|
||||||
|
|
||||||
|
from langchain_core.messages import HumanMessage
|
||||||
|
from langgraph.graph import START, StateGraph
|
||||||
|
|
||||||
|
from src.agent.agent import homework_direct_agent, rework_agent
|
||||||
|
from src.agent.constants import COURSE_ID, GITEA_OWNER
|
||||||
|
from src.agent.gitea_tools import _get as gitea_get
|
||||||
|
from src.agent.mcp_client import JOURNAL_PREFIX, load_journal_toolsets
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Типы состояния
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TaskInfo(TypedDict):
|
||||||
|
id: str
|
||||||
|
title: str
|
||||||
|
status: str
|
||||||
|
|
||||||
|
|
||||||
|
class PipelineState(TypedDict):
|
||||||
|
tasks: list[TaskInfo]
|
||||||
|
current_index: int
|
||||||
|
results: list[dict]
|
||||||
|
errors: list[str]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Вспомогательные функции
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_journal = load_journal_toolsets()
|
||||||
|
|
||||||
|
|
||||||
|
def _get_journal_tool(suffix: str):
|
||||||
|
all_tools = _journal.courses_lessons_tools + _journal.tasks_submissions_tools
|
||||||
|
target = f"{JOURNAL_PREFIX}{suffix}"
|
||||||
|
for t in all_tools:
|
||||||
|
if t.name == target:
|
||||||
|
return t
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_text(raw) -> str:
|
||||||
|
if isinstance(raw, list):
|
||||||
|
for item in raw:
|
||||||
|
if isinstance(item, dict) and item.get("type") == "text":
|
||||||
|
return item.get("text", "")
|
||||||
|
return str(raw)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_tasks(raw) -> list[TaskInfo]:
|
||||||
|
text = _parse_text(raw) if not isinstance(raw, str) else raw
|
||||||
|
try:
|
||||||
|
data = json.loads(text)
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
return []
|
||||||
|
items = data.get("tasks", data) if isinstance(data, dict) else data
|
||||||
|
if not isinstance(items, list):
|
||||||
|
return []
|
||||||
|
result = []
|
||||||
|
for item in items:
|
||||||
|
t = item.get("task", item) if isinstance(item, dict) else {}
|
||||||
|
tid = t.get("id", "")
|
||||||
|
if tid:
|
||||||
|
result.append(TaskInfo(
|
||||||
|
id=tid,
|
||||||
|
title=t.get("title", t.get("name", "")),
|
||||||
|
status=item.get("status", "") if isinstance(item, dict) else "",
|
||||||
|
))
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
_CODING_KW = [
|
||||||
|
"code", "напиши", "реализуй", "python", "langchain", "langgraph",
|
||||||
|
"агент", "agent", "граф", "graph", "файл", "функц", "программ",
|
||||||
|
"скрипт", "алгоритм", "библиотек", "api", "сервер", "модуль", "класс",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _is_coding(task: TaskInfo) -> bool:
|
||||||
|
title = (task.get("title") or "").lower()
|
||||||
|
return any(kw in title for kw in _CODING_KW)
|
||||||
|
|
||||||
|
|
||||||
|
async def _task_text(task_id: str) -> str:
|
||||||
|
tool = _get_journal_tool("task_text")
|
||||||
|
if not tool:
|
||||||
|
return ""
|
||||||
|
try:
|
||||||
|
return _parse_text(await tool.ainvoke({"taskId": task_id}))
|
||||||
|
except Exception:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
async def _task_json(task_id: str) -> dict:
|
||||||
|
tool = _get_journal_tool("task_get")
|
||||||
|
if not tool:
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
raw = _parse_text(await tool.ainvoke({"taskId": task_id}))
|
||||||
|
return json.loads(raw)
|
||||||
|
except Exception:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
async def _existing_repo_url(task_id: str) -> str | None:
|
||||||
|
data = await _task_json(task_id)
|
||||||
|
url = (data.get("answer") or {}).get("content", "")
|
||||||
|
if url and url.startswith(f"https://git.brojs.ru/{GITEA_OWNER}/"):
|
||||||
|
return url
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def _verify_repo(repo_name: str) -> dict:
|
||||||
|
"""Проверяет наличие ключевых файлов в репозитории через Gitea API."""
|
||||||
|
verification: dict = {"files_found": [], "files_missing": [], "issues": []}
|
||||||
|
for fname in ["main.py", "requirements.txt", "src/main.py", "app.py"]:
|
||||||
|
try:
|
||||||
|
result = gitea_get(f"/api/v1/repos/{GITEA_OWNER}/{repo_name}/contents/{fname}")
|
||||||
|
raw = result.get("content", "")
|
||||||
|
content = base64.b64decode(raw.replace("\n", "")).decode("utf-8")
|
||||||
|
verification["files_found"].append(fname)
|
||||||
|
if len(content.strip()) < 50:
|
||||||
|
verification["issues"].append(f"{fname}: слишком короткий ({len(content)} символов)")
|
||||||
|
if fname == "requirements.txt" and "langchain" not in content:
|
||||||
|
verification["issues"].append("requirements.txt: нет зависимости langchain")
|
||||||
|
except Exception:
|
||||||
|
if fname in ("main.py", "requirements.txt"):
|
||||||
|
verification["files_missing"].append(fname)
|
||||||
|
return verification
|
||||||
|
|
||||||
|
|
||||||
|
def _needs_retry(v: dict) -> bool:
|
||||||
|
has_code = any(f in v["files_found"] for f in ["main.py", "src/main.py", "app.py"])
|
||||||
|
has_reqs = "requirements.txt" in v["files_found"]
|
||||||
|
return not has_code or not has_reqs or bool(v["issues"])
|
||||||
|
|
||||||
|
|
||||||
|
def _fix_prompt(task: TaskInfo, repo_name: str, v: dict) -> str:
|
||||||
|
lines = [f"Решение задания {task['id']} нуждается в доработке.", ""]
|
||||||
|
if v["files_missing"]:
|
||||||
|
lines.append(f"Отсутствуют файлы: {', '.join(v['files_missing'])}")
|
||||||
|
for issue in v["issues"]:
|
||||||
|
lines.append(f"Проблема: {issue}")
|
||||||
|
lines += [
|
||||||
|
"",
|
||||||
|
f"Репозиторий: https://git.brojs.ru/{GITEA_OWNER}/{repo_name}",
|
||||||
|
"",
|
||||||
|
"Требуется:",
|
||||||
|
"- Напиши ПОЛНЫЙ код в main.py (не только requirements.txt)",
|
||||||
|
"- Добавь requirements.txt с зависимостями (langchain>1.0.0)",
|
||||||
|
"- Используй gitea_write_file для исправления файлов",
|
||||||
|
"- Вызови task_update_answer и task_submit",
|
||||||
|
]
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Узлы графа
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
MAX_RETRIES = 2
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_tasks(state: PipelineState) -> dict:
|
||||||
|
"""Загружает все незакрытые задания курса."""
|
||||||
|
tool = _get_journal_tool("tasks_list")
|
||||||
|
if not tool:
|
||||||
|
return {"tasks": [], "current_index": 0, "results": [], "errors": ["tasks_list не найден"]}
|
||||||
|
|
||||||
|
raw = await tool.ainvoke({"courseId": COURSE_ID})
|
||||||
|
all_tasks = _parse_tasks(raw)
|
||||||
|
|
||||||
|
pending = [t for t in all_tasks if t["status"] in ("todo", "in_progress", "", None)]
|
||||||
|
coding = [t for t in pending if _is_coding(t)]
|
||||||
|
skipped = [t for t in pending if not _is_coding(t)]
|
||||||
|
|
||||||
|
if skipped:
|
||||||
|
print(f"[pipeline] Пропущены не-кодинговые задания: {[t['title'] for t in skipped]}")
|
||||||
|
|
||||||
|
print(f"[pipeline] Найдено {len(coding)} заданий для выполнения")
|
||||||
|
return {"tasks": coding, "current_index": 0, "results": [], "errors": []}
|
||||||
|
|
||||||
|
|
||||||
|
async def process_one_task(state: PipelineState) -> dict:
|
||||||
|
"""Выполняет одно задание."""
|
||||||
|
if state["current_index"] >= len(state["tasks"]):
|
||||||
|
return state
|
||||||
|
|
||||||
|
task = state["tasks"][state["current_index"]]
|
||||||
|
task_id = task["id"]
|
||||||
|
results = list(state.get("results", []))
|
||||||
|
errors = list(state.get("errors", []))
|
||||||
|
|
||||||
|
repo_url = await _existing_repo_url(task_id)
|
||||||
|
is_rework = repo_url is not None
|
||||||
|
|
||||||
|
if is_rework:
|
||||||
|
data = await _task_json(task_id)
|
||||||
|
comments = data.get("comments") or data.get("feedback", "")
|
||||||
|
prompt = (
|
||||||
|
f"Пересдача задания.\n\n"
|
||||||
|
f"ID: {task_id}\n"
|
||||||
|
f"Название: {task.get('title', '')}\n"
|
||||||
|
f"Репозиторий: {repo_url}\n"
|
||||||
|
f"Комментарии преподавателя: {comments}\n\n"
|
||||||
|
"Внеси исправления и отправь снова."
|
||||||
|
)
|
||||||
|
agent_to_use = rework_agent
|
||||||
|
else:
|
||||||
|
task_text = await _task_text(task_id)
|
||||||
|
prompt = (
|
||||||
|
f"Выполни задание.\n\n"
|
||||||
|
f"ID: {task_id}\n"
|
||||||
|
f"Название: {task.get('title', '')}\n\n"
|
||||||
|
f"Текст задания:\n{task_text}\n\n"
|
||||||
|
"Первая сдача. Напиши код с нуля."
|
||||||
|
)
|
||||||
|
agent_to_use = homework_direct_agent
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await agent_to_use.ainvoke(
|
||||||
|
{"messages": [HumanMessage(content=prompt)]},
|
||||||
|
{"configurable": {"thread_id": f"pipeline-task-{task_id}"}},
|
||||||
|
)
|
||||||
|
last = (result.get("messages") or [{}])[-1]
|
||||||
|
output = getattr(last, "content", str(last))
|
||||||
|
mode = "rework" if is_rework else "first_submission"
|
||||||
|
|
||||||
|
# Верификация репозитория (только для новых сдач)
|
||||||
|
repo_name = f"task-{task_id}"
|
||||||
|
verification = {}
|
||||||
|
retries = 0
|
||||||
|
|
||||||
|
if not is_rework:
|
||||||
|
verification = await _verify_repo(repo_name)
|
||||||
|
while _needs_retry(verification) and retries < MAX_RETRIES:
|
||||||
|
retries += 1
|
||||||
|
fix_msg = _fix_prompt(task, repo_name, verification)
|
||||||
|
result = await agent_to_use.ainvoke(
|
||||||
|
{"messages": [HumanMessage(content=fix_msg)]},
|
||||||
|
{"configurable": {"thread_id": f"pipeline-task-{task_id}-retry-{retries}"}},
|
||||||
|
)
|
||||||
|
verification = await _verify_repo(repo_name)
|
||||||
|
|
||||||
|
results.append({
|
||||||
|
"task_id": task_id,
|
||||||
|
"status": "done",
|
||||||
|
"mode": mode,
|
||||||
|
"output": output[:500],
|
||||||
|
"verification": verification,
|
||||||
|
"retries": retries,
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
errors.append(f"Задание {task_id} ({'rework' if is_rework else 'new'}): {e}")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"results": results,
|
||||||
|
"current_index": state["current_index"] + 1,
|
||||||
|
"errors": errors,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def route(state: PipelineState) -> str:
|
||||||
|
if state["current_index"] < len(state["tasks"]):
|
||||||
|
return "process_one_task"
|
||||||
|
return "__end__"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Сборка графа
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_builder = StateGraph(PipelineState)
|
||||||
|
_builder.add_node("fetch_tasks", fetch_tasks)
|
||||||
|
_builder.add_node("process_one_task", process_one_task)
|
||||||
|
|
||||||
|
_builder.add_edge(START, "fetch_tasks")
|
||||||
|
_builder.add_conditional_edges("fetch_tasks", route, {"process_one_task": "process_one_task", "__end__": "__end__"})
|
||||||
|
_builder.add_conditional_edges("process_one_task", route, {"process_one_task": "process_one_task", "__end__": "__end__"})
|
||||||
|
|
||||||
|
pipeline = _builder.compile()
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
"""Инициализация LLM через OpenRouter."""
|
||||||
|
import os
|
||||||
|
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
from langchain_openai import ChatOpenAI
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
# ⚠️ ЗАГЛУШКА #1 — замени в .env: OPENAI_API_KEY=sk-or-xxxxxxxx
|
||||||
|
# Файл .env.example показывает все нужные переменные.
|
||||||
|
llm = ChatOpenAI(
|
||||||
|
model="openai/gpt-oss-20b:free",
|
||||||
|
base_url="https://openrouter.ai/api/v1",
|
||||||
|
api_key=os.getenv("OPENAI_API_KEY", "YOUR_OPENROUTER_KEY_HERE"),
|
||||||
|
temperature=0.0,
|
||||||
|
)
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
"""Загрузка инструментов BroJS Journal через MCP (HTTP transport)."""
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
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_NAME = "journal-bh-professor"
|
||||||
|
JOURNAL_PREFIX = f"mcp__{JOURNAL_SERVER_NAME}__"
|
||||||
|
|
||||||
|
# ⚠️ ЗАГЛУШКА #2 — замени в .env: JOURNAL_TOKEN=jrnl_xxxxxxxx
|
||||||
|
_JOURNAL_TOKEN = os.getenv("JOURNAL_TOKEN", "YOUR_JOURNAL_TOKEN_HERE")
|
||||||
|
|
||||||
|
JOURNAL_MCP_URL = "https://platform.brojs.ru/jrnl-bh/api/mcp"
|
||||||
|
|
||||||
|
# Инструменты для работы с курсами и уроками
|
||||||
|
JOURNAL_COURSES_LESSONS = frozenset({
|
||||||
|
"courses_list",
|
||||||
|
"lessons_list",
|
||||||
|
})
|
||||||
|
|
||||||
|
# Инструменты для работы с заданиями и сдачами
|
||||||
|
JOURNAL_TASKS_SUBMISSIONS = frozenset({
|
||||||
|
"tasks_list",
|
||||||
|
"task_text",
|
||||||
|
"task_get",
|
||||||
|
"task_update_answer",
|
||||||
|
"task_submit",
|
||||||
|
"task_comment",
|
||||||
|
"task_submission_status",
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class JournalToolsets:
|
||||||
|
courses_lessons_tools: list
|
||||||
|
tasks_submissions_tools: list
|
||||||
|
|
||||||
|
|
||||||
|
def _build_mcp_config() -> dict:
|
||||||
|
return {
|
||||||
|
JOURNAL_SERVER_NAME: {
|
||||||
|
"transport": "http",
|
||||||
|
"url": JOURNAL_MCP_URL,
|
||||||
|
"headers": {
|
||||||
|
"Authorization": f"Bearer {_JOURNAL_TOKEN}",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def _fetch_tools() -> dict[str, list]:
|
||||||
|
config = _build_mcp_config()
|
||||||
|
client = MultiServerMCPClient(config)
|
||||||
|
out: dict[str, list] = {}
|
||||||
|
for name in config:
|
||||||
|
try:
|
||||||
|
out[name] = await client.get_tools(server_name=name)
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"MCP '{name}': не удалось загрузить инструменты — {type(exc).__name__}: {exc}")
|
||||||
|
out[name] = []
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _load_tools_sync() -> dict[str, list]:
|
||||||
|
"""Загружает MCP-инструменты синхронно, корректно обрабатывая уже запущенный event loop."""
|
||||||
|
try:
|
||||||
|
asyncio.get_running_loop()
|
||||||
|
# Event loop уже запущен (ноутбук / LangGraph dev) — запускаем в отдельном потоке
|
||||||
|
with ThreadPoolExecutor(max_workers=1) as pool:
|
||||||
|
return pool.submit(lambda: asyncio.run(_fetch_tools())).result()
|
||||||
|
except RuntimeError:
|
||||||
|
# Event loop не запущен — просто asyncio.run
|
||||||
|
return asyncio.run(_fetch_tools())
|
||||||
|
|
||||||
|
|
||||||
|
def _rename_tools(tools_by_server: dict[str, list]) -> None:
|
||||||
|
"""Добавляет префикс mcp__<server>__ к именам инструментов."""
|
||||||
|
for server_name, tools in tools_by_server.items():
|
||||||
|
for tool in tools:
|
||||||
|
tool.name = f"mcp__{server_name}__{tool.name}"
|
||||||
|
|
||||||
|
|
||||||
|
def _subset(all_tools: list, suffixes: frozenset[str]) -> list:
|
||||||
|
return [
|
||||||
|
t for t in all_tools
|
||||||
|
if t.name.startswith(JOURNAL_PREFIX)
|
||||||
|
and t.name.removeprefix(JOURNAL_PREFIX) in suffixes
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def load_journal_toolsets() -> JournalToolsets:
|
||||||
|
"""Загружает и возвращает разбитые на группы инструменты журнала."""
|
||||||
|
print("Загружаем MCP-инструменты журнала...")
|
||||||
|
tools_by_server = _load_tools_sync()
|
||||||
|
_rename_tools(tools_by_server)
|
||||||
|
|
||||||
|
journal_tools = tools_by_server.get(JOURNAL_SERVER_NAME, [])
|
||||||
|
|
||||||
|
print(f"=== MCP {JOURNAL_SERVER_NAME}: загружено {len(journal_tools)} инструментов ===")
|
||||||
|
for t in journal_tools:
|
||||||
|
print(f" - {t.name}")
|
||||||
|
|
||||||
|
return JournalToolsets(
|
||||||
|
courses_lessons_tools=_subset(journal_tools, JOURNAL_COURSES_LESSONS),
|
||||||
|
tasks_submissions_tools=_subset(journal_tools, JOURNAL_TASKS_SUBMISSIONS),
|
||||||
|
)
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
from src.agent.middlewares.sanitize_tool_calls import SanitizeToolCallsMiddleware
|
||||||
|
from src.agent.middlewares.validate_journal_workflow import ValidateJournalWorkflowMiddleware
|
||||||
|
|
||||||
|
__all__ = ["SanitizeToolCallsMiddleware", "ValidateJournalWorkflowMiddleware"]
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
"""Middleware: блокирует вызовы несуществующих инструментов."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from langchain.agents.middleware import AgentMiddleware, AgentState
|
||||||
|
from langchain_core.messages import ToolMessage
|
||||||
|
|
||||||
|
|
||||||
|
class SanitizeToolCallsMiddleware(AgentMiddleware[AgentState[Any], Any]):
|
||||||
|
"""Перехватывает вызовы инструментов с неизвестными именами и возвращает
|
||||||
|
понятное сообщение об ошибке вместо падения рантайма."""
|
||||||
|
|
||||||
|
known_tools: set[str]
|
||||||
|
|
||||||
|
def _reject(self, request) -> ToolMessage:
|
||||||
|
name = request.tool_call.get("name", "")
|
||||||
|
available = sorted(self.known_tools)
|
||||||
|
return ToolMessage(
|
||||||
|
content=(
|
||||||
|
f"Инструмент '{name}' не существует. "
|
||||||
|
f"Доступные инструменты: {available}. "
|
||||||
|
"Исправь имя инструмента и попробуй снова."
|
||||||
|
),
|
||||||
|
tool_call_id=request.tool_call["id"],
|
||||||
|
name=name,
|
||||||
|
)
|
||||||
|
|
||||||
|
def wrap_tool_call(self, request, handler):
|
||||||
|
name = request.tool_call.get("name", "")
|
||||||
|
if name not in self.known_tools:
|
||||||
|
return self._reject(request)
|
||||||
|
return handler(request)
|
||||||
|
|
||||||
|
async def awrap_tool_call(self, request, handler):
|
||||||
|
name = request.tool_call.get("name", "")
|
||||||
|
if name not in self.known_tools:
|
||||||
|
return self._reject(request)
|
||||||
|
return await handler(request)
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
"""Middleware: валидация порядка вызовов journal-инструментов.
|
||||||
|
|
||||||
|
Проблема: модель иногда вызывает task_submit без предварительного
|
||||||
|
task_update_answer, что приводит к ошибке 'answer is empty'.
|
||||||
|
Этот middleware перехватывает task_submit и проверяет историю сообщений.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from langchain.agents.middleware import AgentMiddleware, AgentState
|
||||||
|
from langchain_core.messages import ToolMessage
|
||||||
|
from langchain_core.tools.base import ToolException
|
||||||
|
|
||||||
|
_NO_UPDATE_MSG = (
|
||||||
|
"Нельзя вызывать task_submit без предварительного task_update_answer.\n\n"
|
||||||
|
"Правильный порядок:\n"
|
||||||
|
'1. task_update_answer(taskId="TASK_ID", answerType="link", content="<URL репозитория>")\n'
|
||||||
|
'2. task_submit(taskId="TASK_ID", confirmSubmit=true)'
|
||||||
|
)
|
||||||
|
|
||||||
|
_SUBMIT_ERR_MSG = (
|
||||||
|
"task_submit завершился ошибкой: {error}\n\n"
|
||||||
|
"Убедись что task_update_answer был вызван с правильными параметрами "
|
||||||
|
'(answerType="link", content="<URL>"), затем повтори task_submit.'
|
||||||
|
)
|
||||||
|
|
||||||
|
_JOURNAL_ERR_TEMPLATE = "Ошибка journal-инструмента ({tool}): {error}"
|
||||||
|
|
||||||
|
|
||||||
|
def _find_prior_update_answer(messages: list, task_id: str) -> bool:
|
||||||
|
"""Проверяет, был ли task_update_answer вызван для данного taskId."""
|
||||||
|
for msg in reversed(messages):
|
||||||
|
if not hasattr(msg, "tool_calls") or not msg.tool_calls:
|
||||||
|
continue
|
||||||
|
for tc in msg.tool_calls:
|
||||||
|
name = tc.get("name", "")
|
||||||
|
args = tc.get("args", {})
|
||||||
|
if "task_update_answer" in name and args.get("taskId") == task_id:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
class ValidateJournalWorkflowMiddleware(AgentMiddleware[AgentState[Any], Any]):
|
||||||
|
"""Предотвращает task_submit без предварительного task_update_answer."""
|
||||||
|
|
||||||
|
# ---- sync ----
|
||||||
|
|
||||||
|
def _safe_submit(self, request, handler) -> ToolMessage:
|
||||||
|
task_id = request.tool_call.get("args", {}).get("taskId", "")
|
||||||
|
tool_name = request.tool_call.get("name", "")
|
||||||
|
messages = request.state.get("messages", [])
|
||||||
|
|
||||||
|
if not _find_prior_update_answer(messages, task_id):
|
||||||
|
return ToolMessage(
|
||||||
|
content=_NO_UPDATE_MSG.replace("TASK_ID", task_id),
|
||||||
|
tool_call_id=request.tool_call["id"],
|
||||||
|
name=tool_name,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
return handler(request)
|
||||||
|
except ToolException as e:
|
||||||
|
return ToolMessage(
|
||||||
|
content=_SUBMIT_ERR_MSG.format(error=e),
|
||||||
|
tool_call_id=request.tool_call["id"],
|
||||||
|
name=tool_name,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _safe_journal_call(self, request, handler):
|
||||||
|
try:
|
||||||
|
return handler(request)
|
||||||
|
except ToolException as e:
|
||||||
|
tool_name = request.tool_call.get("name", "")
|
||||||
|
return ToolMessage(
|
||||||
|
content=_JOURNAL_ERR_TEMPLATE.format(tool=tool_name, error=e),
|
||||||
|
tool_call_id=request.tool_call["id"],
|
||||||
|
name=tool_name,
|
||||||
|
)
|
||||||
|
|
||||||
|
def wrap_tool_call(self, request, handler):
|
||||||
|
name = request.tool_call.get("name", "")
|
||||||
|
if "task_submit" in name:
|
||||||
|
return self._safe_submit(request, handler)
|
||||||
|
if "mcp__journal-bh-professor__" in name:
|
||||||
|
return self._safe_journal_call(request, handler)
|
||||||
|
return handler(request)
|
||||||
|
|
||||||
|
# ---- async ----
|
||||||
|
|
||||||
|
async def _async_safe_submit(self, request, handler) -> ToolMessage:
|
||||||
|
task_id = request.tool_call.get("args", {}).get("taskId", "")
|
||||||
|
tool_name = request.tool_call.get("name", "")
|
||||||
|
messages = request.state.get("messages", [])
|
||||||
|
|
||||||
|
if not _find_prior_update_answer(messages, task_id):
|
||||||
|
return ToolMessage(
|
||||||
|
content=_NO_UPDATE_MSG.replace("TASK_ID", task_id),
|
||||||
|
tool_call_id=request.tool_call["id"],
|
||||||
|
name=tool_name,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
return await handler(request)
|
||||||
|
except ToolException as e:
|
||||||
|
return ToolMessage(
|
||||||
|
content=_SUBMIT_ERR_MSG.format(error=e),
|
||||||
|
tool_call_id=request.tool_call["id"],
|
||||||
|
name=tool_name,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _async_safe_journal_call(self, request, handler):
|
||||||
|
try:
|
||||||
|
return await handler(request)
|
||||||
|
except ToolException as e:
|
||||||
|
tool_name = request.tool_call.get("name", "")
|
||||||
|
return ToolMessage(
|
||||||
|
content=_JOURNAL_ERR_TEMPLATE.format(tool=tool_name, error=e),
|
||||||
|
tool_call_id=request.tool_call["id"],
|
||||||
|
name=tool_name,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def awrap_tool_call(self, request, handler):
|
||||||
|
name = request.tool_call.get("name", "")
|
||||||
|
if "task_submit" in name:
|
||||||
|
return await self._async_safe_submit(request, handler)
|
||||||
|
if "mcp__journal-bh-professor__" in name:
|
||||||
|
return await self._async_safe_journal_call(request, handler)
|
||||||
|
return await handler(request)
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
"""Системные промпты для всех агентов."""
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Субагент: исследование в интернете
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
research_instructions = """
|
||||||
|
Ты — субагент интернет-исследования. Твоя задача — найти релевантные источники,
|
||||||
|
открыть нужные страницы и вернуть аккуратную выжимку по реально прочитанным материалам.
|
||||||
|
|
||||||
|
## Доступные инструменты
|
||||||
|
- `web_search`: ищет кандидатов в интернете и возвращает сниппеты с URL.
|
||||||
|
- `get_page_content`: открывает конкретную страницу по URL и возвращает её содержимое.
|
||||||
|
|
||||||
|
## Один инструмент за шаг
|
||||||
|
За одно сообщение — **только один** вызов. Дождись ответа, затем при необходимости сделай следующий вызов.
|
||||||
|
|
||||||
|
## Правила
|
||||||
|
1. Считай результат `web_search` только черновой наводкой. Сниппеты не являются доказательством.
|
||||||
|
2. Любой факт подтверждён только после успешного `get_page_content`.
|
||||||
|
3. Не придумывай URL, цитаты, даты или факты.
|
||||||
|
4. Если страницу не удалось открыть, честно скажи об этом.
|
||||||
|
|
||||||
|
## Формат ответа
|
||||||
|
- `Короткий вывод:` 2-5 предложений.
|
||||||
|
- `Подтверждено по страницам:` список фактов с URL.
|
||||||
|
- `Не подтверждено:` что осталось на уровне сниппетов.
|
||||||
|
- `Открытые источники:` список URL с успешно загруженным контентом.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Субагент: journal (задания и сдачи)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
journal_tasks_submissions_instructions = """
|
||||||
|
Ты — субагент BroJS Journal: задания (Task) и сдачи.
|
||||||
|
|
||||||
|
## Известные курсы
|
||||||
|
- KFU-26-1 = `698b49da77cb6d4d2e43ce78`
|
||||||
|
|
||||||
|
## Доступные инструменты (с префиксом mcp__journal-bh-professor__)
|
||||||
|
- `mcp__journal-bh-professor__courses_list` — список курсов
|
||||||
|
- `mcp__journal-bh-professor__lessons_list` — уроки курса (нужен courseId)
|
||||||
|
- `mcp__journal-bh-professor__tasks_list` — задания (нужен courseId)
|
||||||
|
- `mcp__journal-bh-professor__task_text` — полный текст задания
|
||||||
|
- `mcp__journal-bh-professor__task_get` — детали задания (включая answer, комментарии)
|
||||||
|
- `mcp__journal-bh-professor__task_update_answer` — установить ответ (answerType, content)
|
||||||
|
- `mcp__journal-bh-professor__task_submit` — отправить задание на проверку
|
||||||
|
- `mcp__journal-bh-professor__task_comment` — оставить комментарий
|
||||||
|
- `mcp__journal-bh-professor__task_submission_status` — статус сдачи
|
||||||
|
|
||||||
|
## Один инструмент за шаг
|
||||||
|
За одно сообщение — **только один** вызов. Параллельные вызовы запрещены.
|
||||||
|
|
||||||
|
## Правила
|
||||||
|
1. Никогда не угадывай ID — бери их только из ответов API.
|
||||||
|
2. Если курс назван по имени — используй courses_list для получения courseId.
|
||||||
|
3. task_update_answer **обязателен** перед task_submit.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Субагент: выполнение домашних заданий (первая сдача)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
homework_doing_instructions = """
|
||||||
|
Ты — исполнитель домашних заданий (ПЕРВАЯ СДАЧА).
|
||||||
|
У тебя есть ВСЕ инструменты напрямую. Не делегируй другим субагентам.
|
||||||
|
|
||||||
|
courseId = "698b49da77cb6d4d2e43ce78"
|
||||||
|
Gitea owner = "glevelll"
|
||||||
|
|
||||||
|
ВАЖНО: Journal-инструменты имеют префикс mcp__journal-bh-professor__
|
||||||
|
Gitea-инструменты: gitea_create_repo, gitea_write_file, gitea_get_file, gitea_list_repos
|
||||||
|
Git-инструменты: git_clone, git_pull, git_status, git_add_and_commit, git_push
|
||||||
|
|
||||||
|
## ПОРЯДОК ВЫПОЛНЕНИЯ:
|
||||||
|
|
||||||
|
[1] mcp__journal-bh-professor__task_text({"taskId": "<id>"})
|
||||||
|
→ Прочитай ПОЛНЫЙ текст задания
|
||||||
|
|
||||||
|
[2] Составь письменный план:
|
||||||
|
- какие файлы нужны (main.py, requirements.txt, etc.)
|
||||||
|
- что реализовать в каждом файле
|
||||||
|
|
||||||
|
[3] gitea_create_repo({"name": "task-<id>", "private": false})
|
||||||
|
→ Создай репозиторий
|
||||||
|
|
||||||
|
[4] Для КАЖДОГО файла вызывай ОТДЕЛЬНО:
|
||||||
|
gitea_write_file({
|
||||||
|
"repo": "task-<id>",
|
||||||
|
"path": "main.py",
|
||||||
|
"content": "ПОЛНЫЙ КОД ФАЙЛА",
|
||||||
|
"message": "add main.py"
|
||||||
|
})
|
||||||
|
- gitea_write_file сам коммитит на сервере — git_add_and_commit НЕ нужен
|
||||||
|
- content — это plain text, НЕ base64
|
||||||
|
- ВСЕГДА указывай message
|
||||||
|
- Один вызов = один файл
|
||||||
|
|
||||||
|
[5] git_clone("https://git.brojs.ru/glevelll/task-<id>")
|
||||||
|
→ Клонируй репозиторий локально для проверки
|
||||||
|
|
||||||
|
[6] Проверь через read_file что код корректен
|
||||||
|
|
||||||
|
[7] mcp__journal-bh-professor__task_update_answer({
|
||||||
|
"taskId": "<id>",
|
||||||
|
"answerType": "link",
|
||||||
|
"content": "https://git.brojs.ru/glevelll/task-<id>"
|
||||||
|
})
|
||||||
|
→ ОБЯЗАТЕЛЬНО перед task_submit!
|
||||||
|
|
||||||
|
[8] Финальная проверка:
|
||||||
|
✓ Все файлы записаны?
|
||||||
|
✓ Нет pass, TODO, ..., заглушек?
|
||||||
|
✓ langchain>1.0.0 в requirements.txt?
|
||||||
|
✓ task_update_answer вызван?
|
||||||
|
|
||||||
|
[9] mcp__journal-bh-professor__task_submit({
|
||||||
|
"taskId": "<id>",
|
||||||
|
"confirmSubmit": true
|
||||||
|
})
|
||||||
|
|
||||||
|
## ТРЕБОВАНИЯ К КОДУ:
|
||||||
|
- ПОЛНЫЙ рабочий код, без pass, TODO, ...
|
||||||
|
- requirements.txt с реальными зависимостями и langchain>1.0.0
|
||||||
|
- Соответствие всем требованиям из текста задания
|
||||||
|
- Используй langchain>=1.2.10 / langgraph>=0.2.0 согласно заданию
|
||||||
|
|
||||||
|
## ЗАПРЕЩЕНО:
|
||||||
|
- pass, TODO, ..., пустые функции
|
||||||
|
- langchain<=1.0.0 в requirements.txt
|
||||||
|
- Пропускать task_update_answer перед task_submit
|
||||||
|
- Писать код только в requirements.txt без main.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Субагент: пересдача после ревью
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
rework_instructions = """
|
||||||
|
Ты — исполнитель домашних заданий (ПЕРЕСДАЧА после ревью преподавателя).
|
||||||
|
У тебя есть ВСЕ инструменты напрямую. Не делегируй.
|
||||||
|
|
||||||
|
courseId = "698b49da77cb6d4d2e43ce78"
|
||||||
|
Gitea owner = "glevelll"
|
||||||
|
|
||||||
|
Ситуация: задание уже было отправлено, получены комментарии. Репозиторий существует.
|
||||||
|
|
||||||
|
## ПОРЯДОК:
|
||||||
|
|
||||||
|
[1] mcp__journal-bh-professor__task_submission_status({"taskId": "<id>"})
|
||||||
|
→ Проверь статус и получи фидбек
|
||||||
|
|
||||||
|
[2] mcp__journal-bh-professor__task_get({"taskId": "<id>"})
|
||||||
|
→ Получи URL репозитория из answer.content и прочитай комментарии
|
||||||
|
|
||||||
|
[3] git_clone(<url из answer.content>)
|
||||||
|
→ Клонируй существующий репозиторий в agent_workspace
|
||||||
|
→ <repo-name> = последняя часть URL (например task-abc123)
|
||||||
|
|
||||||
|
[4] Прочитай файлы через read_file, пойми что исправить
|
||||||
|
|
||||||
|
[5] Внеси исправления через edit_file или write_file
|
||||||
|
|
||||||
|
[6] git_add_and_commit("fix: <описание исправлений>", "<repo-name>")
|
||||||
|
|
||||||
|
[7] git_push("<repo-name>")
|
||||||
|
|
||||||
|
[8] mcp__journal-bh-professor__task_update_answer({
|
||||||
|
"taskId": "<id>",
|
||||||
|
"answerType": "link",
|
||||||
|
"content": "<ТОТ ЖЕ URL репозитория>"
|
||||||
|
})
|
||||||
|
|
||||||
|
[9] mcp__journal-bh-professor__task_submit({"taskId": "<id>", "confirmSubmit": true})
|
||||||
|
|
||||||
|
## ПРАВИЛА:
|
||||||
|
- Клонируй существующий репозиторий, НЕ создавай новый
|
||||||
|
- Исправляй ТОЛЬКО то, что указано в комментариях
|
||||||
|
- task_update_answer обязателен (даже если URL тот же)
|
||||||
|
- Запрещено: pass, TODO, пустые функции
|
||||||
|
"""
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Главный агент-оркестратор
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
main_agent_instructions = """\
|
||||||
|
Ты — главный агент-исполнитель домашних заданий курса KFU-26-1 на platform.brojs.ru.
|
||||||
|
Твоя роль — получать задания из журнала и выполнять их качественно.
|
||||||
|
|
||||||
|
## Известные курсы
|
||||||
|
- KFU-26-1 = courseId `698b49da77cb6d4d2e43ce78`
|
||||||
|
|
||||||
|
## Доступные субагенты (вызывай через инструмент `task`)
|
||||||
|
- `journal_bh_tasks_submissions`: читает задания, проверяет статусы, отправляет ответы
|
||||||
|
- `homework_doing`: ВЫПОЛНЯЕТ задание (пишет код, создаёт репо, сдаёт)
|
||||||
|
- `web_search`: ищет информацию в интернете (только если нужно)
|
||||||
|
|
||||||
|
## Прямые Gitea-инструменты (доступны напрямую без субагента)
|
||||||
|
- `gitea_list_repos` — список репозиториев, также возвращает username
|
||||||
|
- `gitea_create_repo` — создать репозиторий
|
||||||
|
- `gitea_write_file` — создать/обновить файл (автокоммит)
|
||||||
|
- `gitea_get_file` — получить файл
|
||||||
|
|
||||||
|
## Один инструмент за шаг (ОБЯЗАТЕЛЬНО)
|
||||||
|
В одном сообщении — **только один** вызов любого инструмента (`task`, `ls`, `read_file`,
|
||||||
|
`write_file`, `edit_file`, `glob`, `grep`, `execute` и т.д.).
|
||||||
|
Сначала дождись результата, затем следующий вызов.
|
||||||
|
|
||||||
|
## Типовые маршруты
|
||||||
|
|
||||||
|
### Получить список заданий
|
||||||
|
1. Делегируй `journal_bh_tasks_submissions`: получить tasks_list для courseId
|
||||||
|
|
||||||
|
### Выполнить задание
|
||||||
|
1. Делегируй `homework_doing`: выполни задание с taskId=<id>
|
||||||
|
(он сам прочитает текст, создаст репо, напишет код и сдаст)
|
||||||
|
2. Верни пользователю ссылку на репозиторий
|
||||||
|
|
||||||
|
### Проверить статусы
|
||||||
|
1. Делегируй `journal_bh_tasks_submissions`: получить статусы всех заданий курса
|
||||||
|
|
||||||
|
## Жёсткие ограничения
|
||||||
|
- Не вызывай больше одного инструмента за шаг
|
||||||
|
- Не делегируй субагенту несколько независимых задач сразу
|
||||||
|
- Не говори что задание выполнено, если оно не было реально выполнено
|
||||||
|
- Не подменяй требования задания своими догадками
|
||||||
|
"""
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
"""Спецификации субагентов (без инструментов — добавляются в agent.py)."""
|
||||||
|
from src.agent.llm import llm
|
||||||
|
from src.agent.prompts import (
|
||||||
|
homework_doing_instructions,
|
||||||
|
journal_tasks_submissions_instructions,
|
||||||
|
research_instructions,
|
||||||
|
)
|
||||||
|
|
||||||
|
subagent_specs_without_tools: list[dict] = [
|
||||||
|
{
|
||||||
|
"name": "web_search",
|
||||||
|
"description": (
|
||||||
|
"Ищет информацию в интернете, находит URL и открывает страницы, "
|
||||||
|
"чтобы извлекать факты только из реально прочитанного контента"
|
||||||
|
),
|
||||||
|
"model": llm,
|
||||||
|
"system_prompt": research_instructions,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "homework_doing",
|
||||||
|
"description": (
|
||||||
|
"Выполняет домашние задания: читает условие, пишет код, "
|
||||||
|
"создаёт репозиторий на git.brojs.ru, тестирует решение и сдаёт на проверку"
|
||||||
|
),
|
||||||
|
"model": llm,
|
||||||
|
"system_prompt": homework_doing_instructions,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "journal_bh_tasks_submissions",
|
||||||
|
"description": (
|
||||||
|
"BroJS Journal: задания и сдачи — список заданий, чтение задания, "
|
||||||
|
"проверка статуса, установка ответа, отправка на проверку"
|
||||||
|
),
|
||||||
|
"model": llm,
|
||||||
|
"system_prompt": journal_tasks_submissions_instructions,
|
||||||
|
},
|
||||||
|
]
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
"""Git-инструменты и веб-поиск для агента."""
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
from langchain.tools import tool
|
||||||
|
from markdownify import markdownify
|
||||||
|
|
||||||
|
from src.agent.constants import AGENT_WORKSPACE_DIR, GITEA_BASE_URL
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Web tools
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_BROWSER_HEADERS = {
|
||||||
|
"User-Agent": (
|
||||||
|
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "
|
||||||
|
"(KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
|
||||||
|
),
|
||||||
|
"Accept": (
|
||||||
|
"text/html,application/xhtml+xml,application/xml;q=0.9,"
|
||||||
|
"image/avif,image/webp,image/apng,*/*;q=0.8"
|
||||||
|
),
|
||||||
|
"Accept-Language": "ru-RU,ru;q=0.9,en-US;q=0.8,en;q=0.7",
|
||||||
|
}
|
||||||
|
|
||||||
|
_HTTP_CLIENT = httpx.Client(timeout=30.0, follow_redirects=True, headers=_BROWSER_HEADERS)
|
||||||
|
|
||||||
|
|
||||||
|
@tool()
|
||||||
|
def get_page_content(url: str) -> str:
|
||||||
|
"""Получить текстовое содержимое веб-страницы по URL."""
|
||||||
|
try:
|
||||||
|
resp = _HTTP_CLIENT.get(url)
|
||||||
|
if resp.status_code != 200:
|
||||||
|
return f"Ошибка {resp.status_code} при загрузке {url}"
|
||||||
|
return markdownify(resp.text)
|
||||||
|
except Exception as e:
|
||||||
|
return f"Не удалось загрузить страницу: {e}"
|
||||||
|
|
||||||
|
|
||||||
|
@tool()
|
||||||
|
def web_search(query: str, max_results: int = 5) -> str:
|
||||||
|
"""Поиск информации в интернете через Tavily.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
query: поисковый запрос
|
||||||
|
max_results: максимальное количество результатов
|
||||||
|
"""
|
||||||
|
tavily_key = os.getenv("TAVILY_API_KEY", "")
|
||||||
|
if not tavily_key:
|
||||||
|
return (
|
||||||
|
"Поиск недоступен: TAVILY_API_KEY не задан. "
|
||||||
|
"Добавь ключ в .env для использования web_search."
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
from tavily import TavilyClient
|
||||||
|
client = TavilyClient(api_key=tavily_key)
|
||||||
|
resp = client.search(query, max_results=max_results)
|
||||||
|
parts = []
|
||||||
|
for r in resp.get("results", []):
|
||||||
|
parts.append(
|
||||||
|
f"**{r.get('title', '')}**\n"
|
||||||
|
f"url: {r.get('url', '')}\n"
|
||||||
|
f"content: {r.get('content', '')}\n"
|
||||||
|
)
|
||||||
|
return "\n".join(parts) if parts else "Ничего не найдено"
|
||||||
|
except Exception as e:
|
||||||
|
return f"Ошибка поиска: {e}"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Git tools (работают с agent_workspace)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _cwd(subdir: str | None = None) -> str:
|
||||||
|
if subdir:
|
||||||
|
return str(AGENT_WORKSPACE_DIR / subdir)
|
||||||
|
return str(AGENT_WORKSPACE_DIR)
|
||||||
|
|
||||||
|
|
||||||
|
def _inject_token(url: str) -> str:
|
||||||
|
"""Вставляет GITEA_TOKEN в URL для аутентификации при git push/pull."""
|
||||||
|
token = os.getenv("GITEA_TOKEN", "")
|
||||||
|
if token and GITEA_BASE_URL.replace("https://", "") in url and "@" not in url:
|
||||||
|
return url.replace("https://", f"https://oauth2:{token}@")
|
||||||
|
return url
|
||||||
|
|
||||||
|
|
||||||
|
@tool()
|
||||||
|
def git_clone(url: str, depth: int = 1) -> str:
|
||||||
|
"""Клонировать Git-репозиторий в agent_workspace.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
url: URL репозитория (например https://git.brojs.ru/glevelll/task-abc)
|
||||||
|
depth: глубина клонирования (по умолчанию 1 — только последний коммит)
|
||||||
|
"""
|
||||||
|
auth_url = _inject_token(url)
|
||||||
|
result = subprocess.run(
|
||||||
|
["git", "clone", "--depth", str(depth), auth_url],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
cwd=str(AGENT_WORKSPACE_DIR),
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
return f"Ошибка клонирования: {result.stderr}"
|
||||||
|
return f"Репозиторий клонирован в agent_workspace: {result.stdout or 'OK'}"
|
||||||
|
|
||||||
|
|
||||||
|
@tool()
|
||||||
|
def git_pull(path: str | None = None) -> str:
|
||||||
|
"""Обновить репозиторий (git pull).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
path: подпапка в agent_workspace (например task-abc)
|
||||||
|
"""
|
||||||
|
result = subprocess.run(
|
||||||
|
["git", "pull"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
cwd=_cwd(path),
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
return f"Ошибка pull: {result.stderr}"
|
||||||
|
return result.stdout or "Уже актуально"
|
||||||
|
|
||||||
|
|
||||||
|
@tool()
|
||||||
|
def git_status(path: str | None = None) -> str:
|
||||||
|
"""Показать статус репозитория.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
path: подпапка в agent_workspace
|
||||||
|
"""
|
||||||
|
result = subprocess.run(
|
||||||
|
["git", "status", "--porcelain"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
cwd=_cwd(path),
|
||||||
|
)
|
||||||
|
return result.stdout or "Нет изменений"
|
||||||
|
|
||||||
|
|
||||||
|
@tool()
|
||||||
|
def git_add_and_commit(message: str, path: str | None = None) -> str:
|
||||||
|
"""Добавить все изменения и создать коммит.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
message: сообщение коммита
|
||||||
|
path: подпапка в agent_workspace
|
||||||
|
"""
|
||||||
|
cwd = _cwd(path)
|
||||||
|
add = subprocess.run(["git", "add", "."], capture_output=True, text=True, cwd=cwd)
|
||||||
|
if add.returncode != 0:
|
||||||
|
return f"Ошибка git add: {add.stderr}"
|
||||||
|
|
||||||
|
commit = subprocess.run(
|
||||||
|
["git", "commit", "-m", message],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
cwd=cwd,
|
||||||
|
)
|
||||||
|
if commit.returncode != 0:
|
||||||
|
return f"Ошибка git commit: {commit.stderr}"
|
||||||
|
return commit.stdout
|
||||||
|
|
||||||
|
|
||||||
|
@tool()
|
||||||
|
def git_push(path: str | None = None) -> str:
|
||||||
|
"""Отправить коммиты в удалённый репозиторий (git push).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
path: подпапка в agent_workspace
|
||||||
|
"""
|
||||||
|
result = subprocess.run(
|
||||||
|
["git", "push"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
cwd=_cwd(path),
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
return f"Ошибка push: {result.stderr}"
|
||||||
|
return result.stdout or "Push выполнен успешно"
|
||||||
|
|
||||||
|
|
||||||
|
# Удобный список всех git-инструментов
|
||||||
|
GIT_TOOLS = [git_clone, git_pull, git_status, git_add_and_commit, git_push]
|
||||||
|
WEB_TOOLS = [web_search, get_page_content]
|
||||||
Reference in New Issue
Block a user