Compare commits

..

8 Commits

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

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

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

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

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

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

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

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

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

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