diff --git a/pyproject.toml b/pyproject.toml
index 5c35957..00f201d 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -12,8 +12,6 @@ dependencies = [
"pydantic-settings>=2.0.0",
"python-dotenv>=1.0.0",
"httpx>=0.27.0",
- "markdownify>=0.13.0",
- "tavily-python>=0.3.0",
]
[build-system]
diff --git a/requirements.txt b/requirements.txt
index bbbed46..95bdc9e 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -6,5 +6,3 @@ langgraph>=1.0.8
pydantic-settings>=2.0.0
python-dotenv>=1.0.0
httpx>=0.27.0
-markdownify>=0.13.0
-tavily-python>=0.3.0
diff --git a/run_universal.py b/run_universal.py
index d88acdd..4431f68 100644
--- a/run_universal.py
+++ b/run_universal.py
@@ -6,11 +6,14 @@ from src.agent.agent import homework_direct_agent
from src.agent.graph.pipeline import _invoke_with_retry, _get_journal_tool, _parse_text
print("[2/3] Агент готов, запускаем задание...")
-TASK_ID = "6a1855055db1b0a5ea224b8b"
+# ── Настройки ────────────────────────────────────────────────────────────────
+TASK_ID = "6a1867fa8a94f887e50d52bd" # ← taskId из platform.brojs.ru
OWNER = "KirillKutlakhmetov"
REPO = f"task-{TASK_ID}"
URL = f"https://git.brojs.ru/{OWNER}/{REPO}"
+# ── Вспомогательные функции ──────────────────────────────────────────────────
+
def _was_submitted(msgs: list) -> bool:
for m in msgs:
for tc in getattr(m, "tool_calls", []):
@@ -18,31 +21,72 @@ def _was_submitted(msgs: list) -> bool:
return True
return False
+
+def _is_rate_limit(e) -> bool:
+ full = repr(e)
+ if hasattr(e, 'exceptions'):
+ full += " ".join(repr(sub) for sub in e.exceptions)
+ return "429" in full or "rate" in full.lower()
+
+
+def _is_not_found(e) -> bool:
+ full = repr(e)
+ if hasattr(e, 'exceptions'):
+ full += " ".join(repr(sub) for sub in e.exceptions)
+ return "not found" in full.lower() or "submission" in full.lower()
+
+
async def _force_submit(task_id: str):
- update = _get_journal_tool("task_update_answer")
- submit = _get_journal_tool("task_submit")
+ """Страховка: сдаёт задание если агент не сделал этого сам."""
+ task_get = _get_journal_tool("task_get")
+ update = _get_journal_tool("task_update_answer")
+ submit = _get_journal_tool("task_submit")
+
if not update or not submit:
- print("[!] journal-инструменты недоступны")
+ print("[!] journal-инструменты недоступны — сдать не удалось")
return
+
+ # Сначала вызываем task_get — это инициализирует submission если его нет
+ if task_get:
+ try:
+ print("[AUTO] task_get (инициализация submission)...")
+ await task_get.ainvoke({"taskId": task_id})
+ await asyncio.sleep(2)
+ except BaseException:
+ pass # не критично
+
for attempt in range(1, 6):
try:
- print(f"[AUTO] task_update_answer (попытка {attempt})")
- r1 = await update.ainvoke({"taskId": task_id, "answerType": "link", "content": URL})
- print(f"[AUTO] update: {_parse_text(r1)[:120]}")
+ print(f"[AUTO] task_update_answer (попытка {attempt})...")
+ r1 = await update.ainvoke({
+ "taskId": task_id,
+ "answerType": "link",
+ "content": URL,
+ })
+ print(f"[AUTO] update OK: {_parse_text(r1)[:120]}")
await asyncio.sleep(3)
- print(f"[AUTO] task_submit")
+
+ print(f"[AUTO] task_submit...")
r2 = await submit.ainvoke({"taskId": task_id, "confirmSubmit": True})
- print(f"[AUTO] submit: {_parse_text(r2)[:120]}")
+ print(f"[AUTO] submit OK: {_parse_text(r2)[:120]}")
return
+
except BaseException as e:
full = repr(e)
if hasattr(e, 'exceptions'):
full += " ".join(repr(sub) for sub in e.exceptions)
if "429" in full and attempt < 5:
- print(f"[AUTO] Rate limit, жду 70с...")
- await asyncio.sleep(70)
+ print(f"[AUTO] Rate limit 429 — жду 10с...")
+ await asyncio.sleep(10)
+ elif attempt < 5:
+ print(f"[AUTO] Ошибка (попытка {attempt}): {full[:150]} — жду 10с...")
+ await asyncio.sleep(10)
else:
- raise
+ print(f"[!] Все попытки исчерпаны: {full[:200]}")
+ return
+
+
+# ── Основной запуск ──────────────────────────────────────────────────────────
async def main():
prompt = f"""Выполни задание из курса BroJS.
@@ -51,49 +95,33 @@ taskId: {TASK_ID}
owner: {OWNER}
repo: {REPO}
-ШАГ 1. Прочитай задание полностью:
- mcp__journal-bh-professor__task_text(taskId='{TASK_ID}')
-
-ШАГ 2. Создай репозиторий:
- gitea_create_repo(name='{REPO}')
-
-ШАГ 3. Напиши и загрузи все нужные файлы через gitea_write_file.
- - Пиши полный рабочий код, без заглушек и TODO
- - Минимум 4 файла: main.py, requirements.txt, README.md + модули по заданию
- - Читай файлы репозитория только через gitea_get_file, не read_file
-
-ШАГ 4. Отправь ответ:
- mcp__journal-bh-professor__task_update_answer(
- taskId='{TASK_ID}',
- answerType='link',
- content='{URL}'
- )
-
-ШАГ 5. Сдай задание (ОБЯЗАТЕЛЬНО):
- mcp__journal-bh-professor__task_submit(
- taskId='{TASK_ID}',
- confirmSubmit=True
- )"""
+ШАГ 1. mcp__journal-bh-professor__task_text(taskId='{TASK_ID}')
+ШАГ 2. gitea_create_repo(name='{REPO}')
+ШАГ 3. gitea_write_file — загрузи все файлы (main.py, requirements.txt, README.md + модули). Полный код, без заглушек.
+ШАГ 4. mcp__journal-bh-professor__task_update_answer(taskId='{TASK_ID}', answerType='link', content='{URL}')
+ШАГ 5. mcp__journal-bh-professor__task_submit(taskId='{TASK_ID}', confirmSubmit=True)"""
config = {"configurable": {"thread_id": f"hw-{TASK_ID}-{int(time.time())}"}}
print(f"[3/3] Агент работает...")
+
result = await _invoke_with_retry(
homework_direct_agent,
{"messages": [HumanMessage(content=prompt)]},
config,
)
+
print("[ГОТОВО] Агент завершил работу.")
msgs = result.get("messages", [])
print(f"[{len(msgs)} сообщений]")
for i, m in enumerate(msgs):
role = type(m).__name__
- tcs = getattr(m, "tool_calls", [])
- content_str = str(getattr(m, "content", ""))
+ tcs = getattr(m, "tool_calls", [])
+ text = str(getattr(m, "content", ""))
if tcs:
for tc in tcs:
print(f" [{i}] {role} -> {tc['name']}({str(tc.get('args',''))[:120]})")
- elif content_str.strip():
- print(f" [{i}] {role}: {content_str[:200]}")
+ elif text.strip():
+ print(f" [{i}] {role}: {text[:200]}")
if not _was_submitted(msgs):
print("\n[!] Агент не вызвал task_submit — сдаю автоматически...")
@@ -101,4 +129,5 @@ repo: {REPO}
else:
print("\n[OK] Задание сдано агентом самостоятельно.")
+
asyncio.run(main())
diff --git a/src/agent/agent.py b/src/agent/agent.py
index 7cfa312..cfd5a52 100644
--- a/src/agent/agent.py
+++ b/src/agent/agent.py
@@ -1,12 +1,10 @@
"""Создание агентов: главный оркестратор, исполнитель ДЗ, агент пересдачи."""
from deepagents import create_deep_agent
-from deepagents.backends import CompositeBackend, FilesystemBackend, LocalShellBackend
+from deepagents.backends import LocalShellBackend
from src.agent.constants import (
AGENT_WORKSPACE_DIR,
AGENTS_MD_VFS_PATH,
- BUNDLED_SKILLS_DIR,
- SKILLS_VFS_MOUNT,
ensure_agents_md_file,
)
from src.agent.gitea_tools import GITEA_TOOLS
@@ -19,7 +17,7 @@ from src.agent.prompts import (
rework_instructions,
)
from src.agent.subagents import subagent_specs_without_tools
-from src.agent.tools import GIT_TOOLS, WEB_TOOLS
+from src.agent.tools import GIT_TOOLS
# ---------------------------------------------------------------------------
# Инициализация
@@ -36,29 +34,19 @@ print(f"=== Загружено: journal={len(_journal_tools)}, gitea={len(GITEA_
# Бэкенды (виртуальная файловая система агента)
# ---------------------------------------------------------------------------
-_workspace_backend = LocalShellBackend(
+_composite_backend = LocalShellBackend(
root_dir=str(AGENT_WORKSPACE_DIR),
virtual_mode=True,
inherit_env=True,
)
-_skills_backend = FilesystemBackend(
- root_dir=str(BUNDLED_SKILLS_DIR),
- virtual_mode=True,
-)
-_composite_backend = CompositeBackend(
- default=_workspace_backend,
- routes={SKILLS_VFS_MOUNT: _skills_backend},
-)
# ---------------------------------------------------------------------------
# Наборы инструментов
# ---------------------------------------------------------------------------
-_homework_tools = [*GIT_TOOLS, *GITEA_TOOLS, *WEB_TOOLS, *_journal_tools]
-_web_tools = WEB_TOOLS
+_homework_tools = [*GIT_TOOLS, *GITEA_TOOLS, *_journal_tools]
_subagent_tool_map = {
- "web_search": _web_tools,
"homework_doing": _homework_tools,
"journal_bh_tasks_submissions": _journal_tools,
}
@@ -71,13 +59,11 @@ _BUILTIN = {
_gitea_names = {t.name for t in GITEA_TOOLS}
_journal_names = {t.name for t in _journal_tools}
_git_names = {t.name for t in GIT_TOOLS}
-_web_names = {t.name for t in WEB_TOOLS}
_main_tool_names = _BUILTIN | _gitea_names
_subagent_tool_names: dict[str, set[str]] = {
- "web_search": _BUILTIN | _web_names,
- "homework_doing": _BUILTIN | _gitea_names | _journal_names | _git_names | _web_names,
+ "homework_doing": _BUILTIN | _gitea_names | _journal_names | _git_names,
"journal_bh_tasks_submissions": _BUILTIN | _journal_names,
}
@@ -92,6 +78,8 @@ def _make_subagent_middleware(name: str) -> list:
return mw
+
+
# ---------------------------------------------------------------------------
# Субагенты с инструментами и middleware
# ---------------------------------------------------------------------------
diff --git a/src/agent/constants.py b/src/agent/constants.py
index 6fcd591..7b2a6ff 100644
--- a/src/agent/constants.py
+++ b/src/agent/constants.py
@@ -4,11 +4,9 @@ from pathlib import Path
PACKAGE_DIR = Path(__file__).resolve().parent
AGENT_WORKSPACE_DIR = PACKAGE_DIR / "agent_workspace"
-BUNDLED_SKILLS_DIR = PACKAGE_DIR / "skills"
AGENTS_MD_FILENAME = "AGENTS.md"
AGENTS_MD_VFS_PATH = "/AGENTS.md"
-SKILLS_VFS_MOUNT = "/skills/"
# ID курса KFU-26-1 на platform.brojs.ru
COURSE_ID = "698b49da77cb6d4d2e43ce78"
diff --git a/src/agent/graph/pipeline.py b/src/agent/graph/pipeline.py
index 1fe9277..b50c283 100644
--- a/src/agent/graph/pipeline.py
+++ b/src/agent/graph/pipeline.py
@@ -183,8 +183,8 @@ def _fix_prompt(task: TaskInfo, repo_name: str, v: dict) -> str:
MAX_RETRIES = 2
RATE_LIMIT_RETRIES = 5 # сколько раз повторять при 429
-RATE_LIMIT_PAUSE = 90 # секунд ожидания перед повтором
-TASK_PAUSE = 15 # пауза между заданиями (снижает давление на rate limit)
+RATE_LIMIT_PAUSE = 10 # секунд ожидания перед повтором
+TASK_PAUSE = 10 # пауза между заданиями (снижает давление на rate limit)
def _is_rate_limit(exc) -> bool:
@@ -251,7 +251,7 @@ async def fetch_tasks(state: PipelineState) -> dict:
if hasattr(e, "exceptions"):
full += " ".join(repr(sub) for sub in e.exceptions)
if "429" in full and attempt < 5:
- wait = 90 * attempt
+ wait = 10 * attempt
print(f"[pipeline] fetch_tasks: rate limit, жду {wait}с (попытка {attempt}/5)...")
await asyncio.sleep(wait)
else:
@@ -314,8 +314,8 @@ async def _force_submit(task_id: str, repo_url: str) -> None:
if hasattr(e, "exceptions"):
full += " ".join(repr(sub) for sub in e.exceptions)
if "429" in full and attempt < 5:
- print(f"[pipeline] [AUTO] Rate limit, жду 70с...")
- await asyncio.sleep(70)
+ print(f"[pipeline] [AUTO] Rate limit, жду 10с...")
+ await asyncio.sleep(10)
else:
print(f"[pipeline] [AUTO] Ошибка при сдаче: {full[:200]}")
return
@@ -346,7 +346,7 @@ async def process_one_task(state: PipelineState) -> dict:
f"4. В requirements.txt включай ТОЛЬКО пакеты из стека задания — не добавляй ничего лишнего.\n\n"
f"ВАЖНО: в конце обязательно вызови task_submit(taskId='{task_id}', confirmSubmit=True)!"
)
- agent_to_use = homework_direct_agent
+ agent_to_use = rework_agent
else:
prompt = (
f"Выполни задание.\n\n"
diff --git a/src/agent/middlewares/validate_journal_workflow.py b/src/agent/middlewares/validate_journal_workflow.py
index 9714fc9..bebe306 100644
--- a/src/agent/middlewares/validate_journal_workflow.py
+++ b/src/agent/middlewares/validate_journal_workflow.py
@@ -128,7 +128,7 @@ class ValidateJournalWorkflowMiddleware(AgentMiddleware[AgentState[Any], Any]):
return ToolMessage(
content=(
f"Ошибка rate limit (429) от Journal API при вызове {tool_name}.\n"
- "Подожди ~60 секунд и повтори этот же вызов."
+ "Подожди ~10 секунд и повтори этот же вызов."
),
tool_call_id=request.tool_call["id"],
name=tool_name,
diff --git a/src/agent/subagents.py b/src/agent/subagents.py
index d8970d3..a5d9bc7 100644
--- a/src/agent/subagents.py
+++ b/src/agent/subagents.py
@@ -3,19 +3,9 @@ from src.agent.llm import llm
from src.agent.prompts import (
homework_doing_instructions,
journal_tasks_submissions_instructions,
- research_instructions,
)
subagent_specs_without_tools: list[dict] = [
- {
- "name": "web_search",
- "description": (
- "Ищет информацию в интернете, находит URL и открывает страницы, "
- "чтобы извлекать факты только из реально прочитанного контента"
- ),
- "model": llm,
- "system_prompt": research_instructions,
- },
{
"name": "homework_doing",
"description": (
diff --git a/ui.py b/ui.py
new file mode 100644
index 0000000..e848e0a
--- /dev/null
+++ b/ui.py
@@ -0,0 +1,454 @@
+import asyncio
+import ast
+import json
+import queue as q_mod
+import threading
+import time
+from datetime import datetime
+
+import streamlit as st
+from langchain_core.callbacks.base import BaseCallbackHandler
+from langchain_core.messages import HumanMessage
+
+OWNER = "KirillKutlakhmetov"
+
+# ── Page config ───────────────────────────────────────────────────────────────
+st.set_page_config(
+ page_title="BroJS Agent",
+ layout="wide",
+ initial_sidebar_state="collapsed",
+)
+
+# ── CSS ───────────────────────────────────────────────────────────────────────
+st.markdown("""
+
+""", unsafe_allow_html=True)
+
+# ── Header ────────────────────────────────────────────────────────────────────
+st.markdown("""
+
+""", unsafe_allow_html=True)
+
+# ── Cache resources ───────────────────────────────────────────────────────────
+@st.cache_resource(show_spinner="Инициализация агента и MCP-подключения (~30с)...")
+def get_agent():
+ from src.agent.agent import homework_direct_agent
+ from src.agent.graph.pipeline import _invoke_with_retry
+ return homework_direct_agent, _invoke_with_retry
+
+
+@st.cache_resource(show_spinner="Загрузка pipeline...")
+def get_pipeline():
+ from src.agent.graph.pipeline import pipeline
+ return pipeline
+
+
+# ── Callback (realtime events → queue) ───────────────────────────────────────
+class UICallback(BaseCallbackHandler):
+ def __init__(self, q: q_mod.Queue):
+ self.q = q
+
+ def _parse_inputs(self, input_str: str, kwargs: dict) -> dict:
+ """Пробуем достать структурированные inputs из kwargs или распарсить строку."""
+ inputs = kwargs.get("inputs") or {}
+ if inputs:
+ return inputs
+ try:
+ return json.loads(input_str)
+ except Exception:
+ pass
+ try:
+ return ast.literal_eval(input_str)
+ except Exception:
+ pass
+ return {}
+
+ def on_tool_start(self, serialized, input_str, **kwargs):
+ name = serialized.get("name", "?")
+ inputs = self._parse_inputs(str(input_str), kwargs)
+ self.q.put({"t": "tool_start", "name": name, "inputs": inputs})
+
+ def on_tool_end(self, output, **kwargs):
+ self.q.put({"t": "tool_end", "output": str(output)[:500]})
+
+ def on_tool_error(self, error, **kwargs):
+ self.q.put({"t": "tool_error", "msg": str(error)[:300]})
+
+ def on_llm_start(self, *a, **kw):
+ self.q.put({"t": "thinking"})
+
+
+# ── Steps ─────────────────────────────────────────────────────────────────────
+STEPS = [
+ ("task_get", "Читаю состояние задания"),
+ ("task_text", "Читаю текст задания"),
+ ("gitea_create_repo", "Создаю репозиторий"),
+ ("gitea_write_file", "Загружаю файлы"),
+ ("task_update_answer", "Устанавливаю ответ"),
+ ("task_submit", "Сдаю задание"),
+]
+
+_STEP_CLS = {"done": "step-done", "active": "step-active", "pending": "step-pending"}
+_STEP_ICON = {"done": "✓", "active": "⟳", "pending": "○"}
+
+
+def render_steps(states: dict, file_count: int = 0) -> str:
+ parts = []
+ for key, label in STEPS:
+ sv = states.get(key, "pending")
+ cls = _STEP_CLS[sv]
+ ico = _STEP_ICON[sv]
+ extra = f" ({file_count} файлов)" if key == "gitea_write_file" and file_count > 0 else ""
+ parts.append(f'{ico} {label}{extra}
')
+ return "".join(parts)
+
+
+def step_key_for(tool_name: str) -> str | None:
+ for key, _ in STEPS:
+ if key in tool_name:
+ return key
+ return None
+
+
+# ── Background runner ─────────────────────────────────────────────────────────
+def run_agent_background(agent, messages, config, q: q_mod.Queue, cb):
+ 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())
+
+
+# ══════════════════════════════════════════════════════════════════════════════
+# Tabs
+# ══════════════════════════════════════════════════════════════════════════════
+tab1, tab2 = st.tabs(["Одно задание", "Все задания (Pipeline)"])
+
+
+# ── Tab 1 — одно задание ──────────────────────────────────────────────────────
+with tab1:
+ task_id_raw = st.text_input(
+ "Task ID",
+ placeholder="6a1867fa8a94f887e50d52bd",
+ help="Скопируй из URL на platform.brojs.ru",
+ label_visibility="visible",
+ )
+ go = st.button("Выполнить задание", type="primary", use_container_width=True)
+
+ if go:
+ task_id = task_id_raw.strip()
+ if not task_id:
+ st.warning("Введи Task ID")
+ else:
+ repo = f"task-{task_id}"
+ url = f"https://git.brojs.ru/{OWNER}/{repo}"
+
+ prompt = f"""Выполни задание из курса BroJS.
+
+taskId: {task_id}
+owner: {OWNER}
+
+ШАГ 1. mcp__journal-bh-professor__task_get(taskId='{task_id}')
+ → Смотри поле answer.content:
+ • Пусто → ПЕРВАЯ СДАЧА (шаг 2а)
+ • Ссылка на репо → ПЕРЕСДАЧА (шаг 2б)
+ → Читай комментарии преподавателя — они в приоритете
+
+ШАГ 2а (ПЕРВАЯ СДАЧА):
+ a. mcp__journal-bh-professor__task_text(taskId='{task_id}') — прочитай полный текст задания
+ b. gitea_create_repo(name='{repo}') — создай репозиторий
+ c. gitea_write_file — загрузи ВСЕ файлы (main.py, requirements.txt, README.md + модули). Полный код, без заглушек.
+
+ШАГ 2б (ПЕРЕСДАЧА):
+ a. mcp__journal-bh-professor__task_text(taskId='{task_id}') — прочитай оригинальное задание
+ b. Сравни комментарий с требованиями:
+ • Противоречит заданию → task_comment с объяснением + цитата, НЕ меняй код
+ • Реальная ошибка → исправь через gitea_write_file
+
+ШАГ 3. mcp__journal-bh-professor__task_update_answer(taskId='{task_id}', answerType='link', content='{url}')
+ШАГ 4. mcp__journal-bh-professor__task_submit(taskId='{task_id}', confirmSubmit=True)"""
+
+ config = {"configurable": {"thread_id": f"ui-{task_id}-{int(time.time())}"}}
+
+ # ── UI placeholders ────────────────────────────────────────────
+ col_left, col_right = st.columns([1, 2])
+ with col_left:
+ st.markdown("**Pipeline**")
+ steps_ph = st.empty()
+ with col_right:
+ st.markdown("**Лог событий**")
+ log_ph = st.empty()
+
+ st.markdown("**Код (последний записанный файл)**")
+ code_header_ph = st.empty()
+ code_ph = st.empty()
+ status_ph = st.empty()
+
+ # ── Init ───────────────────────────────────────────────────────
+ step_states = {k: "pending" for k, _ in STEPS}
+ logs: list[str] = []
+ files: dict[str, str] = {}
+ file_count = 0
+ active_key = None
+ thinking_shown = False
+
+ steps_ph.markdown(render_steps(step_states), unsafe_allow_html=True)
+
+ # ── Start thread ───────────────────────────────────────────────
+ update_q: q_mod.Queue = q_mod.Queue()
+ agent, _ = get_agent()
+ cb = UICallback(update_q)
+
+ thread = threading.Thread(
+ target=run_agent_background,
+ args=(agent, {"messages": [HumanMessage(content=prompt)]}, config, update_q, cb),
+ daemon=True,
+ )
+ thread.start()
+
+ # ── Poll loop ──────────────────────────────────────────────────
+ final_result = None
+ fatal_error = None
+
+ while thread.is_alive() or not update_q.empty():
+ dirty = False
+
+ while not update_q.empty():
+ ev = update_q.get_nowait()
+ ts = datetime.now().strftime("%H:%M:%S")
+
+ if ev["t"] == "thinking":
+ if not thinking_shown:
+ logs.append(
+ f''
+ f'{ts} '
+ f'модель думает...'
+ f'
'
+ )
+ thinking_shown = True
+ dirty = True
+
+ elif ev["t"] == "tool_start":
+ thinking_shown = False
+ name = ev["name"]
+ inputs = ev.get("inputs", {})
+ key = step_key_for(name)
+
+ if key:
+ if active_key and active_key != key:
+ step_states[active_key] = "done"
+ step_states[key] = "active"
+ active_key = key
+
+ if key == "gitea_write_file":
+ file_count += 1
+ path = inputs.get("path", "")
+ content = inputs.get("content", "")
+ if path and content:
+ files[path] = content
+
+ short = name.replace("mcp__journal-bh-professor__", "mcp::")
+ path = inputs.get("path", "")
+ finfo = f" {path}" if path else ""
+ logs.append(
+ f''
+ f'{ts} '
+ f'[tool] {short}{finfo}'
+ f'
'
+ )
+ dirty = True
+
+ elif ev["t"] == "tool_end":
+ out = ev["output"][:140].replace("<", "<").replace(">", ">")
+ logs.append(
+ f''
+ f'{ts} '
+ f'-> {out}'
+ f'
'
+ )
+ dirty = True
+
+ elif ev["t"] == "tool_error":
+ if active_key:
+ step_states[active_key] = "pending"
+ msg = ev["msg"][:140].replace("<", "<").replace(">", ">")
+ logs.append(
+ f''
+ f'{ts} '
+ f'[error] {msg}'
+ f'
'
+ )
+ dirty = True
+
+ elif ev["t"] == "done":
+ final_result = ev["result"]
+ if active_key:
+ step_states[active_key] = "done"
+ dirty = True
+
+ elif ev["t"] == "fatal":
+ fatal_error = ev["msg"]
+ dirty = True
+
+ if dirty:
+ # Steps
+ steps_ph.markdown(
+ render_steps(step_states, file_count),
+ unsafe_allow_html=True,
+ )
+ # Log
+ log_ph.markdown(
+ '' + "".join(logs[-60:]) + '
',
+ unsafe_allow_html=True,
+ )
+ # Code panel — показываем последний файл
+ if files:
+ last_path = list(files)[-1]
+ lang = "python" if last_path.endswith(".py") else (
+ "text" if last_path.endswith(".txt") else "markdown"
+ )
+ code_header_ph.markdown(
+ f'',
+ unsafe_allow_html=True,
+ )
+ code_ph.code(files[last_path], language=lang)
+
+ time.sleep(0.15)
+
+ # ── Final ──────────────────────────────────────────────────────
+ if fatal_error:
+ status_ph.markdown(
+ f'Ошибка: {fatal_error[:200]}
',
+ unsafe_allow_html=True,
+ )
+ elif final_result is not None:
+ msgs = final_result.get("messages", [])
+ submitted = any(
+ "task_submit" in tc.get("name", "")
+ for m in msgs
+ for tc in getattr(m, "tool_calls", [])
+ )
+ # финально помечаем все шаги
+ for k, _ in STEPS:
+ if step_states[k] in ("active", "done"):
+ step_states[k] = "done"
+ steps_ph.markdown(render_steps(step_states, file_count), unsafe_allow_html=True)
+
+ if submitted:
+ status_ph.markdown(
+ f'',
+ unsafe_allow_html=True,
+ )
+ else:
+ status_ph.markdown(
+ 'Агент не вызвал task_submit — проверь лог
',
+ unsafe_allow_html=True,
+ )
+ else:
+ status_ph.markdown(
+ 'Агент завершился без результата
',
+ unsafe_allow_html=True,
+ )
+
+
+# ── Tab 2 — pipeline ──────────────────────────────────────────────────────────
+with tab2:
+ st.info(
+ "Загружает **все незакрытые задания** курса KFU-26-1 и выполняет по очереди. "
+ "Может работать долго — не закрывай вкладку."
+ )
+
+ if st.button("Запустить Pipeline", type="primary", use_container_width=True):
+ pl = get_pipeline()
+ result_ph = st.empty()
+ with st.spinner("Pipeline работает..."):
+ try:
+ res = asyncio.run(pl.ainvoke({
+ "tasks": [], "current_index": 0, "results": [], "errors": [],
+ }))
+
+ results = res.get("results", [])
+ errors = res.get("errors", [])
+
+ lines = [f"### Выполнено заданий: {len(results)}\n"]
+ for r in results:
+ tid = r.get("task_id", "")
+ repo_link = f"https://git.brojs.ru/{OWNER}/task-{tid}"
+ lines.append(
+ f"- `{tid[:8]}...` [{r.get('status','?')}] "
+ f"mode=**{r.get('mode','?')}** "
+ f"retries={r.get('retries', 0)} "
+ f"[→ репо]({repo_link})"
+ )
+
+ if errors:
+ lines.append(f"\n### Ошибки ({len(errors)})")
+ for e in errors:
+ lines.append(f"- {e}")
+
+ result_ph.markdown("\n".join(lines))
+
+ except Exception as e:
+ result_ph.error(str(e))
diff --git a/ui_demo.py b/ui_demo.py
new file mode 100644
index 0000000..fe35bd3
--- /dev/null
+++ b/ui_demo.py
@@ -0,0 +1,362 @@
+"""
+Демо-режим UI — симулирует работу агента без реальных API вызовов.
+Запусти: streamlit run ui_demo.py
+"""
+import queue as q_mod
+import threading
+import time
+from datetime import datetime
+
+import streamlit as st
+
+OWNER = "KirillKutlakhmetov"
+
+st.set_page_config(
+ page_title="BroJS Agent — DEMO",
+ page_icon="🤖",
+ layout="wide",
+ initial_sidebar_state="collapsed",
+)
+
+st.markdown("""
+
+""", unsafe_allow_html=True)
+
+st.markdown("""
+
+""", unsafe_allow_html=True)
+
+st.markdown('🎬 DEMO-режим — реальные API не вызываются, показывает как выглядит интерфейс в работе
', unsafe_allow_html=True)
+
+# ── Steps ─────────────────────────────────────────────────────────────────────
+STEPS = [
+ ("task_get", "📋 Читаю состояние задания"),
+ ("task_text", "📄 Читаю текст задания"),
+ ("gitea_create_repo", "📁 Создаю репозиторий"),
+ ("gitea_write_file", "💾 Загружаю файлы"),
+ ("task_update_answer", "🔗 Устанавливаю ответ"),
+ ("task_submit", "🚀 Сдаю задание"),
+]
+_CLS = {"done": "step-done", "active": "step-active", "pending": "step-pending"}
+_ICON = {"done": "✓", "active": "⟳", "pending": "○"}
+
+
+def render_steps(states, file_count=0):
+ parts = []
+ for key, label in STEPS:
+ sv = states.get(key, "pending")
+ extra = (
+ f" ({file_count} файлов)"
+ if key == "gitea_write_file" and file_count > 0 else ""
+ )
+ parts.append(f'{_ICON[sv]} {label}{extra}
')
+ return "".join(parts)
+
+
+# ── Фейковые события ──────────────────────────────────────────────────────────
+
+FAKE_MAIN_PY = '''\
+import os, asyncio
+from langchain_openai import ChatOpenAI
+from langchain_core.messages import HumanMessage
+from langchain.agents import create_agent
+from langchain.tools import tool
+from langchain_qdrant import QdrantVectorStore
+from qdrant_client import QdrantClient
+from qdrant_client.models import Distance, VectorParams
+
+llm = ChatOpenAI(
+ model="openai/gpt-oss-20b:free",
+ base_url="https://platform.brojs.ru/jrnl-bh/api/inference/v1",
+ api_key=os.getenv("JOURNAL_MCP_PAT"),
+ temperature=0.0,
+)
+
+client = QdrantClient(":memory:")
+client.create_collection(
+ "knowledge",
+ vectors_config=VectorParams(size=1536, distance=Distance.COSINE),
+)
+
+@tool
+def search_kb(query: str) -> str:
+ """Search the knowledge base."""
+ results = vector_store.similarity_search(query, k=5)
+ if not results:
+ return "No relevant documents found."
+ return "\\n\\n".join(f"{i+1}. {d.page_content}" for i, d in enumerate(results))
+
+agent = create_agent(
+ llm=llm,
+ tools=[search_kb],
+ system_prompt="You are a helpful RAG assistant.",
+)
+
+async def main():
+ result = await agent.ainvoke(
+ {"messages": [HumanMessage(content="What is LangChain?")]},
+ {"configurable": {"thread_id": "demo-1"}},
+ )
+ print(result["messages"][-1].content)
+
+if __name__ == "__main__":
+ asyncio.run(main())
+'''
+
+FAKE_REQUIREMENTS = """\
+langchain-core>=0.3.0
+langchain-openai>=0.3.0
+langgraph>=0.2.0
+langchain-qdrant>=0.1.0
+qdrant-client>=1.7.0
+python-dotenv>=1.0.0
+"""
+
+FAKE_README = """\
+# RAG-агент с Qdrant
+
+AI-ассистент с векторным поиском через Qdrant.
+
+## Стек
+| Компонент | Технология |
+|-----------|-----------|
+| LLM | BroJS gpt-oss-20b |
+| Векторное хранилище | Qdrant in-memory |
+| Фреймворк | LangChain + LangGraph |
+
+## Установка
+```bash
+pip install -r requirements.txt
+```
+
+## Запуск
+```bash
+python main.py
+```
+"""
+
+FAKE_EVENTS = [
+ {"delay": 0.5, "t": "thinking"},
+ {"delay": 1.0, "t": "tool_start", "name": "mcp__journal-bh-professor__task_get",
+ "inputs": {"taskId": "6a1867fa8a94f887e50d52bd"}},
+ {"delay": 1.2, "t": "tool_end", "output": '{"status": "todo", "answer": {"content": ""}, "comments": []}'},
+ {"delay": 0.4, "t": "thinking"},
+ {"delay": 0.8, "t": "tool_start", "name": "mcp__journal-bh-professor__task_text",
+ "inputs": {"taskId": "6a1867fa8a94f887e50d52bd"}},
+ {"delay": 1.1, "t": "tool_end", "output": "Создай RAG-агента с векторным хранилищем Qdrant и поиском по базе знаний..."},
+ {"delay": 0.6, "t": "thinking"},
+ {"delay": 1.5, "t": "tool_start", "name": "gitea_create_repo",
+ "inputs": {"name": "task-6a1867fa8a94f887e50d52bd", "private": False}},
+ {"delay": 0.9, "t": "tool_end", "output": "Репозиторий создан: https://git.brojs.ru/KirillKutlakhmetov/task-6a1867fa..."},
+ {"delay": 0.3, "t": "thinking"},
+ {"delay": 0.8, "t": "tool_start", "name": "gitea_write_file",
+ "inputs": {"repo": "task-6a1867fa...", "path": "main.py",
+ "content": FAKE_MAIN_PY, "message": "add main.py"}},
+ {"delay": 0.7, "t": "tool_end", "output": "Файл main.py создан в KirillKutlakhmetov/task-6a1867fa... (commit: a1b2c3d4)"},
+ {"delay": 0.5, "t": "tool_start", "name": "gitea_write_file",
+ "inputs": {"repo": "task-6a1867fa...", "path": "requirements.txt",
+ "content": FAKE_REQUIREMENTS, "message": "add requirements.txt"}},
+ {"delay": 0.6, "t": "tool_end", "output": "Файл requirements.txt создан в KirillKutlakhmetov/task-6a1867fa... (commit: b2c3d4e5)"},
+ {"delay": 0.5, "t": "tool_start", "name": "gitea_write_file",
+ "inputs": {"repo": "task-6a1867fa...", "path": "README.md",
+ "content": FAKE_README, "message": "add README.md"}},
+ {"delay": 0.6, "t": "tool_end", "output": "Файл README.md создан в KirillKutlakhmetov/task-6a1867fa... (commit: c3d4e5f6)"},
+ {"delay": 0.4, "t": "thinking"},
+ {"delay": 0.9, "t": "tool_start", "name": "mcp__journal-bh-professor__task_update_answer",
+ "inputs": {"taskId": "6a1867fa8a94f887e50d52bd", "answerType": "link",
+ "content": "https://git.brojs.ru/KirillKutlakhmetov/task-6a1867fa8a94f887e50d52bd"}},
+ {"delay": 0.8, "t": "tool_end", "output": '{"success": true, "message": "Answer updated"}'},
+ {"delay": 0.3, "t": "thinking"},
+ {"delay": 0.7, "t": "tool_start", "name": "mcp__journal-bh-professor__task_submit",
+ "inputs": {"taskId": "6a1867fa8a94f887e50d52bd", "confirmSubmit": True}},
+ {"delay": 1.0, "t": "tool_end", "output": '{"success": true, "message": "Task submitted for review"}'},
+ {"delay": 0.3, "t": "done"},
+]
+
+
+def _fake_step_key(tool_name):
+ for key, _ in STEPS:
+ if key in tool_name:
+ return key
+ return None
+
+
+def run_fake_agent(q: q_mod.Queue):
+ for ev in FAKE_EVENTS:
+ time.sleep(ev["delay"])
+ q.put(ev)
+
+
+# ── Layout ────────────────────────────────────────────────────────────────────
+task_id = "6a1867fa8a94f887e50d52bd"
+repo = f"task-{task_id}"
+url = f"https://git.brojs.ru/{OWNER}/{repo}"
+
+col_input, _ = st.columns([2, 1])
+with col_input:
+ st.text_input("Task ID", value=task_id, disabled=True)
+
+go = st.button("▶️ Выполнить задание (DEMO)", type="primary", use_container_width=True)
+
+if go:
+ col_left, col_right = st.columns([1, 2])
+ with col_left:
+ st.markdown("**Pipeline**")
+ steps_ph = st.empty()
+ with col_right:
+ st.markdown("**Лог событий**")
+ log_ph = st.empty()
+
+ st.markdown("**Код (последний записанный файл)**")
+ code_header_ph = st.empty()
+ code_ph = st.empty()
+ status_ph = st.empty()
+
+ step_states = {k: "pending" for k, _ in STEPS}
+ logs: list[str] = []
+ files: dict[str, str] = {}
+ file_count = 0
+ active_key = None
+ thinking_shown = False
+
+ steps_ph.markdown(render_steps(step_states), unsafe_allow_html=True)
+
+ update_q: q_mod.Queue = q_mod.Queue()
+ t = threading.Thread(target=run_fake_agent, args=(update_q,), daemon=True)
+ t.start()
+
+ finished = False
+ while not finished:
+ dirty = False
+ while not update_q.empty():
+ ev = update_q.get_nowait()
+ ts = datetime.now().strftime("%H:%M:%S")
+
+ if ev["t"] == "thinking":
+ if not thinking_shown:
+ logs.append(
+ f'{ts} '
+ f'🤔 модель думает...
'
+ )
+ thinking_shown = True
+ dirty = True
+
+ elif ev["t"] == "tool_start":
+ thinking_shown = False
+ name = ev["name"]
+ inputs = ev.get("inputs", {})
+ key = _fake_step_key(name)
+
+ if key:
+ if active_key and active_key != key:
+ step_states[active_key] = "done"
+ step_states[key] = "active"
+ active_key = key
+
+ if key == "gitea_write_file":
+ file_count += 1
+ path = inputs.get("path", "")
+ content = inputs.get("content", "")
+ if path and content:
+ files[path] = content
+
+ short = name.replace("mcp__journal-bh-professor__", "mcp::")
+ path = inputs.get("path", "")
+ finfo = f" {path}" if path else ""
+ logs.append(
+ f'{ts} '
+ f'🔧 {short}{finfo}
'
+ )
+ dirty = True
+
+ elif ev["t"] == "tool_end":
+ out = ev["output"][:140].replace("<", "<").replace(">", ">")
+ logs.append(
+ f'{ts} '
+ f'↩ {out}
'
+ )
+ dirty = True
+
+ elif ev["t"] == "done":
+ if active_key:
+ step_states[active_key] = "done"
+ finished = True
+ dirty = True
+
+ if dirty:
+ steps_ph.markdown(render_steps(step_states, file_count), unsafe_allow_html=True)
+ log_ph.markdown(
+ '' + "".join(logs[-60:]) + '
',
+ unsafe_allow_html=True,
+ )
+ if files:
+ last_path = list(files)[-1]
+ lang = "python" if last_path.endswith(".py") else (
+ "text" if last_path.endswith(".txt") else "markdown"
+ )
+ code_header_ph.markdown(
+ f'',
+ unsafe_allow_html=True,
+ )
+ code_ph.code(files[last_path], language=lang)
+
+ time.sleep(0.1)
+
+ # финал
+ for k, _ in STEPS:
+ step_states[k] = "done"
+ steps_ph.markdown(render_steps(step_states, file_count), unsafe_allow_html=True)
+ status_ph.markdown(
+ f'',
+ unsafe_allow_html=True,
+ )