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(""" +
+
BroJS Agent
+
Автоматическое выполнение заданий курса KFU-26-1 · platform.brojs.ru
+
+""", 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'
{last_path} ' + f'({len(files)} файлов загружено)
', + 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'
Задание сдано! ' + f'' + 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(""" +
+
🤖 BroJS Agent
+
Автоматическое выполнение заданий курса KFU-26-1 · platform.brojs.ru
+
+""", 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'
📄 {last_path} ' + f'({len(files)} файлов загружено)
', + 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'
✓ Задание сдано! ' + f'Открыть репозиторий →
', + unsafe_allow_html=True, + )