@@ -0,0 +1,627 @@
"""
Агент-решатель домашних заданий.
Запуск: langgraph dev
"""
import os , re , sys , json , argparse
os . environ . setdefault ( " PYTHONUTF8 " , " 1 " )
os . environ . setdefault ( " PYTHONIOENCODING " , " utf-8 " )
from dotenv import load_dotenv
from langchain_core . tools import tool
from langchain_core . messages import SystemMessage , HumanMessage
from langchain . agents import create_agent
load_dotenv ( encoding = " utf-8 " )
from llm import get_llm
from mcp_clients import load_mcp_toolsets
from gitea_rest import GiteaRestClient
# ------------------------------------------------------------------ #
# Версии пакетов #
# ------------------------------------------------------------------ #
KNOWN_VERSIONS = {
" langchain " : " 1.2.15 " ,
" langchain-core " : " 1.3.0 " ,
" langchain-openai " : " 1.1.9 " ,
" langchain-community " : " 0.3.20 " ,
" langchain-mcp-adapters " : " 0.2.1 " ,
" langchain-qdrant " : " 0.2.0 " ,
" langchain-ollama " : " 0.3.0 " ,
" langgraph " : " 1.1.8 " ,
" fastmcp " : " 3.1.0 " ,
" fastapi " : " 0.115.0 " ,
" uvicorn " : " 0.30.0 " ,
" gigachat " : " 0.2.0 " ,
" requests " : " 2.31.0 " ,
" beautifulsoup4 " : " 4.12.0 " ,
" python-dotenv " : " 1.0.0 " ,
" pydantic " : " 2.7.1 " ,
" rich " : " 14.3.3 " ,
" markdownify " : " 1.2.2 " ,
" httpx " : " 0.27.0 " ,
" openai " : " 1.30.0 " ,
" anthropic " : " 0.25.0 " ,
" qdrant-client " : " 1.9.1 " ,
" ollama " : " 0.2.1 " ,
" faiss-cpu " : " 1.8.0 " ,
" chromadb " : " 0.5.3 " ,
" tiktoken " : " 0.7.0 " ,
" numpy " : " 1.26.4 " ,
" pandas " : " 2.2.2 " ,
" pytest " : " 8.2.0 " ,
" aiohttp " : " 3.9.5 " ,
" pyyaml " : " 6.0.1 " ,
" sqlalchemy " : " 2.0.30 " ,
" mcp " : " 1.6.0 " ,
}
def _pin_versions ( requirements_text : str ) - > str :
lines = [ ]
for line in requirements_text . splitlines ( ) :
stripped = line . strip ( )
if not stripped or stripped . startswith ( " # " ) :
lines . append ( stripped )
continue
pkg_name = re . split ( r " [=><!~ \ [ \ s] " , stripped ) [ 0 ] . strip ( ) . lower ( ) . replace ( " _ " , " - " )
if " == " in stripped :
lines . append ( stripped )
elif pkg_name in KNOWN_VERSIONS :
lines . append ( f " { re . split ( r ' [=><!~] ' , stripped ) [ 0 ] . strip ( ) } == { KNOWN_VERSIONS [ pkg_name ] } " )
else :
lines . append ( stripped )
return " \n " . join ( lines )
# ------------------------------------------------------------------ #
# Системный промпт #
# ------------------------------------------------------------------ #
SYSTEM_PROMPT = """ Ты — ассистент, который решает практические задания по программированию.
## Порядок при запросе " реши задание X "
Шаг 1. mcp__journal-mcp__courses_list()
→ получи список курсов, запомни id нужного курса (это courseId)
Шаг 2. mcp__journal-mcp__tasks_list(courseId=<id из шага 1>)
→ передавай аргумент courseId, найди задание по названию
→ taskId — это ПОЛНЫЙ id из поля id/taskId ответа, ровно 24 символа hex (MongoDB ObjectId)
→ ПРОВЕРЬ: id должен быть ровно 24 символа, например: 6a02e23da6fe2e4ac16acf65
→ если id короче 24 символов — сначала вызови task_get чтобы получить полный id
Шаг 3. mcp__journal-mcp__task_text(taskId=<id из шага 2>)
→ получи ПОЛНЫЙ текст условия
Шаг 4. plan_and_upload(task_id=taskId, task_title=название, task_description=текст)
→ возвращает ссылку на папку
Шаг 5. mcp__journal-mcp__task_update_answer(taskId=<id>, answer=<ссылка из шага 4>)
Шаг 6. submit_solution(task_id=<id>, solution_url=<ссылка>) → отправляет на проверку через REST
Шаг 7. Напиши: " Готово! Ссылка: <ссылка> "
## Порядок при запросе " переделай задание X "
Шаг 1. mcp__journal-mcp__courses_list() → courseId
Шаг 2. mcp__journal-mcp__tasks_list(courseId=<courseId>) → taskId
Шаг 3. mcp__journal-mcp__task_text(taskId=<taskId>) → текст условия
Шаг 4. mcp__journal-mcp__task_get(taskId=<taskId>) → комментарий преподавателя
Шаг 5. revise_task(task_id=taskId, task_title=название, task_description=текст, teacher_comment=комментарий)
→ возвращает ссылку (автоматически сохраняет ответ)
Шаг 6. submit_solution(task_id=<taskId>, solution_url=<ссылка>) → отправляет на проверку через REST
Шаг 7. Напиши: " Исправлено! Ссылка: <ссылка> "
## ЗАПРЕЩЕНО: писать код в чат, пропускать шаги, передавать taskId короче 24 символов
## Инструменты — ТОЧНЫЕ сигнатуры (не меняй имена аргументов!)
Журнал:
- mcp__journal-mcp__courses_list()
→ список курсов, у каждого есть поле id (courseId)
- mcp__journal-mcp__tasks_list(courseId=<id курса>)
→ аргумент называется courseId, НЕ taskId, НЕ id
→ список заданий, у каждого есть поле id (taskId)
→ taskId ОБЯЗАН быть ровно 24 символа hex (MongoDB ObjectId)
→ пример правильного id: 6a02e23da6fe2e4ac16acf65
- mcp__journal-mcp__task_text(taskId=<id задания>)
- mcp__journal-mcp__task_get(taskId=<id задания>)
- mcp__journal-mcp__task_update_answer(taskId=<id задания>, answer=<ссылка>)
- mcp__journal-mcp__task_submit(taskId=<id задания>)
- mcp__journal-mcp__task_submission_status(taskId=<id задания>)
Локальные:
- plan_and_upload(task_id, task_title, task_description)
- revise_task(task_id, task_title, task_description, teacher_comment)
- submit_solution(task_id, solution_url) """
# ------------------------------------------------------------------ #
# Промпты #
# ------------------------------------------------------------------ #
ANALYZER_PROMPT = """ Ты — аналитик заданий. Прочитай условие и составь список файлов.
Правила:
1. Если в условии явно названы файлы — включи все.
2. Если нет — один solution.py.
3. Всегда добавляй requirements.txt и README.md последними.
4. Внимательно читай раздел " Стек " — записывай точные библиотеки.
Верни ТОЛЬКО JSON:
{
" files " : [
{ " filename " : " agent.py " , " purpose " : " агент с инструментами " },
{ " filename " : " client.py " , " purpose " : " интерактивный CLI " },
{ " filename " : " requirements.txt " , " purpose " : " зависимости " },
{ " filename " : " README.md " , " purpose " : " документация " }
],
" main_file " : " agent.py " ,
" tech_stack " : " LangChain, create_agent, @tool, rich " ,
" forbidden " : " НЕ использовать: LangGraph StateGraph, ToolExecutorNode "
} """
CODER_PROMPT = """ Ты — опытный Python-разработчик. Пиши ОДИН файл строго по условию.
═══ КРИТИЧЕСКИЕ ПРАВИЛА ═══
1. ИСПОЛЬЗУЙ ТОЛЬКО ТОТ СТЕК ЧТО В УСЛОВИИ:
- Если написано LangChain create_agent → from langchain.agents import create_agent
- Если написано Ollama → from langchain_ollama import ChatOllama
- Если написано Qdrant → from langchain_qdrant import QdrantVectorStore
- Если написано FastMCP → from fastmcp import FastMCP
- Если написано @tool → from langchain.tools import tool
- Если написано rich → from rich import print as rprint
- Если написано .stream() → используй agent.stream(), НЕ agent.invoke()
- Если написано MemorySaver → from langgraph.checkpoint.memory import MemorySaver
2. НИКОГДА НЕ ИСПОЛЬЗУЙ (если не указано явно):
- LangGraph StateGraph / langgraph.graph
- ToolExecutorNode
- OpenAI / ChatOpenAI (если в условии написано Ollama или другой LLM)
3. Реализуй ВСЁ из раздела " Что нужно сделать " и " Критерии зачёта "
4. Код запускается без ошибок, без заглушек (pass, TODO)
5. Комментарии на русском языке
6. Верни ТОЛЬКО чистый Python-код без markdown """
REVISER_CODER_PROMPT = """ Ты — опытный Python-разработчик. Исправь файл ТОЧНО по комментарию преподавателя.
═══ КРИТИЧЕСКИЕ ПРАВИЛА ═══
1. Читай комментарий дословно — исправляй ТОЛЬКО то что он написал, не больше.
2. Если сказано " добавь langchain в requirements.txt " — просто добавь строку, не меняй остальное.
3. Если сказано " добавь логирование " — добавь только logging, остальное не трогай.
4. Если сказано " используй LangChain вместо LangGraph " — тогда переписывай архитектуру.
5. НЕ переписывай весь файл ради мелкого изменения — вноси минимально необходимые правки.
6. Сохраняй стек, структуру и логику оригинального кода если комментарий не требует менять.
7. Верни ТОЛЬКО чистый исправленный код без markdown и пояснений. """
REQUIREMENTS_PROMPT = """ Напиши requirements.txt для этих Python-файлов.
ПРАВИЛА:
- Только библиотеки которые реально импортируются (import ...)
- Формат: название==версия
- Одна строка — одна библиотека
- Никакого текста кроме содержимого файла
Примеры правильных строк:
langchain==1.2.15
langchain-ollama==0.3.0
rich==14.3.3
qdrant-client==1.9.1 """
README_PROMPT = """ Напиши README.md. Включи: описание, предварительные требования, установка, запуск, пример. Только markdown. """
def _strip_md ( text : str ) - > str :
text = text . strip ( )
text = re . sub ( r " ^```[ \ w]* \ n? " , " " , text )
text = re . sub ( r " \ n?```$ " , " " , text )
return text . strip ( )
def _parse_json ( raw : str ) - > dict | None :
raw = _strip_md ( raw )
try :
return json . loads ( raw )
except json . JSONDecodeError :
match = re . search ( r ' \ { [ \ s \ S]* " files " [ \ s \ S]* \ } ' , raw )
if match :
try :
return json . loads ( match . group ( ) )
except Exception :
pass
return None
def _repo_name ( task_title : str ) - > str :
""" Имя репозитория из названия задания: только латиница/цифры/дефис, max 50 символов. """
import unicodedata
# Транслитерация кириллицы
translit = {
' а ' : ' a ' , ' б ' : ' b ' , ' в ' : ' v ' , ' г ' : ' g ' , ' д ' : ' d ' , ' е ' : ' e ' , ' ё ' : ' yo ' , ' ж ' : ' zh ' , ' з ' : ' z ' ,
' и ' : ' i ' , ' й ' : ' y ' , ' к ' : ' k ' , ' л ' : ' l ' , ' м ' : ' m ' , ' н ' : ' n ' , ' о ' : ' o ' , ' п ' : ' p ' , ' р ' : ' r ' ,
' с ' : ' s ' , ' т ' : ' t ' , ' у ' : ' u ' , ' ф ' : ' f ' , ' х ' : ' kh ' , ' ц ' : ' ts ' , ' ч ' : ' ch ' , ' ш ' : ' sh ' ,
' щ ' : ' sch ' , ' ъ ' : ' ' , ' ы ' : ' y ' , ' ь ' : ' ' , ' э ' : ' e ' , ' ю ' : ' yu ' , ' я ' : ' ya ' ,
}
result = " "
for ch in task_title . lower ( ) :
if ch in translit :
result + = translit [ ch ]
elif ch . isascii ( ) and ( ch . isalnum ( ) or ch in ' -_ ' ) :
result + = ch
else :
result + = ' - '
# Заменяем пробелы и подчёркивания на дефис, убираем двойные дефисы
result = re . sub ( r ' [-_ \ s]+ ' , ' - ' , result ) . strip ( ' - ' )
return result [ : 50 ] or " task "
def _folder_url ( repo : str , folder : str = " " ) - > str :
base = os . getenv ( " GITEA_URL " , " https://git.brojs.ru " ) . rstrip ( " / " )
user = os . getenv ( " GITEA_USERNAME " , " " )
# Всегда возвращаем корень репозитория — файлы лежат там
return f " { base } / { user } / { repo } "
def _get_existing_files ( repo : str , folder : str = " " ) - > dict [ str , str ] :
""" Читает существующие файлы из репозитория Gitea. При ошибке возвращает {} . """
import base64
try :
gitea = GiteaRestClient ( )
user = os . getenv ( " GITEA_USERNAME " , " " )
path = f " { folder } " if folder else " "
resp = gitea . session . get (
f " { gitea . api } /repos/ { user } / { repo } /contents/ { path } " ,
params = { " ref " : " main " } ,
verify = False ,
timeout = 15 ,
)
if not resp . ok :
print ( f " [GITEA] Репо/папка не найдена ( { resp . status_code } ) " )
return { }
existing : dict [ str , str ] = { }
for item in resp . json ( ) :
if item . get ( " type " ) != " file " :
continue
try :
file_resp = gitea . session . get ( item [ " url " ] , verify = False , timeout = 15 )
if file_resp . ok :
existing [ item [ " name " ] ] = base64 . b64decode (
file_resp . json ( ) . get ( " content " , " " )
) . decode ( " utf-8 " )
except Exception as e :
print ( f " [GITEA] Не удалось прочитать { item . get ( ' name ' ) } : { e } " )
print ( f " [GITEA] Прочитано файлов: { list ( existing . keys ( ) ) } " )
return existing
except Exception as e :
print ( f " [GITEA] Ошибка чтения: { e } — пишу заново " )
return { }
def _rest_update_answer ( task_id : str , url : str ) - > bool :
""" Сохраняет ссылку через REST API — создаёт submission если нет. """
import requests
token = os . getenv ( " JOURNAL_MCP_PAT " )
if not token or not task_id :
return False
try :
# Пробуем оба варианта эндпоинта
for endpoint in [
f " https://platform.brojs.ru/jrnl-bh/api/tasks/ { task_id } /answer " ,
f " https://platform.brojs.ru/jrnl-bh/api/task/ { task_id } /answer " ,
] :
r = requests . patch (
endpoint ,
json = { " answer " : url } ,
headers = { " Authorization " : f " Bearer { token } " , " Content-Type " : " application/json " } ,
timeout = 15 ,
verify = False ,
)
print ( f " [REST] task_update_answer { endpoint } : { r . status_code } " )
if r . ok :
return True
return False
except Exception as e :
print ( f " [REST] task_update_answer error: { e } " )
return False
def _write_files ( repo : str , files : list [ tuple [ str , str ] ] , task_title : str ) - > str :
""" Создаёт репозиторий (если нет) и загружает файлы в корень. """
gitea = GiteaRestClient ( )
# Явно устанавливаем имя репо
gitea . repo = repo
ok = gitea . ensure_repo_named ( repo , description = f " Решение: { task_title } " )
if not ok :
print ( f " [GITEA] ❌ Не удалось создать репозиторий: { repo } " )
uploaded = [ ]
for fname , content in files :
if not content . strip ( ) :
print ( f " [GITEA] ⚠️ { fname } пустой, пропускаю " )
continue
try :
url = gitea . upload_file (
file_path = fname ,
content = content ,
commit_message = f " Добавлен { fname } " ,
)
uploaded . append ( fname )
print ( f " [GITEA] ✅ { fname } → { url } " )
except Exception as e :
print ( f " [GITEA] ❌ { fname } ОШИБКА: { type ( e ) . __name__ } : { e } " )
folder_url = _folder_url ( repo )
print ( f " [GITEA] 📦 Загружено { len ( uploaded ) } / { len ( files ) } файлов: { uploaded } " )
print ( f " [GITEA] 🔗 URL репозитория: { folder_url } " )
return folder_url
def _extract_stack_and_forbidden ( task_description : str ) - > tuple [ str , str ] :
""" Извлекает стек и что нельзя использовать. """
stack_parts = [ ]
forbidden_parts = [ ]
# Раздел Стек
m = re . search ( r " (?:Стек|Stack)[: \ s]+([ \ s \ S]+?)(?: \ n \ n| \ Z) " , task_description , re . IGNORECASE )
if m :
stack_parts . append ( m . group ( 1 ) . strip ( ) [ : 400 ] )
# Явные упоминания технологий
positive = [ " Ollama " , " Qdrant " , " FastMCP " , " LangChain " , " rich " , " FastAPI " ,
" llama3 " , " nomic-embed-text " , " langchain-qdrant " , " langchain-ollama " ,
" create_agent " , " MemorySaver " , " HumanInTheLoopMiddleware " ,
" @tool " , " .stream() " , " .invoke() " ]
found = [ t for t in positive if t in task_description ]
if found :
stack_parts . append ( " Использовать: " + " , " . join ( found ) )
# Запреты — если есть LangChain но нет LangGraph → запрещаем LangGraph
if " LangChain " in task_description and " LangGraph " not in task_description :
forbidden_parts . append ( " НЕ использовать LangGraph StateGraph вместо LangChain create_agent " )
if " .stream() " in task_description and " .invoke() " in task_description :
forbidden_parts . append ( " Заменить .invoke() на .stream() — не оставлять invoke " )
return " \n " . join ( stack_parts ) , " \n " . join ( forbidden_parts )
def _build_solution (
task_title : str ,
task_description : str ,
revision_comment : str = " " ,
existing_files : dict [ str , str ] | None = None ,
) - > list [ tuple [ str , str ] ] :
llm = get_llm ( )
stack_hint , forbidden_hint = _extract_stack_and_forbidden ( task_description )
# ── Анализ ───────────────────────────────────────────────────────
print ( " [PLANNER] 📋 Анализирую условие... " )
r_plan = llm . invoke ( [
SystemMessage ( content = ANALYZER_PROMPT ) ,
HumanMessage ( content = f " Задание: { task_title } \n \n Условие: \n { task_description } " ) ,
] )
plan = _parse_json ( r_plan . content )
if not plan or not plan . get ( " files " ) :
plan = {
" files " : [
{ " filename " : " solution.py " , " purpose " : " основное решение " } ,
{ " filename " : " requirements.txt " , " purpose " : " зависимости " } ,
{ " filename " : " README.md " , " purpose " : " документация " } ,
] ,
" main_file " : " solution.py " ,
}
all_files_info = plan . get ( " files " , [ ] )
tech_stack = plan . get ( " tech_stack " ) or stack_hint
forbidden = plan . get ( " forbidden " ) or forbidden_hint
code_files = [ f for f in all_files_info if f [ " filename " ] not in ( " requirements.txt " , " README.md " ) ]
if not code_files :
print ( " [PLANNER] ⚠️ Нет кодовых файлов — создаю solution.py " )
code_files = [ { " filename " : " solution.py " , " purpose " : " полное решение задания " } ]
print ( f " [PLANNER] 📁 Файлы: { [ f [ ' filename ' ] for f in code_files ] } " )
print ( f " [PLANNER] 🔧 Стек: { tech_stack [ : 120 ] } " )
if forbidden :
print ( f " [PLANNER] 🚫 Запрещено: { forbidden [ : 120 ] } " )
# ── Пишем каждый файл ────────────────────────────────────────────
written_code : dict [ str , str ] = { }
for file_info in code_files :
fname = file_info [ " filename " ]
purpose = file_info . get ( " purpose " , " " )
print ( f " [PLANNER] ✍️ Пишу { fname } ... " )
if revision_comment and existing_files and fname in existing_files :
prompt = REVISER_CODER_PROMPT
user_msg = (
f " Задание: { task_title } \n \n "
f " Условие: \n { task_description } \n \n "
f " Требуемый стек: { tech_stack } \n "
f " ЗАПРЕЩЕНО: { forbidden } \n \n "
f " Комментарий преподавателя: \n { revision_comment } \n \n "
f " Текущий код { fname } : \n { existing_files [ fname ] } "
)
else :
revision_note = (
f " \n \n Комментарий преподавателя (исправь именно это): \n { revision_comment } "
if revision_comment else " "
)
prompt = CODER_PROMPT
user_msg = (
f " Задание: { task_title } \n \n "
f " Условие (читай каждое слово!): \n { task_description } \n \n "
f " ═══ ОБЯЗАТЕЛЬНЫЙ СТЕК ═══ \n { tech_stack } \n \n "
+ ( f " ═══ ЗАПРЕЩЕНО ═══ \n { forbidden } \n \n " if forbidden else " " )
+ f " Пиши ТОЛЬКО файл: { fname } \n "
f " Что делает этот файл: { purpose } "
+ revision_note
)
r_code = llm . invoke ( [ SystemMessage ( content = prompt ) , HumanMessage ( content = user_msg ) ] )
written_code [ fname ] = _strip_md ( r_code . content )
print ( f " [PLANNER] ✅ { fname } : { len ( written_code [ fname ] ) } символов " )
# ── requirements.txt ─────────────────────────────────────────────
print ( " [PLANNER] 📦 Пишу requirements.txt... " )
code_preview = " \n \n # --- \n \n " . join (
f " # { fn } \n { code [ : 2000 ] } " for fn , code in written_code . items ( )
)
r_req = llm . invoke ( [
SystemMessage ( content = REQUIREMENTS_PROMPT ) ,
HumanMessage ( content = code_preview ) ,
] )
requirements = _pin_versions ( _strip_md ( r_req . content ) )
# ── README.md ─────────────────────────────────────────────────────
print ( " [PLANNER] 📄 Пишу README.md... " )
r_readme = llm . invoke ( [
SystemMessage ( content = README_PROMPT ) ,
HumanMessage ( content = (
f " Проект: { task_title } \n Стек: { tech_stack } \n "
f " Файлы: { ' , ' . join ( written_code . keys ( ) ) } \n \n "
f " Условие: \n { task_description [ : 500 ] } \n \n "
f " Код: \n { list ( written_code . values ( ) ) [ 0 ] [ : 400 ] if written_code else ' ' } "
) ) ,
] )
readme = _strip_md ( r_readme . content )
return list ( written_code . items ( ) ) + [ ( " requirements.txt " , requirements ) , ( " README.md " , readme ) ]
# ------------------------------------------------------------------ #
# Инструменты #
# ------------------------------------------------------------------ #
@tool
def plan_and_upload ( task_id : str , task_title : str , task_description : str ) - > str :
"""
Читает условие задания, пишет все файлы строго по указанному стеку и заливает на Gitea.
Возвращает ссылку на ПАПКУ с решением.
Args:
task_id: ID задания
task_title: Название задания
task_description: Полный текст условия — передавай ВЕСЬ текст без сокращений
"""
# Валидация taskId — должен быть MongoDB ObjectId (24 hex символа)
task_id = task_id . strip ( )
if len ( task_id ) != 24 or not all ( c in ' 0123456789abcdef ' for c in task_id . lower ( ) ) :
return f " ❌ Неверный taskId ' { task_id } ' (длина { len ( task_id ) } ). Нужен MongoDB ObjectId — ровно 24 hex символа. Сначала вызови courses_list → tasks_list и скопируй точный id из поля id/_id ответа. "
print ( f " \n [PLANNER] 🚀 Решаю: « { task_title } » " )
repo = _repo_name ( task_title )
print ( f " [PLANNER] 📦 Репозиторий: { repo } " )
files = _build_solution ( task_title , task_description )
url = _write_files ( repo , files , task_title )
print ( f " [PLANNER] 🎉 { url } " )
# Автоматически сохраняем ссылку через REST — создаёт submission
_rest_update_answer ( task_id , url )
return url
@tool
def revise_task ( task_id : str , task_title : str , task_description : str , teacher_comment : str ) - > str :
"""
Исправляет решение по комментарию преподавателя. Читает старые файлы и переписывает их.
Возвращает ссылку на папку.
Args:
task_id: ID задания
task_title: Название задания
task_description: Полный текст условия
teacher_comment: Комментарий преподавателя
"""
# Валидация taskId
task_id = task_id . strip ( )
if len ( task_id ) != 24 or not all ( c in ' 0123456789abcdef ' for c in task_id . lower ( ) ) :
return f " ❌ Неверный taskId ' { task_id } ' (длина { len ( task_id ) } ). Нужен MongoDB ObjectId — ровно 24 hex символа. Вызови courses_list → tasks_list и скопируй точный id. "
print ( f " \n [REVISER] 🔄 Переделываю: « { task_title } » " )
print ( f " [REVISER] 💬 { teacher_comment [ : 200 ] } " )
repo = _repo_name ( task_title )
print ( f " [REVISER] 📦 Репозиторий: { repo } " )
existing = _get_existing_files ( repo )
files = _build_solution ( task_title , task_description , revision_comment = teacher_comment , existing_files = existing )
url = _write_files ( repo , files , task_title )
print ( f " [REVISER] ✅ { url } " )
# Автоматически сохраняем ссылку через REST — создаёт submission
_rest_update_answer ( task_id , url )
return url
@tool
def submit_solution ( task_id : str , solution_url : str ) - > str :
"""
Fallback: сохраняет ссылку и отправляет задание на проверку через REST.
Используй если MCP-инструменты task_update_answer / task_submit не работают.
Args:
task_id: ID задания (24 символа hex)
solution_url: URL репозитория с решением
"""
import requests
token = os . getenv ( " JOURNAL_MCP_PAT " )
if not token :
return " ❌ JOURNAL_MCP_PAT не задан "
headers = { " Authorization " : f " Bearer { token } " , " Content-Type " : " application/json " }
base = " https://platform.brojs.ru/jrnl-bh/api "
# Шаг 1: сохраняем ответ (создаёт submission если нет)
r1 = requests . patch (
f " { base } /tasks/ { task_id } /answer " ,
json = { " answer " : solution_url } , headers = headers , timeout = 30 ,
)
print ( f " [REST] update_answer: { r1 . status_code } " )
# Шаг 2: отправляем на проверку
r2 = requests . post (
f " { base } /tasks/ { task_id } /submit " ,
headers = headers , timeout = 30 ,
)
print ( f " [REST] submit: { r2 . status_code } " )
if r2 . ok :
return f " ✅ Отправлено на проверку: { solution_url } "
if r1 . ok :
return f " ⚠️ Ссылка сохранена, но submit упал ( { r2 . status_code } ): { solution_url } "
return f " ❌ update= { r1 . status_code } { r1 . text [ : 100 ] } , submit= { r2 . status_code } { r2 . text [ : 100 ] } "
# ------------------------------------------------------------------ #
# Граф #
# ------------------------------------------------------------------ #
def _build_graph ( ) :
llm = get_llm ( )
toolsets = load_mcp_toolsets ( )
all_tools = [
* toolsets . journal_tools ,
* toolsets . gitea_tools ,
plan_and_upload ,
revise_task ,
submit_solution ,
]
print ( f " [AGENT] 🔧 Инструментов: { len ( all_tools ) } " )
return create_agent ( model = llm , tools = all_tools , system_prompt = SYSTEM_PROMPT )
agent_graph = _build_graph ( )
if __name__ == " __main__ " :
parser = argparse . ArgumentParser ( )
parser . add_argument ( " --list " , action = " store_true " )
parser . add_argument ( " --task-id " )
args = parser . parse_args ( )
msg = " Покажи список всех курсов и заданий. " if args . list else \
f " Реши задание с id= { args . task_id } . " if args . task_id else \
" Покажи список всех курсов и заданий. "
print ( f " \n { ' = ' * 60 } \n 📝 { msg } \n { ' = ' * 60 } \n " )
result = agent_graph . invoke (
{ " messages " : [ { " role " : " user " , " content " : msg } ] } ,
config = { " recursion_limit " : 50 } ,
)
print ( f " \n { ' = ' * 60 } \n 🤖 { result [ ' messages ' ] [ - 1 ] . content } \n { ' = ' * 60 } \n " )