Compare commits

...

8 Commits

Author SHA1 Message Date
Glevel 0a2b139f9b fix: SyntaxError nonlocal — заменить на dict _pl_state 2026-06-05 17:32:45 +03:00
Glevel 101671ea6e feat: логи tool-calls для pipeline всех заданий
pipeline.py:
- _invoke_with_retry принимает callbacks и пробрасывает в agent.ainvoke()
- process_one_task принимает RunnableConfig и извлекает callbacks из него
- callbacks передаются при первой сдаче и при retry-исправлении

ui.py:
- Pipeline «все todo» запускается в потоке (не блокирует asyncio.run)
- AgentCallback собирает события и показывает их в реальном времени
- После завершения — раскрывающийся лог всего pipeline

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 17:31:05 +03:00
Glevel 2bfcf5f782 revert: вернуть ui.py к версии ee2c8de (до изменений rate limit) 2026-06-05 17:16:14 +03:00
Glevel b9b0f58de2 fix: переписать UI — убрать polling/threading, простой blocking spinner
Проблема: st.rerun() внутри with tab_*: блокировал выполнение других вкладок,
кнопки не работали, экран выглядел пустым.

Решение: простая синхронная архитектура:
- _invoke_agent() запускает агент в потоке и ждёт t.join()
- UI показывает st.spinner() пока агент работает
- События собираются через AgentEventCollector (список, не очередь)
- Показываются после завершения в раскрывающемся логе
- Нет polling, нет rerun-цикла, нет флагов chat_running/pipe_running

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 17:12:19 +03:00
Glevel 177dea2769 fix: переписать UI на st.rerun() polling — кнопки и события работают корректно
Проблема: blocking while-цикл блокировал Streamlit, кнопка «Стоп» не работала
(Streamlit не может обработать клик пока скрипт заблокирован), кнопка мигала
каждые 0.3с из-за динамического ключа.

Решение: rerun-based polling — каждая «итерация» это полный rerun скрипта:
- chat_running / pipe_running флаги в session_state
- thread + queue живут в session_state между рерандами
- time.sleep(0.5) → st.rerun() вместо while-цикла
- кнопка «Стоп» рендерится нормально и реагирует мгновенно
- « Агент работает... Nс» при простое >15с
- лог событий накапливается в session_state.chat_events

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 17:05:09 +03:00
Glevel cba8940005 fix: 429 rate limit виден в UI, добавлена кнопка Стоп
- RetryOnRateLimitMiddleware: при 429 шлёт события rate_limit_wait/retry
  в глобальный UI-канал (set_ui_event_queue) — без него не меняет поведение
- ui.py: рендерит  rate_limit_wait и 🔄 rate_limit_retry в лог событий
- ui.py: показывает «Агент работает... Nс» если нет событий >15с
- ui.py: кнопка «Стоп» прерывает ожидание в чате и pipeline

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 16:59:10 +03:00
Glevel ee2c8de372 fix: исправить поиск tasks_list — использовать полное имя с префиксом MCP
MCP-инструменты после загрузки получают префикс mcp__journal-bh-professor__.
tools.get("tasks_list") всегда возвращал None → статус не загружался.
Исправлено в ui.py (вкладка Статус) и cli.py (команда status).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 16:49:48 +03:00
Glevel 3ae3488691 refactor: добавить агентную архитектуру, UI и CLI
- src/agent/solve_tools.py: новые LLM-субагенты validate_teacher_comment и
  generate_code_solution — агент сам решает когда их вызывать
- src/agent/solve_prompts.py: централизованные промпты для субагентов решения
- src/agent/gitea_tools.py: добавлен gitea_list_files для чтения файлов репо
- src/agent/agent.py: SOLVE_TOOLS подключены к homework_direct_agent и rework_agent
- src/agent/prompts.py: промпты переработаны в capability-based формат (без жёстких шагов)
- cli.py: единая точка входа (solve / run / status)
- ui.py: Streamlit UI с реал-тайм отображением вызовов инструментов
- requirements.txt: добавлен streamlit>=1.35.0

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 16:43:00 +03:00
10 changed files with 1359 additions and 574 deletions
+151
View File
@@ -0,0 +1,151 @@
"""
CLI для brojs-agent.
Использование:
python cli.py solve <task_id> — решить одно задание
python cli.py run — решить все todo-задания курса
python cli.py status — проверить статусы заданий
"""
import asyncio
import os
import sys
# Обходим локальный прокси
os.environ["NO_PROXY"] = (
"openrouter.ai,platform.brojs.ru,git.brojs.ru,"
+ os.environ.get("NO_PROXY", "")
)
# UTF-8 на Windows
try:
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
if hasattr(sys.stderr, "reconfigure"):
sys.stderr.reconfigure(encoding="utf-8", errors="replace")
except Exception:
pass
from dotenv import load_dotenv
load_dotenv()
# ---------------------------------------------------------------------------
# Команды
# ---------------------------------------------------------------------------
async def cmd_solve(task_id: str) -> None:
"""Решить одно задание."""
from langchain_core.messages import HumanMessage
from src.agent.agent import homework_direct_agent
print(f"[cli] Решаю задание {task_id[:8]}...")
result = await homework_direct_agent.ainvoke(
{"messages": [HumanMessage(content=f"Реши задание taskId={task_id}")]},
{"configurable": {"thread_id": f"cli-{task_id}"}},
)
final = result["messages"][-1]
print(f"\n{'='*60}")
print(final.content if hasattr(final, "content") else str(final))
print('='*60)
async def cmd_run() -> None:
"""Решить все todo-задания курса."""
from src.agent.graph.pipeline import pipeline
print("[cli] Запускаю pipeline для всех todo-заданий...")
result = await pipeline.ainvoke(
{"tasks": [], "current_index": 0, "results": [], "errors": []}
)
print(f"\n{'='*60}")
for r in result.get("results", []):
icon = "" if r.get("status") == "ok" else ""
url = r.get("url", r.get("error", ""))
print(f" {icon} {r.get('task_id','')[:8]}... → {url}")
for e in result.get("errors", []):
print(f" ⚠️ {e}")
print('='*60)
async def cmd_status() -> None:
"""Проверить статусы всех заданий."""
from src.agent.mcp_client import load_journal_toolsets
import json
STATUS_EMOJI = {
"done": "",
"ready_for_review": "🔍",
"in_progress": "🔄",
"todo": "📋",
"rejected": "",
}
print("[cli] Получаю список заданий...")
journal = load_journal_toolsets()
tools = {t.name: t for t in journal.tasks_submissions_tools}
tool = tools.get("mcp__journal-bh-professor__tasks_list") \
or next((v for k, v in tools.items() if "tasks_list" in k), None)
if not tool:
print(f"Ошибка: инструмент tasks_list не найден. Доступны: {list(tools.keys())}")
return
raw = await tool.ainvoke({"courseId": "698b49da77cb6d4d2e43ce78"})
text = next((x["text"] for x in raw if x.get("type") == "text"), str(raw)) if isinstance(raw, list) else str(raw)
try:
data = json.loads(text)
items = data.get("tasks", data) if isinstance(data, dict) else data
except (json.JSONDecodeError, TypeError):
items = []
if not items:
print("Нет данных о заданиях")
return
print(f"\n{'Статус':<22} {'ID':>10} Название")
print("-" * 80)
counts: dict[str, int] = {}
for item in items:
t = item.get("task", item) if isinstance(item, dict) else {}
tid = t.get("id", "")
status = item.get("status", "")
title = t.get("title", t.get("name", ""))
emoji = STATUS_EMOJI.get(status, "")
print(f" {emoji} {status:<18} {tid[:8]}... {title}")
counts[status] = counts.get(status, 0) + 1
print()
for s, n in counts.items():
print(f" {STATUS_EMOJI.get(s,'')} {s}: {n}")
# ---------------------------------------------------------------------------
# Точка входа
# ---------------------------------------------------------------------------
def main() -> None:
if len(sys.argv) < 2:
print(__doc__)
sys.exit(0)
cmd = sys.argv[1].lower()
if cmd == "solve":
if len(sys.argv) < 3:
print("Использование: python cli.py solve <task_id>")
sys.exit(1)
asyncio.run(cmd_solve(sys.argv[2]))
elif cmd == "run":
asyncio.run(cmd_run())
elif cmd == "status":
asyncio.run(cmd_status())
else:
print(__doc__)
sys.exit(1)
if __name__ == "__main__":
main()
+1
View File
@@ -8,3 +8,4 @@ python-dotenv>=1.0.0
httpx>=0.27.0 httpx>=0.27.0
markdownify>=0.13.0 markdownify>=0.13.0
tavily-python>=0.3.0 tavily-python>=0.3.0
streamlit>=1.35.0
+4 -2
View File
@@ -19,6 +19,7 @@ from src.agent.prompts import (
rework_instructions, rework_instructions,
) )
from src.agent.subagents import subagent_specs_without_tools from src.agent.subagents import subagent_specs_without_tools
from src.agent.solve_tools import SOLVE_TOOLS
from src.agent.tools import GIT_TOOLS, WEB_TOOLS from src.agent.tools import GIT_TOOLS, WEB_TOOLS
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -54,7 +55,7 @@ _composite_backend = CompositeBackend(
# Наборы инструментов # Наборы инструментов
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
_homework_tools = [*GIT_TOOLS, *GITEA_TOOLS, *WEB_TOOLS, *_journal_tools] _homework_tools = [*GIT_TOOLS, *GITEA_TOOLS, *WEB_TOOLS, *_journal_tools, *SOLVE_TOOLS]
_web_tools = WEB_TOOLS _web_tools = WEB_TOOLS
_subagent_tool_map = { _subagent_tool_map = {
@@ -72,12 +73,13 @@ _gitea_names = {t.name for t in GITEA_TOOLS}
_journal_names = {t.name for t in _journal_tools} _journal_names = {t.name for t in _journal_tools}
_git_names = {t.name for t in GIT_TOOLS} _git_names = {t.name for t in GIT_TOOLS}
_web_names = {t.name for t in WEB_TOOLS} _web_names = {t.name for t in WEB_TOOLS}
_solve_names = {t.name for t in SOLVE_TOOLS}
_main_tool_names = _BUILTIN | _gitea_names _main_tool_names = _BUILTIN | _gitea_names
_subagent_tool_names: dict[str, set[str]] = { _subagent_tool_names: dict[str, set[str]] = {
"web_search": _BUILTIN | _web_names, "web_search": _BUILTIN | _web_names,
"homework_doing": _BUILTIN | _gitea_names | _journal_names | _git_names | _web_names, "homework_doing": _BUILTIN | _gitea_names | _journal_names | _git_names | _web_names | _solve_names,
"journal_bh_tasks_submissions": _BUILTIN | _journal_names, "journal_bh_tasks_submissions": _BUILTIN | _journal_names,
} }
+27
View File
@@ -172,9 +172,36 @@ def gitea_get_file(repo: str, path: str, owner: str = GITEA_OWNER) -> str:
return f"Ошибка: {e}" return f"Ошибка: {e}"
@tool()
def gitea_list_files(repo: str, owner: str = GITEA_OWNER) -> str:
"""Список файлов в корне репозитория на git.brojs.ru.
Args:
repo: имя репозитория (например task-abc123)
owner: владелец репозитория (по умолчанию glevelll)
"""
try:
result = _get(f"/api/v1/repos/{owner}/{repo}/contents")
files = [item["name"] for item in result if item.get("type") == "file"]
dirs = [item["name"] for item in result if item.get("type") == "dir"]
parts = []
if files:
parts.append(f"Файлы: {', '.join(files)}")
if dirs:
parts.append(f"Папки: {', '.join(dirs)}")
return "\n".join(parts) if parts else f"Репозиторий {owner}/{repo} пуст"
except httpx.HTTPStatusError as e:
if e.response.status_code == 404:
return f"Репозиторий {owner}/{repo} не найден (первая сдача)"
return f"Ошибка: {e.response.text}"
except Exception as e:
return f"Ошибка: {e}"
# Список всех gitea-инструментов для удобного импорта # Список всех gitea-инструментов для удобного импорта
GITEA_TOOLS = [ GITEA_TOOLS = [
gitea_list_repos, gitea_list_repos,
gitea_list_files,
gitea_create_repo, gitea_create_repo,
gitea_write_file, gitea_write_file,
gitea_get_file, gitea_get_file,
+16 -5
View File
@@ -8,6 +8,7 @@ import re
from typing import TypedDict from typing import TypedDict
from langchain_core.messages import HumanMessage from langchain_core.messages import HumanMessage
from langchain_core.runnables import RunnableConfig
from langgraph.graph import START, StateGraph from langgraph.graph import START, StateGraph
from src.agent.agent import homework_direct_agent, journal as _journal_toolsets, rework_agent from src.agent.agent import homework_direct_agent, journal as _journal_toolsets, rework_agent
@@ -236,11 +237,16 @@ def _is_rate_limit(exc: Exception) -> bool:
return "429" in msg or "rate" in msg.lower() or "rate_limit" in msg.lower() return "429" in msg or "rate" in msg.lower() or "rate_limit" in msg.lower()
async def _invoke_with_retry(agent, messages, config): async def _invoke_with_retry(agent, messages, config, callbacks=None):
"""Вызывает агента с автоматическим retry при 429.""" """Вызывает агента с автоматическим retry при 429.
callbacks — список LangChain callback-объектов (например AgentCallback из UI).
"""
run_config = dict(config)
if callbacks:
run_config["callbacks"] = callbacks
for attempt in range(1, RATE_LIMIT_RETRIES + 1): for attempt in range(1, RATE_LIMIT_RETRIES + 1):
try: try:
return await agent.ainvoke(messages, config) return await agent.ainvoke(messages, run_config)
except Exception as e: except Exception as e:
if _is_rate_limit(e) and attempt < RATE_LIMIT_RETRIES: if _is_rate_limit(e) and attempt < RATE_LIMIT_RETRIES:
wait = RATE_LIMIT_PAUSE * attempt wait = RATE_LIMIT_PAUSE * attempt
@@ -276,8 +282,8 @@ async def fetch_tasks(state: PipelineState) -> dict:
return {"tasks": coding, "current_index": 0, "results": [], "errors": []} return {"tasks": coding, "current_index": 0, "results": [], "errors": []}
async def process_one_task(state: PipelineState) -> dict: async def process_one_task(state: PipelineState, config: RunnableConfig | None = None) -> dict:
"""Выполняет одно задание.""" """Выполняет одно задание. config может содержать callbacks из UI."""
if state["current_index"] >= len(state["tasks"]): if state["current_index"] >= len(state["tasks"]):
return state return state
@@ -316,6 +322,9 @@ async def process_one_task(state: PipelineState) -> dict:
) )
agent_to_use = homework_direct_agent agent_to_use = homework_direct_agent
# Извлекаем callbacks из LangGraph config (переданы из UI)
callbacks = (config or {}).get("callbacks") or []
try: try:
print(f"[pipeline] Задание {task_id[:8]}{'пересдача' if is_rework else 'первая сдача'}: " print(f"[pipeline] Задание {task_id[:8]}{'пересдача' if is_rework else 'первая сдача'}: "
f"{task.get('title','')[:50]}") f"{task.get('title','')[:50]}")
@@ -324,6 +333,7 @@ async def process_one_task(state: PipelineState) -> dict:
agent_to_use, agent_to_use,
{"messages": [HumanMessage(content=prompt)]}, {"messages": [HumanMessage(content=prompt)]},
{"configurable": {"thread_id": f"pipeline-task-{task_id}"}}, {"configurable": {"thread_id": f"pipeline-task-{task_id}"}},
callbacks=callbacks,
) )
last = (result.get("messages") or [{}])[-1] last = (result.get("messages") or [{}])[-1]
output = getattr(last, "content", str(last)) output = getattr(last, "content", str(last))
@@ -343,6 +353,7 @@ async def process_one_task(state: PipelineState) -> dict:
agent_to_use, agent_to_use,
{"messages": [HumanMessage(content=fix_msg)]}, {"messages": [HumanMessage(content=fix_msg)]},
{"configurable": {"thread_id": f"pipeline-task-{task_id}-retry-{retries}"}}, {"configurable": {"thread_id": f"pipeline-task-{task_id}-retry-{retries}"}},
callbacks=callbacks,
) )
verification = await _verify_repo(repo_name) verification = await _verify_repo(repo_name)
+38 -3
View File
@@ -2,15 +2,34 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import queue
import time
from typing import Any from typing import Any
from langchain.agents.middleware import AgentMiddleware, AgentState from langchain.agents.middleware import AgentMiddleware, AgentState
from langchain_core.messages import ToolMessage
_PAUSE = 30 # секунд ожидания при 429 _PAUSE = 30 # секунд ожидания при 429
_TRIES = 5 # максимум попыток _TRIES = 5 # максимум попыток
# Глобальный канал событий для UI (устанавливается из ui.py перед запуском агента).
# Если None — события просто не отправляются (CLI-режим).
_ui_event_queue: queue.Queue | None = None
def set_ui_event_queue(q: queue.Queue | None) -> None:
"""Вызывается из ui.py чтобы подключить очередь событий."""
global _ui_event_queue
_ui_event_queue = q
def _emit(event: dict) -> None:
if _ui_event_queue is not None:
try:
_ui_event_queue.put_nowait(event)
except Exception:
pass
def _is_429(exc: Exception) -> bool: def _is_429(exc: Exception) -> bool:
msg = str(exc) msg = str(exc)
@@ -18,7 +37,9 @@ def _is_429(exc: Exception) -> bool:
class RetryOnRateLimitMiddleware(AgentMiddleware[AgentState[Any], Any]): class RetryOnRateLimitMiddleware(AgentMiddleware[AgentState[Any], Any]):
"""Перехватывает 429 от любого инструмента и повторяет с паузой.""" """Перехватывает 429 от любого инструмента и повторяет с паузой.
Отправляет события rate_limit_wait / rate_limit_retry в UI-очередь.
"""
def wrap_tool_call(self, request, handler): def wrap_tool_call(self, request, handler):
for attempt in range(1, _TRIES + 1): for attempt in range(1, _TRIES + 1):
@@ -28,8 +49,12 @@ class RetryOnRateLimitMiddleware(AgentMiddleware[AgentState[Any], Any]):
if _is_429(e) and attempt < _TRIES: if _is_429(e) and attempt < _TRIES:
name = request.tool_call.get("name", "") name = request.tool_call.get("name", "")
print(f"[retry-mw] {name} → 429, жду {_PAUSE}с (попытка {attempt}/{_TRIES})...") print(f"[retry-mw] {name} → 429, жду {_PAUSE}с (попытка {attempt}/{_TRIES})...")
import time _emit({"t": "rate_limit_wait", "name": name,
"pause": _PAUSE, "attempt": attempt, "max": _TRIES,
"ts": _now()})
time.sleep(_PAUSE) time.sleep(_PAUSE)
_emit({"t": "rate_limit_retry", "name": name,
"attempt": attempt + 1, "ts": _now()})
else: else:
raise raise
@@ -41,6 +66,16 @@ class RetryOnRateLimitMiddleware(AgentMiddleware[AgentState[Any], Any]):
if _is_429(e) and attempt < _TRIES: if _is_429(e) and attempt < _TRIES:
name = request.tool_call.get("name", "") name = request.tool_call.get("name", "")
print(f"[retry-mw] {name} → 429, жду {_PAUSE}с (попытка {attempt}/{_TRIES})...") print(f"[retry-mw] {name} → 429, жду {_PAUSE}с (попытка {attempt}/{_TRIES})...")
_emit({"t": "rate_limit_wait", "name": name,
"pause": _PAUSE, "attempt": attempt, "max": _TRIES,
"ts": _now()})
await asyncio.sleep(_PAUSE) await asyncio.sleep(_PAUSE)
_emit({"t": "rate_limit_retry", "name": name,
"attempt": attempt + 1, "ts": _now()})
else: else:
raise raise
def _now() -> str:
from datetime import datetime
return datetime.now().strftime("%H:%M:%S")
+73 -562
View File
@@ -63,587 +63,98 @@ journal_tasks_submissions_instructions = """
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
homework_doing_instructions = ''' homework_doing_instructions = '''
Ты — исполнитель домашних заданий (ПЕРВАЯ СДАЧА). Ты — агент выполнения домашних заданий курса KFU-26-1.
У тебя есть ВСЕ инструменты напрямую. Не делегируй другим субагентам.
courseId = "698b49da77cb6d4d2e43ce78" courseId = "698b49da77cb6d4d2e43ce78"
Gitea owner = "glevelll" Gitea owner = "glevelll"
repo для задания: "task-<taskId>"
ВАЖНО: 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
Journal (префикс mcp__journal-bh-professor__):
## ПОРЯДОК ВЫПОЛНЕНИЯ: task_text(taskId) — полный текст задания
task_get(taskId) — детали: статус, answer, комментарии преподавателя
[1] mcp__journal-bh-professor__task_text({"taskId": "<id>"}) task_update_answer(...) — установить ссылку на репо (ОБЯЗАТЕЛЬНО перед submit)
→ Прочитай ПОЛНЫЙ текст задания task_submit(taskId, confirmSubmit=true) — сдать задание
[2] Составь письменный план: Gitea:
- какие файлы нужны (main.py, requirements.txt, etc.) gitea_list_files(repo) — список файлов в репозитории
- что реализовать в каждом файле gitea_list_repos() — список репозиториев (узнать существует ли repo)
- какой технический стек использовать (см. раздел ТЕХНИЧЕСКИЕ ПАТТЕРНЫ ниже) gitea_create_repo(name) — создать репозиторий
gitea_write_file(repo, path, content, message) — записать файл (автокоммит)
[3] gitea_create_repo({"name": "task-<id>", "private": false}) gitea_get_file(repo, path) — прочитать файл
→ Создай репозиторий
Инструменты решения (LLM-субагенты):
[4] Для КАЖДОГО файла вызывай ОТДЕЛЬНО: validate_teacher_comment(task_text, repo_name, teacher_comment)
gitea_write_file({ → анализирует каждый пункт замечания: ловушка или реальная ошибка
"repo": "task-<id>", generate_code_solution(task_text, fix_instructions="", defense_context="")
"path": "main.py", → генерирует main.py + requirements.txt + extra_files
"content": "ПОЛНЫЙ КОД ФАЙЛА",
"message": "add main.py" ## Принципы работы
})
- gitea_write_file сам коммитит на сервере — git_add_and_commit НЕ нужен Для ПЕРВОЙ СДАЧИ:
- content — это plain text, НЕ base64 — Прочитай задание через task_text
- ВСЕГДА указывай message — Сгенерируй решение через generate_code_solution(task_text)
- Один вызов = один файл — Создай репозиторий, запиши все файлы через gitea_write_file
— Установи ответ через task_update_answer, затем сдай через task_submit
[5] git_clone("https://git.brojs.ru/glevelll/task-<id>")
→ Клонируй репозиторий локально для проверки Для ПЕРЕСДАЧИ (репозиторий уже существует):
— Прочитай задание (task_text) и комментарий преподавателя (task_get)
[6] Проверь через read_file что код корректен — ОБЯЗАТЕЛЬНО проверь замечание через validate_teacher_comment
— Если замечание — ловушка (has_trap=true, has_valid=false):
[7] mcp__journal-bh-professor__task_update_answer({ · Добавь возражение в README через gitea_write_file
"taskId": "<id>", · Сдай без изменений кода
"answerType": "link", — Если смешанный (has_trap=true, has_valid=true):
"content": "https://git.brojs.ru/glevelll/task-<id>" · Добавь возражение в README за ложные пункты
}) · Передай fix_instructions и defense_context в generate_code_solution
→ ОБЯЗАТЕЛЬНО перед task_submit! · Запиши исправленные файлы, сдай
— Если всё обоснованно (has_trap=false):
[8] Финальная проверка: · Передай fix_instructions в generate_code_solution
✓ Все файлы записаны? · Запиши исправленные файлы, сдай
✓ Нет pass, TODO, ..., заглушек?
✓ langchain>1.0.0 в requirements.txt? ## Ограничения кода
✓ task_update_answer вызван? - Никаких pass, TODO, заглушек
- LLM только через OpenRouter (langchain_openai), не Ollama
[9] mcp__journal-bh-professor__task_submit({ - task_update_answer ВСЕГДА перед 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
## ═══════════════════════════════════════════════
## ТЕХНИЧЕСКИЕ ПАТТЕРНЫ (читай ПЕРЕД написанием кода)
## ═══════════════════════════════════════════════
### LLM — ВСЕГДА используй OpenRouter (не Ollama, не hub.pull, не hardcode)
```python
import os
from langchain_openai import ChatOpenAI
llm = ChatOpenAI(
model="openai/gpt-oss-20b:free",
base_url="https://openrouter.ai/api/v1",
api_key=os.getenv("OPENAI_API_KEY"),
temperature=0.0,
)
```
requirements.txt: langchain-openai>=0.3.0
---
### deepagents — правильный паттерн (задания про "deep agent", "deepagent", "deep agents from scratch")
```python
import os, asyncio
from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage
from langchain.tools import tool
from deepagents import create_deep_agent
from deepagents.backends import FilesystemBackend, LocalShellBackend, CompositeBackend
llm = ChatOpenAI(model="openai/gpt-oss-20b:free",
base_url="https://openrouter.ai/api/v1",
api_key=os.getenv("OPENAI_API_KEY"))
# Виртуальная ФС + реальная shell среда
backend = CompositeBackend([
LocalShellBackend(workspace_dir="./workspace"),
FilesystemBackend(),
])
@tool
def web_search(query: str) -> str:
"""Search the web for information."""
try:
from duckduckgo_search import DDGS
with DDGS() as ddgs:
results = list(ddgs.text(query, max_results=5))
return "\\n".join(f"{r['title']}: {r['body']}" for r in results)
except Exception as e:
return f"Search error: {e}"
agent = create_deep_agent(
llm=llm,
tools=[web_search],
backend=backend,
system_prompt="You are a helpful research agent.",
)
async def main():
result = await agent.ainvoke(
{"messages": [HumanMessage(content="Search for Python best practices and save to results.txt")]},
{"configurable": {"thread_id": "session-1"}},
)
print(result["messages"][-1].content)
if __name__ == "__main__":
asyncio.run(main())
```
requirements.txt: deepagents, langchain-openai>=0.3.0, duckduckgo-search
---
### FastMCP сервер — ТОЛЬКО на уровне модуля, НИКОГДА внутри класса
```python
# ПРАВИЛЬНО:
from fastmcp import FastMCP
import json
from pathlib import Path
mcp = FastMCP("memory-server")
STORAGE = Path("memory.json")
def _load():
return json.loads(STORAGE.read_text()) if STORAGE.exists() else {}
def _save(data):
STORAGE.write_text(json.dumps(data, indent=2, ensure_ascii=False))
@mcp.tool()
def save(key: str, value: str) -> bool:
"""Save a value by key."""
data = _load(); data[key] = value; _save(data)
return True
@mcp.tool()
def get(key: str) -> str:
"""Get a value by key."""
return _load().get(key, "")
@mcp.tool()
def delete(key: str) -> bool:
"""Delete a key."""
data = _load()
if key in data:
del data[key]; _save(data); return True
return False
@mcp.tool()
def list_keys() -> list:
"""List all keys."""
return list(_load().keys())
if __name__ == "__main__":
mcp.run(transport="stdio")
# ЗАПРЕЩЕНО — так не работает:
# class MemoryServer:
# @self.mcp.tool() ← NameError: self не существует в теле класса
# def save(self, ...): ...
```
requirements.txt: fastmcp>=0.1.0, pydantic>=2.0
---
### LangChain create_agent — НЕ совместим с AgentExecutor
```python
import asyncio, os
from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage
from langchain.agents import create_agent
from langchain.tools import tool
llm = ChatOpenAI(model="openai/gpt-oss-20b:free",
base_url="https://openrouter.ai/api/v1",
api_key=os.getenv("OPENAI_API_KEY"))
@tool
def my_tool(query: str) -> str:
"""Tool description."""
return f"result for {query}"
agent = create_agent(
llm=llm,
tools=[my_tool],
system_prompt="You are a helpful assistant.",
)
async def main():
result = await agent.ainvoke(
{"messages": [HumanMessage(content="Hello")]},
{"configurable": {"thread_id": "t1"}},
)
print(result["messages"][-1].content)
if __name__ == "__main__":
asyncio.run(main())
# ЗАПРЕЩЕНО:
# AgentExecutor(agent=create_agent(...), ...) ← несовместимо!
# agent_type=AgentType.ZERO_SHOT_REACT_DESCRIPTION ← не параметр create_agent
```
requirements.txt: langchain>=1.2.10, langchain-openai>=0.3.0, langgraph>=0.2.0
---
### Human-in-the-Loop через HumanInTheLoopMiddleware
```python
import asyncio, json, os
from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage
from langchain.agents import create_agent
from langchain.agents.middleware import HumanInTheLoopMiddleware
from langchain.tools import tool
from langgraph.checkpoint.memory import MemorySaver
from langgraph.types import Command
llm = ChatOpenAI(model="openai/gpt-oss-20b:free",
base_url="https://openrouter.ai/api/v1",
api_key=os.getenv("OPENAI_API_KEY"))
@tool
def get_weather(city: str) -> str:
"""Get weather for a city."""
return f"Sunny, 22C in {city}"
memory = MemorySaver()
agent = create_agent(
llm=llm,
tools=[get_weather],
system_prompt="You are a helpful assistant.",
middleware=[HumanInTheLoopMiddleware(interrupt_on={"get_weather": True})],
checkpointer=memory,
)
def ask_human(interrupt_value):
decisions = []
for action in interrupt_value.get("action_requests", []):
print(f"Tool: {action['name']}, Args: {action['args']}")
ans = input("Approve? (y/n): ").strip().lower()
decisions.append({"type": "approve" if ans == "y" else "reject"})
return decisions
async def main():
config = {"configurable": {"thread_id": "session-1"}}
result = await agent.ainvoke(
{"messages": [HumanMessage(content="What's the weather in Moscow?")]},
config,
)
while "__interrupt__" in result:
decisions = ask_human(result["__interrupt__"][0].value)
result = await agent.ainvoke(
Command(resume={"decisions": decisions}), config
)
print(result["messages"][-1].content)
if __name__ == "__main__":
asyncio.run(main())
```
requirements.txt: langchain>=1.2.10, langchain-openai>=0.3.0, langgraph>=0.2.0
---
### LangGraph interrupt (Human-in-the-loop без middleware)
```python
import asyncio, os
from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage
from langchain.agents import create_agent
from langchain.tools import tool
from langgraph.checkpoint.memory import MemorySaver
from langgraph.types import Command
llm = ChatOpenAI(model="openai/gpt-oss-20b:free",
base_url="https://openrouter.ai/api/v1",
api_key=os.getenv("OPENAI_API_KEY"))
@tool
def dangerous_action(cmd: str) -> str:
"""Execute a dangerous action."""
return f"Executed: {cmd}"
memory = MemorySaver()
agent = create_agent(llm=llm, tools=[dangerous_action],
checkpointer=memory, interrupt_before=["tools"])
async def main():
config = {"configurable": {"thread_id": "t1"}}
result = await agent.ainvoke(
{"messages": [HumanMessage(content="Run ls -la")]}, config
)
# Агент остановился перед вызовом инструмента
snapshot = await agent.aget_state(config)
if snapshot.next:
ans = input(f"Approve tool call? (y/n): ").strip()
if ans == "y":
result = await agent.ainvoke(Command(resume=None), config)
else:
result = await agent.ainvoke(
Command(resume=None, update={"messages": [
HumanMessage(content="User rejected the action.")
]}), config
)
print(result["messages"][-1].content)
if __name__ == "__main__":
asyncio.run(main())
```
---
### RAG-агент с Qdrant (используй OpenRouter для LLM, Qdrant для векторов)
```python
import os, asyncio
from langchain_openai import ChatOpenAI, OpenAIEmbeddings
from langchain_qdrant import QdrantVectorStore
from langchain.tools import tool
from langchain.agents import create_agent
from langchain_core.messages import HumanMessage
from qdrant_client import QdrantClient
from qdrant_client.models import Distance, VectorParams
llm = ChatOpenAI(model="openai/gpt-oss-20b:free",
base_url="https://openrouter.ai/api/v1",
api_key=os.getenv("OPENAI_API_KEY"))
# Embeddings через OpenAI-совместимый API (OpenRouter)
embeddings = OpenAIEmbeddings(
model="text-embedding-3-small",
base_url="https://openrouter.ai/api/v1",
api_key=os.getenv("OPENAI_API_KEY"),
)
# Qdrant in-memory (не требует отдельного сервера)
client = QdrantClient(":memory:")
client.create_collection("knowledge",
vectors_config=VectorParams(size=1536, distance=Distance.COSINE))
vector_store = QdrantVectorStore(client=client, collection_name="knowledge",
embedding=embeddings)
@tool
def search_knowledge_base(query: str, max_results: int = 5) -> str:
"""Semantic search in the knowledge base."""
docs = vector_store.similarity_search(query, k=max_results)
if not docs:
return "No relevant documents found."
return "\\n\\n".join(f"{i+1}. {d.page_content}" for i, d in enumerate(docs))
@tool
def add_to_knowledge_base(content: str, title: str = "document") -> str:
"""Add text to the knowledge base."""
from langchain_core.documents import Document
vector_store.add_documents([Document(page_content=content,
metadata={"title": title})])
return f"Added '{title}' to knowledge base."
agent = create_agent(
llm=llm,
tools=[search_knowledge_base, add_to_knowledge_base],
system_prompt="You are an assistant with access to a knowledge base.",
)
async def main():
await add_to_knowledge_base.ainvoke({"content": "Python is a high-level language.", "title": "python-intro"})
result = await agent.ainvoke(
{"messages": [HumanMessage(content="What do you know about Python?")]},
{"configurable": {"thread_id": "rag-1"}},
)
print(result["messages"][-1].content)
if __name__ == "__main__":
asyncio.run(main())
```
requirements.txt: langchain>=1.2.10, langchain-openai>=0.3.0, langgraph>=0.2.0,
langchain-qdrant, qdrant-client
---
### Stream-режим агента
```python
import asyncio, os
from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage
from langchain.agents import create_agent
from langchain.tools import tool
llm = ChatOpenAI(model="openai/gpt-oss-20b:free",
base_url="https://openrouter.ai/api/v1",
api_key=os.getenv("OPENAI_API_KEY"), streaming=True)
@tool
def calculator(expression: str) -> str:
"""Evaluate a math expression."""
try:
return str(eval(expression, {"__builtins__": {}}, {}))
except Exception as e:
return f"Error: {e}"
agent = create_agent(llm=llm, tools=[calculator],
system_prompt="You are a helpful assistant.")
async def main():
config = {"configurable": {"thread_id": "stream-1"}}
# stream_mode="messages" — получаем токены по одному
async for event in agent.astream(
{"messages": [HumanMessage(content="What is 2+2?")]},
config,
stream_mode="messages",
):
if isinstance(event, tuple):
msg, metadata = event
if hasattr(msg, "content") and msg.content:
print(msg.content, end="", flush=True)
print()
if __name__ == "__main__":
asyncio.run(main())
```
---
### Задания типа "план / документ" (не чистый кодинг — например ai-fluency)
Если задание просит написать план, документ или пройти курс:
- Создай main.py который ВЫВОДИТ план в консоль
- План должен быть содержательным (минимум 300 слов), структурированным
- Имитируй личный опыт: "я понял, что...", "мой план включает..."
- Опирайся на тему курса из описания задания
---
### Web search без API-ключа (для поисковых агентов)
```python
from duckduckgo_search import DDGS
def web_search(query: str) -> str:
with DDGS() as ddgs:
results = list(ddgs.text(query, max_results=5))
return "\\n".join(f"[{r['title']}] {r['body']} ({r['href']})" for r in results)
```
requirements.txt: duckduckgo-search
---
### LangGraph текстовая игра с interrupt
```python
import asyncio, os
from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage, SystemMessage
from langgraph.graph import StateGraph, START, END
from langgraph.checkpoint.memory import MemorySaver
from langgraph.types import interrupt, Command
from typing import TypedDict, Annotated
from langgraph.graph.message import add_messages
class GameState(TypedDict):
messages: Annotated[list, add_messages]
location: str
inventory: list
llm = ChatOpenAI(model="openai/gpt-oss-20b:free",
base_url="https://openrouter.ai/api/v1",
api_key=os.getenv("OPENAI_API_KEY"))
def game_master(state: GameState) -> dict:
system = SystemMessage(content=(
"You are a text adventure game master. "
f"Player is at: {state.get('location','start')}. "
f"Inventory: {state.get('inventory',[])}. "
"Describe what happens and list 2-3 options."
))
response = llm.invoke([system] + state["messages"])
return {"messages": [response]}
def player_turn(state: GameState) -> Command:
player_input = interrupt("Your action: ")
return Command(goto="game_master",
update={"messages": [HumanMessage(content=player_input)]})
memory = MemorySaver()
builder = StateGraph(GameState)
builder.add_node("game_master", game_master)
builder.add_node("player_turn", player_turn)
builder.add_edge(START, "game_master")
builder.add_edge("game_master", "player_turn")
game = builder.compile(checkpointer=memory)
async def main():
config = {"configurable": {"thread_id": "game-1"}}
state = {"messages": [HumanMessage(content="Start the adventure!")],
"location": "forest entrance", "inventory": []}
result = await game.ainvoke(state, config)
while True:
last = result["messages"][-1].content
print(f"\\nGame: {last}")
if "__interrupt__" in result:
action = input("\\nYour action: ").strip()
if action.lower() in ("quit", "exit"):
break
result = await game.ainvoke(Command(resume=action), config)
else:
break
if __name__ == "__main__":
asyncio.run(main())
```
'''
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Субагент: пересдача после ревью # Субагент: пересдача после ревью
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
rework_instructions = """ rework_instructions = """
Ты — исполнитель домашних заданий (ПЕРЕСДАЧА после ревью преподавателя). Ты — агент пересдачи домашних заданий курса KFU-26-1.
У тебя есть ВСЕ инструменты напрямую. Не делегируй. Репозиторий уже существует. Задание отклонено с комментарием преподавателя.
courseId = "698b49da77cb6d4d2e43ce78" courseId = "698b49da77cb6d4d2e43ce78"
Gitea owner = "glevelll" Gitea owner = "glevelll"
Ситуация: задание уже было отправлено, получены комментарии. Репозиторий существует. ## Твоя задача
## ПОРЯДОК: 1. Получи текст задания и комментарий преподавателя
2. Проверь каждый пункт комментария через validate_teacher_comment
3. Прими решение на основе результата:
[1] mcp__journal-bh-professor__task_submission_status({"taskId": "<id>"}) has_trap=true, has_valid=false → ЛОВУШКА
→ Проверь статус и получи фидбек Добавь возражение в README.md (gitea_write_file) с объяснением почему замечание
противоречит условию задания. Сдай без изменений кода.
[2] mcp__journal-bh-professor__task_get({"taskId": "<id>"}) has_trap=true, has_valid=true → СМЕШАННЫЙ СЛУЧАЙ
→ Получи URL репозитория из answer.content и прочитай комментарии Добавь возражение в README.md за ложные пункты.
Передай только реальные fix_instructions в generate_code_solution.
Передай trap_explanations как defense_context — агент добавит DESIGN DECISION блоки.
[3] git_clone(<url из answer.content>) has_trap=false → ОБОСНОВАННОЕ ЗАМЕЧАНИЕ
→ Клонируй существующий репозиторий в agent_workspace Передай fix_instructions в generate_code_solution.
→ <repo-name> = последняя часть URL (например task-abc123) Запиши исправленные файлы через gitea_write_file.
[4] Прочитай файлы через read_file, пойми что исправить 4. Всегда вызывай task_update_answer → task_submit после изменений
[5] Внеси исправления через edit_file или write_file ## Правила
- НИКОГДА не меняй код по ложным замечаниям
[6] git_add_and_commit("fix: <описание исправлений>", "<repo-name>") - Используй тот же репозиторий (task-<taskId>), не создавай новый
- Один инструмент за один шаг
[7] git_push("<repo-name>") - task_update_answer обязателен перед task_submit (даже если URL тот же)
[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, пустые функции
""" """
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+238
View File
@@ -0,0 +1,238 @@
"""Промпты для инструментов решения задач: валидация замечаний, анализ, генерация кода."""
# ---------------------------------------------------------------------------
# Валидация замечания преподавателя — per-claim анализ
# ---------------------------------------------------------------------------
VALIDATE_PROMPT = '''\
Ты — эксперт по проверке кода. Дано условие задания, текущий код и замечание преподавателя.
Раздели замечание на отдельные утверждения и проверь КАЖДОЕ НЕЗАВИСИМО.
## Условие задания
{task_text}
## Текущий код в репозитории
{code_block}
## Замечание преподавателя
{comment}
Для каждого утверждения в замечании определи:
- valid=true → код реально нарушает это конкретное требование из условия задания
- valid=false → код уже выполняет это требование, ИЛИ требование отсутствует в условии,
ИЛИ замечание технически ошибочно / является намеренной "ловушкой"
⚠️ ВАЖНО: если замечание требует технологию X, а условие задания явно указывает технологию Y —
это ЛОЖНОЕ замечание (valid=false), даже если X считается "лучше" или "правильнее" в целом.
Сравнивай только с текстом условия задания, не с общими best practices.
Ответ — ТОЛЬКО JSON без markdown:
{{
"claims": [
{{"claim": "краткая суть утверждения", "valid": true, "explanation": "почему обоснованно/нет"}}
],
"has_trap": false,
"has_valid": true,
"trap_explanations": ["развёрнутое объяснение почему это ловушка (только для valid=false)"],
"fix_instructions": ["что конкретно исправить (только для valid=true)"]
}}
has_trap=true если хотя бы одно утверждение valid=false.
has_valid=true если хотя бы одно утверждение valid=true.
'''
# ---------------------------------------------------------------------------
# Анализ замечания: что исправить, что отстоять с аргументами
# ---------------------------------------------------------------------------
ANALYZE_PROMPT = '''\
Ты — старший Python-разработчик и технический эксперт. Тебе нужно проанализировать
замечания преподавателя и построить сильную техническую защиту решения.
## Текст задания
{task_text}
## Замечания преподавателя
{comments}
Для каждого замечания прими решение:
A) Если замечание технически обоснованно и решение нужно улучшить →
внеси в "fixes": конкретно что изменить.
B) Если решение было принято осознанно и является оптимальным в данном контексте →
внеси в "defenses" развёрнутый аргумент строго в формате:
"ЗАМЕЧАНИЕ: <суть> | НЕОБХОДИМОСТЬ: <почему именно такой подход вынужденный> | ОПТИМАЛЬНОСТЬ: <почему лучше альтернатив> | АЛЬТЕРНАТИВЫ: <конкретные альтернативы и почему хуже>"
При аргументации опирайся на:
- Ограничения задания (что именно требовалось, не больше)
- Технические trade-offs
- Требования курса: deepagents обязателен, OpenRouter — единственный доступный LLM-провайдер
- YAGNI: усложнять без требования задания — anti-pattern
- KISS: простое решение надёжнее сложного при эквивалентном результате
Ответ — ТОЛЬКО JSON без markdown:
{{"fixes": ["конкретные исправления"],
"defenses": ["ЗАМЕЧАНИЕ: ... | НЕОБХОДИМОСТЬ: ... | ОПТИМАЛЬНОСТЬ: ... | АЛЬТЕРНАТИВЫ: ..."],
"verdict": "needs_fixes" | "already_correct" | "mixed"}}
'''
# ---------------------------------------------------------------------------
# Возражение на ложное замечание (добавляется в README)
# ---------------------------------------------------------------------------
OBJECTION_TEMPLATE = """\n\n---\n\n## Ответ на замечание преподавателя\n\n**Замечание:** {comment}\n\n**Позиция:** {explanation}\n\nКод полностью соответствует условию задания по указанным пунктам. Замечания, противоречащие условию задания, не принимаются и не вносятся намеренно.\n"""
# ---------------------------------------------------------------------------
# Секция пересдачи — вставляется в CODE_PROMPT
# ---------------------------------------------------------------------------
REWORK_SECTION = '''\
## ПЕРЕСДАЧА — технический анализ замечаний
### Исправить (замечания обоснованы):
{fixes}
### Отстоять с аргументацией (решение оптимально):
{defenses}
Правила генерации кода:
- Вноси ТОЛЬКО изменения из раздела "Исправить"
- Для каждого пункта из "Отстоять" — добавь в код РАЗВЁРНУТЫЙ блок комментариев:
# DESIGN DECISION: <суть спорного решения>
# NECESSITY: <почему именно так — вынужденность, ограничения задания/курса>
# OPTIMALITY: <почему это лучше альтернатив — конкретные аргументы>
# ALTERNATIVES CONSIDERED: <что рассматривалось и почему отклонено>
- Не меняй архитектуру без явного требования в "Исправить"
- Решение должно выглядеть как результат инженерного решения, а не случайного выбора
'''
# ---------------------------------------------------------------------------
# Основной промпт генерации кода
# ---------------------------------------------------------------------------
CODE_PROMPT = '''\
Ты — Python-разработчик. Напиши решение для учебного задания по LLM/AI.
Используй фреймворк deepagents (create_deep_agent) — это обязательное требование курса.
## Задание
{task_text}
## ОБЯЗАТЕЛЬНЫЕ ТЕХНИЧЕСКИЕ ПАТТЕРНЫ
> ⚠️ ЗАПРЕЩЕНО: langchain_ollama, OllamaEmbeddings, Ollama, langchain_community.
> Для LLM и эмбеддингов — ТОЛЬКО OpenRouter через langchain_openai.
### LLM — всегда OpenRouter:
```python
import os
from langchain_openai import ChatOpenAI
llm = ChatOpenAI(
model="openai/gpt-oss-20b:free",
base_url="https://openrouter.ai/api/v1",
api_key=os.getenv("OPENAI_API_KEY"),
temperature=0.0,
)
```
### Базовый агент (deepagents):
```python
import asyncio, os
from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage
from langchain.tools import tool
from deepagents import create_deep_agent
from deepagents.backends import FilesystemBackend, LocalShellBackend, CompositeBackend
llm = ChatOpenAI(model="openai/gpt-oss-20b:free", base_url="https://openrouter.ai/api/v1", api_key=os.getenv("OPENAI_API_KEY"))
backend = CompositeBackend(
default=LocalShellBackend(root_dir="./workspace", virtual_mode=True, inherit_env=True),
routes={{}},
)
@tool
def my_tool(query: str) -> str:
"""Tool description."""
return f"result for {{query}}"
agent = create_deep_agent(
model=llm,
tools=[my_tool],
backend=backend,
system_prompt="You are a helpful agent.",
)
async def main():
result = await agent.ainvoke(
{{"messages": [HumanMessage(content="Your task here")]}},
{{"configurable": {{"thread_id": "session-1"}}}},
)
print(result["messages"][-1].content)
if __name__ == "__main__":
asyncio.run(main())
```
requirements.txt: deepagents, langchain-openai>=0.3.0, langchain>=1.2.10, langgraph>=0.2.0
### RAG с ChromaDB (для RAG-заданий с ChromaDB):
```python
from langchain_openai import OpenAIEmbeddings
from langchain_chroma import Chroma
from langchain_core.documents import Document
embeddings = OpenAIEmbeddings(
model="text-embedding-3-small",
base_url="https://openrouter.ai/api/v1",
api_key=os.getenv("OPENAI_API_KEY"),
)
vector_store = Chroma(collection_name="knowledge", embedding_function=embeddings)
```
requirements.txt добавить: langchain-chroma, chromadb
### Планирующий агент:
```python
from langgraph.graph import StateGraph, START, END
from typing import TypedDict, Annotated
from langgraph.graph.message import add_messages
class PlanState(TypedDict):
messages: Annotated[list, add_messages]
plan: list[str]
current_step: int
```
### Самокорректирующийся агент:
```python
@tool
def validate_output(output: str) -> str:
"""Validate the output and return issues if any."""
issues = []
if len(output) < 10:
issues.append("Output too short")
return "OK" if not issues else f"Issues: {{', '.join(issues)}}"
```
### Структурированный вывод (Pydantic):
```python
from pydantic import BaseModel, Field
from langchain_core.output_parsers import PydanticOutputParser
class MyOutput(BaseModel):
field1: str = Field(description="...")
field2: int = Field(description="...")
```
## Требования
- Полный рабочий код без заглушек (no pass, TODO, ...)
- ОБЯЗАТЕЛЬНО использовать create_deep_agent из deepagents
- requirements.txt: deepagents, langchain>=1.2.10, langchain-openai>=0.3.0, langgraph>=0.2.0 + нужные доп. зависимости
{rework_section}
## Ответ — ТОЛЬКО JSON без markdown:
{{"main_py": "...", "requirements_txt": "...", "extra_files": {{}}}}
extra_files — только если нужны доп. файлы, иначе пустой объект.
'''
+249
View File
@@ -0,0 +1,249 @@
"""
Инструменты агента для решения задач.
Агент вызывает эти инструменты САМОСТОЯТЕЛЬНО — Python не управляет порядком.
Каждый инструмент — специализированный LLM-субагент со своим промптом.
Субагенты:
validate_teacher_comment — per-claim валидация замечания преподавателя
generate_code_solution — генерация кода (первая сдача или пересдача)
"""
import asyncio
import base64
import json
import os
import httpx
from langchain.tools import tool
from src.agent.constants import GITEA_BASE_URL, GITEA_OWNER
from src.agent.llm import llm
from src.agent.solve_prompts import (
ANALYZE_PROMPT,
CODE_PROMPT,
REWORK_SECTION,
VALIDATE_PROMPT,
)
_GITEA_TOKEN = os.getenv("GITEA_TOKEN", "")
_CODE_EXTS = (".py", ".js", ".ts", ".sh", ".sql", ".md")
_BACKOFF = [30, 60, 120]
# ---------------------------------------------------------------------------
# Вспомогательные функции (не инструменты)
# ---------------------------------------------------------------------------
def _gh() -> dict:
return {"Authorization": f"token {_GITEA_TOKEN}", "Content-Type": "application/json"}
def _read_repo_files(repo: str) -> dict[str, str]:
"""Читает все кодовые файлы из корня репозитория на Gitea."""
files: dict[str, str] = {}
url_root = f"{GITEA_BASE_URL}/api/v1/repos/{GITEA_OWNER}/{repo}/contents"
try:
with httpx.Client(timeout=30) as c:
r = c.get(url_root, headers=_gh())
if r.status_code != 200:
return files
for item in r.json():
if item.get("type") != "file":
continue
if not any(item["name"].endswith(ext) for ext in _CODE_EXTS):
continue
fr = c.get(
f"{GITEA_BASE_URL}/api/v1/repos/{GITEA_OWNER}/{repo}/contents/{item['name']}",
headers=_gh(),
)
if fr.status_code == 200:
raw = fr.json().get("content", "")
files[item["name"]] = base64.b64decode(raw.replace("\n", "")).decode(
"utf-8", errors="replace"
)
except Exception:
pass
return files
def _parse_llm_json(raw: str) -> dict:
"""Убирает markdown-обёртку и парсит JSON из ответа LLM."""
text = raw.strip()
if text.startswith("```"):
parts = text.split("```")
text = parts[1] if len(parts) > 1 else text
if text.startswith("json"):
text = text[4:]
text = text.strip()
return json.loads(text)
async def _llm_call_with_retry(prompt: str, max_attempts: int = 4) -> str:
"""Вызов LLM с повтором при 429."""
for attempt in range(1, max_attempts + 1):
try:
resp = await llm.ainvoke(prompt)
return resp.content
except Exception as e:
if "429" in str(e) and attempt < max_attempts:
wait = _BACKOFF[min(attempt - 1, len(_BACKOFF) - 1)]
print(f" [solve_tools] 429, жду {wait}с (попытка {attempt})...")
await asyncio.sleep(wait)
else:
raise
# ---------------------------------------------------------------------------
# Субагент 1: Валидатор замечаний преподавателя
# ---------------------------------------------------------------------------
@tool
async def validate_teacher_comment(
task_text: str,
repo_name: str,
teacher_comment: str,
) -> str:
"""[СУБАГЕНТ-ВАЛИДАТОР] Анализирует каждый пункт замечания преподавателя НЕЗАВИСИМО.
Читает текущий код из Gitea репозитория и сверяет каждое утверждение
с условием задания. Отличает ловушки от реальных ошибок.
Args:
task_text: полный текст условия задания
repo_name: имя репозитория (например task-6a1864f7fd30e81cf3...)
teacher_comment: замечание преподавателя
Returns:
JSON: {
"has_trap": true если есть ложные пункты,
"has_valid": true если есть реальные ошибки,
"claims": список {claim, valid, explanation},
"trap_explanations": объяснения ложных пунктов (для README),
"fix_instructions": что конкретно исправить (для реальных ошибок)
}
"""
print(f" [ВАЛИДАТОР] Проверяю замечание для {repo_name}...")
# Читаем код из Gitea
code_files = _read_repo_files(repo_name)
if code_files:
code_block = "\n\n".join(
f"### {fn}\n```\n{content[:2000]}\n```"
for fn, content in code_files.items()
)
print(f" [ВАЛИДАТОР] Прочитано файлов: {', '.join(code_files.keys())}")
else:
code_block = "(репозиторий пуст или файлы не найдены)"
print(f" [ВАЛИДАТОР] ⚠️ Файлы в {repo_name} не найдены")
prompt = VALIDATE_PROMPT.format(
task_text=task_text,
code_block=code_block,
comment=teacher_comment,
)
try:
raw = await _llm_call_with_retry(prompt)
result = _parse_llm_json(raw)
result.setdefault("has_trap", False)
result.setdefault("has_valid", True)
result.setdefault("claims", [])
result.setdefault("trap_explanations", [])
result.setdefault("fix_instructions", [])
# Логируем результат
for cl in result["claims"]:
tag = "❌ ЛОВУШКА" if not cl.get("valid") else "✓ обоснованно"
print(f" {tag}: {cl.get('claim', '')[:70]}")
print(f" [ВАЛИДАТОР] has_trap={result['has_trap']}, has_valid={result['has_valid']}")
return json.dumps(result, ensure_ascii=False)
except Exception as e:
print(f" [ВАЛИДАТОР] Ошибка: {e} — считаем замечание обоснованным")
fallback = {
"has_trap": False,
"has_valid": True,
"claims": [],
"trap_explanations": [],
"fix_instructions": [teacher_comment],
}
return json.dumps(fallback, ensure_ascii=False)
# ---------------------------------------------------------------------------
# Субагент 2: Кодер — генерирует решение
# ---------------------------------------------------------------------------
@tool
async def generate_code_solution(
task_text: str,
fix_instructions: str = "",
defense_context: str = "",
) -> str:
"""[СУБАГЕНТ-КОДЕР] Генерирует полное решение задания.
При пересдаче принимает что исправить и что отстоять с DESIGN DECISION аргументами.
Запрещено использовать Ollama — только OpenRouter через langchain_openai.
Args:
task_text: полный текст условия задания
fix_instructions: что конкретно исправить (для пересдачи, иначе "")
defense_context: что отстоять с DESIGN DECISION комментариями (иначе "")
Returns:
JSON: {
"main_py": содержимое main.py,
"requirements_txt": содержимое requirements.txt,
"extra_files": доп. файлы {имя: содержимое} или {}
}
"""
is_rework = bool(fix_instructions or defense_context)
mode = "ПЕРЕСДАЧА" if is_rework else "первая сдача"
print(f" [КОДЕР] Генерирую решение ({mode})...")
if is_rework:
# Строим секцию пересдачи
fixes = [fix_instructions] if fix_instructions else []
defenses = [defense_context] if defense_context else []
rework_section = REWORK_SECTION.format(
fixes = "\n".join(f"- {f}" for f in fixes) or "— нет",
defenses = "\n".join(f"- {d}" for d in defenses) or "— нет",
)
else:
rework_section = ""
prompt = CODE_PROMPT.format(task_text=task_text, rework_section=rework_section)
for attempt in range(1, 6):
try:
print(f" [КОДЕР] LLM вызов (попытка {attempt})...")
raw = await _llm_call_with_retry(prompt, max_attempts=3)
result = _parse_llm_json(raw)
if "main_py" in result:
main_size = len(result.get("main_py", ""))
req_size = len(result.get("requirements_txt", ""))
extra = list(result.get("extra_files", {}).keys())
print(f" [КОДЕР] ✅ main.py={main_size}с, requirements.txt={req_size}с"
+ (f", extra={extra}" if extra else ""))
return json.dumps(result, ensure_ascii=False)
except json.JSONDecodeError:
print(f" [КОДЕР] JSON parse error на попытке {attempt}, повтор...")
if attempt == 5:
raise
except Exception as e:
if attempt < 5:
wait = _BACKOFF[min(attempt - 1, len(_BACKOFF) - 1)]
print(f" [КОДЕР] Ошибка: {e}, жду {wait}с...")
await asyncio.sleep(wait)
else:
raise
raise RuntimeError("Не удалось сгенерировать код после 5 попыток")
# ---------------------------------------------------------------------------
# Список инструментов для импорта в agent.py
# ---------------------------------------------------------------------------
SOLVE_TOOLS = [validate_teacher_comment, generate_code_solution]
+560
View File
@@ -0,0 +1,560 @@
"""
Streamlit UI для brojs-agent.
Запуск: streamlit run ui.py
"""
import asyncio
import json
import os
import queue
import threading
import time
from datetime import datetime
os.environ["NO_PROXY"] = (
"openrouter.ai,platform.brojs.ru,git.brojs.ru,"
+ os.environ.get("NO_PROXY", "")
)
import streamlit as st
from dotenv import load_dotenv
from langchain_core.callbacks.base import BaseCallbackHandler
from langchain_core.messages import AIMessage, HumanMessage
load_dotenv()
# ---------------------------------------------------------------------------
# Конфигурация страницы
# ---------------------------------------------------------------------------
st.set_page_config(
page_title="BroJS Agent",
page_icon="🤖",
layout="wide",
initial_sidebar_state="collapsed",
)
GITEA_OWNER = os.getenv("GITEA_OWNER", "glevelll")
# ---------------------------------------------------------------------------
# CSS
# ---------------------------------------------------------------------------
st.markdown("""
<style>
.agent-header {
background: linear-gradient(135deg, #1a1a2e 0%, #0f3460 100%);
border-radius: 12px; padding: 18px 24px; margin-bottom: 16px;
border: 1px solid #16213e;
}
.agent-title { font-size: 1.6em; font-weight: bold; color: #e2e8f0; margin: 0; }
.agent-sub { color: #64748b; font-size: .85em; margin-top: 4px; }
.tool-call {
background: #0f172a; border-left: 3px solid #3b82f6;
border-radius: 6px; padding: 6px 12px; margin: 3px 0;
font-family: monospace; font-size: .82em; color: #93c5fd;
}
.tool-result {
background: #052e16; border-left: 3px solid #22c55e;
border-radius: 6px; padding: 6px 12px; margin: 3px 0;
font-family: monospace; font-size: .78em; color: #86efac;
}
.tool-subagent {
background: #1e1b4b; border-left: 3px solid #818cf8;
border-radius: 6px; padding: 6px 12px; margin: 3px 0;
font-family: monospace; font-size: .82em; color: #c4b5fd;
}
.thinking {
color: #64748b; font-style: italic; font-size: .82em; padding: 4px 0;
}
.status-ok { background:#052e16; border:1px solid #22c55e; color:#4ade80; padding:10px 16px; border-radius:8px; }
.status-warn { background:#1c1917; border:1px solid #f59e0b; color:#fbbf24; padding:10px 16px; border-radius:8px; }
.status-err { background:#1c0a0a; border:1px solid #ef4444; color:#f87171; padding:10px 16px; border-radius:8px; }
.chat-user { background:#1e3a5f; border-radius:12px 12px 2px 12px; padding:10px 14px; margin:6px 0; }
.chat-agent { background:#1a1a2e; border-radius:12px 12px 12px 2px; padding:10px 14px; margin:6px 0; }
</style>
""", unsafe_allow_html=True)
# ---------------------------------------------------------------------------
# Заголовок
# ---------------------------------------------------------------------------
st.markdown("""
<div class="agent-header">
<div class="agent-title">🤖 BroJS Agent</div>
<div class="agent-sub">Агентная система выполнения заданий · KFU-26-1 · platform.brojs.ru</div>
</div>
""", unsafe_allow_html=True)
# ---------------------------------------------------------------------------
# Кэш агента
# ---------------------------------------------------------------------------
@st.cache_resource(show_spinner="Инициализация агента (~30с)...")
def get_agent():
from src.agent.agent import homework_direct_agent
return homework_direct_agent
@st.cache_resource(show_spinner="Загрузка pipeline...")
def get_pipeline():
from src.agent.graph.pipeline import pipeline
return pipeline
# ---------------------------------------------------------------------------
# Callback — перехватывает события агента и шлёт в очередь
# ---------------------------------------------------------------------------
_SOLVE_TOOLS = {"validate_teacher_comment", "generate_code_solution"}
_JOURNAL_PREFIX = "mcp__journal-bh-professor__"
class AgentCallback(BaseCallbackHandler):
def __init__(self, q: queue.Queue):
self.q = q
def _ts(self) -> str:
return datetime.now().strftime("%H:%M:%S")
def on_tool_start(self, serialized, input_str, **kwargs):
name = serialized.get("name", "?")
try:
args = json.loads(str(input_str)) if isinstance(input_str, str) else input_str
except Exception:
args = {}
self.q.put({"t": "tool_start", "ts": self._ts(), "name": name, "args": args})
def on_tool_end(self, output, **kwargs):
self.q.put({"t": "tool_end", "ts": self._ts(), "output": str(output)[:300]})
def on_tool_error(self, error, **kwargs):
self.q.put({"t": "tool_error", "ts": self._ts(), "msg": str(error)[:200]})
def on_llm_start(self, *a, **kw):
self.q.put({"t": "thinking", "ts": self._ts()})
def on_llm_end(self, response, **kwargs):
try:
text = response.generations[0][0].text[:120]
self.q.put({"t": "llm_end", "ts": self._ts(), "preview": text})
except Exception:
pass
# ---------------------------------------------------------------------------
# Запуск агента в фоне
# ---------------------------------------------------------------------------
def _run_agent_thread(agent, messages, config, q: queue.Queue, cb: AgentCallback):
async def _inner():
try:
result = await agent.ainvoke(messages, {**config, "callbacks": [cb]})
q.put({"t": "done", "result": result})
except Exception as e:
q.put({"t": "fatal", "msg": str(e)})
asyncio.run(_inner())
# ---------------------------------------------------------------------------
# Рендер одного события в лог
# ---------------------------------------------------------------------------
def _render_event(ev: dict) -> str:
ts = ev.get("ts", "")
kind = ev.get("t", "")
if kind == "thinking":
return f'<div class="thinking">💭 {ts} модель думает...</div>'
if kind == "tool_start":
name = ev["name"]
args = ev.get("args", {})
short = name.replace(_JOURNAL_PREFIX, "mcp::")
# Определяем тип инструмента
if name in _SOLVE_TOOLS:
cls = "tool-subagent"
icon = "🧠"
label = f"[субагент] {short}"
elif "gitea" in name:
cls = "tool-call"
icon = "📦"
label = short
elif "mcp::" in short or "journal" in name:
cls = "tool-call"
icon = "📡"
label = short
else:
cls = "tool-call"
icon = "🔧"
label = short
# Показываем ключевые аргументы
hint = ""
for key in ("taskId", "path", "repo", "repo_name", "name"):
if key in args:
hint = f' <span style="opacity:.6">{args[key]}</span>'
break
return f'<div class="{cls}">{icon} {ts} {label}{hint}</div>'
if kind == "tool_end":
out = ev["output"].replace("<", "&lt;").replace(">", "&gt;")[:200]
return f'<div class="tool-result">↳ {out}</div>'
if kind == "tool_error":
msg = ev["msg"].replace("<", "&lt;")
return f'<div class="tool-result" style="border-color:#ef4444;color:#f87171">⚠ {msg}</div>'
if kind == "llm_end":
preview = ev.get("preview", "").replace("<", "&lt;")[:100]
return f'<div class="thinking">✏ {ts} {preview}...</div>'
return ""
# ---------------------------------------------------------------------------
# Вкладки
# ---------------------------------------------------------------------------
tab_chat, tab_pipeline, tab_status = st.tabs(["💬 Чат с агентом", "⚡ Pipeline", "📊 Статус заданий"])
# ══════════════════════════════════════════════════════════════════════════
# ВК 1 — ЧАТ
# ══════════════════════════════════════════════════════════════════════════
with tab_chat:
st.caption("Общайся с агентом: задай вопрос, попроси решить задание или разобрать ситуацию.")
# История сообщений
if "chat_history" not in st.session_state:
st.session_state.chat_history = []
if "chat_events" not in st.session_state:
st.session_state.chat_events = []
if "chat_thread_id" not in st.session_state:
st.session_state.chat_thread_id = f"ui-{int(time.time())}"
# Показываем историю
for msg in st.session_state.chat_history:
role = msg["role"]
text = msg["text"]
if role == "user":
st.markdown(f'<div class="chat-user">👤 {text}</div>', unsafe_allow_html=True)
else:
st.markdown(f'<div class="chat-agent">🤖 {text}</div>', unsafe_allow_html=True)
# Лог событий (раскрывающийся)
if st.session_state.chat_events:
with st.expander(f"🔍 Лог инструментов ({len(st.session_state.chat_events)} событий)", expanded=False):
html = "".join(_render_event(e) for e in st.session_state.chat_events[-80:])
st.markdown(f'<div style="max-height:300px;overflow-y:auto">{html}</div>',
unsafe_allow_html=True)
# Ввод
col_input, col_btn = st.columns([5, 1])
with col_input:
user_input = st.text_input(
"Сообщение",
placeholder='Например: "Реши задание 6a1864f7..." или "Какие задания у меня есть?"',
label_visibility="collapsed",
key="chat_input",
)
with col_btn:
send = st.button("Отправить", use_container_width=True, type="primary")
if send and user_input.strip():
msg_text = user_input.strip()
st.session_state.chat_history.append({"role": "user", "text": msg_text})
st.session_state.chat_events = []
# Строим историю сообщений для агента
lc_messages = []
for m in st.session_state.chat_history:
if m["role"] == "user":
lc_messages.append(HumanMessage(content=m["text"]))
else:
lc_messages.append(AIMessage(content=m["text"]))
config = {"configurable": {"thread_id": st.session_state.chat_thread_id}}
agent = get_agent()
# Placeholders для обновления в реальном времени
events_ph = st.empty()
status_ph = st.empty()
evq: queue.Queue = queue.Queue()
cb = AgentCallback(evq)
all_events: list[dict] = []
thread = threading.Thread(
target=_run_agent_thread,
args=(agent, {"messages": lc_messages}, config, evq, cb),
daemon=True,
)
thread.start()
final_result = None
fatal = None
while thread.is_alive() or not evq.empty():
changed = False
while not evq.empty():
ev = evq.get_nowait()
if ev["t"] in ("done", "fatal"):
if ev["t"] == "done":
final_result = ev["result"]
else:
fatal = ev["msg"]
else:
all_events.append(ev)
changed = True
if changed and all_events:
html = "".join(_render_event(e) for e in all_events[-60:])
events_ph.markdown(
f'<div style="background:#0b0f1a;border-radius:8px;padding:10px;'
f'max-height:250px;overflow-y:auto">{html}</div>',
unsafe_allow_html=True,
)
time.sleep(0.15)
events_ph.empty()
st.session_state.chat_events = all_events
if fatal:
st.session_state.chat_history.append({"role": "agent", "text": f"⚠️ Ошибка: {fatal}"})
elif final_result:
msgs = final_result.get("messages", [])
last = msgs[-1] if msgs else None
reply = last.content if last and hasattr(last, "content") else "Готово."
st.session_state.chat_history.append({"role": "agent", "text": reply})
st.rerun()
# Кнопка очистки
if st.session_state.chat_history:
if st.button("🗑 Очистить чат"):
st.session_state.chat_history = []
st.session_state.chat_events = []
st.session_state.chat_thread_id = f"ui-{int(time.time())}"
st.rerun()
# ══════════════════════════════════════════════════════════════════════════
# ВК 2 — PIPELINE
# ══════════════════════════════════════════════════════════════════════════
with tab_pipeline:
st.caption("Автоматически решает все todo-задания курса по очереди.")
col1, col2 = st.columns([3, 1])
with col1:
task_id_input = st.text_input(
"Task ID (оставь пустым — решить все todo)",
placeholder="6a1864f7fd30e81cf3146d65",
label_visibility="visible",
)
with col2:
st.write("")
run_btn = st.button("▶ Запустить", type="primary", use_container_width=True)
if run_btn:
result_ph = st.empty()
events_ph2 = st.empty()
agent = get_agent()
if task_id_input.strip():
# Одно задание
task_id = task_id_input.strip()
repo_url = f"https://git.brojs.ru/{GITEA_OWNER}/task-{task_id}"
prompt = f"Реши задание taskId={task_id} курса 698b49da77cb6d4d2e43ce78"
config = {"configurable": {"thread_id": f"pipe-{task_id}-{int(time.time())}"}}
messages = {"messages": [HumanMessage(content=prompt)]}
else:
result_ph.info("Pipeline для всех todo-заданий — используй раздел ниже")
st.stop()
evq2: queue.Queue = queue.Queue()
cb2 = AgentCallback(evq2)
all_events2: list[dict] = []
thread2 = threading.Thread(
target=_run_agent_thread,
args=(agent, messages, config, evq2, cb2),
daemon=True,
)
thread2.start()
final2 = None
fatal2 = None
with st.spinner(f"Агент решает {task_id[:8]}..."):
while thread2.is_alive() or not evq2.empty():
while not evq2.empty():
ev = evq2.get_nowait()
if ev["t"] == "done":
final2 = ev["result"]
elif ev["t"] == "fatal":
fatal2 = ev["msg"]
else:
all_events2.append(ev)
if all_events2:
html = "".join(_render_event(e) for e in all_events2[-50:])
events_ph2.markdown(
f'<div style="background:#0b0f1a;border-radius:8px;padding:10px;'
f'max-height:300px;overflow-y:auto">{html}</div>',
unsafe_allow_html=True,
)
time.sleep(0.15)
if fatal2:
result_ph.markdown(
f'<div class="status-err">❌ Ошибка: {fatal2[:300]}</div>',
unsafe_allow_html=True,
)
elif final2:
result_ph.markdown(
f'<div class="status-ok">✅ Готово! '
f'<a href="{repo_url}" target="_blank" style="color:#4ade80">Открыть репозиторий</a>'
f'</div>',
unsafe_allow_html=True,
)
st.divider()
st.subheader("Запустить все todo-задания")
if st.button("⚡ Запустить pipeline для всех заданий", use_container_width=True):
pl = get_pipeline()
evq_pl = queue.Queue()
cb_pl = AgentCallback(evq_pl)
all_pl_events: list[dict] = []
_pl_state = {"result": None, "error": None}
def _run_pipeline():
async def _inner():
try:
_pl_state["result"] = await pl.ainvoke(
{"tasks": [], "current_index": 0, "results": [], "errors": []},
{"callbacks": [cb_pl]},
)
except Exception as e:
_pl_state["error"] = str(e)
asyncio.run(_inner())
t_pl = threading.Thread(target=_run_pipeline, daemon=True)
t_pl.start()
events_pl_ph = st.empty()
with st.spinner("Pipeline работает... (может занять несколько минут)"):
while t_pl.is_alive() or not evq_pl.empty():
while not evq_pl.empty():
ev = evq_pl.get_nowait()
if ev["t"] not in ("done", "fatal"):
all_pl_events.append(ev)
if all_pl_events:
html = "".join(_render_event(e) for e in all_pl_events[-60:])
events_pl_ph.markdown(
f'<div style="background:#0b0f1a;border-radius:8px;padding:10px;'
f'max-height:300px;overflow-y:auto">{html}</div>',
unsafe_allow_html=True,
)
time.sleep(0.15)
events_pl_ph.empty()
if all_pl_events:
with st.expander(f"🔍 Лог pipeline ({len(all_pl_events)} событий)", expanded=False):
html = "".join(_render_event(e) for e in all_pl_events[-80:])
st.markdown(f'<div style="max-height:300px;overflow-y:auto">{html}</div>',
unsafe_allow_html=True)
if _pl_state["error"]:
st.error(_pl_state["error"])
elif _pl_state["result"]:
results = _pl_state["result"].get("results", [])
errors = _pl_state["result"].get("errors", [])
md = [f"### Результат: {len(results)} заданий\n"]
for r in results:
tid = r.get("task_id", "")
url = f"https://git.brojs.ru/{GITEA_OWNER}/task-{tid}"
icon = "" if r.get("status") == "ok" else ""
md.append(f"- {icon} `{tid[:8]}...` — [{r.get('status','')}]({url})")
if errors:
md.append(f"\n**Ошибки ({len(errors)}):**")
for e in errors:
md.append(f"- {e}")
st.markdown("\n".join(md))
# ══════════════════════════════════════════════════════════════════════════
# ВК 3 — СТАТУС
# ══════════════════════════════════════════════════════════════════════════
with tab_status:
st.caption("Статусы всех заданий курса KFU-26-1.")
if st.button("🔄 Обновить статусы", type="primary"):
with st.spinner("Загружаю статусы..."):
try:
from src.agent.mcp_client import load_journal_toolsets
async def _fetch():
j = load_journal_toolsets()
# Инструменты имеют префикс mcp__journal-bh-professor__
tools = {t.name: t for t in j.tasks_submissions_tools}
full_name = "mcp__journal-bh-professor__tasks_list"
t = tools.get(full_name)
if not t:
# fallback: ищем по любому имени содержащему tasks_list
t = next((v for k, v in tools.items() if "tasks_list" in k), None)
if not t:
st.warning(f"Инструмент tasks_list не найден. Доступны: {list(tools.keys())}")
return []
raw = await t.ainvoke({"courseId": "698b49da77cb6d4d2e43ce78"})
text = next((x["text"] for x in raw if x.get("type") == "text"), str(raw)) if isinstance(raw, list) else str(raw)
data = json.loads(text)
return data.get("tasks", data) if isinstance(data, dict) else data
items = asyncio.run(_fetch())
st.session_state["task_statuses"] = items
except Exception as e:
st.error(str(e))
items = []
items = st.session_state.get("task_statuses", [])
STATUS_EMOJI = {
"done": "",
"ready_for_review": "🔍",
"in_progress": "🔄",
"todo": "📋",
"rejected": "",
}
if items:
counts: dict[str, int] = {}
rows = []
for item in items:
t = item.get("task", item) if isinstance(item, dict) else {}
tid = t.get("id", "")
status = item.get("status", "")
title = t.get("title", t.get("name", ""))
counts[status] = counts.get(status, 0) + 1
rows.append({
"": STATUS_EMOJI.get(status, ""),
"Статус": status,
"ID": tid[:12] + "...",
"Название": title,
"Репо": f"https://git.brojs.ru/{GITEA_OWNER}/task-{tid}",
})
st.dataframe(rows, use_container_width=True, hide_index=True)
st.divider()
cols = st.columns(len(counts))
for col, (s, n) in zip(cols, counts.items()):
col.metric(f"{STATUS_EMOJI.get(s,'')} {s}", n)
else:
st.info("Нажми «Обновить статусы» чтобы загрузить данные.")