Files
bro/agent.py
T

627 lines
29 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Агент-решатель домашних заданий.
Запуск: 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")