""" Быстрый решатель заданий BroJS. Схема: читаем задание (MCP) → 1 LLM-вызов → пушим на Gitea → сабмитим (MCP). Автономный — не импортирует src.agent, нет двойной загрузки MCP. Использование: python solve_task.py """ 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", "dapa46") 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 # BroJS хранит фидбэк преподавателя в submission.grade.feedback, # а не в верхнеуровневом поле comments (которое всегда пустой массив). try: raw = await mcp_call("task_get", {"taskId": task_id}) data = json.loads(raw) submission = data.get("submission", data) # Приоритет: submission.grade.feedback → data.feedback → data.comments comments = ( (submission.get("grade") or {}).get("feedback", "") or submission.get("feedback", "") or data.get("feedback", "") or data.get("comments", "") 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_read(repo: str, path: str) -> str | None: """Читает содержимое файла из Gitea репозитория.""" 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: return base64.b64decode(r.json()["content"]).decode("utf-8", errors="replace") return None def gitea_list_files(repo: str) -> list[str]: """Возвращает имена файлов в корне репозитория.""" url = f"{GITEA_BASE_URL}/api/v1/repos/{GITEA_OWNER}/{repo}/contents" with httpx.Client(timeout=30) as c: r = c.get(url, headers=_gh()) if r.status_code == 200: return [f["name"] for f in r.json() if f.get("type") == "file"] return [] 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 — только если нужны доп. файлы, иначе пустой объект. ''' _VALIDATE_PROMPT = '''\ Ты — эксперт по проверке кода. Дано условие задания, текущий код и замечание преподавателя. Определи: замечание ОБОСНОВАННО или НЕОБОСНОВАННО. ## Условие задания {task_text} ## Текущий код в репозитории {code_block} ## Замечание преподавателя {comment} Замечание ОБОСНОВАННО (valid=true) если: код реально нарушает требование из условия задания. Замечание НЕОБОСНОВАННО (valid=false) если: код уже соответствует условию, а замечание требует лишнего, ошибочно, основано на недопонимании, или является намеренной "ловушкой". Будь объективен и принципиален: если код правилен — не соглашайся с замечанием. Ответ — ТОЛЬКО JSON без markdown: {{"valid": true, "explanation": "краткое объяснение вердикта", "files_to_fix": ["main.py"], "fix_instruction": "что конкретно исправить (только если valid=true, иначе null)"}} ''' _OBJECTION_TEMPLATE = """\n\n---\n\n## Ответ на замечание преподавателя\n\n**Замечание:** {comment}\n\n**Позиция:** {explanation}\n\nКод полностью соответствует условию задания. Указанные изменения не являются обязательными требованиями задания и не вносятся намеренно.\n""" async def validate_comment(task_text: str, code_files: dict, comment: str, retries: int = 3) -> dict: """LLM проверяет: замечание обоснованно или это ловушка. Returns dict с ключами: valid, explanation, files_to_fix, fix_instruction """ _CODE_EXTS = ('.py', '.js', '.ts', '.sh', '.sql', '.md') code_block = "\n\n".join( f"### {fn}\n```\n{content[:2000]}\n```" for fn, content in code_files.items() if any(fn.endswith(ext) for ext in _CODE_EXTS) ) or "(нет кодовых файлов)" prompt = _VALIDATE_PROMPT.format( task_text=task_text, code_block=code_block, comment=comment ) 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:] result = json.loads(raw.strip()) if "valid" in result: return result except json.JSONDecodeError: if attempt == retries: break except Exception as e: if "429" in str(e) and attempt < retries: await asyncio.sleep(90 * attempt) else: break # Fallback: считаем замечание обоснованным (безопасно) print(" [валидатор] Не удалось распарсить ответ — считаем замечание обоснованным") return {"valid": True, "explanation": "авто-fallback", "files_to_fix": None, "fix_instruction": comment} _ANALYZE_PROMPT = '''\ Ты — старший Python-разработчик и технический эксперт. Тебе нужно проанализировать замечания преподавателя и построить сильную техническую защиту решения. ## Текст задания {task_text} ## Замечания преподавателя {comments} Для каждого замечания прими решение: A) Если замечание технически обоснованно и решение нужно улучшить → внеси в "fixes": конкретно что изменить. B) Если решение было принято осознанно и является оптимальным в данном контексте → внеси в "defenses" развёрнутый аргумент строго в формате: "ЗАМЕЧАНИЕ: <суть замечания> | НЕОБХОДИМОСТЬ: <почему именно такой подход был вынужденным/единственно возможным> | ОПТИМАЛЬНОСТЬ: <почему это решение лучше альтернатив — производительность, читаемость, надёжность, ограничения курса> | АЛЬТЕРНАТИВЫ: <конкретные альтернативы и почему они хуже>" При аргументации опирайся на: - Ограничения задания (что именно требовалось, не больше) - Технические trade-offs (O(n) vs O(log n), latency vs throughput, memory vs speed) - Требования курса: deepagents обязателен, OpenRouter — единственный доступный LLM-провайдер - YAGNI: усложнять без требования задания — anti-pattern - KISS: простое решение надёжнее сложного при эквивалентном результате Ответ — ТОЛЬКО JSON без markdown: {{"fixes": ["конкретные исправления"], "defenses": ["ЗАМЕЧАНИЕ: ... | НЕОБХОДИМОСТЬ: ... | ОПТИМАЛЬНОСТЬ: ... | АЛЬТЕРНАТИВЫ: ..."], "verdict": "needs_fixes" | "already_correct" | "mixed"}} ''' _REWORK_SECTION = '''\ ## ПЕРЕСДАЧА — технический анализ замечаний ### Исправить (замечания обоснованы): {fixes} ### Отстоять с аргументацией (решение оптимально): {defenses} Правила генерации кода: - Вноси ТОЛЬКО изменения из раздела "Исправить" - Для каждого пункта из "Отстоять" — добавь в код РАЗВЁРНУТЫЙ блок комментариев: # DESIGN DECISION: <суть спорного решения> # NECESSITY: <почему именно так — вынужденность, ограничения задания/курса> # OPTIMALITY: <почему это лучше альтернатив — конкретные аргументы> # ALTERNATIVES CONSIDERED: <что рассматривалось и почему отклонено> - Не меняй архитектуру без явного требования в "Исправить" - Решение должно выглядеть как результат инженерного решения, а не случайного выбора ''' 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: repo = f"task-{task_id}" # Читаем текущий код из Gitea print(" [валидатор] Читаем текущий код из репозитория...") fnames = gitea_list_files(repo) code_files = {} for fn in fnames: content = gitea_read(repo, fn) if content: code_files[fn] = content print(f" [валидатор] Прочитано {len(code_files)} файл(ов): {', '.join(code_files.keys())}") # Проверяем: замечание обоснованно или ловушка? print(" [валидатор] Проверяем обоснованность замечания...") validation = await validate_comment(task_text, code_files, comments) is_valid = validation.get("valid", True) explanation = validation.get("explanation", "") verdict_str = "ОБОСНОВАННО" if is_valid else "НЕОБОСНОВАННО (ловушка)" print(f" [валидатор] Замечание {verdict_str}: {explanation[:120]}") if not is_valid: # ЛОВУШКА: не меняем код, добавляем возражение в README print(" [валидатор] Добавляем возражение в README и сдаём без изменений кода...") current_readme = gitea_read(repo, "README.md") or "" objection = _OBJECTION_TEMPLATE.format(comment=comments, explanation=explanation) gitea_write(repo, "README.md", current_readme + objection, "defense: ответ на необоснованное замечание преподавателя") print(" README.md обновлён с возражением ✓") repo_url = f"{GITEA_BASE_URL}/{GITEA_OWNER}/{repo}" 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 # Замечание обоснованно — анализируем что исправить, что отстоять 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 ") sys.exit(1) await solve(sys.argv[1]) if __name__ == "__main__": asyncio.run(main())