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>
This commit is contained in:
+4
-2
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
+75
-564
@@ -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 тот же)
|
||||
"""
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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 — только если нужны доп. файлы, иначе пустой объект.
|
||||
'''
|
||||
@@ -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]
|
||||
Reference in New Issue
Block a user