From 3ae3488691b7478c23440b4f520144c1f2574a0c Mon Sep 17 00:00:00 2001 From: gleb Date: Fri, 5 Jun 2026 16:43:00 +0300 Subject: [PATCH] =?UTF-8?q?refactor:=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2?= =?UTF-8?q?=D0=B8=D1=82=D1=8C=20=D0=B0=D0=B3=D0=B5=D0=BD=D1=82=D0=BD=D1=83?= =?UTF-8?q?=D1=8E=20=D0=B0=D1=80=D1=85=D0=B8=D1=82=D0=B5=D0=BA=D1=82=D1=83?= =?UTF-8?q?=D1=80=D1=83,=20UI=20=D0=B8=20CLI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- cli.py | 150 +++++++++ requirements.txt | 1 + src/agent/agent.py | 6 +- src/agent/gitea_tools.py | 27 ++ src/agent/prompts.py | 639 +++++-------------------------------- src/agent/solve_prompts.py | 238 ++++++++++++++ src/agent/solve_tools.py | 249 +++++++++++++++ ui.py | 516 ++++++++++++++++++++++++++++++ 8 files changed, 1260 insertions(+), 566 deletions(-) create mode 100644 cli.py create mode 100644 src/agent/solve_prompts.py create mode 100644 src/agent/solve_tools.py create mode 100644 ui.py diff --git a/cli.py b/cli.py new file mode 100644 index 0000000..dca9268 --- /dev/null +++ b/cli.py @@ -0,0 +1,150 @@ +""" +CLI для brojs-agent. + +Использование: + python cli.py solve — решить одно задание + 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("tasks_list") + if not tool: + print("Ошибка: инструмент tasks_list не найден") + 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 ") + 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() diff --git a/requirements.txt b/requirements.txt index bbbed46..571db0e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,3 +8,4 @@ python-dotenv>=1.0.0 httpx>=0.27.0 markdownify>=0.13.0 tavily-python>=0.3.0 +streamlit>=1.35.0 diff --git a/src/agent/agent.py b/src/agent/agent.py index c027b90..882e77d 100644 --- a/src/agent/agent.py +++ b/src/agent/agent.py @@ -19,6 +19,7 @@ from src.agent.prompts import ( rework_instructions, ) 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 # --------------------------------------------------------------------------- @@ -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 _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} _git_names = {t.name for t in GIT_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 _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 | _web_names | _solve_names, "journal_bh_tasks_submissions": _BUILTIN | _journal_names, } diff --git a/src/agent/gitea_tools.py b/src/agent/gitea_tools.py index 6d87aa1..9fcde06 100644 --- a/src/agent/gitea_tools.py +++ b/src/agent/gitea_tools.py @@ -172,9 +172,36 @@ def gitea_get_file(repo: str, path: str, owner: str = GITEA_OWNER) -> str: 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_TOOLS = [ gitea_list_repos, + gitea_list_files, gitea_create_repo, gitea_write_file, gitea_get_file, diff --git a/src/agent/prompts.py b/src/agent/prompts.py index c6592a8..4993a69 100644 --- a/src/agent/prompts.py +++ b/src/agent/prompts.py @@ -63,587 +63,98 @@ journal_tasks_submissions_instructions = """ # --------------------------------------------------------------------------- homework_doing_instructions = ''' -Ты — исполнитель домашних заданий (ПЕРВАЯ СДАЧА). -У тебя есть ВСЕ инструменты напрямую. Не делегируй другим субагентам. +Ты — агент выполнения домашних заданий курса KFU-26-1. -courseId = "698b49da77cb6d4d2e43ce78" +courseId = "698b49da77cb6d4d2e43ce78" Gitea owner = "glevelll" - -ВАЖНО: 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 - -## ПОРЯДОК ВЫПОЛНЕНИЯ: - -[1] mcp__journal-bh-professor__task_text({"taskId": ""}) - → Прочитай ПОЛНЫЙ текст задания - -[2] Составь письменный план: - - какие файлы нужны (main.py, requirements.txt, etc.) - - что реализовать в каждом файле - - какой технический стек использовать (см. раздел ТЕХНИЧЕСКИЕ ПАТТЕРНЫ ниже) - -[3] gitea_create_repo({"name": "task-", "private": false}) - → Создай репозиторий - -[4] Для КАЖДОГО файла вызывай ОТДЕЛЬНО: - gitea_write_file({ - "repo": "task-", - "path": "main.py", - "content": "ПОЛНЫЙ КОД ФАЙЛА", - "message": "add main.py" - }) - - gitea_write_file сам коммитит на сервере — git_add_and_commit НЕ нужен - - content — это plain text, НЕ base64 - - ВСЕГДА указывай message - - Один вызов = один файл - -[5] git_clone("https://git.brojs.ru/glevelll/task-") - → Клонируй репозиторий локально для проверки - -[6] Проверь через read_file что код корректен - -[7] mcp__journal-bh-professor__task_update_answer({ - "taskId": "", - "answerType": "link", - "content": "https://git.brojs.ru/glevelll/task-" - }) - → ОБЯЗАТЕЛЬНО перед task_submit! - -[8] Финальная проверка: - ✓ Все файлы записаны? - ✓ Нет pass, TODO, ..., заглушек? - ✓ langchain>1.0.0 в requirements.txt? - ✓ task_update_answer вызван? - -[9] mcp__journal-bh-professor__task_submit({ - "taskId": "", - "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()) -``` -''' +repo для задания: "task-" + +## Доступные инструменты + +Journal (префикс mcp__journal-bh-professor__): + task_text(taskId) — полный текст задания + task_get(taskId) — детали: статус, answer, комментарии преподавателя + task_update_answer(...) — установить ссылку на репо (ОБЯЗАТЕЛЬНО перед submit) + task_submit(taskId, confirmSubmit=true) — сдать задание + +Gitea: + gitea_list_files(repo) — список файлов в репозитории + gitea_list_repos() — список репозиториев (узнать существует ли repo) + gitea_create_repo(name) — создать репозиторий + gitea_write_file(repo, path, content, message) — записать файл (автокоммит) + gitea_get_file(repo, path) — прочитать файл + +Инструменты решения (LLM-субагенты): + validate_teacher_comment(task_text, repo_name, teacher_comment) + → анализирует каждый пункт замечания: ловушка или реальная ошибка + generate_code_solution(task_text, fix_instructions="", defense_context="") + → генерирует main.py + requirements.txt + extra_files + +## Принципы работы + +Для ПЕРВОЙ СДАЧИ: + — Прочитай задание через task_text + — Сгенерируй решение через generate_code_solution(task_text) + — Создай репозиторий, запиши все файлы через gitea_write_file + — Установи ответ через task_update_answer, затем сдай через task_submit + +Для ПЕРЕСДАЧИ (репозиторий уже существует): + — Прочитай задание (task_text) и комментарий преподавателя (task_get) + — ОБЯЗАТЕЛЬНО проверь замечание через validate_teacher_comment + — Если замечание — ловушка (has_trap=true, has_valid=false): + · Добавь возражение в README через gitea_write_file + · Сдай без изменений кода + — Если смешанный (has_trap=true, has_valid=true): + · Добавь возражение в README за ложные пункты + · Передай fix_instructions и defense_context в generate_code_solution + · Запиши исправленные файлы, сдай + — Если всё обоснованно (has_trap=false): + · Передай fix_instructions в generate_code_solution + · Запиши исправленные файлы, сдай + +## Ограничения кода +- Никаких pass, TODO, заглушек +- LLM только через OpenRouter (langchain_openai), не Ollama +- task_update_answer ВСЕГДА перед task_submit +- Один инструмент за один шаг''' # --------------------------------------------------------------------------- # Субагент: пересдача после ревью # --------------------------------------------------------------------------- rework_instructions = """ -Ты — исполнитель домашних заданий (ПЕРЕСДАЧА после ревью преподавателя). -У тебя есть ВСЕ инструменты напрямую. Не делегируй. +Ты — агент пересдачи домашних заданий курса KFU-26-1. +Репозиторий уже существует. Задание отклонено с комментарием преподавателя. -courseId = "698b49da77cb6d4d2e43ce78" +courseId = "698b49da77cb6d4d2e43ce78" Gitea owner = "glevelll" -Ситуация: задание уже было отправлено, получены комментарии. Репозиторий существует. +## Твоя задача -## ПОРЯДОК: +1. Получи текст задания и комментарий преподавателя +2. Проверь каждый пункт комментария через validate_teacher_comment +3. Прими решение на основе результата: -[1] mcp__journal-bh-professor__task_submission_status({"taskId": ""}) - → Проверь статус и получи фидбек + has_trap=true, has_valid=false → ЛОВУШКА + Добавь возражение в README.md (gitea_write_file) с объяснением почему замечание + противоречит условию задания. Сдай без изменений кода. -[2] mcp__journal-bh-professor__task_get({"taskId": ""}) - → Получи URL репозитория из answer.content и прочитай комментарии + has_trap=true, has_valid=true → СМЕШАННЫЙ СЛУЧАЙ + Добавь возражение в README.md за ложные пункты. + Передай только реальные fix_instructions в generate_code_solution. + Передай trap_explanations как defense_context — агент добавит DESIGN DECISION блоки. -[3] git_clone() - → Клонируй существующий репозиторий в agent_workspace - → = последняя часть URL (например task-abc123) + has_trap=false → ОБОСНОВАННОЕ ЗАМЕЧАНИЕ + Передай fix_instructions в generate_code_solution. + Запиши исправленные файлы через gitea_write_file. -[4] Прочитай файлы через read_file, пойми что исправить +4. Всегда вызывай task_update_answer → task_submit после изменений -[5] Внеси исправления через edit_file или write_file - -[6] git_add_and_commit("fix: <описание исправлений>", "") - -[7] git_push("") - -[8] mcp__journal-bh-professor__task_update_answer({ - "taskId": "", - "answerType": "link", - "content": "<ТОТ ЖЕ URL репозитория>" - }) - -[9] mcp__journal-bh-professor__task_submit({"taskId": "", "confirmSubmit": true}) - -## ПРАВИЛА: -- Клонируй существующий репозиторий, НЕ создавай новый -- Исправляй ТОЛЬКО то, что указано в комментариях -- task_update_answer обязателен (даже если URL тот же) -- Запрещено: pass, TODO, пустые функции +## Правила +- НИКОГДА не меняй код по ложным замечаниям +- Используй тот же репозиторий (task-), не создавай новый +- Один инструмент за один шаг +- task_update_answer обязателен перед task_submit (даже если URL тот же) """ # --------------------------------------------------------------------------- diff --git a/src/agent/solve_prompts.py b/src/agent/solve_prompts.py new file mode 100644 index 0000000..7f5112a --- /dev/null +++ b/src/agent/solve_prompts.py @@ -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 — только если нужны доп. файлы, иначе пустой объект. +''' diff --git a/src/agent/solve_tools.py b/src/agent/solve_tools.py new file mode 100644 index 0000000..7176562 --- /dev/null +++ b/src/agent/solve_tools.py @@ -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] diff --git a/ui.py b/ui.py new file mode 100644 index 0000000..736f331 --- /dev/null +++ b/ui.py @@ -0,0 +1,516 @@ +""" +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(""" + +""", unsafe_allow_html=True) + +# --------------------------------------------------------------------------- +# Заголовок +# --------------------------------------------------------------------------- + +st.markdown(""" +
+
🤖 BroJS Agent
+
Агентная система выполнения заданий · KFU-26-1 · platform.brojs.ru
+
+""", 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'
💭 {ts} модель думает...
' + + 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' {args[key]}' + break + + return f'
{icon} {ts} {label}{hint}
' + + if kind == "tool_end": + out = ev["output"].replace("<", "<").replace(">", ">")[:200] + return f'
↳ {out}
' + + if kind == "tool_error": + msg = ev["msg"].replace("<", "<") + return f'
⚠ {msg}
' + + if kind == "llm_end": + preview = ev.get("preview", "").replace("<", "<")[:100] + return f'
✏ {ts} {preview}...
' + + 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'
👤 {text}
', unsafe_allow_html=True) + else: + st.markdown(f'
🤖 {text}
', 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'
{html}
', + 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'
{html}
', + 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'
{html}
', + unsafe_allow_html=True, + ) + time.sleep(0.15) + + if fatal2: + result_ph.markdown( + f'
❌ Ошибка: {fatal2[:300]}
', + unsafe_allow_html=True, + ) + elif final2: + result_ph.markdown( + f'
✅ Готово! ' + f'Открыть репозиторий' + f'
', + unsafe_allow_html=True, + ) + + st.divider() + st.subheader("Запустить все todo-задания") + if st.button("⚡ Запустить pipeline для всех заданий", use_container_width=True): + pl = get_pipeline() + with st.spinner("Pipeline работает... (может занять несколько минут)"): + try: + res = asyncio.run(pl.ainvoke( + {"tasks": [], "current_index": 0, "results": [], "errors": []} + )) + results = res.get("results", []) + errors = res.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)) + except Exception as e: + st.error(str(e)) + + +# ══════════════════════════════════════════════════════════════════════════ +# ВК 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() + tools = {t.name: t for t in j.tasks_submissions_tools} + t = tools.get("tasks_list") + if not t: + 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("Нажми «Обновить статусы» чтобы загрузить данные.")