Files
brojs-agent/solve_task.py
T
Glevel 02ed3aa99f fix: detect rework via Gitea repo existence, not BroJS answer field
BroJS clears the answer field when rejecting a task, so repo_url was
always None on rework. Now we check Gitea API directly — if the repo
exists, it's a rework regardless of BroJS answer state.
Comments are still read from BroJS task_get.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-06-04 19:30:30 +03:00

667 lines
27 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Быстрый решатель заданий BroJS.
Схема: читаем задание (MCP) → 1 LLM-вызов → пушим на Gitea → сабмитим (MCP).
Автономный — не импортирует src.agent, нет двойной загрузки MCP.
Использование:
python solve_task.py <full_task_id>
"""
import asyncio
import base64
import io
import json
import os
import sys
# Перенастраиваем stdout/stderr на UTF-8 (Windows cp1251 не осиливает →, ✅ и т.д.)
# reconfigure() меняет кодировку у существующего враппера на месте — безопаснее, чем
# создавать новый TextIOWrapper поверх буфера (последнее ломается при перенаправлении в файл).
try:
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
elif hasattr(sys.stdout, "buffer"):
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
except Exception:
pass
try:
if hasattr(sys.stderr, "reconfigure"):
sys.stderr.reconfigure(encoding="utf-8", errors="replace")
elif hasattr(sys.stderr, "buffer"):
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8", errors="replace")
except Exception:
pass
# Обходим локальный прокси
os.environ["NO_PROXY"] = "openrouter.ai,platform.brojs.ru,git.brojs.ru," + os.environ.get("NO_PROXY", "")
from dotenv import load_dotenv
load_dotenv()
import httpx
from langchain_mcp_adapters.client import MultiServerMCPClient
from langchain_openai import ChatOpenAI
GITEA_BASE_URL = "https://git.brojs.ru"
GITEA_OWNER = os.getenv("GITEA_OWNER", "glevelll")
GITEA_TOKEN = os.getenv("GITEA_TOKEN", "")
JOURNAL_TOKEN = os.getenv("JOURNAL_TOKEN", "")
OPENAI_API_KEY = os.getenv("OPENAI_API_KEY", "")
MCP_URL = "https://platform.brojs.ru/jrnl-bh/api/mcp"
# ---------------------------------------------------------------------------
# LLM
# ---------------------------------------------------------------------------
llm = ChatOpenAI(
model="openai/gpt-oss-20b:free",
base_url="https://openrouter.ai/api/v1",
api_key=OPENAI_API_KEY,
temperature=0.0,
max_tokens=4096,
)
# ---------------------------------------------------------------------------
# MCP — один постоянный клиент на весь запуск
# ---------------------------------------------------------------------------
_mcp_tools: dict = {}
_mcp_client = None # держим клиент живым чтобы сессия не переоткрывалась
_last_mcp_call_time = 0.0 # для паузы между вызовами
# Exponential backoff: 15, 30, 60, 120, 240 секунд
_BACKOFF = [15, 30, 60, 120, 240]
_INTER_CALL_DELAY = 1.5 # секунд между последовательными MCP-вызовами
def _is_429(exc: BaseException) -> bool:
"""Рекурсивно проверяет, содержит ли исключение (или вложенные) ошибку 429.
Нужно потому что anyio оборачивает HTTP-ошибки в ExceptionGroup,
и '429' есть только во вложенном исключении, а не в str(ExceptionGroup).
"""
if "429" in str(exc):
return True
# ExceptionGroup (Python 3.11+ / anyio): смотрим вложенные
if hasattr(exc, "exceptions"):
return any(_is_429(sub) for sub in exc.exceptions)
# __cause__ / __context__
if exc.__cause__ is not None and exc.__cause__ is not exc:
return _is_429(exc.__cause__)
return False
async def _load_mcp():
global _mcp_tools, _mcp_client
if _mcp_tools:
return
config = {
"journal": {
"transport": "streamable_http",
"url": MCP_URL,
"headers": {"Authorization": f"Bearer {JOURNAL_TOKEN}"},
}
}
_mcp_client = MultiServerMCPClient(config)
for i, pause in enumerate([0] + _BACKOFF):
try:
if pause:
print(f" [mcp] 429 при загрузке, жду {pause}с (попытка {i+1})...")
await asyncio.sleep(pause)
tools = await _mcp_client.get_tools(server_name="journal")
_mcp_tools = {t.name: t for t in tools}
print(f" [mcp] Загружено {len(_mcp_tools)} инструментов")
return
except BaseException as e:
if not _is_429(e) or i == len(_BACKOFF):
raise
async def mcp_call(name: str, args: dict):
global _last_mcp_call_time
await _load_mcp()
# Пауза между вызовами — предотвращает burst
import time
elapsed = time.monotonic() - _last_mcp_call_time
if elapsed < _INTER_CALL_DELAY:
await asyncio.sleep(_INTER_CALL_DELAY - elapsed)
tool = _mcp_tools.get(name)
if not tool:
raise RuntimeError(f"MCP tool '{name}' not found. Available: {list(_mcp_tools.keys())}")
for i, pause in enumerate([0] + _BACKOFF):
try:
if pause:
print(f" [mcp] {name} → 429, жду {pause}с (попытка {i+1})...")
await asyncio.sleep(pause)
result = await tool.ainvoke(args)
_last_mcp_call_time = time.monotonic()
if isinstance(result, list):
return next((x["text"] for x in result if x.get("type") == "text"), str(result))
return str(result)
except BaseException as e:
if not _is_429(e) or i == len(_BACKOFF):
raise
# ---------------------------------------------------------------------------
# Gitea
# ---------------------------------------------------------------------------
def _gh():
return {"Authorization": f"token {GITEA_TOKEN}", "Content-Type": "application/json"}
async def _get_task_meta(task_id: str) -> dict:
"""Возвращает существующий repo_url и комментарии преподавателя (если есть).
Пересдача определяется по наличию репозитория на Gitea — независимо от того,
очистил ли BroJS поле answer при отклонении задания.
"""
repo_url = None
comments = ""
# 1. Проверяем репо напрямую на Gitea (надёжнее, чем BroJS answer)
repo_name = f"task-{task_id}"
try:
with httpx.Client(timeout=10) as c:
r = c.get(
f"{GITEA_BASE_URL}/api/v1/repos/{GITEA_OWNER}/{repo_name}",
headers=_gh(),
)
if r.status_code == 200:
repo_url = f"{GITEA_BASE_URL}/{GITEA_OWNER}/{repo_name}"
except Exception as e:
print(f" [meta] Не удалось проверить Gitea: {e}")
# 2. Читаем комментарии из BroJS
try:
raw = await mcp_call("task_get", {"taskId": task_id})
data = json.loads(raw)
comments = data.get("comments") or data.get("feedback", "") or ""
if isinstance(comments, list):
comments = "\n".join(
c.get("text", c.get("content", str(c))) for c in comments if c
)
except Exception as e:
print(f" [meta] Не удалось получить комментарии BroJS: {e}")
return {"repo_url": repo_url, "comments": str(comments).strip()}
def gitea_create_repo(name: str) -> str:
with httpx.Client(timeout=30) as c:
r = c.post(f"{GITEA_BASE_URL}/api/v1/user/repos", headers=_gh(),
json={"name": name, "private": False, "auto_init": False})
if r.status_code == 409:
return f"{GITEA_BASE_URL}/{GITEA_OWNER}/{name}"
r.raise_for_status()
return r.json().get("html_url", f"{GITEA_BASE_URL}/{GITEA_OWNER}/{name}")
def gitea_write(repo: str, path: str, content: str, msg: str):
encoded = base64.b64encode(content.encode()).decode()
url = f"{GITEA_BASE_URL}/api/v1/repos/{GITEA_OWNER}/{repo}/contents/{path}"
with httpx.Client(timeout=30) as c:
r = c.get(url, headers=_gh())
if r.status_code == 200:
sha = r.json().get("sha", "")
c.put(url, headers=_gh(), json={"message": msg, "content": encoded, "sha": sha}).raise_for_status()
else:
c.post(url, headers=_gh(), json={"message": msg, "content": encoded}).raise_for_status()
# ---------------------------------------------------------------------------
# LLM: генерация кода
# ---------------------------------------------------------------------------
_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
# Используем OpenRouter вместо Ollama: облачный API не требует локального GPU,
# совместим с OpenAI SDK «из коробки» (только base_url), легко масштабируется.
# Модель gpt-oss-20b:free — бесплатный тир OpenRouter для учебных задач.
# Ключ OPENAI_API_KEY=sk-or-v1-... хранится в .env (не в репозитории).
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([
LocalShellBackend(workspace_dir="./workspace"),
FilesystemBackend(),
])
@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
# ВАЖНО: embeddings — ТОЛЬКО OpenAIEmbeddings через OpenRouter, НЕ OllamaEmbeddings!
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)
@tool
def search_knowledge(query: str) -> str:
"""Search the knowledge base for relevant information."""
docs = vector_store.similarity_search(query, k=3)
return "\\n".join(d.page_content for d in docs) if docs else "No results."
@tool
def add_to_knowledge(content: str, title: str = "doc") -> str:
"""Add content to the knowledge base."""
vector_store.add_documents([Document(page_content=content, metadata={{"title": title}})])
return f"Added: {{title}}"
```
requirements.txt добавить: langchain-chroma, chromadb
### Планирующий агент (для planning-заданий):
```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
def planner_node(state):
# LLM создаёт план
...
def executor_node(state):
# LLM выполняет шаг плана
...
```
### Самокорректирующийся агент:
```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="...")
parser = PydanticOutputParser(pydantic_object=MyOutput)
```
## Требования
- Полный рабочий код без заглушек (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 — только если нужны доп. файлы, иначе пустой объект.
'''
_ANALYZE_PROMPT = '''\
Ты — опытный Python-разработчик. Проанализируй комментарии преподавателя к решению
учебного задания и вынеси вердикт по каждому пункту.
## Текст задания
{task_text}
## Комментарии преподавателя
{comments}
Для каждого замечания:
- Если оно технически справедливо → внеси в "fixes" (что именно исправить)
- Если исходное решение было верным и замечание ошибочно или основано на недопонимании →
внеси в "defenses" в формате "замечание: ОБЪЯСНЕНИЕ почему решение правильное"
Ответ — ТОЛЬКО JSON без markdown:
{{"fixes": ["список конкретных исправлений"],
"defenses": ["замечание: объяснение"],
"verdict": "needs_fixes" | "already_correct" | "mixed"}}
'''
_REWORK_SECTION = '''\
## ПЕРЕСДАЧА — результат анализа замечаний
### Исправить (замечания справедливы):
{fixes}
### Оставить и объяснить (решение было верным):
{defenses}
Правила:
- Вноси ТОЛЬКО изменения из раздела "Исправить"
- Для каждого пункта из "Оставить" — добавь в код комментарий "# NOTE: <объяснение>"
- Не трогай логику, которая работала правильно
'''
async def analyze_comments(task_text: str, comments: str, retries: int = 3) -> dict:
"""LLM оценивает замечания преподавателя: что исправить, что отстоять."""
prompt = _ANALYZE_PROMPT.format(task_text=task_text, comments=comments)
for attempt in range(1, retries + 1):
try:
resp = await llm.ainvoke(prompt)
raw = resp.content.strip()
if raw.startswith("```"):
raw = raw.split("```")[1]
if raw.startswith("json"):
raw = raw[4:]
return json.loads(raw.strip())
except json.JSONDecodeError as e:
if attempt == retries:
# Если LLM не дал валидный JSON — возвращаем «исправить всё»
print(f" [анализ] JSON parse error: {e}. Fallback: исправить всё.")
return {"fixes": [comments], "defenses": [], "verdict": "needs_fixes"}
except Exception as e:
if "429" in str(e) and attempt < retries:
wait = 90 * attempt
print(f" [анализ] 429, жду {wait}с...")
await asyncio.sleep(wait)
else:
print(f" [анализ] Ошибка: {e}. Fallback: исправить всё.")
return {"fixes": [comments], "defenses": [], "verdict": "needs_fixes"}
async def generate(task_text: str, rework_analysis: dict | None = None, retries=5) -> dict:
if rework_analysis:
fixes = "\n".join(f"- {f}" for f in rework_analysis.get("fixes", [])) or "— нет"
defenses = "\n".join(f"- {d}" for d in rework_analysis.get("defenses", [])) or "— нет"
rework_section = _REWORK_SECTION.format(fixes=fixes, defenses=defenses)
else:
rework_section = ""
prompt = _PROMPT.format(task_text=task_text, rework_section=rework_section)
for attempt in range(1, retries + 1):
try:
print(f" [llm] Генерирую решение (попытка {attempt})...")
resp = await llm.ainvoke(prompt)
raw = resp.content.strip()
if raw.startswith("```"):
raw = raw.split("```")[1]
if raw.startswith("json"):
raw = raw[4:]
return json.loads(raw.strip())
except json.JSONDecodeError as e:
print(f" [llm] JSON parse error: {e}. Повтор...")
if attempt == retries:
raise
except Exception as e:
if "429" in str(e) and attempt < retries:
wait = 90 * attempt
print(f" [llm] 429, жду {wait}с (попытка {attempt}/{retries})...")
await asyncio.sleep(wait)
else:
raise
# ---------------------------------------------------------------------------
# Основная логика
# ---------------------------------------------------------------------------
COURSE_ID = "698b49da77cb6d4d2e43ce78"
# ---------------------------------------------------------------------------
# Автоматическая выборка todo-заданий
# ---------------------------------------------------------------------------
def _parse_todo_tasks(raw: str) -> list[str]:
"""Парсит ответ tasks_list и возвращает ID заданий со статусом todo/in_progress."""
try:
data = json.loads(raw)
except (json.JSONDecodeError, TypeError):
return []
items = data.get("tasks", data) if isinstance(data, dict) else data
if not isinstance(items, list):
return []
result = []
for item in items:
t = item.get("task", item) if isinstance(item, dict) else {}
tid = t.get("id", "")
status = item.get("status", "")
if tid and status in ("todo", "in_progress", "", None):
title = t.get("title", t.get("name", ""))
result.append((tid, title))
return result
async def fetch_todo_tasks(course_id: str = COURSE_ID) -> list[tuple[str, str]]:
"""Возвращает список (task_id, title) незакрытых заданий курса."""
print(f"[auto] Получаем список заданий курса {course_id}...")
raw = await mcp_call("tasks_list", {"courseId": course_id})
tasks = _parse_todo_tasks(raw)
print(f"[auto] Найдено todo-заданий: {len(tasks)}")
for tid, title in tasks:
print(f" - {tid[:8]}... {title}")
return tasks
async def fetch_all_tasks(course_id: str = COURSE_ID) -> list[dict]:
"""Возвращает все задания курса с их статусами (для мониторинга)."""
raw = await mcp_call("tasks_list", {"courseId": course_id})
try:
data = json.loads(raw)
except (json.JSONDecodeError, TypeError):
return []
items = data.get("tasks", data) if isinstance(data, dict) else data
if not isinstance(items, list):
return []
result = []
for item in items:
t = item.get("task", item) if isinstance(item, dict) else {}
tid = t.get("id", "")
if tid:
result.append({
"id": tid,
"title": t.get("title", t.get("name", "")),
"status": item.get("status", ""),
})
return result
# ---------------------------------------------------------------------------
# Полный автоматический прогон
# ---------------------------------------------------------------------------
async def run_all(target_ids: list[str] | None = None, course_id: str = COURSE_ID):
"""Решает все todo-задания курса (или только target_ids если указан список).
Это точка входа для run_pipeline.py — никакого ручного вызова не нужно.
"""
if target_ids:
tasks = [(tid, "") for tid in target_ids]
print(f"[auto] Целевые задания: {target_ids}")
else:
tasks = await fetch_todo_tasks(course_id)
if not tasks:
print("[auto] Нет заданий для выполнения.")
return
results = []
for i, (task_id, title) in enumerate(tasks, 1):
print(f"\n[auto] Задание {i}/{len(tasks)}: {task_id[:8]}... {title}")
try:
repo_url = await solve(task_id)
results.append({"task_id": task_id, "status": "ok", "url": repo_url})
except Exception as e:
print(f"[auto] ОШИБКА при решении {task_id[:8]}: {e}")
results.append({"task_id": task_id, "status": "error", "error": str(e)})
# Пауза между заданиями
if i < len(tasks):
print("[auto] Пауза 15с перед следующим заданием...")
await asyncio.sleep(15)
print(f"\n{'='*60}")
print("ИТОГ:")
for r in results:
status_icon = "" if r["status"] == "ok" else ""
detail = r.get("url") or r.get("error", "")
print(f" {status_icon} {r['task_id'][:8]}... → {detail}")
print('='*60)
return results
async def solve(task_id: str):
print(f"\n{'='*60}")
print(f"Задание: {task_id}")
print('='*60)
# 0. Проверяем: первая сдача или пересдача
print("[0/5] Проверяем статус задания...")
meta = await _get_task_meta(task_id)
is_rework = meta["repo_url"] is not None
comments = meta["comments"]
if is_rework:
print(f" ⟳ ПЕРЕСДАЧА (репо уже есть: {meta['repo_url']})")
if comments:
print(f" Комментарии преподавателя: {comments[:200]}")
else:
print(" ✦ Первая сдача")
# 1. Читаем текст задания
print("[1/5] Читаем текст задания...")
task_text = await mcp_call("task_text", {"taskId": task_id})
print(f" Получено {len(task_text)} символов")
# 1.5. Анализируем замечания (только при пересдаче с комментариями)
rework_analysis = None
if is_rework and comments:
print(" [анализ] Оцениваем замечания преподавателя...")
rework_analysis = await analyze_comments(task_text, comments)
verdict = rework_analysis.get("verdict", "unknown")
fixes = rework_analysis.get("fixes", [])
defenses = rework_analysis.get("defenses", [])
print(f" [анализ] Вердикт: {verdict}")
if fixes:
print(f" [анализ] Исправить {len(fixes)} пункт(ов)")
if defenses:
print(f" [анализ] Отстоять {len(defenses)} пункт(ов)")
# 2. Генерируем код
print("[2/5] Генерируем код (1 LLM-вызов)...")
solution = await generate(task_text, rework_analysis=rework_analysis if is_rework else None)
main_py = solution.get("main_py", "")
requirements = solution.get("requirements_txt", "")
extra = solution.get("extra_files", {})
print(f" main.py: {len(main_py)} символов, requirements.txt: {len(requirements)} символов")
# 3. Создаём репо (при пересдаче — 409, вернёт существующий URL)
repo = f"task-{task_id}"
print(f"[3/5] {'Обновляем' if is_rework else 'Создаём'} репозиторий {repo}...")
repo_url = gitea_create_repo(repo)
print(f" {repo_url}")
# 4. Пушим файлы
if is_rework and rework_analysis:
verdict = rework_analysis.get("verdict", "")
n_fixes = len(rework_analysis.get("fixes", []))
n_def = len(rework_analysis.get("defenses", []))
commit_prefix = f"fix({verdict}): {n_fixes} исправлений, {n_def} отстояно —"
elif is_rework:
commit_prefix = "fix:"
else:
commit_prefix = "add:"
print("[4/5] Пушим файлы...")
gitea_write(repo, "main.py", main_py, f"{commit_prefix} main.py")
print(" main.py ✓")
gitea_write(repo, "requirements.txt", requirements, f"{commit_prefix} requirements.txt")
print(" requirements.txt ✓")
for fname, fcontent in extra.items():
gitea_write(repo, fname, fcontent, f"{commit_prefix} {fname}")
print(f" {fname}")
# 5. Сабмитим
print("[5/5] Сабмитим...")
await mcp_call("task_update_answer", {
"taskId": task_id, "answerType": "link", "content": repo_url,
})
print(" task_update_answer ✓")
await asyncio.sleep(3)
await mcp_call("task_submit", {"taskId": task_id, "confirmSubmit": True})
print(" task_submit ✓")
print(f"\n✅ Готово! Репозиторий: {repo_url}")
return repo_url
async def main():
if len(sys.argv) < 2:
print("Использование: python solve_task.py <task_id>")
sys.exit(1)
await solve(sys.argv[1])
if __name__ == "__main__":
asyncio.run(main())