Загрузить файлы в «/»

This commit is contained in:
2026-05-28 15:48:08 +00:00
commit f7a27c4088
5 changed files with 787 additions and 0 deletions
+627
View File
@@ -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")