diff --git a/src/app/main.py b/src/app/main.py index 30f985f..d5df92e 100644 --- a/src/app/main.py +++ b/src/app/main.py @@ -31,7 +31,6 @@ def create_app() -> FastAPI: app.include_router(modules.rag.public_router()) app.include_router(modules.rag.internal_router()) app.include_router(modules.rag_repo.internal_router()) - app.include_router(modules.agent.internal_router()) register_error_handlers(app) diff --git a/src/app/modules/agent/README.md b/src/app/modules/agent/README.md index d582dc5..18c8317 100644 --- a/src/app/modules/agent/README.md +++ b/src/app/modules/agent/README.md @@ -1,91 +1,37 @@ # Модуль agent -## 1. Функции модуля -- Оркестрация выполнения пользовательского запроса поверх роутера интентов и графов. -- Формирование `TaskSpec`, запуск оркестратора шагов и сборка финального результата. -- Реализация необходимых для агента tools и их интеграция с остальной логикой выполнения. -- Сохранение quality-метрик и session-артефактов для последующей привязки к Story. +## 1. Назначение +Модуль обеспечивает выполнение code-QA пайплайна для pipeline_setup_v3 и интеграцию с chat-слоем через адаптер к контракту `AgentRunner`. Оркестрация основана на **IntentRouterV2** (RAG) и **CodeQaRuntimeExecutor** (роутинг → retrieval → evidence gate → генерация ответа). -## 2. Диаграмма классов и взаимосвязей +## 2. Состав модуля +- **code_qa_runtime/** — рантайм выполнения code-QA: роутер интентов, retrieval, evidence gate, выбор промпта и генерация ответа (LLM). +- **llm/** — сервис вызова LLM (GigaChat) с загрузкой системных промптов через `PromptLoader`. +- **prompt_loader.py** — загрузка текстов промптов из каталога `prompts/`. +- **code_qa_runner_adapter.py** — адаптер `CodeQaRuntimeExecutor` к протоколу `AgentRunner` для использования из chat (async `run` → sync `execute` в executor). + +## 3. Диаграмма зависимостей ```mermaid classDiagram - class AgentModule - class GraphAgentRuntime - class OrchestratorService - class TaskSpecBuilder - class StorySessionRecorder - class StoryContextRepository - class ConfluenceService - class AgentRepository + class CodeQaRuntimeExecutor + class CodeQaRunnerAdapter + class AgentLlmService + class PromptLoader + class IntentRouterV2 + class CodeQaRetrievalAdapter - AgentModule --> GraphAgentRuntime - AgentModule --> ConfluenceService - AgentModule --> StorySessionRecorder - StorySessionRecorder --> StoryContextRepository - GraphAgentRuntime --> OrchestratorService - GraphAgentRuntime --> TaskSpecBuilder - GraphAgentRuntime --> AgentRepository - GraphAgentRuntime --> ConfluenceService + CodeQaRunnerAdapter --> CodeQaRuntimeExecutor + CodeQaRuntimeExecutor --> AgentLlmService + CodeQaRuntimeExecutor --> IntentRouterV2 + CodeQaRuntimeExecutor --> CodeQaRetrievalAdapter + AgentLlmService --> PromptLoader ``` -## 3. Описание классов -- `AgentModule`: собирает runtime и публикует внутренние tools-роуты. - Методы: `__init__` — связывает зависимости модуля; `internal_router` — регистрирует internal API tools. -- `GraphAgentRuntime`: основной исполнитель агентного запроса. - Методы: `run` — выполняет цикл route -> retrieval -> orchestration -> ответ/changeset. -- `OrchestratorService`: управляет планом шагов и выполнением quality gates. - Методы: `run` — строит, валидирует и исполняет execution plan. -- `TaskSpecBuilder`: формирует спецификацию задачи для оркестратора. - Методы: `build` — собирает `TaskSpec` из route, контекстов и ограничений. -- `ProjectQaConversationGraphFactory`, `ProjectQaClassificationGraphFactory`, `ProjectQaRetrievalGraphFactory`, `ProjectQaAnalysisGraphFactory`, `ProjectQaAnswerGraphFactory`: набор маленьких graph-исполнителей для `project/qa`. - Роли: нормализация запроса; классификация project-question; поздний retrieval из `RAG`; анализ code/docs контекста; сборка финального ответа. -- `StorySessionRecorder`: пишет session-scoped артефакты для последующего bind к Story. - Методы: `record_run` — сохраняет входные источники и выходные артефакты сессии. -- `StoryContextRepository`: репозиторий Story-контекста и его связей. - Методы: `record_story_commit` — фиксирует commit-контекст Story; `upsert_story` — создает/обновляет карточку Story; `add_session_artifact` — добавляет session-артефакт; `bind_session_to_story` — переносит артефакты сессии в Story; `add_artifact` — добавляет версионный Story-артефакт; `get_story_context` — возвращает агрегированный контекст Story. -- `ConfluenceService`: tool для загрузки страницы по URL. - Методы: `fetch_page` — валидирует URL и возвращает нормализованный payload страницы. -- `AgentRepository`: хранение router-контекста и quality-метрик. - Методы: `ensure_tables` — создает таблицы модуля; `get_router_context` — читает контекст маршрутизации; `update_router_context` — обновляет историю диалога и last-route; `save_quality_metrics` — сохраняет метрики качества; `get_quality_metrics` — читает историю метрик. +## 4. Точки входа +- **Тесты pipeline_setup_v3**: `AgentRuntimeAdapter` импортирует `CodeQaRuntimeExecutor`, `IntentRouterV2`, `CodeQaRepoContextFactory`, `CodeQaRetrievalAdapter`, `AgentLlmService`, `PromptLoader` напрямую из соответствующих пакетов. +- **Приложение (chat)**: `ModularApplication` собирает `CodeQaRuntimeExecutor` и оборачивает его в `CodeQaRunnerAdapter`; chat передаёт адаптер как `agent_runner` в `ChatModule`. -## 4. Сиквенс-диаграммы API - -### POST /internal/tools/confluence/fetch -Назначение: загружает страницу Confluence по URL и возвращает ее контент для дальнейшего использования в сценариях агента. -```mermaid -sequenceDiagram - participant Router as AgentModule.APIRouter - participant Confluence as ConfluenceService - - Router->>Confluence: fetch_page(url) - Confluence-->>Router: page(content_markdown, metadata) -``` - -### `project/qa` reasoning flow -Назначение: оркестратор планирует шаги, а каждый шаг исполняется отдельным graph. Retrieval вызывается поздно, внутри шага `context_retrieval`. -```mermaid -sequenceDiagram - participant Runtime as GraphAgentRuntime - participant Orch as OrchestratorService - participant G1 as conversation_understanding - participant G2 as question_classification - participant G3 as context_retrieval - participant Rag as RagService - participant G4 as context_analysis - participant G5 as answer_composition - - Runtime->>Orch: run(task) - Orch->>G1: execute - G1-->>Orch: resolved_request - Orch->>G2: execute - G2-->>Orch: question_profile - Orch->>G3: execute - G3->>Rag: retrieve(query) - Rag-->>G3: rag_items - G3-->>Orch: source_bundle - Orch->>G4: execute - G4-->>Orch: analysis_brief - Orch->>G5: execute - G5-->>Orch: final_answer - Orch-->>Runtime: final_answer -``` +## 5. Промпты +Используются только промпты, загружаемые из `prompts/`: +- **code_qa_*** — ответы по sub_intent (architecture, explain, find_entrypoints, find_tests, general, open_file, trace_flow, degraded, repair). +- **rag_intent_router_v2** — классификация интента в IntentRouterV2. +- **code_explain_answer_v2** — прямой code-explain в chat (direct_service). diff --git a/src/app/modules/agent/changeset_validator.py b/src/app/modules/agent/changeset_validator.py deleted file mode 100644 index f764fea..0000000 --- a/src/app/modules/agent/changeset_validator.py +++ /dev/null @@ -1,20 +0,0 @@ -from app.core.constants import SUPPORTED_SCHEMA_VERSION -from app.core.exceptions import AppError -from app.schemas.changeset import ChangeItem, ChangeSetPayload -from app.schemas.common import ModuleName - - -class ChangeSetValidator: - def validate(self, task_id: str, changeset: list[ChangeItem]) -> list[ChangeItem]: - payload = ChangeSetPayload( - schema_version=SUPPORTED_SCHEMA_VERSION, - task_id=task_id, - changeset=changeset, - ) - if payload.schema_version != SUPPORTED_SCHEMA_VERSION: - raise AppError( - "unsupported_schema", - f"Unsupported schema version: {payload.schema_version}", - ModuleName.AGENT, - ) - return payload.changeset diff --git a/src/app/modules/agent/code_qa_runner_adapter.py b/src/app/modules/agent/code_qa_runner_adapter.py new file mode 100644 index 0000000..c48e8e3 --- /dev/null +++ b/src/app/modules/agent/code_qa_runner_adapter.py @@ -0,0 +1,70 @@ +"""Адаптер CodeQaRuntimeExecutor к протоколу AgentRunner для интеграции с chat-слоем.""" + +from __future__ import annotations + +import asyncio +import logging + +from app.modules.agent.code_qa_runtime import CodeQaRuntimeExecutor +from app.modules.contracts import AgentRunner +from app.schemas.chat import TaskResultType + +LOGGER = logging.getLogger(__name__) + + +class CodeQaRunnerAdapter: + """Реализация AgentRunner через CodeQaRuntimeExecutor (sync execute в executor).""" + + def __init__(self, executor: CodeQaRuntimeExecutor) -> None: + self._executor = executor + + async def run( + self, + *, + task_id: str, + dialog_session_id: str, + rag_session_id: str, + mode: str, + message: str, + attachments: list[dict], + files: list[dict], + progress_cb=None, + ): + files_map = _files_to_map(files) + loop = asyncio.get_running_loop() + result = await loop.run_in_executor( + None, + lambda: self._executor.execute( + user_query=message, + rag_session_id=rag_session_id, + files_map=files_map, + ), + ) + return _to_agent_run_result(result, task_id=task_id) + + +def _files_to_map(files: list[dict]) -> dict[str, dict]: + out: dict[str, dict] = {} + for item in files or []: + if isinstance(item, dict) and item.get("path"): + out[str(item["path"])] = dict(item) + return out + + +def _to_agent_run_result(final, *, task_id: str): + from types import SimpleNamespace + + meta = {} + if final: + meta = { + "task_id": task_id, + "answer_mode": getattr(final, "answer_mode", "normal"), + } + if getattr(final, "diagnostics", None) is not None: + meta["diagnostics"] = getattr(final.diagnostics, "model_dump", lambda **kw: {})(mode="json") + return SimpleNamespace( + result_type=TaskResultType.ANSWER, + answer=final.final_answer if final else "", + changeset=[], + meta=meta, + ) diff --git a/src/app/modules/agent/confluence_service.py b/src/app/modules/agent/confluence_service.py deleted file mode 100644 index 0854daf..0000000 --- a/src/app/modules/agent/confluence_service.py +++ /dev/null @@ -1,20 +0,0 @@ -from datetime import datetime, timezone -from urllib.parse import urlparse -from uuid import uuid4 - -from app.core.exceptions import AppError -from app.schemas.common import ModuleName - - -class ConfluenceService: - async def fetch_page(self, url: str) -> dict: - parsed = urlparse(url) - if not parsed.scheme.startswith("http"): - raise AppError("invalid_url", "Invalid Confluence URL", ModuleName.CONFLUENCE) - return { - "page_id": str(uuid4()), - "title": "Confluence page", - "content_markdown": f"Fetched content from {url}", - "version": 1, - "fetched_at": datetime.now(timezone.utc).isoformat(), - } diff --git a/src/app/modules/agent/engine/graphs/__init__.py b/src/app/modules/agent/engine/graphs/__init__.py deleted file mode 100644 index 55c1956..0000000 --- a/src/app/modules/agent/engine/graphs/__init__.py +++ /dev/null @@ -1,56 +0,0 @@ -__all__ = [ - "BaseGraphFactory", - "CodeQaGraphFactory", - "DocsGraphFactory", - "ProjectQaAnalysisGraphFactory", - "ProjectQaAnswerGraphFactory", - "ProjectQaClassificationGraphFactory", - "ProjectQaConversationGraphFactory", - "ProjectEditsGraphFactory", - "ProjectQaGraphFactory", - "ProjectQaRetrievalGraphFactory", -] - - -def __getattr__(name: str): - if name == "BaseGraphFactory": - from app.modules.agent.engine.graphs.base_graph import BaseGraphFactory - - return BaseGraphFactory - if name == "CodeQaGraphFactory": - from app.modules.agent.engine.graphs.code_qa_graph import CodeQaGraphFactory - - return CodeQaGraphFactory - if name == "DocsGraphFactory": - from app.modules.agent.engine.graphs.docs_graph import DocsGraphFactory - - return DocsGraphFactory - if name == "ProjectQaConversationGraphFactory": - from app.modules.agent.engine.graphs.project_qa_step_graphs import ProjectQaConversationGraphFactory - - return ProjectQaConversationGraphFactory - if name == "ProjectQaClassificationGraphFactory": - from app.modules.agent.engine.graphs.project_qa_step_graphs import ProjectQaClassificationGraphFactory - - return ProjectQaClassificationGraphFactory - if name == "ProjectQaRetrievalGraphFactory": - from app.modules.agent.engine.graphs.project_qa_step_graphs import ProjectQaRetrievalGraphFactory - - return ProjectQaRetrievalGraphFactory - if name == "ProjectQaAnalysisGraphFactory": - from app.modules.agent.engine.graphs.project_qa_step_graphs import ProjectQaAnalysisGraphFactory - - return ProjectQaAnalysisGraphFactory - if name == "ProjectQaAnswerGraphFactory": - from app.modules.agent.engine.graphs.project_qa_step_graphs import ProjectQaAnswerGraphFactory - - return ProjectQaAnswerGraphFactory - if name == "ProjectEditsGraphFactory": - from app.modules.agent.engine.graphs.project_edits_graph import ProjectEditsGraphFactory - - return ProjectEditsGraphFactory - if name == "ProjectQaGraphFactory": - from app.modules.agent.engine.graphs.project_qa_graph import ProjectQaGraphFactory - - return ProjectQaGraphFactory - raise AttributeError(name) diff --git a/src/app/modules/agent/engine/graphs/base_graph.py b/src/app/modules/agent/engine/graphs/base_graph.py deleted file mode 100644 index c2021a2..0000000 --- a/src/app/modules/agent/engine/graphs/base_graph.py +++ /dev/null @@ -1,73 +0,0 @@ -import logging - -from langgraph.graph import END, START, StateGraph - -from app.modules.agent.engine.graphs.progress import emit_progress_sync -from app.modules.agent.llm import AgentLlmService -from app.modules.agent.engine.graphs.state import AgentGraphState - -LOGGER = logging.getLogger(__name__) - - -class BaseGraphFactory: - def __init__(self, llm: AgentLlmService) -> None: - self._llm = llm - - def build(self, checkpointer=None): - graph = StateGraph(AgentGraphState) - graph.add_node("context", self._context_node) - graph.add_node("answer", self._answer_node) - graph.add_edge(START, "context") - graph.add_edge("context", "answer") - graph.add_edge("answer", END) - return graph.compile(checkpointer=checkpointer) - - def _context_node(self, state: AgentGraphState) -> dict: - emit_progress_sync( - state, - stage="graph.default.context", - message="Готовлю контекст ответа по данным запроса.", - ) - rag = state.get("rag_context", "") - conf = state.get("confluence_context", "") - emit_progress_sync( - state, - stage="graph.default.context.done", - message="Контекст собран, перехожу к формированию ответа.", - ) - result = {"rag_context": rag, "confluence_context": conf} - LOGGER.warning( - "graph step result: graph=default step=context rag_len=%s confluence_len=%s", - len(rag or ""), - len(conf or ""), - ) - return result - - def _answer_node(self, state: AgentGraphState) -> dict: - emit_progress_sync( - state, - stage="graph.default.answer", - message="Формирую текст ответа для пользователя.", - ) - msg = state.get("message", "") - rag = state.get("rag_context", "") - conf = state.get("confluence_context", "") - user_input = "\n\n".join( - [ - f"User request:\n{msg}", - f"RAG context:\n{rag}", - f"Confluence context:\n{conf}", - ] - ) - answer = self._llm.generate("general_answer", user_input, log_context="graph.default.answer") - emit_progress_sync( - state, - stage="graph.default.answer.done", - message="Черновик ответа подготовлен.", - ) - result = {"answer": answer} - LOGGER.warning( - "graph step result: graph=default step=answer answer_len=%s", - len(answer or ""), - ) - return result diff --git a/src/app/modules/agent/engine/graphs/code_qa_graph.py b/src/app/modules/agent/engine/graphs/code_qa_graph.py deleted file mode 100644 index fbfc973..0000000 --- a/src/app/modules/agent/engine/graphs/code_qa_graph.py +++ /dev/null @@ -1,45 +0,0 @@ -from __future__ import annotations - -import logging - -from langgraph.graph import END, START, StateGraph - -from app.modules.agent.code_qa_runtime import CodeQaRuntimeExecutor -from app.modules.agent.engine.graphs.progress import emit_progress_sync -from app.modules.agent.engine.graphs.state import AgentGraphState -from app.modules.agent.llm import AgentLlmService - -LOGGER = logging.getLogger(__name__) - - -class CodeQaGraphFactory: - def __init__(self, llm: AgentLlmService) -> None: - self._executor = CodeQaRuntimeExecutor(llm) - - def build(self, checkpointer=None): - graph = StateGraph(AgentGraphState) - graph.add_node("execute_code_qa", self._execute_code_qa) - graph.add_edge(START, "execute_code_qa") - graph.add_edge("execute_code_qa", END) - return graph.compile(checkpointer=checkpointer) - - def _execute_code_qa(self, state: AgentGraphState) -> dict: - emit_progress_sync( - state, - stage="graph.project_qa.code_qa", - message="Исполняю CODE_QA runtime pipeline.", - ) - result = self._executor.execute( - user_query=str(state.get("message", "") or ""), - rag_session_id=str(state.get("project_id", "") or ""), - files_map=dict(state.get("files_map", {}) or {}), - ) - LOGGER.warning( - "graph step result: graph=project_qa/code_qa_runtime answer_mode=%s repair_used=%s", - result.answer_mode, - result.repair_used, - ) - return { - "final_answer": result.final_answer, - "code_qa_result": result.model_dump(mode="json"), - } diff --git a/src/app/modules/agent/engine/graphs/docs_examples_loader.py b/src/app/modules/agent/engine/graphs/docs_examples_loader.py deleted file mode 100644 index ddc139a..0000000 --- a/src/app/modules/agent/engine/graphs/docs_examples_loader.py +++ /dev/null @@ -1,26 +0,0 @@ -from pathlib import Path -import os - - -class DocsExamplesLoader: - def __init__(self, prompts_dir: Path | None = None) -> None: - base = prompts_dir or Path(__file__).resolve().parents[2] / "prompts" - env_override = os.getenv("AGENT_PROMPTS_DIR", "").strip() - root = Path(env_override) if env_override else base - self._examples_dir = root / "docs_examples" - - def load_bundle(self, *, max_files: int = 6, max_chars_per_file: int = 1800) -> str: - if not self._examples_dir.is_dir(): - return "" - files = sorted( - [p for p in self._examples_dir.iterdir() if p.is_file() and p.suffix.lower() in {".md", ".txt"}], - key=lambda p: p.name.lower(), - )[:max_files] - chunks: list[str] = [] - for path in files: - content = path.read_text(encoding="utf-8", errors="ignore").strip() - if not content: - continue - excerpt = content[:max_chars_per_file].strip() - chunks.append(f"### Example: {path.name}\n{excerpt}") - return "\n\n".join(chunks).strip() diff --git a/src/app/modules/agent/engine/graphs/docs_graph.py b/src/app/modules/agent/engine/graphs/docs_graph.py deleted file mode 100644 index 298ddb7..0000000 --- a/src/app/modules/agent/engine/graphs/docs_graph.py +++ /dev/null @@ -1,128 +0,0 @@ -from langgraph.graph import END, START, StateGraph -import logging - -from app.modules.agent.engine.graphs.file_targeting import FileTargeting -from app.modules.agent.engine.graphs.docs_graph_logic import DocsContentComposer, DocsContextAnalyzer -from app.modules.agent.engine.graphs.progress import emit_progress_sync -from app.modules.agent.engine.graphs.state import AgentGraphState -from app.modules.agent.llm import AgentLlmService - -LOGGER = logging.getLogger(__name__) - - -class DocsGraphFactory: - _max_validation_attempts = 2 - - def __init__(self, llm: AgentLlmService) -> None: - self._targeting = FileTargeting() - self._analyzer = DocsContextAnalyzer(llm, self._targeting) - self._composer = DocsContentComposer(llm, self._targeting) - - def build(self, checkpointer=None): - graph = StateGraph(AgentGraphState) - graph.add_node("collect_code_context", self._collect_code_context) - graph.add_node("detect_existing_docs", self._detect_existing_docs) - graph.add_node("decide_strategy", self._decide_strategy) - graph.add_node("load_rules_and_examples", self._load_rules_and_examples) - graph.add_node("plan_incremental_changes", self._plan_incremental_changes) - graph.add_node("plan_new_document", self._plan_new_document) - graph.add_node("generate_doc_content", self._generate_doc_content) - graph.add_node("self_check", self._self_check) - graph.add_node("build_changeset", self._build_changeset) - graph.add_node("summarize_result", self._summarize_result) - - graph.add_edge(START, "collect_code_context") - graph.add_edge("collect_code_context", "detect_existing_docs") - graph.add_edge("detect_existing_docs", "decide_strategy") - graph.add_edge("decide_strategy", "load_rules_and_examples") - graph.add_conditional_edges( - "load_rules_and_examples", - self._route_after_rules_loading, - { - "incremental": "plan_incremental_changes", - "from_scratch": "plan_new_document", - }, - ) - graph.add_edge("plan_incremental_changes", "generate_doc_content") - graph.add_edge("plan_new_document", "generate_doc_content") - graph.add_edge("generate_doc_content", "self_check") - graph.add_conditional_edges( - "self_check", - self._route_after_self_check, - {"retry": "generate_doc_content", "ready": "build_changeset"}, - ) - graph.add_edge("build_changeset", "summarize_result") - graph.add_edge("summarize_result", END) - return graph.compile(checkpointer=checkpointer) - - def _collect_code_context(self, state: AgentGraphState) -> dict: - return self._run_node(state, "collect_code_context", "Собираю контекст кода и файлов.", self._analyzer.collect_code_context) - - def _detect_existing_docs(self, state: AgentGraphState) -> dict: - return self._run_node( - state, - "detect_existing_docs", - "Определяю, есть ли существующая документация проекта.", - self._analyzer.detect_existing_docs, - ) - - def _decide_strategy(self, state: AgentGraphState) -> dict: - return self._run_node(state, "decide_strategy", "Выбираю стратегию: инкремент или генерация с нуля.", self._analyzer.decide_strategy) - - def _load_rules_and_examples(self, state: AgentGraphState) -> dict: - return self._run_node( - state, - "load_rules_and_examples", - "Загружаю правила и примеры формата документации.", - self._composer.load_rules_and_examples, - ) - - def _plan_incremental_changes(self, state: AgentGraphState) -> dict: - return self._run_node( - state, - "plan_incremental_changes", - "Планирую точечные изменения в существующей документации.", - lambda st: self._composer.plan_incremental_changes(st, self._analyzer), - ) - - def _plan_new_document(self, state: AgentGraphState) -> dict: - return self._run_node(state, "plan_new_document", "Проектирую структуру новой документации.", self._composer.plan_new_document) - - def _generate_doc_content(self, state: AgentGraphState) -> dict: - return self._run_node(state, "generate_doc_content", "Генерирую содержимое документации.", self._composer.generate_doc_content) - - def _self_check(self, state: AgentGraphState) -> dict: - return self._run_node(state, "self_check", "Проверяю соответствие результата правилам.", self._composer.self_check) - - def _build_changeset(self, state: AgentGraphState) -> dict: - return self._run_node(state, "build_changeset", "Формирую итоговый набор изменений файлов.", self._composer.build_changeset) - - def _summarize_result(self, state: AgentGraphState) -> dict: - return self._run_node( - state, - "summarize_result", - "Формирую краткий обзор выполненных действий и измененных файлов.", - self._composer.build_execution_summary, - ) - - def _route_after_rules_loading(self, state: AgentGraphState) -> str: - if state.get("docs_strategy") == "incremental_update": - return "incremental" - return "from_scratch" - - def _route_after_self_check(self, state: AgentGraphState) -> str: - if state.get("validation_passed"): - return "ready" - attempts = int(state.get("validation_attempts", 0) or 0) - return "ready" if attempts >= self._max_validation_attempts else "retry" - - def _run_node(self, state: AgentGraphState, node_name: str, message: str, fn): - emit_progress_sync(state, stage=f"graph.docs.{node_name}", message=message) - try: - result = fn(state) - emit_progress_sync(state, stage=f"graph.docs.{node_name}.done", message=f"Шаг '{node_name}' завершен.") - LOGGER.warning("docs graph node completed: node=%s keys=%s", node_name, sorted(result.keys())) - return result - except Exception: - LOGGER.exception("docs graph node failed: node=%s", node_name) - raise diff --git a/src/app/modules/agent/engine/graphs/docs_graph_logic.py b/src/app/modules/agent/engine/graphs/docs_graph_logic.py deleted file mode 100644 index daf7947..0000000 --- a/src/app/modules/agent/engine/graphs/docs_graph_logic.py +++ /dev/null @@ -1,523 +0,0 @@ -import json -from difflib import SequenceMatcher - -from app.modules.agent.engine.graphs.docs_examples_loader import DocsExamplesLoader -from app.modules.agent.engine.graphs.file_targeting import FileTargeting -from app.modules.agent.engine.graphs.state import AgentGraphState -from app.modules.agent.llm import AgentLlmService -from app.schemas.changeset import ChangeItem -import logging - -LOGGER = logging.getLogger(__name__) - - -class DocsContextAnalyzer: - def __init__(self, llm: AgentLlmService, targeting: FileTargeting) -> None: - self._llm = llm - self._targeting = targeting - - def collect_code_context(self, state: AgentGraphState) -> dict: - message = state.get("message", "") - files_map = state.get("files_map", {}) or {} - requested_path = self._targeting.extract_target_path(message) - target_file = self._targeting.lookup_file(files_map, requested_path) if requested_path else None - docs_candidates = self._collect_doc_candidates(files_map) - target_path = str((target_file or {}).get("path") or (requested_path or "")).strip() or "" - return { - "docs_candidates": docs_candidates, - "target_path": target_path, - "target_file_content": str((target_file or {}).get("content", "")), - "target_file_hash": str((target_file or {}).get("content_hash", "")), - "validation_attempts": 0, - } - - def detect_existing_docs(self, state: AgentGraphState) -> dict: - docs_candidates = state.get("docs_candidates", []) or [] - if not docs_candidates: - return { - "existing_docs_detected": False, - "existing_docs_summary": "No documentation files detected in current project context.", - } - - snippets = "\n\n".join( - [ - f"Path: {item.get('path', '')}\nSnippet:\n{self._shorten(item.get('content', ''), 500)}" - for item in docs_candidates[:8] - ] - ) - user_input = "\n\n".join( - [ - f"User request:\n{state.get('message', '')}", - f"Requested target path:\n{state.get('target_path', '') or '(not specified)'}", - f"Detected documentation candidates:\n{snippets}", - ] - ) - raw = self._llm.generate("docs_detect", user_input, log_context="graph.docs.detect_existing_docs") - exists = self.parse_bool_marker(raw, "exists", default=True) - summary = self.parse_text_marker(raw, "summary", default="Documentation files detected.") - return {"existing_docs_detected": exists, "existing_docs_summary": summary} - - def decide_strategy(self, state: AgentGraphState) -> dict: - message = (state.get("message", "") or "").lower() - if any(token in message for token in ("с нуля", "from scratch", "new documentation", "создай документацию")): - return {"docs_strategy": "from_scratch"} - if any(token in message for token in ("дополни", "обнови документацию", "extend docs", "update docs")): - return {"docs_strategy": "incremental_update"} - - user_input = "\n\n".join( - [ - f"User request:\n{state.get('message', '')}", - f"Existing docs detected:\n{state.get('existing_docs_detected', False)}", - f"Existing docs summary:\n{state.get('existing_docs_summary', '')}", - ] - ) - raw = self._llm.generate("docs_strategy", user_input, log_context="graph.docs.decide_strategy") - strategy = self.parse_text_marker(raw, "strategy", default="").lower() - if strategy not in {"incremental_update", "from_scratch"}: - strategy = "incremental_update" if state.get("existing_docs_detected", False) else "from_scratch" - return {"docs_strategy": strategy} - - def resolve_target_for_incremental(self, state: AgentGraphState) -> tuple[str, dict | None]: - files_map = state.get("files_map", {}) or {} - preferred_path = state.get("target_path", "") - preferred = self._targeting.lookup_file(files_map, preferred_path) - if preferred: - return str(preferred.get("path") or preferred_path), preferred - candidates = state.get("docs_candidates", []) or [] - if candidates: - first_path = str(candidates[0].get("path", "")) - resolved = self._targeting.lookup_file(files_map, first_path) or candidates[0] - return first_path, resolved - fallback = preferred_path.strip() or "docs/AGENT_DRAFT.md" - return fallback, None - - def _collect_doc_candidates(self, files_map: dict[str, dict]) -> list[dict]: - candidates: list[dict] = [] - for raw_path, payload in files_map.items(): - path = str(raw_path or "").replace("\\", "/").strip() - if not path: - continue - low = path.lower() - is_doc = low.startswith("docs/") or low.endswith(".md") or low.endswith(".rst") or "/readme" in low or low.startswith("readme") - if not is_doc: - continue - candidates.append( - { - "path": str(payload.get("path") or path), - "content": str(payload.get("content", "")), - "content_hash": str(payload.get("content_hash", "")), - } - ) - candidates.sort(key=lambda item: (0 if str(item.get("path", "")).lower().startswith("docs/") else 1, str(item.get("path", "")).lower())) - return candidates - - def _shorten(self, text: str, max_chars: int) -> str: - value = (text or "").strip() - if len(value) <= max_chars: - return value - return value[:max_chars].rstrip() + "\n...[truncated]" - - @staticmethod - def parse_bool_marker(text: str, marker: str, *, default: bool) -> bool: - value = DocsContextAnalyzer.parse_text_marker(text, marker, default="") - if not value: - return default - token = value.split()[0].strip().lower() - if token in {"yes", "true", "1", "да"}: - return True - if token in {"no", "false", "0", "нет"}: - return False - return default - - @staticmethod - def parse_text_marker(text: str, marker: str, *, default: str) -> str: - low_marker = f"{marker.lower()}:" - for line in (text or "").splitlines(): - raw = line.strip() - if raw.lower().startswith(low_marker): - return raw.split(":", 1)[1].strip() - return default - - -class DocsBundleFormatter: - def shorten(self, text: str, max_chars: int) -> str: - value = (text or "").strip() - if len(value) <= max_chars: - return value - return value[:max_chars].rstrip() + "\n...[truncated]" - - def normalize_file_output(self, text: str) -> str: - value = (text or "").strip() - if value.startswith("```") and value.endswith("```"): - lines = value.splitlines() - if len(lines) >= 3: - return "\n".join(lines[1:-1]).strip() - return value - - def parse_docs_bundle(self, raw_text: str) -> list[dict]: - text = (raw_text or "").strip() - if not text: - return [] - - candidate = self.normalize_file_output(text) - parsed = self._parse_json_candidate(candidate) - if parsed is None: - start = candidate.find("{") - end = candidate.rfind("}") - if start != -1 and end > start: - parsed = self._parse_json_candidate(candidate[start : end + 1]) - if parsed is None: - return [] - - files: list[dict] - if isinstance(parsed, dict): - raw_files = parsed.get("files") - files = raw_files if isinstance(raw_files, list) else [] - elif isinstance(parsed, list): - files = parsed - else: - files = [] - - out: list[dict] = [] - seen: set[str] = set() - for item in files: - if not isinstance(item, dict): - continue - path = str(item.get("path", "")).replace("\\", "/").strip() - content = str(item.get("content", "")) - if not path or not content.strip(): - continue - if path in seen: - continue - seen.add(path) - out.append( - { - "path": path, - "content": content, - "reason": str(item.get("reason", "")).strip(), - } - ) - return out - - def bundle_has_required_structure(self, bundle: list[dict]) -> bool: - if not bundle: - return False - has_api = any(str(item.get("path", "")).replace("\\", "/").startswith("docs/api/") for item in bundle) - has_logic = any(str(item.get("path", "")).replace("\\", "/").startswith("docs/logic/") for item in bundle) - return has_api and has_logic - - def similarity(self, original: str, updated: str) -> float: - return SequenceMatcher(None, original or "", updated or "").ratio() - - def line_change_ratio(self, original: str, updated: str) -> float: - orig_lines = (original or "").splitlines() - new_lines = (updated or "").splitlines() - if not orig_lines and not new_lines: - return 0.0 - matcher = SequenceMatcher(None, orig_lines, new_lines) - changed = 0 - for tag, i1, i2, j1, j2 in matcher.get_opcodes(): - if tag == "equal": - continue - changed += max(i2 - i1, j2 - j1) - total = max(len(orig_lines), len(new_lines), 1) - return changed / total - - def added_headings(self, original: str, updated: str) -> int: - old_heads = {line.strip() for line in (original or "").splitlines() if line.strip().startswith("#")} - new_heads = {line.strip() for line in (updated or "").splitlines() if line.strip().startswith("#")} - return len(new_heads - old_heads) - - def collapse_whitespace(self, text: str) -> str: - return " ".join((text or "").split()) - - def _parse_json_candidate(self, text: str): - try: - return json.loads(text) - except Exception: - return None - - -class DocsContentComposer: - def __init__(self, llm: AgentLlmService, targeting: FileTargeting) -> None: - self._llm = llm - self._targeting = targeting - self._examples = DocsExamplesLoader() - self._bundle = DocsBundleFormatter() - - def load_rules_and_examples(self, _state: AgentGraphState) -> dict: - return {"rules_bundle": self._examples.load_bundle()} - - def plan_incremental_changes(self, state: AgentGraphState, analyzer: DocsContextAnalyzer) -> dict: - target_path, target = analyzer.resolve_target_for_incremental(state) - user_input = "\n\n".join( - [ - "Strategy: incremental_update", - f"User request:\n{state.get('message', '')}", - f"Target path:\n{target_path}", - f"Current target content:\n{self._bundle.shorten((target or {}).get('content', ''), 3000)}", - f"RAG context:\n{self._bundle.shorten(state.get('rag_context', ''), 6000)}", - f"Examples bundle:\n{state.get('rules_bundle', '')}", - ] - ) - plan = self._llm.generate("docs_plan_sections", user_input, log_context="graph.docs.plan_incremental_changes") - return { - "doc_plan": plan, - "target_path": target_path, - "target_file_content": str((target or {}).get("content", "")), - "target_file_hash": str((target or {}).get("content_hash", "")), - } - - def plan_new_document(self, state: AgentGraphState) -> dict: - target_path = state.get("target_path", "").strip() or "docs/AGENT_DRAFT.md" - user_input = "\n\n".join( - [ - "Strategy: from_scratch", - f"User request:\n{state.get('message', '')}", - f"Target path:\n{target_path}", - f"RAG context:\n{self._bundle.shorten(state.get('rag_context', ''), 6000)}", - f"Examples bundle:\n{state.get('rules_bundle', '')}", - ] - ) - plan = self._llm.generate("docs_plan_sections", user_input, log_context="graph.docs.plan_new_document") - return {"doc_plan": plan, "target_path": target_path, "target_file_content": "", "target_file_hash": ""} - - def generate_doc_content(self, state: AgentGraphState) -> dict: - user_input = "\n\n".join( - [ - f"Strategy:\n{state.get('docs_strategy', 'from_scratch')}", - f"User request:\n{state.get('message', '')}", - f"Target path:\n{state.get('target_path', '')}", - f"Document plan:\n{state.get('doc_plan', '')}", - f"Current target content:\n{self._bundle.shorten(state.get('target_file_content', ''), 3500)}", - f"RAG context:\n{self._bundle.shorten(state.get('rag_context', ''), 7000)}", - f"Examples bundle:\n{state.get('rules_bundle', '')}", - ] - ) - raw = self._llm.generate("docs_generation", user_input, log_context="graph.docs.generate_doc_content") - bundle = self._bundle.parse_docs_bundle(raw) - if bundle: - first_content = str(bundle[0].get("content", "")).strip() - return {"generated_docs_bundle": bundle, "generated_doc": first_content} - content = self._bundle.normalize_file_output(raw) - return {"generated_docs_bundle": [], "generated_doc": content} - - def self_check(self, state: AgentGraphState) -> dict: - attempts = int(state.get("validation_attempts", 0) or 0) + 1 - bundle = state.get("generated_docs_bundle", []) or [] - generated = state.get("generated_doc", "") - if not generated.strip() and not bundle: - return { - "validation_attempts": attempts, - "validation_passed": False, - "validation_feedback": "Generated document is empty.", - } - strategy = state.get("docs_strategy", "from_scratch") - if strategy == "from_scratch" and not self._bundle.bundle_has_required_structure(bundle): - return { - "validation_attempts": attempts, - "validation_passed": False, - "validation_feedback": "Bundle must include both docs/api and docs/logic for from_scratch strategy.", - } - if strategy == "incremental_update": - if bundle and len(bundle) > 1 and not self._is_broad_rewrite_request(str(state.get("message", ""))): - return { - "validation_attempts": attempts, - "validation_passed": False, - "validation_feedback": "Incremental update should not touch multiple files without explicit broad rewrite request.", - } - original = str(state.get("target_file_content", "")) - broad = self._is_broad_rewrite_request(str(state.get("message", ""))) - if original and generated: - if self._bundle.collapse_whitespace(original) == self._bundle.collapse_whitespace(generated): - return { - "validation_attempts": attempts, - "validation_passed": False, - "validation_feedback": "Only formatting/whitespace changes detected.", - } - similarity = self._bundle.similarity(original, generated) - change_ratio = self._bundle.line_change_ratio(original, generated) - added_headings = self._bundle.added_headings(original, generated) - min_similarity = 0.75 if broad else 0.9 - max_change_ratio = 0.7 if broad else 0.35 - if similarity < min_similarity: - return { - "validation_attempts": attempts, - "validation_passed": False, - "validation_feedback": f"Incremental update is too broad (similarity={similarity:.2f}).", - } - if change_ratio > max_change_ratio: - return { - "validation_attempts": attempts, - "validation_passed": False, - "validation_feedback": f"Incremental update changes too many lines (change_ratio={change_ratio:.2f}).", - } - if not broad and added_headings > 0: - return { - "validation_attempts": attempts, - "validation_passed": False, - "validation_feedback": "New section headings were added outside requested scope.", - } - - bundle_text = "\n".join([f"- {item.get('path', '')}" for item in bundle[:30]]) - user_input = "\n\n".join( - [ - f"Strategy:\n{strategy}", - f"User request:\n{state.get('message', '')}", - f"Document plan:\n{state.get('doc_plan', '')}", - f"Generated file paths:\n{bundle_text or '(single-file mode)'}", - f"Generated document:\n{generated}", - ] - ) - raw = self._llm.generate("docs_self_check", user_input, log_context="graph.docs.self_check") - passed = DocsContextAnalyzer.parse_bool_marker(raw, "pass", default=False) - feedback = DocsContextAnalyzer.parse_text_marker(raw, "feedback", default="No validation feedback provided.") - return {"validation_attempts": attempts, "validation_passed": passed, "validation_feedback": feedback} - - def build_changeset(self, state: AgentGraphState) -> dict: - files_map = state.get("files_map", {}) or {} - bundle = state.get("generated_docs_bundle", []) or [] - strategy = state.get("docs_strategy", "from_scratch") - if strategy == "from_scratch" and not self._bundle.bundle_has_required_structure(bundle): - LOGGER.info( - "build_changeset fallback bundle used: strategy=%s bundle_items=%s", - strategy, - len(bundle), - ) - bundle = self._build_fallback_bundle_from_text(state.get("generated_doc", "")) - if bundle: - changes: list[ChangeItem] = [] - for item in bundle: - path = str(item.get("path", "")).replace("\\", "/").strip() - content = str(item.get("content", "")) - if not path or not content.strip(): - continue - target = self._targeting.lookup_file(files_map, path) - reason = str(item.get("reason", "")).strip() or f"Documentation {strategy}: generated file from structured bundle." - if target and target.get("content_hash"): - changes.append( - ChangeItem( - op="update", - path=str(target.get("path") or path), - base_hash=str(target.get("content_hash", "")), - proposed_content=content, - reason=reason, - ) - ) - else: - changes.append( - ChangeItem( - op="create", - path=path, - proposed_content=content, - reason=reason, - ) - ) - if changes: - return {"changeset": changes} - - target_path = (state.get("target_path", "") or "").strip() or "docs/AGENT_DRAFT.md" - target = self._targeting.lookup_file(files_map, target_path) - content = state.get("generated_doc", "") - if target and target.get("content_hash"): - change = ChangeItem( - op="update", - path=str(target.get("path") or target_path), - base_hash=str(target.get("content_hash", "")), - proposed_content=content, - reason=f"Documentation {strategy}: update existing document increment.", - ) - else: - change = ChangeItem( - op="create", - path=target_path, - proposed_content=content, - reason=f"Documentation {strategy}: create document from current project context.", - ) - return {"changeset": [change]} - - def build_execution_summary(self, state: AgentGraphState) -> dict: - changeset = state.get("changeset", []) or [] - if not changeset: - return {"answer": "Документация не была изменена: итоговый changeset пуст."} - - file_lines = self._format_changed_files(changeset) - user_input = "\n\n".join( - [ - f"User request:\n{state.get('message', '')}", - f"Documentation strategy:\n{state.get('docs_strategy', 'from_scratch')}", - f"Document plan:\n{state.get('doc_plan', '')}", - f"Validation feedback:\n{state.get('validation_feedback', '')}", - f"Changed files:\n{file_lines}", - ] - ) - try: - summary = self._llm.generate( - "docs_execution_summary", - user_input, - log_context="graph.docs.summarize_result", - ).strip() - except Exception: - summary = "" - if not summary: - summary = self._build_fallback_summary(state, file_lines) - return {"answer": summary} - - def _build_fallback_bundle_from_text(self, text: str) -> list[dict]: - content = (text or "").strip() - if not content: - content = ( - "# Project Documentation Draft\n\n" - "## Overview\n" - "Documentation draft was generated, but structured sections require уточнение.\n" - ) - return [ - { - "path": "docs/logic/project_overview.md", - "content": content, - "reason": "Fallback: generated structured logic document from non-JSON model output.", - }, - { - "path": "docs/api/README.md", - "content": ( - "# API Methods\n\n" - "This file is a fallback placeholder for API method documentation.\n\n" - "## Next Step\n" - "- Add one file per API method under `docs/api/`.\n" - ), - "reason": "Fallback: ensure required docs/api structure exists.", - }, - ] - - def _format_changed_files(self, changeset: list[ChangeItem]) -> str: - lines: list[str] = [] - for item in changeset[:30]: - lines.append(f"- {item.op.value} {item.path}: {item.reason}") - return "\n".join(lines) - - def _build_fallback_summary(self, state: AgentGraphState, file_lines: str) -> str: - request = (state.get("message", "") or "").strip() - return "\n".join( - [ - "Выполненные действия:", - f"- Обработан запрос: {request or '(пустой запрос)'}", - f"- Применена стратегия документации: {state.get('docs_strategy', 'from_scratch')}", - "- Сформирован и проверен changeset для документации.", - "", - "Измененные файлы:", - file_lines or "- (нет изменений)", - ] - ) - - def _is_broad_rewrite_request(self, message: str) -> bool: - low = (message or "").lower() - markers = ( - "перепиши", - "полностью", - "целиком", - "с нуля", - "full rewrite", - "rewrite all", - "реорганизуй", - ) - return any(marker in low for marker in markers) diff --git a/src/app/modules/agent/engine/graphs/file_targeting.py b/src/app/modules/agent/engine/graphs/file_targeting.py deleted file mode 100644 index 6f14659..0000000 --- a/src/app/modules/agent/engine/graphs/file_targeting.py +++ /dev/null @@ -1,28 +0,0 @@ -import re - - -class FileTargeting: - _path_pattern = re.compile(r"([A-Za-z0-9_.\-/]+?\.[A-Za-z0-9_]+)") - - def extract_target_path(self, message: str) -> str | None: - text = (message or "").replace("\\", "/") - candidates = self._path_pattern.findall(text) - if not candidates: - return None - for candidate in candidates: - cleaned = candidate.strip("`'\".,:;()[]{}") - if "/" in cleaned or cleaned.startswith("."): - return cleaned - return candidates[0].strip("`'\".,:;()[]{}") - - def lookup_file(self, files_map: dict[str, dict], path: str | None) -> dict | None: - if not path: - return None - normalized = path.replace("\\", "/") - if normalized in files_map: - return files_map[normalized] - low = normalized.lower() - for key, value in files_map.items(): - if key.lower() == low: - return value - return None diff --git a/src/app/modules/agent/engine/graphs/progress.py b/src/app/modules/agent/engine/graphs/progress.py deleted file mode 100644 index 7fe878a..0000000 --- a/src/app/modules/agent/engine/graphs/progress.py +++ /dev/null @@ -1,44 +0,0 @@ -from collections.abc import Awaitable, Callable -import inspect -import asyncio - -from app.modules.agent.engine.graphs.progress_registry import progress_registry -from app.modules.agent.engine.graphs.state import AgentGraphState - -ProgressCallback = Callable[[str, str, str, dict | None], Awaitable[None] | None] - - -async def emit_progress( - state: AgentGraphState, - *, - stage: str, - message: str, - kind: str = "task_progress", - meta: dict | None = None, -) -> None: - callback = progress_registry.get(state.get("progress_key")) - if callback is None: - return - result = callback(stage, message, kind, meta or {}) - if inspect.isawaitable(result): - await result - - -def emit_progress_sync( - state: AgentGraphState, - *, - stage: str, - message: str, - kind: str = "task_progress", - meta: dict | None = None, -) -> None: - callback = progress_registry.get(state.get("progress_key")) - if callback is None: - return - result = callback(stage, message, kind, meta or {}) - if inspect.isawaitable(result): - try: - loop = asyncio.get_running_loop() - loop.create_task(result) - except RuntimeError: - pass diff --git a/src/app/modules/agent/engine/graphs/progress_registry.py b/src/app/modules/agent/engine/graphs/progress_registry.py deleted file mode 100644 index 91648d0..0000000 --- a/src/app/modules/agent/engine/graphs/progress_registry.py +++ /dev/null @@ -1,27 +0,0 @@ -from collections.abc import Awaitable, Callable -from threading import Lock - -ProgressCallback = Callable[[str, str, str, dict | None], Awaitable[None] | None] - - -class ProgressRegistry: - def __init__(self) -> None: - self._items: dict[str, ProgressCallback] = {} - self._lock = Lock() - - def register(self, key: str, callback: ProgressCallback) -> None: - with self._lock: - self._items[key] = callback - - def get(self, key: str | None) -> ProgressCallback | None: - if not key: - return None - with self._lock: - return self._items.get(key) - - def unregister(self, key: str) -> None: - with self._lock: - self._items.pop(key, None) - - -progress_registry = ProgressRegistry() diff --git a/src/app/modules/agent/engine/graphs/project_edits_contract.py b/src/app/modules/agent/engine/graphs/project_edits_contract.py deleted file mode 100644 index 1c9e72a..0000000 --- a/src/app/modules/agent/engine/graphs/project_edits_contract.py +++ /dev/null @@ -1,171 +0,0 @@ -import re -from dataclasses import dataclass, field - - -@dataclass -class BlockContract: - type: str - max_changed_lines: int = 6 - start_anchor: str = "" - end_anchor: str = "" - old_line: str = "" - - def as_dict(self) -> dict: - return { - "type": self.type, - "max_changed_lines": self.max_changed_lines, - "start_anchor": self.start_anchor, - "end_anchor": self.end_anchor, - "old_line": self.old_line, - } - - -@dataclass -class FileEditContract: - path: str - reason: str - intent: str = "update" - max_hunks: int = 1 - max_changed_lines: int = 8 - allowed_blocks: list[BlockContract] = field(default_factory=list) - - def as_dict(self) -> dict: - return { - "path": self.path, - "reason": self.reason, - "intent": self.intent, - "max_hunks": self.max_hunks, - "max_changed_lines": self.max_changed_lines, - "allowed_blocks": [block.as_dict() for block in self.allowed_blocks], - } - - -class ContractParser: - _supported_block_types = {"append_end", "replace_between", "replace_line_equals"} - - def parse(self, payload: dict, *, request: str, requested_path: str) -> list[dict]: - files = payload.get("files", []) if isinstance(payload, dict) else [] - parsed: list[FileEditContract] = [] - for item in files if isinstance(files, list) else []: - contract = self._parse_file_contract(item) - if contract: - parsed.append(contract) - - if not parsed: - fallback = self._fallback_contract(request=request, requested_path=requested_path) - if fallback: - parsed.append(fallback) - - return [item.as_dict() for item in parsed] - - def _parse_file_contract(self, item: object) -> FileEditContract | None: - if not isinstance(item, dict): - return None - path = str(item.get("path", "")).replace("\\", "/").strip() - if not path: - return None - reason = str(item.get("reason", "")).strip() or "Requested user adjustment." - intent = str(item.get("intent", "update")).strip().lower() or "update" - if intent not in {"update", "create"}: - intent = "update" - max_hunks = self._clamp_int(item.get("max_hunks"), default=1, min_value=1, max_value=5) - max_changed_lines = self._clamp_int(item.get("max_changed_lines"), default=8, min_value=1, max_value=120) - blocks: list[BlockContract] = [] - raw_blocks = item.get("allowed_blocks", []) - for raw in raw_blocks if isinstance(raw_blocks, list) else []: - block = self._parse_block(raw) - if block: - blocks.append(block) - if not blocks: - return None - return FileEditContract( - path=path, - reason=reason, - intent=intent, - max_hunks=max_hunks, - max_changed_lines=max_changed_lines, - allowed_blocks=blocks, - ) - - def _parse_block(self, raw: object) -> BlockContract | None: - if not isinstance(raw, dict): - return None - kind = self._normalize_block_type(str(raw.get("type", "")).strip().lower()) - if kind not in self._supported_block_types: - return None - max_changed_lines = self._clamp_int(raw.get("max_changed_lines"), default=6, min_value=1, max_value=80) - block = BlockContract( - type=kind, - max_changed_lines=max_changed_lines, - start_anchor=str(raw.get("start_anchor", "")).strip(), - end_anchor=str(raw.get("end_anchor", "")).strip(), - old_line=str(raw.get("old_line", "")).strip(), - ) - if block.type == "replace_between" and (not block.start_anchor or not block.end_anchor): - return None - if block.type == "replace_line_equals" and not block.old_line: - return None - return block - - def _fallback_contract(self, *, request: str, requested_path: str) -> FileEditContract | None: - path = requested_path.strip() - if not path: - return None - low = (request or "").lower() - if any(marker in low for marker in ("в конец", "в самый конец", "append to end", "append at the end")): - return FileEditContract( - path=path, - reason="Append-only update inferred from user request.", - intent="update", - max_hunks=1, - max_changed_lines=8, - allowed_blocks=[BlockContract(type="append_end", max_changed_lines=8)], - ) - quoted = self._extract_quoted_line(request) - if quoted: - return FileEditContract( - path=path, - reason="Single-line replacement inferred from quoted segment in user request.", - intent="update", - max_hunks=1, - max_changed_lines=4, - allowed_blocks=[BlockContract(type="replace_line_equals", old_line=quoted, max_changed_lines=4)], - ) - return None - - def _extract_quoted_line(self, text: str) -> str: - value = (text or "").strip() - patterns = [ - r"`([^`]+)`", - r"\"([^\"]+)\"", - r"'([^']+)'", - r"«([^»]+)»", - ] - for pattern in patterns: - match = re.search(pattern, value) - if not match: - continue - candidate = match.group(1).strip() - if candidate: - return candidate - return "" - - def _normalize_block_type(self, value: str) -> str: - mapping = { - "append": "append_end", - "append_eof": "append_end", - "end_append": "append_end", - "replace_block": "replace_between", - "replace_section": "replace_between", - "replace_range": "replace_between", - "replace_line": "replace_line_equals", - "line_equals": "replace_line_equals", - } - return mapping.get(value, value) - - def _clamp_int(self, value: object, *, default: int, min_value: int, max_value: int) -> int: - try: - numeric = int(value) # type: ignore[arg-type] - except Exception: - numeric = default - return max(min_value, min(max_value, numeric)) diff --git a/src/app/modules/agent/engine/graphs/project_edits_graph.py b/src/app/modules/agent/engine/graphs/project_edits_graph.py deleted file mode 100644 index 47291d4..0000000 --- a/src/app/modules/agent/engine/graphs/project_edits_graph.py +++ /dev/null @@ -1,102 +0,0 @@ -import logging - -from langgraph.graph import END, START, StateGraph - -from app.modules.agent.engine.graphs.progress import emit_progress_sync -from app.modules.agent.engine.graphs.project_edits_logic import ProjectEditsLogic -from app.modules.agent.engine.graphs.state import AgentGraphState -from app.modules.agent.llm import AgentLlmService - -LOGGER = logging.getLogger(__name__) - - -class ProjectEditsGraphFactory: - _max_validation_attempts = 2 - - def __init__(self, llm: AgentLlmService) -> None: - self._logic = ProjectEditsLogic(llm) - - def build(self, checkpointer=None): - graph = StateGraph(AgentGraphState) - graph.add_node("collect_context", self._collect_context) - graph.add_node("plan_changes", self._plan_changes) - graph.add_node("generate_changeset", self._generate_changeset) - graph.add_node("self_check", self._self_check) - graph.add_node("build_result", self._build_result) - - graph.add_edge(START, "collect_context") - graph.add_edge("collect_context", "plan_changes") - graph.add_edge("plan_changes", "generate_changeset") - graph.add_edge("generate_changeset", "self_check") - graph.add_conditional_edges( - "self_check", - self._route_after_self_check, - {"retry": "generate_changeset", "ready": "build_result"}, - ) - graph.add_edge("build_result", END) - return graph.compile(checkpointer=checkpointer) - - def _collect_context(self, state: AgentGraphState) -> dict: - emit_progress_sync( - state, - stage="graph.project_edits.collect_context", - message="Собираю контекст и релевантные файлы для правок.", - ) - result = self._logic.collect_context(state) - self._log_step_result("collect_context", result) - return result - - def _plan_changes(self, state: AgentGraphState) -> dict: - emit_progress_sync( - state, - stage="graph.project_edits.plan_changes", - message="Определяю, что именно нужно изменить и в каких файлах.", - ) - result = self._logic.plan_changes(state) - self._log_step_result("plan_changes", result) - return result - - def _generate_changeset(self, state: AgentGraphState) -> dict: - emit_progress_sync( - state, - stage="graph.project_edits.generate_changeset", - message="Формирую предлагаемые правки по выбранным файлам.", - ) - result = self._logic.generate_changeset(state) - self._log_step_result("generate_changeset", result) - return result - - def _self_check(self, state: AgentGraphState) -> dict: - emit_progress_sync( - state, - stage="graph.project_edits.self_check", - message="Проверяю, что правки соответствуют запросу и не трогают лишнее.", - ) - result = self._logic.self_check(state) - self._log_step_result("self_check", result) - return result - - def _build_result(self, state: AgentGraphState) -> dict: - emit_progress_sync( - state, - stage="graph.project_edits.build_result", - message="Формирую итоговый changeset и краткий обзор.", - ) - result = self._logic.build_result(state) - self._log_step_result("build_result", result) - return result - - def _route_after_self_check(self, state: AgentGraphState) -> str: - if state.get("validation_passed"): - return "ready" - attempts = int(state.get("validation_attempts", 0) or 0) - return "ready" if attempts >= self._max_validation_attempts else "retry" - - def _log_step_result(self, step: str, result: dict) -> None: - LOGGER.warning( - "graph step result: graph=project_edits step=%s keys=%s changeset_items=%s answer_len=%s", - step, - sorted(result.keys()), - len(result.get("changeset", []) or []), - len(str(result.get("answer", "") or "")), - ) diff --git a/src/app/modules/agent/engine/graphs/project_edits_logic.py b/src/app/modules/agent/engine/graphs/project_edits_logic.py deleted file mode 100644 index 661e302..0000000 --- a/src/app/modules/agent/engine/graphs/project_edits_logic.py +++ /dev/null @@ -1,240 +0,0 @@ -import json - -from app.modules.agent.engine.graphs.project_edits_contract import ContractParser -from app.modules.agent.engine.graphs.project_edits_patcher import ContractPatcher -from app.modules.agent.engine.graphs.project_edits_support import ProjectEditsSupport -from app.modules.agent.engine.graphs.state import AgentGraphState -from app.modules.agent.llm import AgentLlmService -from app.schemas.changeset import ChangeItem - - -class ProjectEditsLogic: - def __init__(self, llm: AgentLlmService) -> None: - self._llm = llm - self._support = ProjectEditsSupport() - self._contracts = ContractParser() - self._patcher = ContractPatcher() - - def collect_context(self, state: AgentGraphState) -> dict: - message = state.get("message", "") - files_map = state.get("files_map", {}) or {} - requested_path = self._support.lookup_file(files_map, self._extract_path_hint(message)) - candidates = self._support.pick_relevant_files(message, files_map) - if requested_path and not any(x["path"] == requested_path.get("path") for x in candidates): - candidates.insert(0, self._support.as_candidate(requested_path)) - return { - "edits_requested_path": str((requested_path or {}).get("path", "")).strip() or self._extract_path_hint(message), - "edits_context_files": candidates[:12], - "validation_attempts": 0, - } - - def plan_changes(self, state: AgentGraphState) -> dict: - user_input = json.dumps( - { - "request": state.get("message", ""), - "requested_path": state.get("edits_requested_path", ""), - "context_files": [ - { - "path": item.get("path", ""), - "content_preview": self._support.shorten(str(item.get("content", "")), 2200), - } - for item in (state.get("edits_context_files", []) or []) - ], - "contract_requirements": { - "must_define_allowed_blocks": True, - "max_hunks_per_file": 5, - "default_intent": "update", - }, - }, - ensure_ascii=False, - ) - parsed = self._support.parse_json( - self._llm.generate("project_edits_plan", user_input, log_context="graph.project_edits.plan_changes") - ) - contracts = self._contracts.parse( - parsed, - request=str(state.get("message", "")), - requested_path=str(state.get("edits_requested_path", "")), - ) - plan = [{"path": item.get("path", ""), "reason": item.get("reason", "")} for item in contracts] - return {"edits_contracts": contracts, "edits_plan": plan} - - def generate_changeset(self, state: AgentGraphState) -> dict: - files_map = state.get("files_map", {}) or {} - contracts = state.get("edits_contracts", []) or [] - changeset: list[ChangeItem] = [] - feedback: list[str] = [] - - for contract in contracts: - if not isinstance(contract, dict): - continue - path = str(contract.get("path", "")).replace("\\", "/").strip() - if not path: - continue - intent = str(contract.get("intent", "update")).strip().lower() or "update" - source = self._support.lookup_file(files_map, path) - if intent == "update" and source is None: - feedback.append(f"{path}: update requested but source file was not provided.") - continue - current_content = str((source or {}).get("content", "")) - hunks, error = self._generate_hunks_for_contract(state, contract, current_content) - if error: - feedback.append(f"{path}: {error}") - continue - proposed, apply_error = self._patcher.apply(current_content, contract, hunks) - if apply_error: - feedback.append(f"{path}: {apply_error}") - continue - if proposed is None: - feedback.append(f"{path}: patch application returned empty result.") - continue - if intent == "update": - if proposed == current_content: - feedback.append(f"{path}: no-op update produced by model.") - continue - if self._support.collapse_whitespace(proposed) == self._support.collapse_whitespace(current_content): - feedback.append(f"{path}: whitespace-only update is not allowed.") - continue - reason = str(contract.get("reason", "")).strip() or "Requested user adjustment." - if source and source.get("content_hash"): - changeset.append( - ChangeItem( - op="update", - path=str(source.get("path") or path), - base_hash=str(source.get("content_hash", "")), - proposed_content=proposed, - reason=reason, - hunks=hunks, - ) - ) - else: - changeset.append( - ChangeItem( - op="create", - path=path, - proposed_content=proposed, - reason=reason, - hunks=hunks, - ) - ) - - return {"changeset": changeset, "edits_generation_feedback": " | ".join(feedback)} - - def self_check(self, state: AgentGraphState) -> dict: - attempts = int(state.get("validation_attempts", 0) or 0) + 1 - changeset = state.get("changeset", []) or [] - files_map = state.get("files_map", {}) or {} - if not changeset: - feedback = str(state.get("edits_generation_feedback", "")).strip() or "Generated changeset is empty." - return {"validation_attempts": attempts, "validation_passed": False, "validation_feedback": feedback} - - broad = self._support.is_broad_rewrite_request(str(state.get("message", ""))) - for item in changeset: - if item.op.value != "update": - continue - source = self._support.lookup_file(files_map, item.path) - if not source: - continue - original = str(source.get("content", "")) - proposed = item.proposed_content or "" - similarity = self._support.similarity(original, proposed) - change_ratio = self._support.line_change_ratio(original, proposed) - added_headings = self._support.added_headings(original, proposed) - min_similarity = 0.75 if broad else 0.9 - max_change_ratio = 0.7 if broad else 0.35 - if similarity < min_similarity: - return { - "validation_attempts": attempts, - "validation_passed": False, - "validation_feedback": f"File {item.path} changed too aggressively (similarity={similarity:.2f}).", - } - if change_ratio > max_change_ratio: - return { - "validation_attempts": attempts, - "validation_passed": False, - "validation_feedback": f"File {item.path} changed too broadly (change_ratio={change_ratio:.2f}).", - } - if not broad and added_headings > 0: - return { - "validation_attempts": attempts, - "validation_passed": False, - "validation_feedback": f"File {item.path} adds new sections outside requested scope.", - } - - payload = { - "request": state.get("message", ""), - "contracts": state.get("edits_contracts", []), - "changeset": [{"op": x.op.value, "path": x.path, "reason": x.reason} for x in changeset[:20]], - "rule": "Changes must stay inside contract blocks and not affect unrelated sections.", - } - parsed = self._support.parse_json( - self._llm.generate( - "project_edits_self_check", - json.dumps(payload, ensure_ascii=False), - log_context="graph.project_edits.self_check", - ) - ) - passed = bool(parsed.get("pass")) if isinstance(parsed, dict) else False - feedback = str(parsed.get("feedback", "")).strip() if isinstance(parsed, dict) else "" - return { - "validation_attempts": attempts, - "validation_passed": passed, - "validation_feedback": feedback or "No validation feedback provided.", - } - - def build_result(self, state: AgentGraphState) -> dict: - changeset = state.get("changeset", []) or [] - return {"changeset": changeset, "answer": self._support.build_summary(state, changeset)} - - def _generate_hunks_for_contract( - self, - state: AgentGraphState, - contract: dict, - current_content: str, - ) -> tuple[list[dict], str | None]: - prompt_payload = { - "request": state.get("message", ""), - "contract": contract, - "current_content": self._support.shorten(current_content, 18000), - "previous_validation_feedback": state.get("validation_feedback", ""), - "rag_context": self._support.shorten(state.get("rag_context", ""), 5000), - "confluence_context": self._support.shorten(state.get("confluence_context", ""), 5000), - } - raw = self._llm.generate( - "project_edits_hunks", - json.dumps(prompt_payload, ensure_ascii=False), - log_context="graph.project_edits.generate_changeset", - ) - parsed = self._support.parse_json(raw) - hunks = parsed.get("hunks", []) if isinstance(parsed, dict) else [] - if not isinstance(hunks, list) or not hunks: - return [], "Model did not return contract hunks." - normalized: list[dict] = [] - for hunk in hunks: - if not isinstance(hunk, dict): - continue - kind = str(hunk.get("type", "")).strip().lower() - if kind not in {"append_end", "replace_between", "replace_line_equals"}: - continue - normalized.append( - { - "type": kind, - "start_anchor": str(hunk.get("start_anchor", "")), - "end_anchor": str(hunk.get("end_anchor", "")), - "old_line": str(hunk.get("old_line", "")), - "new_text": str(hunk.get("new_text", "")), - } - ) - if not normalized: - return [], "Model hunks are empty or invalid." - return normalized, None - - def _extract_path_hint(self, message: str) -> str: - words = (message or "").replace("\\", "/").split() - for token in words: - cleaned = token.strip("`'\".,:;()[]{}") - if "/" in cleaned and "." in cleaned: - return cleaned - if cleaned.lower().startswith("readme"): - return "README.md" - return "" diff --git a/src/app/modules/agent/engine/graphs/project_edits_patcher.py b/src/app/modules/agent/engine/graphs/project_edits_patcher.py deleted file mode 100644 index 71eecc1..0000000 --- a/src/app/modules/agent/engine/graphs/project_edits_patcher.py +++ /dev/null @@ -1,142 +0,0 @@ -from difflib import SequenceMatcher - - -class ContractPatcher: - def apply(self, current_content: str, contract: dict, hunks: list[dict]) -> tuple[str | None, str | None]: - if not hunks: - return None, "No hunks were generated." - - max_hunks = int(contract.get("max_hunks", 1) or 1) - if len(hunks) > max_hunks: - return None, f"Too many hunks: got={len(hunks)} allowed={max_hunks}." - - allowed_blocks = contract.get("allowed_blocks", []) - if not isinstance(allowed_blocks, list) or not allowed_blocks: - return None, "No allowed blocks in edit contract." - - result = current_content - total_changed_lines = 0 - for idx, hunk in enumerate(hunks, start=1): - applied, changed_lines, error = self._apply_hunk(result, hunk, allowed_blocks) - if error: - return None, f"Hunk {idx} rejected: {error}" - result = applied - total_changed_lines += changed_lines - - max_changed_lines = int(contract.get("max_changed_lines", 8) or 8) - if total_changed_lines > max_changed_lines: - return ( - None, - f"Changed lines exceed contract limit: changed={total_changed_lines} allowed={max_changed_lines}.", - ) - return result, None - - def _apply_hunk( - self, - content: str, - hunk: dict, - allowed_blocks: list[dict], - ) -> tuple[str, int, str | None]: - if not isinstance(hunk, dict): - return content, 0, "Invalid hunk payload." - kind = str(hunk.get("type", "")).strip().lower() - if kind not in {"append_end", "replace_between", "replace_line_equals"}: - return content, 0, f"Unsupported hunk type: {kind or '(empty)'}." - - block = self._find_matching_block(hunk, allowed_blocks) - if block is None: - return content, 0, "Hunk does not match allowed contract blocks." - - if kind == "append_end": - return self._apply_append_end(content, hunk, block) - if kind == "replace_between": - return self._apply_replace_between(content, hunk, block) - return self._apply_replace_line_equals(content, hunk, block) - - def _find_matching_block(self, hunk: dict, allowed_blocks: list[dict]) -> dict | None: - kind = str(hunk.get("type", "")).strip().lower() - for block in allowed_blocks: - if not isinstance(block, dict): - continue - block_type = str(block.get("type", "")).strip().lower() - if block_type != kind: - continue - if kind == "replace_between": - start = str(hunk.get("start_anchor", "")).strip() - end = str(hunk.get("end_anchor", "")).strip() - if start != str(block.get("start_anchor", "")).strip(): - continue - if end != str(block.get("end_anchor", "")).strip(): - continue - if kind == "replace_line_equals": - old_line = str(hunk.get("old_line", "")).strip() - if old_line != str(block.get("old_line", "")).strip(): - continue - return block - return None - - def _apply_append_end(self, content: str, hunk: dict, block: dict) -> tuple[str, int, str | None]: - new_text = str(hunk.get("new_text", "")) - if not new_text.strip(): - return content, 0, "append_end new_text is empty." - changed_lines = self._changed_line_count("", new_text) - block_limit = int(block.get("max_changed_lines", 6) or 6) - if changed_lines > block_limit: - return content, 0, f"append_end is too large: changed={changed_lines} allowed={block_limit}." - base = content.rstrip("\n") - suffix = new_text.strip("\n") - if not suffix: - return content, 0, "append_end resolved to empty suffix." - merged = f"{base}\n\n{suffix}\n" if base else f"{suffix}\n" - return merged, changed_lines, None - - def _apply_replace_between(self, content: str, hunk: dict, block: dict) -> tuple[str, int, str | None]: - start_anchor = str(hunk.get("start_anchor", "")).strip() - end_anchor = str(hunk.get("end_anchor", "")).strip() - new_text = str(hunk.get("new_text", "")) - if not start_anchor or not end_anchor: - return content, 0, "replace_between anchors are required." - start_pos = content.find(start_anchor) - if start_pos < 0: - return content, 0, "start_anchor not found in file." - middle_start = start_pos + len(start_anchor) - end_pos = content.find(end_anchor, middle_start) - if end_pos < 0: - return content, 0, "end_anchor not found after start_anchor." - old_segment = content[middle_start:end_pos] - changed_lines = self._changed_line_count(old_segment, new_text) - block_limit = int(block.get("max_changed_lines", 6) or 6) - if changed_lines > block_limit: - return content, 0, f"replace_between is too large: changed={changed_lines} allowed={block_limit}." - merged = content[:middle_start] + new_text + content[end_pos:] - return merged, changed_lines, None - - def _apply_replace_line_equals(self, content: str, hunk: dict, block: dict) -> tuple[str, int, str | None]: - old_line = str(hunk.get("old_line", "")).strip() - new_text = str(hunk.get("new_text", "")) - if not old_line: - return content, 0, "replace_line_equals old_line is required." - lines = content.splitlines(keepends=True) - matches = [idx for idx, line in enumerate(lines) if line.rstrip("\n") == old_line] - if len(matches) != 1: - return content, 0, f"replace_line_equals expected exactly one match, got={len(matches)}." - replacement = new_text.rstrip("\n") + "\n" - changed_lines = self._changed_line_count(old_line + "\n", replacement) - block_limit = int(block.get("max_changed_lines", 6) or 6) - if changed_lines > block_limit: - return content, 0, f"replace_line_equals is too large: changed={changed_lines} allowed={block_limit}." - lines[matches[0] : matches[0] + 1] = [replacement] - return "".join(lines), changed_lines, None - - def _changed_line_count(self, old_text: str, new_text: str) -> int: - old_lines = (old_text or "").splitlines() - new_lines = (new_text or "").splitlines() - if not old_lines and not new_lines: - return 0 - matcher = SequenceMatcher(None, old_lines, new_lines) - changed = 0 - for tag, i1, i2, j1, j2 in matcher.get_opcodes(): - if tag == "equal": - continue - changed += max(i2 - i1, j2 - j1) - return max(changed, 1) diff --git a/src/app/modules/agent/engine/graphs/project_edits_support.py b/src/app/modules/agent/engine/graphs/project_edits_support.py deleted file mode 100644 index 95cf245..0000000 --- a/src/app/modules/agent/engine/graphs/project_edits_support.py +++ /dev/null @@ -1,116 +0,0 @@ -import json -import re -from difflib import SequenceMatcher - -from app.modules.agent.engine.graphs.file_targeting import FileTargeting -from app.modules.agent.engine.graphs.state import AgentGraphState -from app.schemas.changeset import ChangeItem - - -class ProjectEditsSupport: - def __init__(self, max_context_files: int = 12, max_preview_chars: int = 2500) -> None: - self._max_context_files = max_context_files - self._max_preview_chars = max_preview_chars - self._targeting = FileTargeting() - - def pick_relevant_files(self, message: str, files_map: dict[str, dict]) -> list[dict]: - tokens = {x for x in (message or "").lower().replace("/", " ").split() if len(x) >= 4} - scored: list[tuple[int, dict]] = [] - for path, payload in files_map.items(): - content = str(payload.get("content", "")) - score = 0 - low_path = path.lower() - low_content = content.lower() - for token in tokens: - if token in low_path: - score += 3 - if token in low_content: - score += 1 - scored.append((score, self.as_candidate(payload))) - scored.sort(key=lambda x: (-x[0], x[1]["path"])) - return [item for _, item in scored[: self._max_context_files]] - - def as_candidate(self, payload: dict) -> dict: - return { - "path": str(payload.get("path", "")).replace("\\", "/"), - "content": str(payload.get("content", "")), - "content_hash": str(payload.get("content_hash", "")), - } - - def normalize_file_output(self, text: str) -> str: - value = (text or "").strip() - if value.startswith("```") and value.endswith("```"): - lines = value.splitlines() - if len(lines) >= 3: - return "\n".join(lines[1:-1]).strip() - return value - - def parse_json(self, raw: str): - text = self.normalize_file_output(raw) - try: - return json.loads(text) - except Exception: - return {} - - def shorten(self, text: str, max_chars: int | None = None) -> str: - limit = max_chars or self._max_preview_chars - value = (text or "").strip() - if len(value) <= limit: - return value - return value[:limit].rstrip() + "\n...[truncated]" - - def collapse_whitespace(self, text: str) -> str: - return re.sub(r"\s+", " ", (text or "").strip()) - - def similarity(self, original: str, updated: str) -> float: - return SequenceMatcher(None, original or "", updated or "").ratio() - - def line_change_ratio(self, original: str, updated: str) -> float: - orig_lines = (original or "").splitlines() - new_lines = (updated or "").splitlines() - if not orig_lines and not new_lines: - return 0.0 - matcher = SequenceMatcher(None, orig_lines, new_lines) - changed = 0 - for tag, i1, i2, j1, j2 in matcher.get_opcodes(): - if tag == "equal": - continue - changed += max(i2 - i1, j2 - j1) - total = max(len(orig_lines), len(new_lines), 1) - return changed / total - - def added_headings(self, original: str, updated: str) -> int: - old_heads = {line.strip() for line in (original or "").splitlines() if line.strip().startswith("#")} - new_heads = {line.strip() for line in (updated or "").splitlines() if line.strip().startswith("#")} - return len(new_heads - old_heads) - - def build_summary(self, state: AgentGraphState, changeset: list[ChangeItem]) -> str: - if not changeset: - return "Правки не сформированы: changeset пуст." - lines = [ - "Выполненные действия:", - f"- Проанализирован запрос: {state.get('message', '')}", - "- Сформирован контракт правок с разрешенными блоками изменений.", - f"- Проведен self-check: {state.get('validation_feedback', 'без замечаний')}", - "", - "Измененные файлы:", - ] - for item in changeset[:30]: - lines.append(f"- {item.op.value} {item.path}: {item.reason}") - return "\n".join(lines) - - def is_broad_rewrite_request(self, message: str) -> bool: - low = (message or "").lower() - markers = ( - "перепиши", - "полностью", - "целиком", - "с нуля", - "full rewrite", - "rewrite all", - "реорганизуй документ", - ) - return any(marker in low for marker in markers) - - def lookup_file(self, files_map: dict[str, dict], path: str) -> dict | None: - return self._targeting.lookup_file(files_map, path) diff --git a/src/app/modules/agent/engine/graphs/project_qa_graph.py b/src/app/modules/agent/engine/graphs/project_qa_graph.py deleted file mode 100644 index 1f6f005..0000000 --- a/src/app/modules/agent/engine/graphs/project_qa_graph.py +++ /dev/null @@ -1,47 +0,0 @@ -import logging - -from langgraph.graph import END, START, StateGraph - -from app.modules.agent.engine.graphs.progress import emit_progress_sync -from app.modules.agent.engine.graphs.state import AgentGraphState -from app.modules.agent.llm import AgentLlmService - -LOGGER = logging.getLogger(__name__) - - -class ProjectQaGraphFactory: - def __init__(self, llm: AgentLlmService) -> None: - self._llm = llm - - def build(self, checkpointer=None): - graph = StateGraph(AgentGraphState) - graph.add_node("answer", self._answer_node) - graph.add_edge(START, "answer") - graph.add_edge("answer", END) - return graph.compile(checkpointer=checkpointer) - - def _answer_node(self, state: AgentGraphState) -> dict: - emit_progress_sync( - state, - stage="graph.project_qa.answer", - message="Готовлю ответ по контексту текущего проекта.", - ) - user_input = "\n\n".join( - [ - f"User request:\n{state.get('message', '')}", - f"RAG context:\n{state.get('rag_context', '')}", - f"Confluence context:\n{state.get('confluence_context', '')}", - ] - ) - answer = self._llm.generate("project_answer", user_input, log_context="graph.project_qa.answer") - emit_progress_sync( - state, - stage="graph.project_qa.answer.done", - message="Ответ по проекту сформирован.", - ) - result = {"answer": answer} - LOGGER.warning( - "graph step result: graph=project_qa step=answer answer_len=%s", - len(answer or ""), - ) - return result diff --git a/src/app/modules/agent/engine/graphs/project_qa_step_graphs.py b/src/app/modules/agent/engine/graphs/project_qa_step_graphs.py deleted file mode 100644 index f8059bc..0000000 --- a/src/app/modules/agent/engine/graphs/project_qa_step_graphs.py +++ /dev/null @@ -1,172 +0,0 @@ -from __future__ import annotations -import logging - -from langgraph.graph import END, START, StateGraph - -from app.modules.agent.engine.graphs.progress import emit_progress_sync -from app.modules.agent.engine.graphs.state import AgentGraphState -from app.modules.agent.engine.orchestrator.actions.project_qa_analyzer import ProjectQaAnalyzer -from app.modules.agent.engine.orchestrator.actions.project_qa_support import ProjectQaSupport -from app.modules.agent.llm import AgentLlmService -from app.modules.contracts import RagRetriever -from app.modules.rag.explain import ExplainPack, PromptBudgeter - -LOGGER = logging.getLogger(__name__) - - -class ProjectQaConversationGraphFactory: - def __init__(self, llm: AgentLlmService | None = None) -> None: - self._support = ProjectQaSupport() - - def build(self, checkpointer=None): - graph = StateGraph(AgentGraphState) - graph.add_node("resolve_request", self._resolve_request) - graph.add_edge(START, "resolve_request") - graph.add_edge("resolve_request", END) - return graph.compile(checkpointer=checkpointer) - - def _resolve_request(self, state: AgentGraphState) -> dict: - emit_progress_sync(state, stage="graph.project_qa.conversation_understanding", message="Нормализую пользовательский запрос.") - resolved = self._support.resolve_request(str(state.get("message", "") or "")) - LOGGER.warning("graph step result: graph=project_qa/conversation_understanding normalized=%s", resolved.get("normalized_message", "")) - return {"resolved_request": resolved} - - -class ProjectQaClassificationGraphFactory: - def __init__(self, llm: AgentLlmService | None = None) -> None: - self._support = ProjectQaSupport() - - def build(self, checkpointer=None): - graph = StateGraph(AgentGraphState) - graph.add_node("classify_question", self._classify_question) - graph.add_edge(START, "classify_question") - graph.add_edge("classify_question", END) - return graph.compile(checkpointer=checkpointer) - - def _classify_question(self, state: AgentGraphState) -> dict: - resolved = state.get("resolved_request", {}) or {} - message = str(resolved.get("normalized_message") or state.get("message", "") or "") - profile = self._support.build_profile(message) - LOGGER.warning("graph step result: graph=project_qa/question_classification domain=%s intent=%s", profile.get("domain"), profile.get("intent")) - return {"question_profile": profile} - - -class ProjectQaRetrievalGraphFactory: - def __init__(self, rag: RagRetriever, llm: AgentLlmService | None = None) -> None: - self._rag = rag - self._support = ProjectQaSupport() - - def build(self, checkpointer=None): - graph = StateGraph(AgentGraphState) - graph.add_node("retrieve_context", self._retrieve_context) - graph.add_edge(START, "retrieve_context") - graph.add_edge("retrieve_context", END) - return graph.compile(checkpointer=checkpointer) - - def _retrieve_context(self, state: AgentGraphState) -> dict: - emit_progress_sync(state, stage="graph.project_qa.context_retrieval", message="Собираю контекст по проекту.") - resolved = state.get("resolved_request", {}) or {} - profile = state.get("question_profile", {}) or {} - files_map = dict(state.get("files_map", {}) or {}) - rag_items: list[dict] = [] - source_bundle = self._support.build_source_bundle(profile, list(rag_items), files_map) - LOGGER.warning( - "graph step result: graph=project_qa/context_retrieval mode=%s rag_items=%s file_candidates=%s legacy_rag=%s", - profile.get("domain"), - len(source_bundle.get("rag_items", []) or []), - len(source_bundle.get("file_candidates", []) or []), - False, - ) - return {"source_bundle": source_bundle} - - -class ProjectQaAnalysisGraphFactory: - def __init__(self, llm: AgentLlmService | None = None) -> None: - self._support = ProjectQaSupport() - self._analyzer = ProjectQaAnalyzer() - - def build(self, checkpointer=None): - graph = StateGraph(AgentGraphState) - graph.add_node("analyze_context", self._analyze_context) - graph.add_edge(START, "analyze_context") - graph.add_edge("analyze_context", END) - return graph.compile(checkpointer=checkpointer) - - def _analyze_context(self, state: AgentGraphState) -> dict: - explain_pack = state.get("explain_pack") - if explain_pack: - analysis = self._analysis_from_pack(explain_pack) - LOGGER.warning( - "graph step result: graph=project_qa/context_analysis findings=%s evidence=%s", - len(analysis.get("findings", []) or []), - len(analysis.get("evidence", []) or []), - ) - return {"analysis_brief": analysis} - bundle = state.get("source_bundle", {}) or {} - profile = bundle.get("profile", {}) or state.get("question_profile", {}) or {} - rag_items = list(bundle.get("rag_items", []) or []) - file_candidates = list(bundle.get("file_candidates", []) or []) - analysis = self._analyzer.analyze_code(profile, rag_items, file_candidates) if str(profile.get("domain")) == "code" else self._analyzer.analyze_docs(profile, rag_items) - LOGGER.warning( - "graph step result: graph=project_qa/context_analysis findings=%s evidence=%s", - len(analysis.get("findings", []) or []), - len(analysis.get("evidence", []) or []), - ) - return {"analysis_brief": analysis} - - def _analysis_from_pack(self, raw_pack) -> dict: - pack = ExplainPack.model_validate(raw_pack) - findings: list[str] = [] - evidence: list[str] = [] - for entrypoint in pack.selected_entrypoints[:3]: - findings.append(f"Entrypoint `{entrypoint.title}` maps to handler `{entrypoint.metadata.get('handler_symbol_id', '')}`.") - if entrypoint.source: - evidence.append(entrypoint.source) - for path in pack.trace_paths[:3]: - if path.symbol_ids: - findings.append(f"Trace path: {' -> '.join(path.symbol_ids)}") - for excerpt in pack.code_excerpts[:4]: - evidence.append(f"{excerpt.path}:{excerpt.start_line}-{excerpt.end_line} [{excerpt.evidence_id}]") - return { - "subject": pack.intent.normalized_query, - "findings": findings or ["No explain trace was built from the available code evidence."], - "evidence": evidence, - "gaps": list(pack.missing), - "answer_mode": "summary", - } - - -class ProjectQaAnswerGraphFactory: - def __init__(self, llm: AgentLlmService | None = None) -> None: - self._support = ProjectQaSupport() - self._llm = llm - self._budgeter = PromptBudgeter() - - def build(self, checkpointer=None): - graph = StateGraph(AgentGraphState) - graph.add_node("compose_answer", self._compose_answer) - graph.add_edge(START, "compose_answer") - graph.add_edge("compose_answer", END) - return graph.compile(checkpointer=checkpointer) - - def _compose_answer(self, state: AgentGraphState) -> dict: - profile = state.get("question_profile", {}) or {} - analysis = state.get("analysis_brief", {}) or {} - brief = self._support.build_answer_brief(profile, analysis) - explain_pack = state.get("explain_pack") - answer = self._compose_explain_answer(state, explain_pack) - if not answer: - answer = self._support.compose_answer(brief) - LOGGER.warning("graph step result: graph=project_qa/answer_composition answer_len=%s", len(answer or "")) - return {"answer_brief": brief, "final_answer": answer} - - def _compose_explain_answer(self, state: AgentGraphState, raw_pack) -> str: - if raw_pack is None or self._llm is None: - return "" - pack = ExplainPack.model_validate(raw_pack) - prompt_input = self._budgeter.build_prompt_input(str(state.get("message", "") or ""), pack) - return self._llm.generate( - "code_explain_answer_v2", - prompt_input, - log_context="graph.project_qa.answer_v2", - ).strip() diff --git a/src/app/modules/agent/engine/graphs/state.py b/src/app/modules/agent/engine/graphs/state.py deleted file mode 100644 index 72e297c..0000000 --- a/src/app/modules/agent/engine/graphs/state.py +++ /dev/null @@ -1,40 +0,0 @@ -from typing import TypedDict - -from app.schemas.changeset import ChangeItem - - -class AgentGraphState(TypedDict, total=False): - task_id: str - project_id: str - message: str - progress_key: str - rag_context: str - confluence_context: str - files_map: dict[str, dict] - docs_candidates: list[dict] - target_path: str - target_file_content: str - target_file_hash: str - existing_docs_detected: bool - existing_docs_summary: str - docs_strategy: str - rules_bundle: str - doc_plan: str - generated_doc: str - generated_docs_bundle: list[dict] - validation_passed: bool - validation_feedback: str - validation_attempts: int - resolved_request: dict - question_profile: dict - source_bundle: dict - analysis_brief: dict - answer_brief: dict - final_answer: str - answer: str - changeset: list[ChangeItem] - edits_requested_path: str - edits_context_files: list[dict] - edits_plan: list[dict] - edits_contracts: list[dict] - edits_generation_feedback: str diff --git a/src/app/modules/agent/engine/orchestrator/__init__.py b/src/app/modules/agent/engine/orchestrator/__init__.py deleted file mode 100644 index 7163dac..0000000 --- a/src/app/modules/agent/engine/orchestrator/__init__.py +++ /dev/null @@ -1,21 +0,0 @@ -from app.modules.agent.engine.orchestrator.models import ( - ExecutionPlan, - OrchestratorResult, - PlanStep, - Scenario, - StepResult, - TaskSpec, -) -from app.modules.agent.engine.orchestrator.service import OrchestratorService -from app.modules.agent.engine.orchestrator.task_spec_builder import TaskSpecBuilder - -__all__ = [ - "ExecutionPlan", - "OrchestratorResult", - "OrchestratorService", - "PlanStep", - "Scenario", - "StepResult", - "TaskSpec", - "TaskSpecBuilder", -] diff --git a/src/app/modules/agent/engine/orchestrator/actions/__init__.py b/src/app/modules/agent/engine/orchestrator/actions/__init__.py deleted file mode 100644 index ab47e51..0000000 --- a/src/app/modules/agent/engine/orchestrator/actions/__init__.py +++ /dev/null @@ -1,17 +0,0 @@ -from app.modules.agent.engine.orchestrator.actions.code_explain_actions import CodeExplainActions -from app.modules.agent.engine.orchestrator.actions.docs_actions import DocsActions -from app.modules.agent.engine.orchestrator.actions.edit_actions import EditActions -from app.modules.agent.engine.orchestrator.actions.explain_actions import ExplainActions -from app.modules.agent.engine.orchestrator.actions.gherkin_actions import GherkinActions -from app.modules.agent.engine.orchestrator.actions.project_qa_actions import ProjectQaActions -from app.modules.agent.engine.orchestrator.actions.review_actions import ReviewActions - -__all__ = [ - "CodeExplainActions", - "DocsActions", - "EditActions", - "ExplainActions", - "GherkinActions", - "ProjectQaActions", - "ReviewActions", -] diff --git a/src/app/modules/agent/engine/orchestrator/actions/code_explain_actions.py b/src/app/modules/agent/engine/orchestrator/actions/code_explain_actions.py deleted file mode 100644 index e94535a..0000000 --- a/src/app/modules/agent/engine/orchestrator/actions/code_explain_actions.py +++ /dev/null @@ -1,46 +0,0 @@ -from __future__ import annotations - -import logging - -from typing import TYPE_CHECKING - -from app.modules.agent.engine.orchestrator.actions.common import ActionSupport -from app.modules.agent.engine.orchestrator.execution_context import ExecutionContext -from app.modules.agent.engine.orchestrator.models import ArtifactType -from app.modules.rag.explain.intent_builder import ExplainIntentBuilder -from app.modules.rag.explain.models import ExplainPack - -if TYPE_CHECKING: - from app.modules.rag.explain.retriever_v2 import CodeExplainRetrieverV2 - -LOGGER = logging.getLogger(__name__) - - -class CodeExplainActions(ActionSupport): - def __init__(self, retriever: CodeExplainRetrieverV2 | None = None) -> None: - self._retriever = retriever - self._intent_builder = ExplainIntentBuilder() - - def build_code_explain_pack(self, ctx: ExecutionContext) -> list[str]: - file_candidates = list((self.get(ctx, "source_bundle", {}) or {}).get("file_candidates", []) or []) - if self._retriever is None: - pack = ExplainPack( - intent=self._intent_builder.build(ctx.task.user_message), - missing=["code_explain_retriever_unavailable"], - ) - else: - pack = self._retriever.build_pack( - ctx.task.rag_session_id, - ctx.task.user_message, - file_candidates=file_candidates, - ) - LOGGER.warning( - "code explain action: task_id=%s entrypoints=%s seeds=%s paths=%s excerpts=%s missing=%s", - ctx.task.task_id, - len(pack.selected_entrypoints), - len(pack.seed_symbols), - len(pack.trace_paths), - len(pack.code_excerpts), - pack.missing, - ) - return [self.put(ctx, "explain_pack", ArtifactType.STRUCTURED_JSON, pack.model_dump(mode="json"))] diff --git a/src/app/modules/agent/engine/orchestrator/actions/common.py b/src/app/modules/agent/engine/orchestrator/actions/common.py deleted file mode 100644 index bd9cd9f..0000000 --- a/src/app/modules/agent/engine/orchestrator/actions/common.py +++ /dev/null @@ -1,26 +0,0 @@ -from __future__ import annotations - -from uuid import uuid4 - -from app.modules.agent.engine.orchestrator.execution_context import ExecutionContext -from app.modules.agent.engine.orchestrator.models import ArtifactType, EvidenceItem - - -class ActionSupport: - def put(self, ctx: ExecutionContext, key: str, artifact_type: ArtifactType, value, *, meta: dict | None = None) -> str: - item = ctx.artifacts.put(key=key, artifact_type=artifact_type, content=value, meta=meta) - return item.artifact_id - - def get(self, ctx: ExecutionContext, key: str, default=None): - return ctx.artifacts.get_content(key, default) - - def add_evidence(self, ctx: ExecutionContext, *, source_type: str, source_ref: str, snippet: str, score: float = 0.8) -> str: - evidence = EvidenceItem( - evidence_id=f"evidence_{uuid4().hex}", - source_type=source_type, - source_ref=source_ref, - snippet=(snippet or "").strip()[:600], - score=max(0.0, min(1.0, float(score))), - ) - ctx.evidences.put_many([evidence]) - return evidence.evidence_id diff --git a/src/app/modules/agent/engine/orchestrator/actions/docs_actions.py b/src/app/modules/agent/engine/orchestrator/actions/docs_actions.py deleted file mode 100644 index b289b74..0000000 --- a/src/app/modules/agent/engine/orchestrator/actions/docs_actions.py +++ /dev/null @@ -1,95 +0,0 @@ -from __future__ import annotations - -from app.modules.agent.engine.orchestrator.actions.common import ActionSupport -from app.modules.agent.engine.orchestrator.execution_context import ExecutionContext -from app.modules.agent.engine.orchestrator.models import ArtifactType - - -class DocsActions(ActionSupport): - def extract_change_intents(self, ctx: ExecutionContext) -> list[str]: - text = str(self.get(ctx, "source_doc_text", "") or ctx.task.user_message) - intents = { - "summary": text[:240], - "api": ["Update endpoint behavior contract"], - "logic": ["Adjust reusable business rules"], - "db": ["Reflect schema/table notes if needed"], - "ui": ["Adjust form behavior and validation"], - } - return [self.put(ctx, "change_intents", ArtifactType.STRUCTURED_JSON, intents)] - - def map_to_doc_tree(self, ctx: ExecutionContext) -> list[str]: - targets = [ - "docs/api/increment.md", - "docs/logic/increment.md", - "docs/db/increment.md", - "docs/ui/increment.md", - ] - return [self.put(ctx, "doc_targets", ArtifactType.STRUCTURED_JSON, {"targets": targets})] - - def load_current_docs_context(self, ctx: ExecutionContext) -> list[str]: - files_map = dict(ctx.task.metadata.get("files_map", {}) or {}) - targets = (self.get(ctx, "doc_targets", {}) or {}).get("targets", []) - current = [] - for path in targets: - current.append( - { - "path": path, - "content": str((files_map.get(path) or {}).get("content", "")), - "content_hash": str((files_map.get(path) or {}).get("content_hash", "")), - } - ) - return [self.put(ctx, "current_docs_context", ArtifactType.STRUCTURED_JSON, {"files": current})] - - def generate_doc_updates(self, ctx: ExecutionContext) -> list[str]: - intents = self.get(ctx, "change_intents", {}) or {} - targets = (self.get(ctx, "doc_targets", {}) or {}).get("targets", []) - bundle = [] - for path in targets: - bundle.append( - { - "path": path, - "content": "\n".join( - [ - f"# Increment Update: {path}", - "", - "## Scope", - str(intents.get("summary", "")), - "", - "## Changes", - "- Updated according to analytics increment.", - ] - ), - "reason": "align docs with analytics increment", - } - ) - return [self.put(ctx, "generated_doc_bundle", ArtifactType.DOC_BUNDLE, bundle)] - - def cross_file_validation(self, ctx: ExecutionContext) -> list[str]: - bundle = self.get(ctx, "generated_doc_bundle", []) or [] - paths = [str(item.get("path", "")) for item in bundle if isinstance(item, dict)] - has_required = any(path.startswith("docs/api/") for path in paths) and any(path.startswith("docs/logic/") for path in paths) - report = {"paths": paths, "required_core_paths_present": has_required} - return [self.put(ctx, "consistency_report", ArtifactType.STRUCTURED_JSON, report)] - - def build_changeset(self, ctx: ExecutionContext) -> list[str]: - bundle = self.get(ctx, "generated_doc_bundle", []) or [] - changeset = [] - for item in bundle: - if not isinstance(item, dict): - continue - changeset.append( - { - "op": "update", - "path": str(item.get("path", "")).strip(), - "base_hash": "orchestrator-generated", - "proposed_content": str(item.get("content", "")), - "reason": str(item.get("reason", "documentation update")), - "hunks": [], - } - ) - return [self.put(ctx, "final_changeset", ArtifactType.CHANGESET, changeset)] - - def compose_summary(self, ctx: ExecutionContext) -> list[str]: - count = len(self.get(ctx, "final_changeset", []) or []) - text = f"Prepared documentation changeset with {count} files updated." - return [self.put(ctx, "final_answer", ArtifactType.TEXT, text)] diff --git a/src/app/modules/agent/engine/orchestrator/actions/edit_actions.py b/src/app/modules/agent/engine/orchestrator/actions/edit_actions.py deleted file mode 100644 index 14806f0..0000000 --- a/src/app/modules/agent/engine/orchestrator/actions/edit_actions.py +++ /dev/null @@ -1,101 +0,0 @@ -from __future__ import annotations - -import re - -from app.modules.agent.engine.orchestrator.actions.common import ActionSupport -from app.modules.agent.engine.orchestrator.execution_context import ExecutionContext -from app.modules.agent.engine.orchestrator.models import ArtifactType - - -class EditActions(ActionSupport): - def resolve_target(self, ctx: ExecutionContext) -> list[str]: - message = ctx.task.user_message - files_map = dict(ctx.task.metadata.get("files_map", {}) or {}) - requested = self._extract_path(message) - matched = self._lookup_source(files_map, requested) - if matched: - requested = str(matched.get("path") or requested or "") - if not requested and files_map: - requested = next(iter(files_map.keys())) - payload = {"path": requested or "", "allowed": bool(requested)} - return [self.put(ctx, "resolved_target", ArtifactType.STRUCTURED_JSON, payload)] - - def load_target_context(self, ctx: ExecutionContext) -> list[str]: - files_map = dict(ctx.task.metadata.get("files_map", {}) or {}) - resolved = self.get(ctx, "resolved_target", {}) or {} - path = str(resolved.get("path", "")) - source = dict(self._lookup_source(files_map, path) or {}) - current = { - "path": str(source.get("path", "")) or path, - "content": str(source.get("content", "")), - "content_hash": str(source.get("content_hash", "")), - } - return [self.put(ctx, "target_context", ArtifactType.STRUCTURED_JSON, current)] - - def plan_minimal_patch(self, ctx: ExecutionContext) -> list[str]: - target = self.get(ctx, "target_context", {}) or {} - plan = { - "path": target.get("path", ""), - "intent": "minimal_update", - "instruction": ctx.task.user_message[:240], - } - return [self.put(ctx, "patch_plan", ArtifactType.STRUCTURED_JSON, plan)] - - def generate_patch(self, ctx: ExecutionContext) -> list[str]: - target = self.get(ctx, "target_context", {}) or {} - plan = self.get(ctx, "patch_plan", {}) or {} - path = str(target.get("path", "")) - base = str(target.get("content_hash", "") or "orchestrator-generated") - original = str(target.get("content", "")) - note = f"\n\n\n" - proposed = (original + note).strip() if original else note.strip() - changeset = [ - { - "op": "update" if original else "create", - "path": path, - "base_hash": base if original else None, - "proposed_content": proposed, - "reason": "targeted file update", - "hunks": [], - } - ] - return [self.put(ctx, "raw_changeset", ArtifactType.CHANGESET, changeset)] - - def validate_patch_safety(self, ctx: ExecutionContext) -> list[str]: - changeset = self.get(ctx, "raw_changeset", []) or [] - safe = len(changeset) == 1 - report = {"safe": safe, "items": len(changeset), "reason": "single-file patch expected"} - return [self.put(ctx, "patch_validation_report", ArtifactType.STRUCTURED_JSON, report)] - - def finalize_changeset(self, ctx: ExecutionContext) -> list[str]: - report = self.get(ctx, "patch_validation_report", {}) or {} - if not report.get("safe"): - return [self.put(ctx, "final_changeset", ArtifactType.CHANGESET, [])] - changeset = self.get(ctx, "raw_changeset", []) or [] - return [self.put(ctx, "final_changeset", ArtifactType.CHANGESET, changeset)] - - def compose_edit_summary(self, ctx: ExecutionContext) -> list[str]: - count = len(self.get(ctx, "final_changeset", []) or []) - text = f"Prepared targeted edit changeset with {count} item(s)." - return [self.put(ctx, "final_answer", ArtifactType.TEXT, text)] - - def _extract_path(self, text: str) -> str | None: - match = re.search(r"\b[\w./-]+\.(md|txt|rst|yaml|yml|json|toml|ini|cfg)\b", text or "", flags=re.IGNORECASE) - if not match: - return None - return match.group(0).replace("\\", "/").strip() - - def _lookup_source(self, files_map: dict[str, dict], path: str | None) -> dict | None: - if not path: - return None - normalized = str(path).replace("\\", "/").strip() - if not normalized: - return None - source = files_map.get(normalized) - if source: - return source - normalized_low = normalized.lower() - for key, value in files_map.items(): - if str(key).replace("\\", "/").lower() == normalized_low: - return value - return None diff --git a/src/app/modules/agent/engine/orchestrator/actions/explain_actions.py b/src/app/modules/agent/engine/orchestrator/actions/explain_actions.py deleted file mode 100644 index 42ea04e..0000000 --- a/src/app/modules/agent/engine/orchestrator/actions/explain_actions.py +++ /dev/null @@ -1,259 +0,0 @@ -from __future__ import annotations - -from collections import Counter - -from app.modules.agent.engine.orchestrator.actions.common import ActionSupport -from app.modules.agent.engine.orchestrator.execution_context import ExecutionContext -from app.modules.agent.engine.orchestrator.models import ArtifactType - - -class ExplainActions(ActionSupport): - def collect_sources(self, ctx: ExecutionContext) -> list[str]: - rag_items = list(ctx.task.metadata.get("rag_items", []) or []) - rag_context = str(ctx.task.metadata.get("rag_context", "")) - confluence_context = str(ctx.task.metadata.get("confluence_context", "")) - files_map = dict(ctx.task.metadata.get("files_map", {}) or {}) - payload = { - "rag_items": rag_items, - "rag_context": rag_context, - "confluence_context": confluence_context, - "files_count": len(files_map), - "source_profile": self._source_profile(rag_items), - } - evidence_ids: list[str] = [] - for item in rag_items[:5]: - snippet = str(item.get("content", "") or "").strip() - if not snippet: - continue - evidence_ids.append( - self.add_evidence( - ctx, - source_type="rag_chunk", - source_ref=str(item.get("source", ctx.task.rag_session_id)), - snippet=snippet, - score=0.9, - ) - ) - artifact_id = self.put( - ctx, - "sources", - ArtifactType.STRUCTURED_JSON, - payload, - meta={"evidence_ids": evidence_ids}, - ) - return [artifact_id] - - def extract_logic(self, ctx: ExecutionContext) -> list[str]: - sources = self.get(ctx, "sources", {}) or {} - message = ctx.task.user_message - profile = str(sources.get("source_profile", "docs")) - ru = self._is_russian(message) - notes = ( - "Используй код как основной источник и ссылайся на конкретные файлы и слои." - if profile == "code" and ru - else "Use code as the primary source and cite concrete files/layers." - if profile == "code" - else "Используй требования и документацию как основной источник." - if ru - else "Use requirements/docs as primary source over code." - ) - logic = { - "request": message, - "assumptions": [f"{profile}-first"], - "notes": notes, - "source_summary": sources, - } - return [self.put(ctx, "logic_model", ArtifactType.STRUCTURED_JSON, logic)] - - def summarize(self, ctx: ExecutionContext) -> list[str]: - sources = self.get(ctx, "sources", {}) or {} - profile = str(sources.get("source_profile", "docs")) - items = list(sources.get("rag_items", []) or []) - message = ctx.task.user_message - ru = self._is_russian(message) - answer = self._code_answer(items, russian=ru) if profile == "code" else self._docs_answer(items, russian=ru) - return [self.put(ctx, "final_answer", ArtifactType.TEXT, answer)] - - def _source_profile(self, items: list[dict]) -> str: - layers = [str(item.get("layer", "") or "") for item in items] - if any(layer.startswith("C") for layer in layers): - return "code" - return "docs" - - def _is_russian(self, text: str) -> bool: - return any("а" <= ch.lower() <= "я" or ch.lower() == "ё" for ch in text) - - def _code_answer(self, items: list[dict], *, russian: bool) -> str: - if not items: - return ( - "Не удалось найти релевантный кодовый контекст по этому запросу." - if russian - else "No relevant code context was found for this request." - ) - details = self._code_details(items, russian=russian) - refs = self._code_references(items, russian=russian) - parts = [ - "## Кратко" if russian else "## Summary", - details, - ] - if refs: - parts.append(refs) - return "\n\n".join(part for part in parts if part.strip()) - - def _docs_answer(self, items: list[dict], *, russian: bool) -> str: - return ( - "Запрошенная часть проекта объяснена на основе требований и документации." - if russian - else "The requested project part is explained from requirements/docs context." - ) - - def _code_details(self, items: list[dict], *, russian: bool) -> str: - if not items: - return "" - symbol_items = [item for item in items if str(item.get("layer", "")) == "C1_SYMBOL_CATALOG"] - edge_items = [item for item in items if str(item.get("layer", "")) == "C2_DEPENDENCY_GRAPH"] - source_items = [item for item in items if str(item.get("layer", "")) == "C0_SOURCE_CHUNKS"] - - lines = ["### Что видно по коду" if russian else "### What the code shows"] - alias = self._find_alias_symbol(symbol_items) - if alias: - imported_from = str(alias.get("metadata", {}).get("lang_payload", {}).get("imported_from", "")).strip() - if russian: - lines.append(f"- `ConfigManager` в проекте доступен как alias в `{alias.get('source', '')}` и указывает на `{imported_from}`.") - else: - lines.append(f"- `ConfigManager` is exposed as an alias in `{alias.get('source', '')}` and points to `{imported_from}`.") - - management_hint = self._management_summary(symbol_items, edge_items, source_items, russian=russian) - if management_hint: - lines.extend(management_hint) - - symbol_lines = 0 - for item in symbol_items[:4]: - title = str(item.get("title", "") or "") - source = str(item.get("source", "") or "") - content = str(item.get("content", "") or "").strip() - summary = content.splitlines()[-1].strip() if content else "" - if not title: - continue - if self._is_test_path(source): - continue - if self._is_control_symbol(title): - continue - if russian: - lines.append(f"- Символ `{title}` из `{source}`: {summary}") - else: - lines.append(f"- Symbol `{title}` from `{source}`: {summary}") - symbol_lines += 1 - if symbol_lines >= 2: - break - - edge_map: dict[str, list[str]] = {} - for item in edge_items: - meta = item.get("metadata", {}) or {} - src_qname = str(meta.get("src_qname", "") or "").strip() - dst_ref = str(meta.get("dst_ref", "") or "").strip() - if not src_qname or not dst_ref: - continue - if self._is_test_path(str(item.get("source", "") or "")): - continue - edge_map.setdefault(src_qname, []) - if dst_ref not in edge_map[src_qname]: - edge_map[src_qname].append(dst_ref) - for src_qname, targets in list(edge_map.items())[:3]: - joined = ", ".join(targets[:4]) - if russian: - lines.append(f"- `{src_qname}` вызывает или использует: {joined}.") - else: - lines.append(f"- `{src_qname}` calls or uses: {joined}.") - - for item in source_items[:2]: - source = str(item.get("source", "") or "") - content = str(item.get("content", "") or "") - if self._is_test_path(source): - continue - if "management" in content.lower() or "control" in content.lower(): - snippet = " ".join(content.splitlines()[:4]).strip() - if russian: - lines.append(f"- В `{source}` есть прямое указание на управление через конфиг/API: `{snippet[:220]}`") - else: - lines.append(f"- `{source}` directly mentions config/API control: `{snippet[:220]}`") - - return "\n".join(lines) - - def _code_references(self, items: list[dict], *, russian: bool) -> str: - paths = [str(item.get("source", "") or "") for item in items if item.get("source") and not self._is_test_path(str(item.get("source", "") or ""))] - if not paths: - return "" - lines = ["### Где смотреть в проекте" if russian else "### Where to look in the project"] - for path, _count in Counter(paths).most_common(3): - lines.append(f"- `{path}`") - return "\n".join(lines) - - def _find_alias_symbol(self, items: list[dict]) -> dict | None: - for item in items: - meta = item.get("metadata", {}) or {} - payload = meta.get("lang_payload", {}) or {} - qname = str(meta.get("qname", "") or "") - if qname == "ConfigManager" and payload.get("import_alias"): - return item - return None - - def _is_test_path(self, path: str) -> bool: - lowered = path.lower() - return lowered.startswith("tests/") or "/tests/" in lowered or lowered.startswith("test_") or "/test_" in lowered - - def _is_control_symbol(self, title: str) -> bool: - lowered = title.lower() - return any(token in lowered for token in ("controlchannel", "controlchannelbridge", "on_start", "on_stop", "on_status")) - - def _management_summary( - self, - symbol_items: list[dict], - edge_items: list[dict], - source_items: list[dict], - *, - russian: bool, - ) -> list[str]: - qnames = {str((item.get("metadata", {}) or {}).get("qname", "") or ""): item for item in symbol_items if not self._is_test_path(str(item.get("source", "") or ""))} - source_texts = [str(item.get("content", "") or "") for item in source_items if not self._is_test_path(str(item.get("source", "") or ""))] - result: list[str] = [] - - if any("управление через api" in text.lower() or "section management" in text.lower() or "секция management" in text.lower() for text in source_texts): - result.append( - "- Для `ConfigManager` в коде предусмотрен отдельный интерфейс управления через API/конфиг: это прямо указано в публичной точке входа модуля." - if russian - else "- `ConfigManager` has a dedicated API/config-based management interface; this is stated in the module's public entrypoint." - ) - - has_control_channel = "ControlChannel" in qnames - has_bridge = "ControlChannelBridge" in qnames - if has_control_channel: - result.append( - "- Базовый контракт управления задает `ControlChannel`: он определяет команды `start` и `stop` для внешнего канала управления." - if russian - else "- The base management contract is `ControlChannel`, which defines external `start` and `stop` commands." - ) - if has_bridge: - result.append( - "- `ControlChannelBridge` связывает внешний канал управления с lifecycle-методами менеджера: `on_start`, `on_stop`, `on_status`." - if russian - else "- `ControlChannelBridge` maps the external control channel to manager lifecycle methods: `on_start`, `on_stop`, `on_status`." - ) - - edge_refs = [] - for item in edge_items: - if self._is_test_path(str(item.get("source", "") or "")): - continue - meta = item.get("metadata", {}) or {} - src = str(meta.get("src_qname", "") or "") - dst = str(meta.get("dst_ref", "") or "") - if src.startswith("ControlChannelBridge.") and dst in {"self._start_runtime", "self._stop_runtime", "self._get_status"}: - edge_refs.append((src, dst)) - if edge_refs: - mappings = ", ".join(f"{src} -> {dst}" for src, dst in edge_refs[:3]) - result.append( - f"- По связям в коде видно, что команды управления маршрутизируются так: {mappings}." - if russian - else f"- The code relationships show the management command routing: {mappings}." - ) - return result diff --git a/src/app/modules/agent/engine/orchestrator/actions/gherkin_actions.py b/src/app/modules/agent/engine/orchestrator/actions/gherkin_actions.py deleted file mode 100644 index 25ed754..0000000 --- a/src/app/modules/agent/engine/orchestrator/actions/gherkin_actions.py +++ /dev/null @@ -1,76 +0,0 @@ -from __future__ import annotations - -from app.modules.agent.engine.orchestrator.actions.common import ActionSupport -from app.modules.agent.engine.orchestrator.execution_context import ExecutionContext -from app.modules.agent.engine.orchestrator.models import ArtifactType - - -class GherkinActions(ActionSupport): - def extract_increment_scope(self, ctx: ExecutionContext) -> list[str]: - text = str(self.get(ctx, "source_doc_text", "") or ctx.task.user_message) - scope = { - "title": "Increment scope", - "summary": text[:220], - "entities": ["User", "System"], - } - return [self.put(ctx, "increment_scope", ArtifactType.STRUCTURED_JSON, scope)] - - def partition_features(self, ctx: ExecutionContext) -> list[str]: - scope = self.get(ctx, "increment_scope", {}) or {} - groups = [ - {"feature": "Main flow", "goal": scope.get("summary", "")}, - {"feature": "Validation", "goal": "Input validation and error behavior"}, - ] - return [self.put(ctx, "feature_groups", ArtifactType.STRUCTURED_JSON, groups)] - - def generate_gherkin_bundle(self, ctx: ExecutionContext) -> list[str]: - groups = self.get(ctx, "feature_groups", []) or [] - files = [] - for idx, group in enumerate(groups, start=1): - feature_name = str(group.get("feature", f"Feature {idx}")) - content = "\n".join( - [ - f"Feature: {feature_name}", - " Scenario: Happy path", - " Given system is available", - " When user performs increment action", - " Then system applies expected increment behavior", - ] - ) - files.append({"path": f"tests/gherkin/feature_{idx}.feature", "content": content}) - return [self.put(ctx, "gherkin_bundle", ArtifactType.GHERKIN_BUNDLE, files)] - - def lint_gherkin(self, ctx: ExecutionContext) -> list[str]: - bundle = self.get(ctx, "gherkin_bundle", []) or [] - invalid = [] - for item in bundle: - content = str(item.get("content", "")) if isinstance(item, dict) else "" - if "Feature:" not in content or "Scenario:" not in content: - invalid.append(str(item.get("path", "unknown"))) - report = {"valid": len(invalid) == 0, "invalid_files": invalid} - return [self.put(ctx, "gherkin_lint_report", ArtifactType.STRUCTURED_JSON, report)] - - def validate_coverage(self, ctx: ExecutionContext) -> list[str]: - bundle = self.get(ctx, "gherkin_bundle", []) or [] - report = {"covered": len(bundle) > 0, "feature_files": len(bundle)} - return [self.put(ctx, "coverage_report", ArtifactType.STRUCTURED_JSON, report)] - - def compose_test_model_summary(self, ctx: ExecutionContext) -> list[str]: - bundle = self.get(ctx, "gherkin_bundle", []) or [] - summary = f"Prepared gherkin model with {len(bundle)} feature file(s)." - changeset = [ - { - "op": "create", - "path": str(item.get("path", "")), - "base_hash": None, - "proposed_content": str(item.get("content", "")), - "reason": "generated gherkin feature", - "hunks": [], - } - for item in bundle - if isinstance(item, dict) - ] - return [ - self.put(ctx, "final_answer", ArtifactType.TEXT, summary), - self.put(ctx, "final_changeset", ArtifactType.CHANGESET, changeset), - ] diff --git a/src/app/modules/agent/engine/orchestrator/actions/project_qa_actions.py b/src/app/modules/agent/engine/orchestrator/actions/project_qa_actions.py deleted file mode 100644 index 570680d..0000000 --- a/src/app/modules/agent/engine/orchestrator/actions/project_qa_actions.py +++ /dev/null @@ -1,117 +0,0 @@ -from __future__ import annotations - -from app.modules.agent.engine.orchestrator.actions.project_qa_analyzer import ProjectQaAnalyzer -from app.modules.agent.engine.orchestrator.actions.common import ActionSupport -from app.modules.agent.engine.orchestrator.actions.project_qa_support import ProjectQaSupport -from app.modules.agent.engine.orchestrator.execution_context import ExecutionContext -from app.modules.agent.engine.orchestrator.models import ArtifactType - - -class ProjectQaActions(ActionSupport): - def __init__(self) -> None: - self._support = ProjectQaSupport() - self._analyzer = ProjectQaAnalyzer() - - def classify_project_question(self, ctx: ExecutionContext) -> list[str]: - message = str(ctx.task.user_message or "") - profile = self._support.build_profile(message) - return [self.put(ctx, "question_profile", ArtifactType.STRUCTURED_JSON, profile)] - - def collect_project_sources(self, ctx: ExecutionContext) -> list[str]: - profile = self.get(ctx, "question_profile", {}) or {} - terms = list(profile.get("terms", []) or []) - entities = list(profile.get("entities", []) or []) - rag_items = list(ctx.task.metadata.get("rag_items", []) or []) - files_map = dict(ctx.task.metadata.get("files_map", {}) or {}) - explicit_test = any(term in {"test", "tests", "тест", "тесты"} for term in terms) - - ranked_rag = [] - for item in rag_items: - score = self._support.rag_score(item, terms, entities) - source = str(item.get("source", "") or "") - if not explicit_test and self._support.is_test_path(source): - score -= 3 - if score > 0: - ranked_rag.append((score, item)) - ranked_rag.sort(key=lambda pair: pair[0], reverse=True) - - ranked_files = [] - for path, payload in files_map.items(): - score = self._support.file_score(path, payload, terms, entities) - if not explicit_test and self._support.is_test_path(path): - score -= 3 - if score > 0: - ranked_files.append( - ( - score, - { - "path": path, - "content": str(payload.get("content", "")), - "content_hash": str(payload.get("content_hash", "")), - }, - ) - ) - ranked_files.sort(key=lambda pair: pair[0], reverse=True) - - bundle = { - "profile": profile, - "rag_items": [item for _, item in ranked_rag[:12]], - "file_candidates": [item for _, item in ranked_files[:10]], - "rag_total": len(ranked_rag), - "files_total": len(ranked_files), - } - return [self.put(ctx, "source_bundle", ArtifactType.STRUCTURED_JSON, bundle)] - - def analyze_project_sources(self, ctx: ExecutionContext) -> list[str]: - bundle = self.get(ctx, "source_bundle", {}) or {} - profile = bundle.get("profile", {}) or {} - rag_items = list(bundle.get("rag_items", []) or []) - file_candidates = list(bundle.get("file_candidates", []) or []) - - if str(profile.get("domain")) == "code": - analysis = self._analyzer.analyze_code(profile, rag_items, file_candidates) - else: - analysis = self._analyzer.analyze_docs(profile, rag_items) - return [self.put(ctx, "analysis_brief", ArtifactType.STRUCTURED_JSON, analysis)] - - def build_project_answer_brief(self, ctx: ExecutionContext) -> list[str]: - profile = self.get(ctx, "question_profile", {}) or {} - analysis = self.get(ctx, "analysis_brief", {}) or {} - brief = { - "question_profile": profile, - "resolved_subject": analysis.get("subject"), - "key_findings": analysis.get("findings", []), - "supporting_evidence": analysis.get("evidence", []), - "missing_evidence": analysis.get("gaps", []), - "answer_mode": analysis.get("answer_mode", "summary"), - } - return [self.put(ctx, "answer_brief", ArtifactType.STRUCTURED_JSON, brief)] - - def compose_project_answer(self, ctx: ExecutionContext) -> list[str]: - brief = self.get(ctx, "answer_brief", {}) or {} - profile = brief.get("question_profile", {}) or {} - russian = bool(profile.get("russian")) - answer_mode = str(brief.get("answer_mode") or "summary") - findings = list(brief.get("key_findings", []) or []) - evidence = list(brief.get("supporting_evidence", []) or []) - gaps = list(brief.get("missing_evidence", []) or []) - - title = "## Кратко" if russian else "## Summary" - lines = [title] - if answer_mode == "inventory": - lines.append("### Что реализовано" if russian else "### Implemented items") - else: - lines.append("### Что видно по проекту" if russian else "### What the project shows") - if findings: - lines.extend(f"- {item}" for item in findings) - else: - lines.append("Не удалось собрать подтвержденные выводы по доступным данным." if russian else "No supported findings could be assembled from the available data.") - if evidence: - lines.append("") - lines.append("### Где смотреть в проекте" if russian else "### Where to look in the project") - lines.extend(f"- `{item}`" for item in evidence[:5]) - if gaps: - lines.append("") - lines.append("### Что пока не подтверждено кодом" if russian else "### What is not yet confirmed in code") - lines.extend(f"- {item}" for item in gaps[:3]) - return [self.put(ctx, "final_answer", ArtifactType.TEXT, "\n".join(lines))] diff --git a/src/app/modules/agent/engine/orchestrator/actions/project_qa_analyzer.py b/src/app/modules/agent/engine/orchestrator/actions/project_qa_analyzer.py deleted file mode 100644 index 0374f52..0000000 --- a/src/app/modules/agent/engine/orchestrator/actions/project_qa_analyzer.py +++ /dev/null @@ -1,154 +0,0 @@ -from __future__ import annotations - - -class ProjectQaAnalyzer: - def analyze_code(self, profile: dict, rag_items: list[dict], file_candidates: list[dict]) -> dict: - terms = list(profile.get("terms", []) or []) - intent = str(profile.get("intent") or "lookup") - russian = bool(profile.get("russian")) - findings: list[str] = [] - evidence: list[str] = [] - gaps: list[str] = [] - - symbol_titles = [str(item.get("title", "") or "") for item in rag_items if str(item.get("layer", "")).startswith("C1")] - symbol_set = set(symbol_titles) - file_paths = [str(item.get("path", "") or item.get("source", "") or "") for item in rag_items] - file_paths.extend(str(item.get("path", "") or "") for item in file_candidates) - - if "ConfigManager" in profile.get("entities", []) or "configmanager" in terms or "config_manager" in terms: - alias_file = self.find_path(file_paths, "src/config_manager/__init__.py") - if alias_file: - findings.append( - "Публичный `ConfigManager` экспортируется из `src/config_manager/__init__.py` как alias на `ConfigManagerV2`." - if russian - else "Public `ConfigManager` is exported from `src/config_manager/__init__.py` as an alias to `ConfigManagerV2`." - ) - evidence.append("src/config_manager/__init__.py") - - if "controlchannel" in {name.lower() for name in symbol_set}: - findings.append( - "Базовый контракт управления задает `ControlChannel`: он определяет команды `start` и `stop` для внешнего канала управления." - if russian - else "`ControlChannel` defines the base management contract with `start` and `stop` commands." - ) - evidence.append("src/config_manager/v2/control/base.py") - - if "ControlChannelBridge" in symbol_set: - findings.append( - "`ControlChannelBridge` связывает внешний канал управления с lifecycle-методами менеджера: `on_start`, `on_stop`, `on_status`." - if russian - else "`ControlChannelBridge` connects the external control channel to manager lifecycle methods: `on_start`, `on_stop`, `on_status`." - ) - evidence.append("src/config_manager/v2/core/control_bridge.py") - - implementation_files = self.find_management_implementations(file_candidates) - if implementation_files: - labels = ", ".join(f"`{path}`" for path in implementation_files) - channel_names = self.implementation_names(implementation_files) - findings.append( - f"В коде найдены конкретные реализации каналов управления: {', '.join(channel_names)} ({labels})." - if russian - else f"Concrete management channel implementations were found in code: {', '.join(channel_names)} ({labels})." - ) - evidence.extend(implementation_files) - elif intent == "inventory": - gaps.append( - "В текущем контексте не удалось уверенно подтвердить конкретные файлы-реализации каналов, кроме базового контракта и bridge-слоя." - if russian - else "The current context does not yet confirm concrete channel implementation files beyond the base contract and bridge layer." - ) - - package_doc = self.find_management_doc(file_candidates) - if package_doc: - findings.append( - f"Пакет управления прямо описывает внешние каналы через `{package_doc}`." - if russian - else f"The control package directly describes external channels in `{package_doc}`." - ) - evidence.append(package_doc) - - subject = "management channels" - if profile.get("entities"): - subject = ", ".join(profile["entities"]) - return { - "subject": subject, - "findings": self.dedupe(findings), - "evidence": self.dedupe(evidence), - "gaps": gaps, - "answer_mode": "inventory" if intent == "inventory" else "summary", - } - - def analyze_docs(self, profile: dict, rag_items: list[dict]) -> dict: - findings: list[str] = [] - evidence: list[str] = [] - for item in rag_items[:5]: - title = str(item.get("title", "") or "") - source = str(item.get("source", "") or "") - content = str(item.get("content", "") or "").strip() - if content: - findings.append(content.splitlines()[0][:220]) - if source: - evidence.append(source) - elif title: - evidence.append(title) - return { - "subject": "docs", - "findings": self.dedupe(findings), - "evidence": self.dedupe(evidence), - "gaps": [] if findings else ["Недостаточно данных в документации." if profile.get("russian") else "Not enough data in documentation."], - "answer_mode": "summary", - } - - def find_management_implementations(self, file_candidates: list[dict]) -> list[str]: - found: list[str] = [] - for item in file_candidates: - path = str(item.get("path", "") or "") - lowered = path.lower() - if self.is_test_path(path): - continue - if any(token in lowered for token in ("http_channel.py", "telegram.py", "telegram_channel.py", "http.py")): - found.append(path) - continue - content = str(item.get("content", "") or "").lower() - if "controlchannel" in content and "class " in content: - found.append(path) - continue - if ("channel" in lowered or "control" in lowered) and any(token in content for token in ("http", "telegram", "bot")): - found.append(path) - return self.dedupe(found)[:4] - - def implementation_names(self, paths: list[str]) -> list[str]: - names: list[str] = [] - for path in paths: - stem = path.rsplit("/", 1)[-1].rsplit(".", 1)[0] - label = stem.replace("_", " ").strip() - if label and label not in names: - names.append(label) - return names - - def find_management_doc(self, file_candidates: list[dict]) -> str | None: - for item in file_candidates: - path = str(item.get("path", "") or "") - if self.is_test_path(path): - continue - content = str(item.get("content", "") or "").lower() - if any(token in content for token in ("каналы внешнего управления", "external control channels", "http api", "telegram")): - return path - return None - - def find_path(self, paths: list[str], target: str) -> str | None: - for path in paths: - if path == target: - return path - return None - - def dedupe(self, items: list[str]) -> list[str]: - seen: list[str] = [] - for item in items: - if item and item not in seen: - seen.append(item) - return seen - - def is_test_path(self, path: str) -> bool: - lowered = path.lower() - return lowered.startswith("tests/") or "/tests/" in lowered or lowered.startswith("test_") or "/test_" in lowered diff --git a/src/app/modules/agent/engine/orchestrator/actions/project_qa_support.py b/src/app/modules/agent/engine/orchestrator/actions/project_qa_support.py deleted file mode 100644 index d449430..0000000 --- a/src/app/modules/agent/engine/orchestrator/actions/project_qa_support.py +++ /dev/null @@ -1,166 +0,0 @@ -from __future__ import annotations - -import re - -from app.modules.rag.retrieval.query_terms import extract_query_terms - - -class ProjectQaSupport: - def resolve_request(self, message: str) -> dict: - profile = self.build_profile(message) - subject = profile["entities"][0] if profile.get("entities") else "" - return { - "original_message": message, - "normalized_message": " ".join((message or "").split()), - "subject_hint": subject, - "source_hint": profile["domain"], - "russian": profile["russian"], - } - - def build_profile(self, message: str) -> dict: - lowered = message.lower() - return { - "domain": "code" if self.looks_like_code_question(lowered) else "docs", - "intent": self.detect_intent(lowered), - "terms": extract_query_terms(message), - "entities": self.extract_entities(message), - "russian": self.is_russian(message), - } - - def build_retrieval_query(self, resolved_request: dict, profile: dict) -> str: - normalized = str(resolved_request.get("normalized_message") or resolved_request.get("original_message") or "").strip() - if profile.get("domain") == "code" and "по коду" not in normalized.lower(): - return f"по коду {normalized}".strip() - return normalized - - def build_source_bundle(self, profile: dict, rag_items: list[dict], files_map: dict[str, dict]) -> dict: - terms = list(profile.get("terms", []) or []) - entities = list(profile.get("entities", []) or []) - explicit_test = any(term in {"test", "tests", "тест", "тесты"} for term in terms) - - ranked_rag: list[tuple[int, dict]] = [] - for item in rag_items: - score = self.rag_score(item, terms, entities) - source = str(item.get("source", "") or "") - if not explicit_test and self.is_test_path(source): - score -= 3 - if score > 0: - ranked_rag.append((score, item)) - ranked_rag.sort(key=lambda pair: pair[0], reverse=True) - - ranked_files: list[tuple[int, dict]] = [] - for path, payload in files_map.items(): - score = self.file_score(path, payload, terms, entities) - if not explicit_test and self.is_test_path(path): - score -= 3 - if score > 0: - ranked_files.append( - ( - score, - { - "path": path, - "content": str(payload.get("content", "")), - "content_hash": str(payload.get("content_hash", "")), - }, - ) - ) - ranked_files.sort(key=lambda pair: pair[0], reverse=True) - - return { - "profile": profile, - "rag_items": [item for _, item in ranked_rag[:12]], - "file_candidates": [item for _, item in ranked_files[:10]], - "rag_total": len(ranked_rag), - "files_total": len(ranked_files), - } - - def build_answer_brief(self, profile: dict, analysis: dict) -> dict: - return { - "question_profile": profile, - "resolved_subject": analysis.get("subject"), - "key_findings": analysis.get("findings", []), - "supporting_evidence": analysis.get("evidence", []), - "missing_evidence": analysis.get("gaps", []), - "answer_mode": analysis.get("answer_mode", "summary"), - } - - def compose_answer(self, brief: dict) -> str: - profile = brief.get("question_profile", {}) or {} - russian = bool(profile.get("russian")) - answer_mode = str(brief.get("answer_mode") or "summary") - findings = list(brief.get("key_findings", []) or []) - evidence = list(brief.get("supporting_evidence", []) or []) - gaps = list(brief.get("missing_evidence", []) or []) - - title = "## Кратко" if russian else "## Summary" - lines = [title] - lines.append("### Что реализовано" if answer_mode == "inventory" and russian else "### Implemented items" if answer_mode == "inventory" else "### Что видно по проекту" if russian else "### What the project shows") - if findings: - lines.extend(f"- {item}" for item in findings) - else: - lines.append("Не удалось собрать подтвержденные выводы по доступным данным." if russian else "No supported findings could be assembled from the available data.") - if evidence: - lines.append("") - lines.append("### Где смотреть в проекте" if russian else "### Where to look in the project") - lines.extend(f"- `{item}`" for item in evidence[:5]) - if gaps: - lines.append("") - lines.append("### Что пока не подтверждено кодом" if russian else "### What is not yet confirmed in code") - lines.extend(f"- {item}" for item in gaps[:3]) - return "\n".join(lines) - - def detect_intent(self, lowered: str) -> str: - if any(token in lowered for token in ("какие", "что уже реализ", "список", "перечень", "какие есть")): - return "inventory" - if any(token in lowered for token in ("где", "find", "where")): - return "lookup" - if any(token in lowered for token in ("сравни", "compare")): - return "compare" - return "explain" - - def looks_like_code_question(self, lowered: str) -> bool: - code_markers = ("по коду", "код", "реализ", "имплементац", "класс", "метод", "модул", "файл", "канал", "handler", "endpoint") - return any(marker in lowered for marker in code_markers) or bool(re.search(r"\b[A-Z][A-Za-z0-9_]{2,}\b", lowered)) - - def extract_entities(self, message: str) -> list[str]: - return re.findall(r"\b[A-Z][A-Za-z0-9_]{2,}\b", message)[:5] - - def rag_score(self, item: dict, terms: list[str], entities: list[str]) -> int: - haystacks = [ - str(item.get("source", "") or "").lower(), - str(item.get("title", "") or "").lower(), - str(item.get("content", "") or "").lower(), - str((item.get("metadata", {}) or {}).get("qname", "") or "").lower(), - ] - score = 0 - for term in terms: - if any(term in hay for hay in haystacks): - score += 3 - for entity in entities: - if any(entity.lower() in hay for hay in haystacks): - score += 5 - return score - - def file_score(self, path: str, payload: dict, terms: list[str], entities: list[str]) -> int: - content = str(payload.get("content", "") or "").lower() - path_lower = path.lower() - score = 0 - for term in terms: - if term in path_lower: - score += 4 - elif term in content: - score += 2 - for entity in entities: - entity_lower = entity.lower() - if entity_lower in path_lower: - score += 5 - elif entity_lower in content: - score += 3 - return score - - def is_test_path(self, path: str) -> bool: - lowered = path.lower() - return lowered.startswith("tests/") or "/tests/" in lowered or lowered.startswith("test_") or "/test_" in lowered - - def is_russian(self, text: str) -> bool: - return any("а" <= ch.lower() <= "я" or ch.lower() == "ё" for ch in text) diff --git a/src/app/modules/agent/engine/orchestrator/actions/review_actions.py b/src/app/modules/agent/engine/orchestrator/actions/review_actions.py deleted file mode 100644 index da7e9e3..0000000 --- a/src/app/modules/agent/engine/orchestrator/actions/review_actions.py +++ /dev/null @@ -1,102 +0,0 @@ -from __future__ import annotations - -from urllib.parse import urlparse - -from app.modules.agent.engine.orchestrator.actions.common import ActionSupport -from app.modules.agent.engine.orchestrator.execution_context import ExecutionContext -from app.modules.agent.engine.orchestrator.models import ArtifactType - - -class ReviewActions(ActionSupport): - def fetch_source_doc(self, ctx: ExecutionContext) -> list[str]: - attachment = next((a for a in ctx.task.attachments if a.value), None) - if attachment is None: - text = ctx.task.user_message - source_ref = "inline:message" - else: - parsed = urlparse(attachment.value) - source_ref = attachment.value - text = f"Source: {parsed.netloc}\nPath: {parsed.path}\nRequest: {ctx.task.user_message}" - evidence_id = self.add_evidence( - ctx, - source_type="external_doc", - source_ref=source_ref, - snippet=text, - score=0.75, - ) - return [ - self.put( - ctx, - "source_doc_raw", - ArtifactType.TEXT, - text, - meta={"source_ref": source_ref, "evidence_ids": [evidence_id]}, - ) - ] - - def normalize_document(self, ctx: ExecutionContext) -> list[str]: - raw = str(self.get(ctx, "source_doc_raw", "") or "") - normalized = "\n".join(line.rstrip() for line in raw.splitlines()).strip() - return [self.put(ctx, "source_doc_text", ArtifactType.TEXT, normalized)] - - def structural_check(self, ctx: ExecutionContext) -> list[str]: - text = str(self.get(ctx, "source_doc_text", "") or "") - required = ["цель", "границ", "риски", "api", "данные"] - found = [token for token in required if token in text.lower()] - findings = { - "required_sections": required, - "found_markers": found, - "missing_markers": [token for token in required if token not in found], - } - return [self.put(ctx, "structural_findings", ArtifactType.STRUCTURED_JSON, findings)] - - def semantic_consistency_check(self, ctx: ExecutionContext) -> list[str]: - text = str(self.get(ctx, "source_doc_text", "") or "") - contradictions = [] - if "без изменений" in text.lower() and "новый" in text.lower(): - contradictions.append("Contains both 'no changes' and 'new behavior' markers.") - payload = {"contradictions": contradictions, "status": "ok" if not contradictions else "needs_attention"} - return [self.put(ctx, "semantic_findings", ArtifactType.STRUCTURED_JSON, payload)] - - def architecture_fit_check(self, ctx: ExecutionContext) -> list[str]: - text = str(self.get(ctx, "source_doc_text", "") or "") - files_count = len(dict(ctx.task.metadata.get("files_map", {}) or {})) - payload = { - "architecture_fit": "medium" if files_count == 0 else "high", - "notes": "Evaluate fit against existing docs and interfaces.", - "markers": ["integration"] if "integr" in text.lower() else [], - } - return [self.put(ctx, "architecture_findings", ArtifactType.STRUCTURED_JSON, payload)] - - def optimization_check(self, ctx: ExecutionContext) -> list[str]: - text = str(self.get(ctx, "source_doc_text", "") or "") - has_perf = any(token in text.lower() for token in ("latency", "performance", "оптим")) - payload = { - "optimization_considered": has_perf, - "recommendation": "Add explicit non-functional targets." if not has_perf else "Optimization criteria present.", - } - return [self.put(ctx, "optimization_findings", ArtifactType.STRUCTURED_JSON, payload)] - - def compose_review_report(self, ctx: ExecutionContext) -> list[str]: - structural = self.get(ctx, "structural_findings", {}) or {} - semantic = self.get(ctx, "semantic_findings", {}) or {} - architecture = self.get(ctx, "architecture_findings", {}) or {} - optimization = self.get(ctx, "optimization_findings", {}) or {} - report = "\n".join( - [ - "## Findings", - f"- Missing structure markers: {', '.join(structural.get('missing_markers', [])) or 'none'}", - f"- Contradictions: {len(semantic.get('contradictions', []))}", - f"- Architecture fit: {architecture.get('architecture_fit', 'unknown')}", - f"- Optimization: {optimization.get('recommendation', 'n/a')}", - "", - "## Recommendations", - "- Clarify boundaries and data contracts.", - "- Add explicit error and rollback behavior.", - "- Add measurable non-functional requirements.", - ] - ) - return [ - self.put(ctx, "review_report", ArtifactType.REVIEW_REPORT, report), - self.put(ctx, "final_answer", ArtifactType.TEXT, report), - ] diff --git a/src/app/modules/agent/engine/orchestrator/artifact_store.py b/src/app/modules/agent/engine/orchestrator/artifact_store.py deleted file mode 100644 index 83c668e..0000000 --- a/src/app/modules/agent/engine/orchestrator/artifact_store.py +++ /dev/null @@ -1,50 +0,0 @@ -from __future__ import annotations - -from uuid import uuid4 - -from app.modules.agent.engine.orchestrator.models import ArtifactItem, ArtifactType - - -class ArtifactStore: - def __init__(self) -> None: - self._by_id: dict[str, ArtifactItem] = {} - self._by_key: dict[str, ArtifactItem] = {} - - def put(self, *, key: str, artifact_type: ArtifactType, content=None, meta: dict | None = None) -> ArtifactItem: - item_meta = dict(meta or {}) - if content is not None and not isinstance(content, str): - item_meta.setdefault("value", content) - item = ArtifactItem( - artifact_id=f"artifact_{uuid4().hex}", - key=key, - type=artifact_type, - content=self._as_content(content), - meta=item_meta, - ) - self._by_id[item.artifact_id] = item - self._by_key[key] = item - return item - - def get(self, key: str) -> ArtifactItem | None: - return self._by_key.get(key) - - def get_content(self, key: str, default=None): - item = self.get(key) - if item is None: - return default - if item.content is not None: - return item.content - return item.meta.get("value", default) - - def has(self, key: str) -> bool: - return key in self._by_key - - def all_items(self) -> list[ArtifactItem]: - return list(self._by_id.values()) - - def _as_content(self, value): - if value is None: - return None - if isinstance(value, str): - return value - return None diff --git a/src/app/modules/agent/engine/orchestrator/evidence_store.py b/src/app/modules/agent/engine/orchestrator/evidence_store.py deleted file mode 100644 index 7197978..0000000 --- a/src/app/modules/agent/engine/orchestrator/evidence_store.py +++ /dev/null @@ -1,14 +0,0 @@ -from __future__ import annotations - -from app.modules.agent.engine.orchestrator.models import EvidenceItem - - -class EvidenceStore: - def __init__(self) -> None: - self._items: list[EvidenceItem] = [] - - def put_many(self, items: list[EvidenceItem]) -> None: - self._items.extend(items) - - def all_items(self) -> list[EvidenceItem]: - return list(self._items) diff --git a/src/app/modules/agent/engine/orchestrator/execution_context.py b/src/app/modules/agent/engine/orchestrator/execution_context.py deleted file mode 100644 index f054d28..0000000 --- a/src/app/modules/agent/engine/orchestrator/execution_context.py +++ /dev/null @@ -1,30 +0,0 @@ -from __future__ import annotations - -from collections.abc import Awaitable, Callable -from dataclasses import dataclass -from typing import Any - -from app.modules.agent.engine.orchestrator.artifact_store import ArtifactStore -from app.modules.agent.engine.orchestrator.evidence_store import EvidenceStore -from app.modules.agent.engine.orchestrator.models import ExecutionPlan, TaskSpec - -ProgressCallback = Callable[[str, str, str, dict | None], Awaitable[None] | None] -GraphResolver = Callable[[str, str], Any] -GraphInvoker = Callable[[Any, dict, str], dict] - - -@dataclass -class ExecutionContext: - task: TaskSpec - plan: ExecutionPlan - graph_resolver: GraphResolver - graph_invoker: GraphInvoker - progress_cb: ProgressCallback | None = None - artifacts: ArtifactStore | None = None - evidences: EvidenceStore | None = None - - def __post_init__(self) -> None: - if self.artifacts is None: - self.artifacts = ArtifactStore() - if self.evidences is None: - self.evidences = EvidenceStore() diff --git a/src/app/modules/agent/engine/orchestrator/execution_engine.py b/src/app/modules/agent/engine/orchestrator/execution_engine.py deleted file mode 100644 index e747fea..0000000 --- a/src/app/modules/agent/engine/orchestrator/execution_engine.py +++ /dev/null @@ -1,146 +0,0 @@ -from __future__ import annotations - -import asyncio -import inspect -import logging -import time - -from app.modules.agent.engine.orchestrator.execution_context import ExecutionContext -from app.modules.agent.engine.orchestrator.models import PlanStatus, PlanStep, StepResult, StepStatus -from app.modules.agent.engine.orchestrator.quality_gates import QualityGateRunner -from app.modules.agent.engine.orchestrator.step_registry import StepRegistry - -LOGGER = logging.getLogger(__name__) - - -class ExecutionEngine: - def __init__(self, step_registry: StepRegistry, gates: QualityGateRunner) -> None: - self._steps = step_registry - self._gates = gates - - async def run(self, ctx: ExecutionContext) -> list[StepResult]: - ctx.plan.status = PlanStatus.RUNNING - step_results: list[StepResult] = [] - - for step in ctx.plan.steps: - dep_issue = self._dependency_issue(step, step_results) - if dep_issue: - result = StepResult( - step_id=step.step_id, - status=StepStatus.SKIPPED, - warnings=[dep_issue], - ) - step_results.append(result) - self._log_step_result(ctx, step, result) - continue - - result = await self._run_with_retry(step, ctx) - step_results.append(result) - self._log_step_result(ctx, step, result) - if result.status in {StepStatus.FAILED, StepStatus.RETRY_EXHAUSTED} and step.on_failure == "fail": - ctx.plan.status = PlanStatus.FAILED - return step_results - - passed, global_messages = self._gates.check_global(ctx.plan.global_gates, ctx) - if not passed: - step_results.append( - StepResult( - step_id="global_gates", - status=StepStatus.FAILED, - warnings=global_messages, - ) - ) - ctx.plan.status = PlanStatus.FAILED - return step_results - - if any(item.status in {StepStatus.FAILED, StepStatus.RETRY_EXHAUSTED} for item in step_results): - ctx.plan.status = PlanStatus.FAILED - elif any(item.status == StepStatus.SKIPPED for item in step_results): - ctx.plan.status = PlanStatus.PARTIAL - else: - ctx.plan.status = PlanStatus.COMPLETED - return step_results - - async def _run_with_retry(self, step: PlanStep, ctx: ExecutionContext) -> StepResult: - max_attempts = max(1, int(step.retry.max_attempts or 1)) - attempt = 0 - last_error: Exception | None = None - - while attempt < max_attempts: - attempt += 1 - started_at = time.monotonic() - LOGGER.warning( - "orchestrator step start: task_id=%s step_id=%s action_id=%s executor=%s attempt=%s graph_id=%s", - ctx.task.task_id, - step.step_id, - step.action_id, - step.executor, - attempt, - step.graph_id or "", - ) - await self._emit_progress(ctx, f"orchestrator.step.{step.step_id}", step.title) - - try: - artifact_ids = await self._steps.execute(step, ctx) - passed, gate_messages = self._gates.check_step(step, ctx) - if not passed: - raise RuntimeError(";".join(gate_messages) or "step_quality_gate_failed") - - elapsed = int((time.monotonic() - started_at) * 1000) - return StepResult( - step_id=step.step_id, - status=StepStatus.SUCCESS, - produced_artifact_ids=artifact_ids, - warnings=gate_messages, - duration_ms=elapsed, - ) - except Exception as exc: - last_error = exc - if attempt < max_attempts and step.retry.backoff_sec > 0: - await asyncio.sleep(step.retry.backoff_sec) - - elapsed = int((time.monotonic() - started_at) * 1000) - return StepResult( - step_id=step.step_id, - status=StepStatus.RETRY_EXHAUSTED if max_attempts > 1 else StepStatus.FAILED, - error_code="step_execution_failed", - error_message=str(last_error) if last_error else "step_execution_failed", - duration_ms=elapsed, - ) - - def _dependency_issue(self, step: PlanStep, results: list[StepResult]) -> str | None: - if not step.depends_on: - return None - by_step = {item.step_id: item for item in results} - for dep in step.depends_on: - dep_result = by_step.get(dep) - if dep_result is None: - return f"dependency_not_executed:{dep}" - if dep_result.status != StepStatus.SUCCESS: - return f"dependency_not_success:{dep}:{dep_result.status.value}" - return None - - async def _emit_progress(self, ctx: ExecutionContext, stage: str, message: str) -> None: - if ctx.progress_cb is None: - return - result = ctx.progress_cb(stage, message, "task_progress", {"layer": "orchestrator"}) - if inspect.isawaitable(result): - await result - - def _log_step_result(self, ctx: ExecutionContext, step: PlanStep, result: StepResult) -> None: - artifact_keys = [] - for artifact_id in result.produced_artifact_ids: - item = next((artifact for artifact in ctx.artifacts.all_items() if artifact.artifact_id == artifact_id), None) - if item is not None: - artifact_keys.append(item.key) - LOGGER.warning( - "orchestrator step result: task_id=%s step_id=%s action_id=%s status=%s duration_ms=%s artifact_keys=%s warnings=%s error=%s", - ctx.task.task_id, - step.step_id, - step.action_id, - result.status.value, - result.duration_ms, - artifact_keys, - result.warnings, - result.error_message or "", - ) diff --git a/src/app/modules/agent/engine/orchestrator/metrics_persister.py b/src/app/modules/agent/engine/orchestrator/metrics_persister.py deleted file mode 100644 index 618c73f..0000000 --- a/src/app/modules/agent/engine/orchestrator/metrics_persister.py +++ /dev/null @@ -1,36 +0,0 @@ -from __future__ import annotations - -import logging - -from app.modules.agent.repository import AgentRepository - -LOGGER = logging.getLogger(__name__) - - -class MetricsPersister: - def __init__(self, repository: AgentRepository) -> None: - self._repository = repository - - def save( - self, - *, - task_id: str, - dialog_session_id: str, - rag_session_id: str, - scenario: str, - domain_id: str, - process_id: str, - quality: dict, - ) -> None: - try: - self._repository.save_quality_metrics( - task_id=task_id, - dialog_session_id=dialog_session_id, - rag_session_id=rag_session_id, - scenario=scenario, - domain_id=domain_id, - process_id=process_id, - quality=quality, - ) - except Exception: - LOGGER.exception("Failed to persist quality metrics: task_id=%s", task_id) diff --git a/src/app/modules/agent/engine/orchestrator/models/__init__.py b/src/app/modules/agent/engine/orchestrator/models/__init__.py deleted file mode 100644 index e1402a3..0000000 --- a/src/app/modules/agent/engine/orchestrator/models/__init__.py +++ /dev/null @@ -1,51 +0,0 @@ -from app.modules.agent.engine.orchestrator.models.plan import ( - ArtifactSpec, - ArtifactType, - ExecutionPlan, - PlanStatus, - PlanStep, - QualityGateRef, - RetryPolicy, -) -from app.modules.agent.engine.orchestrator.models.result import ( - ArtifactItem, - EvidenceItem, - OrchestratorResult, - StepResult, - StepStatus, -) -from app.modules.agent.engine.orchestrator.models.task_spec import ( - AttachmentRef, - FileRef, - OutputContract, - OutputSection, - RoutingMeta, - Scenario, - SourcePolicy, - TaskConstraints, - TaskSpec, -) - -__all__ = [ - "ArtifactItem", - "ArtifactSpec", - "ArtifactType", - "AttachmentRef", - "EvidenceItem", - "ExecutionPlan", - "FileRef", - "OrchestratorResult", - "OutputContract", - "OutputSection", - "PlanStatus", - "PlanStep", - "QualityGateRef", - "RetryPolicy", - "RoutingMeta", - "Scenario", - "SourcePolicy", - "StepResult", - "StepStatus", - "TaskConstraints", - "TaskSpec", -] diff --git a/src/app/modules/agent/engine/orchestrator/models/plan.py b/src/app/modules/agent/engine/orchestrator/models/plan.py deleted file mode 100644 index 8fad2aa..0000000 --- a/src/app/modules/agent/engine/orchestrator/models/plan.py +++ /dev/null @@ -1,88 +0,0 @@ -from __future__ import annotations - -from enum import Enum -from typing import Any, Literal - -from pydantic import BaseModel, ConfigDict, Field - -from app.modules.agent.engine.orchestrator.models.task_spec import Scenario - - -class ArtifactType(str, Enum): - TEXT = "text" - REVIEW_REPORT = "review_report" - CHANGESET = "changeset" - DOC_BUNDLE = "doc_bundle" - GHERKIN_BUNDLE = "gherkin_bundle" - STRUCTURED_JSON = "structured_json" - - -class PlanStatus(str, Enum): - DRAFT = "draft" - VALIDATED = "validated" - RUNNING = "running" - COMPLETED = "completed" - FAILED = "failed" - PARTIAL = "partial" - - -class InputBinding(BaseModel): - model_config = ConfigDict(extra="forbid") - - name: str - from_key: str - required: bool = True - - -class ArtifactSpec(BaseModel): - model_config = ConfigDict(extra="forbid") - - key: str - type: ArtifactType - required: bool = True - - -class RetryPolicy(BaseModel): - model_config = ConfigDict(extra="forbid") - - max_attempts: int = 1 - backoff_sec: int = 0 - - -class QualityGateRef(BaseModel): - model_config = ConfigDict(extra="forbid") - - gate_id: str - blocking: bool = True - - -class PlanStep(BaseModel): - model_config = ConfigDict(extra="forbid") - - step_id: str - title: str - action_id: str - executor: Literal["function", "graph"] - graph_id: str | None = None - depends_on: list[str] = Field(default_factory=list) - inputs: list[InputBinding] = Field(default_factory=list) - outputs: list[ArtifactSpec] = Field(default_factory=list) - side_effect: Literal["read", "write", "external"] = "read" - retry: RetryPolicy = Field(default_factory=RetryPolicy) - timeout_sec: int = 120 - on_failure: Literal["fail", "skip", "replan"] = "fail" - quality_gates: list[QualityGateRef] = Field(default_factory=list) - - -class ExecutionPlan(BaseModel): - model_config = ConfigDict(extra="forbid") - - plan_id: str - task_id: str - scenario: Scenario - template_id: str - template_version: str - status: PlanStatus = PlanStatus.DRAFT - steps: list[PlanStep] - variables: dict[str, Any] = Field(default_factory=dict) - global_gates: list[QualityGateRef] = Field(default_factory=list) diff --git a/src/app/modules/agent/engine/orchestrator/models/result.py b/src/app/modules/agent/engine/orchestrator/models/result.py deleted file mode 100644 index 1e9642f..0000000 --- a/src/app/modules/agent/engine/orchestrator/models/result.py +++ /dev/null @@ -1,62 +0,0 @@ -from __future__ import annotations - -from enum import Enum -from typing import Any, Literal - -from pydantic import BaseModel, ConfigDict, Field - -from app.modules.agent.engine.orchestrator.models.plan import ArtifactType -from app.schemas.changeset import ChangeItem - - -class StepStatus(str, Enum): - SUCCESS = "success" - FAILED = "failed" - SKIPPED = "skipped" - RETRY_EXHAUSTED = "retry_exhausted" - - -class EvidenceItem(BaseModel): - model_config = ConfigDict(extra="forbid") - - evidence_id: str - source_type: Literal["rag_chunk", "project_file", "external_doc", "confluence"] - source_ref: str - snippet: str - score: float = Field(ge=0.0, le=1.0) - - -class ArtifactItem(BaseModel): - model_config = ConfigDict(extra="forbid") - - artifact_id: str - key: str - type: ArtifactType - content: str | None = None - path: str | None = None - content_hash: str | None = None - meta: dict[str, Any] = Field(default_factory=dict) - - -class StepResult(BaseModel): - model_config = ConfigDict(extra="forbid") - - step_id: str - status: StepStatus - produced_artifact_ids: list[str] = Field(default_factory=list) - evidence_ids: list[str] = Field(default_factory=list) - warnings: list[str] = Field(default_factory=list) - error_code: str | None = None - error_message: str | None = None - duration_ms: int = 0 - token_usage: int | None = None - replan_hint: str | None = None - - -class OrchestratorResult(BaseModel): - model_config = ConfigDict(extra="forbid") - - answer: str | None = None - changeset: list[ChangeItem] = Field(default_factory=list) - meta: dict[str, Any] = Field(default_factory=dict) - steps: list[StepResult] = Field(default_factory=list) diff --git a/src/app/modules/agent/engine/orchestrator/models/task_spec.py b/src/app/modules/agent/engine/orchestrator/models/task_spec.py deleted file mode 100644 index b0d3cc1..0000000 --- a/src/app/modules/agent/engine/orchestrator/models/task_spec.py +++ /dev/null @@ -1,93 +0,0 @@ -from __future__ import annotations - -from enum import Enum -from typing import Any, Literal - -from pydantic import BaseModel, ConfigDict, Field - - -class Scenario(str, Enum): - EXPLAIN_PART = "explain_part" - ANALYTICS_REVIEW = "analytics_review" - DOCS_FROM_ANALYTICS = "docs_from_analytics" - TARGETED_EDIT = "targeted_edit" - GHERKIN_MODEL = "gherkin_model" - GENERAL_QA = "general_qa" - - -class AttachmentRef(BaseModel): - model_config = ConfigDict(extra="forbid") - - type: Literal["confluence_url", "http_url", "file_ref"] - value: str - - -class FileRef(BaseModel): - model_config = ConfigDict(extra="forbid") - - path: str - content: str = "" - content_hash: str = "" - - -class RoutingMeta(BaseModel): - model_config = ConfigDict(extra="forbid") - - domain_id: str - process_id: str - confidence: float = Field(ge=0.0, le=1.0) - reason: str = "" - fallback_used: bool = False - - -class SourcePolicy(BaseModel): - model_config = ConfigDict(extra="forbid") - - priority: list[Literal["requirements", "tech_docs", "code", "external_doc"]] = Field( - default_factory=lambda: ["requirements", "tech_docs", "code"] - ) - require_evidence: bool = True - max_sources_per_step: int = 12 - - -class TaskConstraints(BaseModel): - model_config = ConfigDict(extra="forbid") - - allow_writes: bool = False - max_steps: int = 20 - max_retries_per_step: int = 2 - step_timeout_sec: int = 120 - target_paths: list[str] = Field(default_factory=list) - - -class OutputSection(BaseModel): - model_config = ConfigDict(extra="forbid") - - name: str - format: Literal["markdown", "mermaid", "gherkin", "json", "changeset"] - required: bool = True - - -class OutputContract(BaseModel): - model_config = ConfigDict(extra="forbid") - - result_type: Literal["answer", "changeset", "review_report", "doc_bundle", "gherkin_bundle"] - sections: list[OutputSection] = Field(default_factory=list) - - -class TaskSpec(BaseModel): - model_config = ConfigDict(extra="forbid") - - task_id: str - dialog_session_id: str - rag_session_id: str - mode: str = "auto" - user_message: str - scenario: Scenario - routing: RoutingMeta - attachments: list[AttachmentRef] = Field(default_factory=list) - files: list[FileRef] = Field(default_factory=list) - source_policy: SourcePolicy = Field(default_factory=SourcePolicy) - constraints: TaskConstraints = Field(default_factory=TaskConstraints) - output_contract: OutputContract - metadata: dict[str, Any] = Field(default_factory=dict) diff --git a/src/app/modules/agent/engine/orchestrator/plan_compiler.py b/src/app/modules/agent/engine/orchestrator/plan_compiler.py deleted file mode 100644 index 0216f1e..0000000 --- a/src/app/modules/agent/engine/orchestrator/plan_compiler.py +++ /dev/null @@ -1,30 +0,0 @@ -from __future__ import annotations - -from app.modules.agent.engine.orchestrator.models import ExecutionPlan, PlanStatus, TaskSpec - - -class PlanCompiler: - def compile(self, template: ExecutionPlan, task: TaskSpec) -> ExecutionPlan: - plan = template.model_copy(deep=True) - plan.plan_id = f"{task.task_id}:{template.template_id}" - plan.task_id = task.task_id - plan.status = PlanStatus.DRAFT - plan.variables = { - "scenario": task.scenario.value, - "route": { - "domain_id": task.routing.domain_id, - "process_id": task.routing.process_id, - "confidence": task.routing.confidence, - }, - } - - for step in plan.steps: - step.timeout_sec = max(1, min(step.timeout_sec, task.constraints.step_timeout_sec)) - step.retry.max_attempts = max(1, min(step.retry.max_attempts, task.constraints.max_retries_per_step)) - if step.side_effect == "write" and not task.constraints.allow_writes: - step.on_failure = "fail" - - if len(plan.steps) > task.constraints.max_steps: - plan.steps = plan.steps[: task.constraints.max_steps] - - return plan diff --git a/src/app/modules/agent/engine/orchestrator/plan_validator.py b/src/app/modules/agent/engine/orchestrator/plan_validator.py deleted file mode 100644 index ffdb9cd..0000000 --- a/src/app/modules/agent/engine/orchestrator/plan_validator.py +++ /dev/null @@ -1,79 +0,0 @@ -from __future__ import annotations - -from app.modules.agent.engine.orchestrator.models import ExecutionPlan, TaskSpec - - -class PlanValidator: - def validate(self, plan: ExecutionPlan, task: TaskSpec) -> list[str]: - errors: list[str] = [] - if not plan.steps: - errors.append("execution_plan_has_no_steps") - return errors - - if len(plan.steps) > task.constraints.max_steps: - errors.append("execution_plan_exceeds_max_steps") - - errors.extend(self._validate_step_ids(plan)) - errors.extend(self._validate_dependencies(plan)) - errors.extend(self._validate_side_effects(plan, task)) - errors.extend(self._validate_step_shape(plan)) - return errors - - def _validate_step_ids(self, plan: ExecutionPlan) -> list[str]: - seen: set[str] = set() - out: list[str] = [] - for step in plan.steps: - if step.step_id in seen: - out.append(f"duplicate_step_id:{step.step_id}") - seen.add(step.step_id) - return out - - def _validate_dependencies(self, plan: ExecutionPlan) -> list[str]: - out: list[str] = [] - valid_ids = {step.step_id for step in plan.steps} - for step in plan.steps: - for dep in step.depends_on: - if dep not in valid_ids: - out.append(f"unknown_dependency:{step.step_id}->{dep}") - - # lightweight cycle detection for directed graph - graph = {step.step_id: list(step.depends_on) for step in plan.steps} - visiting: set[str] = set() - visited: set[str] = set() - - def dfs(node: str) -> bool: - if node in visiting: - return True - if node in visited: - return False - visiting.add(node) - for dep in graph.get(node, []): - if dfs(dep): - return True - visiting.remove(node) - visited.add(node) - return False - - if any(dfs(node) for node in graph): - out.append("dependency_cycle_detected") - return out - - def _validate_side_effects(self, plan: ExecutionPlan, task: TaskSpec) -> list[str]: - if task.constraints.allow_writes: - return [] - out: list[str] = [] - for step in plan.steps: - if step.side_effect == "write": - out.append(f"write_step_not_allowed:{step.step_id}") - return out - - def _validate_step_shape(self, plan: ExecutionPlan) -> list[str]: - out: list[str] = [] - for step in plan.steps: - if step.executor == "graph" and not step.graph_id: - out.append(f"graph_step_missing_graph_id:{step.step_id}") - if step.retry.max_attempts < 1: - out.append(f"invalid_retry_attempts:{step.step_id}") - if step.timeout_sec < 1: - out.append(f"invalid_step_timeout:{step.step_id}") - return out diff --git a/src/app/modules/agent/engine/orchestrator/quality_gates.py b/src/app/modules/agent/engine/orchestrator/quality_gates.py deleted file mode 100644 index 4230804..0000000 --- a/src/app/modules/agent/engine/orchestrator/quality_gates.py +++ /dev/null @@ -1,116 +0,0 @@ -from __future__ import annotations - -from app.modules.agent.engine.orchestrator.execution_context import ExecutionContext -from app.modules.agent.engine.orchestrator.models import PlanStep, QualityGateRef - - -class QualityGateRunner: - def check_step(self, step: PlanStep, ctx: ExecutionContext) -> tuple[bool, list[str]]: - return self._run(step.quality_gates, step=step, ctx=ctx) - - def check_global(self, gates: list[QualityGateRef], ctx: ExecutionContext) -> tuple[bool, list[str]]: - return self._run(gates, step=None, ctx=ctx) - - def _run(self, gates: list[QualityGateRef], *, step: PlanStep | None, ctx: ExecutionContext) -> tuple[bool, list[str]]: - failures: list[str] = [] - warnings: list[str] = [] - for gate in gates: - ok, details = self._check(gate.gate_id, step=step, ctx=ctx) - if ok: - continue - if gate.blocking: - failures.extend(details) - else: - warnings.extend(details) - return len(failures) == 0, failures + warnings - - def _check(self, gate_id: str, *, step: PlanStep | None, ctx: ExecutionContext) -> tuple[bool, list[str]]: - checks = { - "required_outputs": lambda: self._required_outputs(step, ctx), - "non_empty_answer_or_changeset": lambda: self._non_empty_output(ctx), - "changeset_required_for_write": lambda: self._changeset_required(ctx), - "changeset_schema": lambda: self._changeset_schema(ctx), - "evidence_required": lambda: self._evidence_required(ctx), - "review_report_schema": lambda: self._review_schema(ctx), - "cross_file_consistency": lambda: self._cross_file_consistency(ctx), - "target_path_must_exist_or_be_allowed": lambda: self._target_path_gate(ctx), - "minimal_patch_policy": lambda: self._minimal_patch_policy(ctx), - "gherkin_syntax_lint": lambda: self._gherkin_lint(ctx), - "coverage_of_change_intents": lambda: self._coverage_gate(ctx), - "explain_format_hint": lambda: self._explain_hint(ctx), - } - fn = checks.get(gate_id) - if fn is None: - return True, [] - return fn() - - def _required_outputs(self, step: PlanStep | None, ctx: ExecutionContext) -> tuple[bool, list[str]]: - if step is None: - return True, [] - missing = [f"missing_required_artifact:{spec.key}" for spec in step.outputs if spec.required and not ctx.artifacts.has(spec.key)] - return len(missing) == 0, missing - - def _non_empty_output(self, ctx: ExecutionContext) -> tuple[bool, list[str]]: - answer = str(ctx.artifacts.get_content("final_answer", "") or "").strip() - changeset = ctx.artifacts.get_content("final_changeset", []) or [] - ok = bool(answer) or (isinstance(changeset, list) and len(changeset) > 0) - return ok, [] if ok else ["empty_final_output"] - - def _changeset_required(self, ctx: ExecutionContext) -> tuple[bool, list[str]]: - if not ctx.task.constraints.allow_writes: - return True, [] - changeset = ctx.artifacts.get_content("final_changeset", []) or [] - ok = isinstance(changeset, list) and len(changeset) > 0 - return ok, [] if ok else ["changeset_required_for_write"] - - def _changeset_schema(self, ctx: ExecutionContext) -> tuple[bool, list[str]]: - changeset = ctx.artifacts.get_content("final_changeset", []) or [] - if not isinstance(changeset, list): - return False, ["changeset_not_list"] - for idx, item in enumerate(changeset): - if not isinstance(item, dict): - return False, [f"changeset_item_not_object:{idx}"] - if not item.get("op") or not item.get("path"): - return False, [f"changeset_item_missing_fields:{idx}"] - return True, [] - - def _evidence_required(self, ctx: ExecutionContext) -> tuple[bool, list[str]]: - if not ctx.task.source_policy.require_evidence: - return True, [] - evidences = ctx.evidences.all_items() - return len(evidences) > 0, ([] if evidences else ["no_evidence_collected"]) - - def _review_schema(self, ctx: ExecutionContext) -> tuple[bool, list[str]]: - report = str(ctx.artifacts.get_content("review_report", "") or "") - ok = "## Findings" in report and "## Recommendations" in report - return ok, [] if ok else ["review_report_missing_sections"] - - def _cross_file_consistency(self, ctx: ExecutionContext) -> tuple[bool, list[str]]: - report = ctx.artifacts.get_content("consistency_report", {}) or {} - ok = bool(report.get("required_core_paths_present")) - return ok, [] if ok else ["cross_file_consistency_failed"] - - def _target_path_gate(self, ctx: ExecutionContext) -> tuple[bool, list[str]]: - target = ctx.artifacts.get_content("resolved_target", {}) or {} - ok = bool(str(target.get("path", "")).strip()) - return ok, [] if ok else ["target_path_not_resolved"] - - def _minimal_patch_policy(self, ctx: ExecutionContext) -> tuple[bool, list[str]]: - report = ctx.artifacts.get_content("patch_validation_report", {}) or {} - ok = bool(report.get("safe")) - return ok, [] if ok else ["patch_validation_failed"] - - def _gherkin_lint(self, ctx: ExecutionContext) -> tuple[bool, list[str]]: - report = ctx.artifacts.get_content("gherkin_lint_report", {}) or {} - ok = bool(report.get("valid")) - return ok, [] if ok else ["gherkin_lint_failed"] - - def _coverage_gate(self, ctx: ExecutionContext) -> tuple[bool, list[str]]: - report = ctx.artifacts.get_content("coverage_report", {}) or {} - ok = bool(report.get("covered")) - return ok, [] if ok else ["coverage_check_failed"] - - def _explain_hint(self, ctx: ExecutionContext) -> tuple[bool, list[str]]: - answer = str(ctx.artifacts.get_content("final_answer", "") or "") - ok = "```mermaid" in answer or "sequenceDiagram" in answer - return ok, [] if ok else ["hint:explain_answer_missing_mermaid_block"] diff --git a/src/app/modules/agent/engine/orchestrator/quality_metrics.py b/src/app/modules/agent/engine/orchestrator/quality_metrics.py deleted file mode 100644 index b3f7ce8..0000000 --- a/src/app/modules/agent/engine/orchestrator/quality_metrics.py +++ /dev/null @@ -1,116 +0,0 @@ -from __future__ import annotations - -import re - -from app.modules.agent.engine.orchestrator.execution_context import ExecutionContext -from app.modules.agent.engine.orchestrator.models import StepResult - - -class QualityMetricsCalculator: - def build(self, ctx: ExecutionContext, step_results: list[StepResult]) -> dict: - answer = str(ctx.artifacts.get_content("final_answer", "") or "") - changeset = ctx.artifacts.get_content("final_changeset", []) or [] - evidences = ctx.evidences.all_items() - - faithfulness = self._faithfulness(answer=answer, changeset=changeset, evidence_count=len(evidences)) - coverage = self._coverage(ctx=ctx, answer=answer, changeset=changeset) - status = self._status(faithfulness["score"], coverage["score"]) - - return { - "faithfulness": faithfulness, - "coverage": coverage, - "status": status, - "steps": { - "total": len(ctx.plan.steps), - "completed": len([item for item in step_results if item.status.value == "success"]), - }, - } - - def _faithfulness(self, *, answer: str, changeset, evidence_count: int) -> dict: - claims_total = self._estimate_claims(answer, changeset) - if claims_total <= 0: - claims_total = 1 - - support_capacity = min(claims_total, evidence_count * 3) - claims_supported = support_capacity if evidence_count > 0 else 0 - score = claims_supported / claims_total - unsupported = max(0, claims_total - claims_supported) - - return { - "score": round(score, 4), - "claims_total": claims_total, - "claims_supported": claims_supported, - "claims_unsupported": unsupported, - "evidence_items": evidence_count, - } - - def _coverage(self, *, ctx: ExecutionContext, answer: str, changeset) -> dict: - required = [section.name for section in ctx.task.output_contract.sections if section.required] - if not required: - required = ["final_output"] - - covered: list[str] = [] - for item in required: - if self._is_item_covered(item=item, ctx=ctx, answer=answer, changeset=changeset): - covered.append(item) - - missing = [item for item in required if item not in covered] - score = len(covered) / len(required) - - return { - "score": round(score, 4), - "required_items": required, - "covered_items": covered, - "missing_items": missing, - "required_count": len(required), - "covered_count": len(covered), - } - - def _status(self, faithfulness: float, coverage: float) -> str: - if faithfulness >= 0.75 and coverage >= 0.85: - return "ok" - if faithfulness >= 0.55 and coverage >= 0.6: - return "needs_review" - return "fail" - - def _estimate_claims(self, answer: str, changeset) -> int: - lines = [line.strip() for line in answer.splitlines() if line.strip()] - bullet_claims = len([line for line in lines if line.startswith("-") or line.startswith("*")]) - sentence_claims = len([part for part in re.split(r"[.!?]\s+", answer) if part.strip()]) - - changeset_claims = 0 - if isinstance(changeset, list): - for item in changeset: - if isinstance(item, dict): - reason = str(item.get("reason", "")).strip() - if reason: - changeset_claims += 1 - else: - reason = str(getattr(item, "reason", "")).strip() - if reason: - changeset_claims += 1 - - return max(bullet_claims, min(sentence_claims, 12), changeset_claims) - - def _is_item_covered(self, *, item: str, ctx: ExecutionContext, answer: str, changeset) -> bool: - name = (item or "").strip().lower() - if name == "final_output": - return bool(answer.strip()) or (isinstance(changeset, list) and len(changeset) > 0) - if name in {"changeset", "final_changeset"}: - return isinstance(changeset, list) and len(changeset) > 0 - if name in {"sequence_diagram", "mermaid"}: - sequence = str(ctx.artifacts.get_content("sequence_diagram", "") or "").strip() - return "```mermaid" in answer or bool(sequence) - if name == "use_cases": - if ctx.artifacts.has("use_cases"): - return True - low = answer.lower() - return "use case" in low or "сценар" in low - if name in {"summary", "findings", "recommendations", "gherkin_bundle", "review_report"}: - if ctx.artifacts.has(name): - return True - if name == "gherkin_bundle": - bundle = ctx.artifacts.get_content("gherkin_bundle", []) or [] - return isinstance(bundle, list) and len(bundle) > 0 - return name.replace("_", " ") in answer.lower() - return ctx.artifacts.has(name) diff --git a/src/app/modules/agent/engine/orchestrator/result_assembler.py b/src/app/modules/agent/engine/orchestrator/result_assembler.py deleted file mode 100644 index 57430bb..0000000 --- a/src/app/modules/agent/engine/orchestrator/result_assembler.py +++ /dev/null @@ -1,55 +0,0 @@ -from __future__ import annotations - -from app.modules.agent.engine.orchestrator.execution_context import ExecutionContext -from app.modules.agent.engine.orchestrator.models import OrchestratorResult, StepResult -from app.modules.agent.engine.orchestrator.quality_metrics import QualityMetricsCalculator -from app.schemas.changeset import ChangeItem - - -class ResultAssembler: - def __init__(self, quality: QualityMetricsCalculator | None = None) -> None: - self._quality = quality or QualityMetricsCalculator() - - def assemble(self, ctx: ExecutionContext, step_results: list[StepResult]) -> OrchestratorResult: - answer = str(ctx.artifacts.get_content("final_answer", "") or "").strip() or None - raw_changeset = ctx.artifacts.get_content("final_changeset", []) or [] - changeset = self._normalize_changeset(raw_changeset) - quality = self._quality.build(ctx, step_results) - - meta = { - "scenario": ctx.task.scenario.value, - "plan": { - "plan_id": ctx.plan.plan_id, - "template_id": ctx.plan.template_id, - "template_version": ctx.plan.template_version, - "status": ctx.plan.status.value, - }, - "route": { - "domain_id": ctx.task.routing.domain_id, - "process_id": ctx.task.routing.process_id, - "confidence": ctx.task.routing.confidence, - "reason": ctx.task.routing.reason, - "fallback_used": ctx.task.routing.fallback_used, - }, - "orchestrator": { - "steps_total": len(ctx.plan.steps), - "steps_success": len([step for step in step_results if step.status.value == "success"]), - }, - "quality": quality, - } - return OrchestratorResult(answer=answer, changeset=changeset, meta=meta, steps=step_results) - - def _normalize_changeset(self, value) -> list[ChangeItem]: - if not isinstance(value, list): - return [] - items: list[ChangeItem] = [] - for raw in value: - if isinstance(raw, ChangeItem): - items.append(raw) - continue - if isinstance(raw, dict): - try: - items.append(ChangeItem.model_validate(raw)) - except Exception: - continue - return items diff --git a/src/app/modules/agent/engine/orchestrator/service.py b/src/app/modules/agent/engine/orchestrator/service.py deleted file mode 100644 index 7b7ace2..0000000 --- a/src/app/modules/agent/engine/orchestrator/service.py +++ /dev/null @@ -1,102 +0,0 @@ -from __future__ import annotations - -import inspect -import logging - -from app.core.exceptions import AppError -from app.modules.agent.engine.orchestrator.execution_context import ExecutionContext, GraphInvoker, GraphResolver, ProgressCallback -from app.modules.agent.engine.orchestrator.execution_engine import ExecutionEngine -from app.modules.agent.engine.orchestrator.models import OrchestratorResult, PlanStatus, TaskSpec -from app.modules.agent.engine.orchestrator.plan_compiler import PlanCompiler -from app.modules.agent.engine.orchestrator.plan_validator import PlanValidator -from app.modules.agent.engine.orchestrator.quality_gates import QualityGateRunner -from app.modules.agent.engine.orchestrator.result_assembler import ResultAssembler -from app.modules.agent.engine.orchestrator.step_registry import StepRegistry -from app.modules.agent.engine.orchestrator.template_registry import ScenarioTemplateRegistry -from app.schemas.common import ModuleName - -LOGGER = logging.getLogger(__name__) - - -class OrchestratorService: - def __init__( - self, - templates: ScenarioTemplateRegistry | None = None, - compiler: PlanCompiler | None = None, - validator: PlanValidator | None = None, - step_registry: StepRegistry | None = None, - gates: QualityGateRunner | None = None, - engine: ExecutionEngine | None = None, - assembler: ResultAssembler | None = None, - ) -> None: - self._templates = templates or ScenarioTemplateRegistry() - self._compiler = compiler or PlanCompiler() - self._validator = validator or PlanValidator() - self._registry = step_registry or StepRegistry() - self._gates = gates or QualityGateRunner() - self._engine = engine or ExecutionEngine(self._registry, self._gates) - self._assembler = assembler or ResultAssembler() - - async def run( - self, - *, - task: TaskSpec, - graph_resolver: GraphResolver, - graph_invoker: GraphInvoker, - progress_cb: ProgressCallback | None = None, - ) -> OrchestratorResult: - await self._emit_progress(progress_cb, "orchestrator.plan", "Building execution plan.") - template = self._templates.build(task) - plan = self._compiler.compile(template, task) - - errors = self._validator.validate(plan, task) - if errors: - raise AppError( - code="invalid_execution_plan", - desc=f"Execution plan validation failed: {'; '.join(errors)}", - module=ModuleName.AGENT, - ) - - plan.status = PlanStatus.VALIDATED - ctx = ExecutionContext( - task=task, - plan=plan, - graph_resolver=graph_resolver, - graph_invoker=graph_invoker, - progress_cb=progress_cb, - ) - - await self._emit_progress(progress_cb, "orchestrator.run", "Executing plan steps.") - step_results = await self._engine.run(ctx) - if plan.status == PlanStatus.FAILED: - errors = [f"{step.step_id}:{step.error_message or ','.join(step.warnings)}" for step in step_results if step.status.value != "success"] - raise AppError( - code="execution_plan_failed", - desc=f"Execution plan failed: {'; '.join(errors)}", - module=ModuleName.AGENT, - ) - result = self._assembler.assemble(ctx, step_results) - await self._emit_progress(progress_cb, "orchestrator.done", "Execution plan completed.") - LOGGER.warning( - "orchestrator decision: task_id=%s scenario=%s plan_status=%s steps=%s changeset_items=%s answer_len=%s", - task.task_id, - task.scenario.value, - result.meta.get("plan", {}).get("status", ""), - [ - { - "step_id": step.step_id, - "status": step.status.value, - } - for step in result.steps - ], - len(result.changeset), - len(result.answer or ""), - ) - return result - - async def _emit_progress(self, progress_cb: ProgressCallback | None, stage: str, message: str) -> None: - if progress_cb is None: - return - result = progress_cb(stage, message, "task_progress", {"layer": "orchestrator"}) - if inspect.isawaitable(result): - await result diff --git a/src/app/modules/agent/engine/orchestrator/step_registry.py b/src/app/modules/agent/engine/orchestrator/step_registry.py deleted file mode 100644 index 01bbf71..0000000 --- a/src/app/modules/agent/engine/orchestrator/step_registry.py +++ /dev/null @@ -1,164 +0,0 @@ -from __future__ import annotations - -import asyncio -from collections.abc import Callable -from typing import TYPE_CHECKING - -from app.modules.agent.engine.graphs.progress_registry import progress_registry -from app.modules.agent.engine.orchestrator.actions import ( - CodeExplainActions, - DocsActions, - EditActions, - ExplainActions, - GherkinActions, - ProjectQaActions, - ReviewActions, -) -from app.modules.agent.engine.orchestrator.execution_context import ExecutionContext -from app.modules.agent.engine.orchestrator.models import ArtifactType, PlanStep - -if TYPE_CHECKING: - from app.modules.rag.explain.retriever_v2 import CodeExplainRetrieverV2 - -StepFn = Callable[[ExecutionContext], list[str]] - - -class StepRegistry: - def __init__(self, code_explain_retriever: CodeExplainRetrieverV2 | None = None) -> None: - code_explain = CodeExplainActions(code_explain_retriever) - explain = ExplainActions() - review = ReviewActions() - docs = DocsActions() - edits = EditActions() - gherkin = GherkinActions() - project_qa = ProjectQaActions() - - self._functions: dict[str, StepFn] = { - "collect_state": self._collect_state, - "finalize_graph_output": self._finalize_graph_output, - "execute_project_qa_graph": self._collect_state, - "build_code_explain_pack": code_explain.build_code_explain_pack, - "collect_sources": explain.collect_sources, - "extract_logic": explain.extract_logic, - "summarize": explain.summarize, - "classify_project_question": project_qa.classify_project_question, - "collect_project_sources": project_qa.collect_project_sources, - "analyze_project_sources": project_qa.analyze_project_sources, - "build_project_answer_brief": project_qa.build_project_answer_brief, - "compose_project_answer": project_qa.compose_project_answer, - "fetch_source_doc": review.fetch_source_doc, - "normalize_document": review.normalize_document, - "structural_check": review.structural_check, - "semantic_consistency_check": review.semantic_consistency_check, - "architecture_fit_check": review.architecture_fit_check, - "optimization_check": review.optimization_check, - "compose_review_report": review.compose_review_report, - "extract_change_intents": docs.extract_change_intents, - "map_to_doc_tree": docs.map_to_doc_tree, - "load_current_docs_context": docs.load_current_docs_context, - "generate_doc_updates": docs.generate_doc_updates, - "cross_file_validation": docs.cross_file_validation, - "build_changeset": docs.build_changeset, - "compose_summary": docs.compose_summary, - "resolve_target": edits.resolve_target, - "load_target_context": edits.load_target_context, - "plan_minimal_patch": edits.plan_minimal_patch, - "generate_patch": edits.generate_patch, - "validate_patch_safety": edits.validate_patch_safety, - "finalize_changeset": edits.finalize_changeset, - "compose_edit_summary": edits.compose_edit_summary, - "extract_increment_scope": gherkin.extract_increment_scope, - "partition_features": gherkin.partition_features, - "generate_gherkin_bundle": gherkin.generate_gherkin_bundle, - "lint_gherkin": gherkin.lint_gherkin, - "validate_coverage": gherkin.validate_coverage, - "compose_test_model_summary": gherkin.compose_test_model_summary, - } - - async def execute(self, step: PlanStep, ctx: ExecutionContext) -> list[str]: - if step.executor == "graph": - return await self._execute_graph_step(step, ctx) - fn = self._functions.get(step.action_id) - if fn is None: - raise RuntimeError(f"Unknown function action_id: {step.action_id}") - return fn(ctx) - - def _collect_state(self, ctx: ExecutionContext) -> list[str]: - state = { - "task_id": ctx.task.task_id, - "project_id": ctx.task.rag_session_id, - "scenario": ctx.task.scenario.value, - "message": ctx.task.user_message, - "progress_key": ctx.task.task_id, - "rag_context": str(ctx.task.metadata.get("rag_context", "")), - "confluence_context": str(ctx.task.metadata.get("confluence_context", "")), - "files_map": dict(ctx.task.metadata.get("files_map", {}) or {}), - } - item = ctx.artifacts.put(key="agent_state", artifact_type=ArtifactType.STRUCTURED_JSON, content=state) - return [item.artifact_id] - - async def _execute_graph_step(self, step: PlanStep, ctx: ExecutionContext) -> list[str]: - graph_key = step.graph_id or "route" - if graph_key == "route": - domain_id = ctx.task.routing.domain_id - process_id = ctx.task.routing.process_id - elif "/" in graph_key: - domain_id, process_id = graph_key.split("/", 1) - else: - raise RuntimeError(f"Unsupported graph_id: {graph_key}") - - graph = ctx.graph_resolver(domain_id, process_id) - state = self._build_graph_state(ctx) - - if ctx.progress_cb is not None: - progress_registry.register(ctx.task.task_id, ctx.progress_cb) - try: - result = await asyncio.to_thread(ctx.graph_invoker, graph, state, ctx.task.dialog_session_id) - finally: - if ctx.progress_cb is not None: - progress_registry.unregister(ctx.task.task_id) - - return self._store_graph_outputs(step, ctx, result) - - def _build_graph_state(self, ctx: ExecutionContext) -> dict: - state = dict(ctx.artifacts.get_content("agent_state", {}) or {}) - for item in ctx.artifacts.all_items(): - state[item.key] = ctx.artifacts.get_content(item.key) - return state - - def _store_graph_outputs(self, step: PlanStep, ctx: ExecutionContext, result: dict) -> list[str]: - if not isinstance(result, dict): - raise RuntimeError("graph_result must be an object") - if len(step.outputs) == 1 and step.outputs[0].key == "graph_result": - item = ctx.artifacts.put(key="graph_result", artifact_type=ArtifactType.STRUCTURED_JSON, content=result) - return [item.artifact_id] - - artifact_ids: list[str] = [] - for output in step.outputs: - value = result.get(output.key) - if value is None and output.required: - raise RuntimeError(f"graph_output_missing:{step.step_id}:{output.key}") - item = ctx.artifacts.put(key=output.key, artifact_type=output.type, content=value) - artifact_ids.append(item.artifact_id) - return artifact_ids - - def _finalize_graph_output(self, ctx: ExecutionContext) -> list[str]: - raw = ctx.artifacts.get_content("graph_result", {}) or {} - if not isinstance(raw, dict): - raise RuntimeError("graph_result must be an object") - - answer = raw.get("answer") - changeset = raw.get("changeset") or [] - output = [ - ctx.artifacts.put( - key="final_answer", - artifact_type=ArtifactType.TEXT, - content=(str(answer) if answer is not None else ""), - ).artifact_id, - ctx.artifacts.put( - key="final_changeset", - artifact_type=ArtifactType.CHANGESET, - content=changeset, - ).artifact_id, - ] - return output diff --git a/src/app/modules/agent/engine/orchestrator/task_spec_builder.py b/src/app/modules/agent/engine/orchestrator/task_spec_builder.py deleted file mode 100644 index 858bea7..0000000 --- a/src/app/modules/agent/engine/orchestrator/task_spec_builder.py +++ /dev/null @@ -1,145 +0,0 @@ -from __future__ import annotations - -from app.modules.agent.engine.orchestrator.models import ( - AttachmentRef, - FileRef, - OutputContract, - OutputSection, - RoutingMeta, - Scenario, - TaskConstraints, - TaskSpec, -) - - -class TaskSpecBuilder: - def build( - self, - *, - task_id: str, - dialog_session_id: str, - rag_session_id: str, - mode: str, - message: str, - route: RoutingMeta, - attachments: list[dict], - files: list[dict], - rag_items: list[dict], - rag_context: str, - confluence_context: str, - files_map: dict[str, dict], - ) -> TaskSpec: - scenario = self._detect_scenario(mode=mode, message=message, route=route) - output_contract = self._output_contract(scenario) - constraints = self._constraints_for(scenario) - metadata = { - "rag_items": rag_items, - "rag_context": rag_context, - "confluence_context": confluence_context, - "files_map": files_map, - } - return TaskSpec( - task_id=task_id, - dialog_session_id=dialog_session_id, - rag_session_id=rag_session_id, - mode=mode, - user_message=message, - scenario=scenario, - routing=route, - attachments=self._map_attachments(attachments), - files=self._map_files(files), - constraints=constraints, - output_contract=output_contract, - metadata=metadata, - ) - - def _detect_scenario(self, *, mode: str, message: str, route: RoutingMeta) -> Scenario: - mode_key = (mode or "").strip().lower() - text = (message or "").strip().lower() - - if mode_key == "analytics_review": - return Scenario.ANALYTICS_REVIEW - if "gherkin" in text or "cucumber" in text: - return Scenario.GHERKIN_MODEL - if any(token in text for token in ("review analytics", "ревью аналитики", "проведи ревью")): - return Scenario.ANALYTICS_REVIEW - if any(token in text for token in ("сформируй документацию", "документацию из аналитики", "generate docs")): - return Scenario.DOCS_FROM_ANALYTICS - if any(token in text for token in ("точечн", "измени файл", "targeted edit", "patch file")): - return Scenario.TARGETED_EDIT - if route.domain_id == "project" and route.process_id == "edits": - return Scenario.TARGETED_EDIT - if route.domain_id == "docs" and route.process_id == "generation": - return Scenario.DOCS_FROM_ANALYTICS - if route.domain_id == "project" and route.process_id == "qa" and self._looks_like_explain_request(text): - return Scenario.EXPLAIN_PART - if route.domain_id == "project" and route.process_id == "qa" and "review" in text: - return Scenario.ANALYTICS_REVIEW - return Scenario.GENERAL_QA - - def _looks_like_explain_request(self, text: str) -> bool: - markers = ( - "explain", - "how it works", - "sequence", - "diagram", - "obiasni", - "kak rabotaet", - "kak ustroeno", - "объясни", - "как работает", - "как устроен", - "диаграм", - ) - return any(marker in text for marker in markers) - - def _map_attachments(self, attachments: list[dict]) -> list[AttachmentRef]: - mapped: list[AttachmentRef] = [] - for item in attachments: - value = str(item.get("url") or item.get("value") or "").strip() - if not value: - continue - raw_type = str(item.get("type") or "http_url").strip().lower() - attachment_type = raw_type if raw_type in {"confluence_url", "http_url", "file_ref"} else "http_url" - mapped.append(AttachmentRef(type=attachment_type, value=value)) - return mapped - - def _map_files(self, files: list[dict]) -> list[FileRef]: - mapped: list[FileRef] = [] - for item in files: - path = str(item.get("path") or "").replace("\\", "/").strip() - if not path: - continue - mapped.append( - FileRef( - path=path, - content=str(item.get("content") or ""), - content_hash=str(item.get("content_hash") or ""), - ) - ) - return mapped - - def _constraints_for(self, scenario: Scenario) -> TaskConstraints: - if scenario in {Scenario.DOCS_FROM_ANALYTICS, Scenario.TARGETED_EDIT, Scenario.GHERKIN_MODEL}: - return TaskConstraints(allow_writes=True, max_steps=16, max_retries_per_step=2, step_timeout_sec=120) - return TaskConstraints(allow_writes=False, max_steps=12, max_retries_per_step=2, step_timeout_sec=90) - - def _output_contract(self, scenario: Scenario) -> OutputContract: - if scenario == Scenario.EXPLAIN_PART: - return OutputContract(result_type="answer", sections=[OutputSection(name="summary", format="markdown")]) - if scenario == Scenario.ANALYTICS_REVIEW: - return OutputContract( - result_type="review_report", - sections=[ - OutputSection(name="findings", format="markdown"), - OutputSection(name="recommendations", format="markdown"), - ], - ) - if scenario in {Scenario.DOCS_FROM_ANALYTICS, Scenario.TARGETED_EDIT}: - return OutputContract(result_type="changeset", sections=[OutputSection(name="changeset", format="changeset")]) - if scenario == Scenario.GHERKIN_MODEL: - return OutputContract( - result_type="gherkin_bundle", - sections=[OutputSection(name="gherkin_bundle", format="gherkin")], - ) - return OutputContract(result_type="answer", sections=[OutputSection(name="summary", format="markdown")]) diff --git a/src/app/modules/agent/engine/orchestrator/template_registry.py b/src/app/modules/agent/engine/orchestrator/template_registry.py deleted file mode 100644 index 251ff46..0000000 --- a/src/app/modules/agent/engine/orchestrator/template_registry.py +++ /dev/null @@ -1,171 +0,0 @@ -from __future__ import annotations - -from app.modules.agent.engine.orchestrator.models import ArtifactSpec, ArtifactType, ExecutionPlan, PlanStep, QualityGateRef, Scenario, TaskSpec - - -class ScenarioTemplateRegistry: - def build(self, task: TaskSpec) -> ExecutionPlan: - builders = { - Scenario.EXPLAIN_PART: self._explain, - Scenario.ANALYTICS_REVIEW: self._review, - Scenario.DOCS_FROM_ANALYTICS: self._docs, - Scenario.TARGETED_EDIT: self._edit, - Scenario.GHERKIN_MODEL: self._gherkin, - Scenario.GENERAL_QA: self._general, - } - return builders.get(task.scenario, self._general)(task) - - def _general(self, task: TaskSpec) -> ExecutionPlan: - if task.routing.domain_id == "project" and task.routing.process_id == "qa": - return self._project_qa(task) - steps = [ - self._step("collect_state", "Collect state", "collect_state", outputs=[self._out("agent_state", ArtifactType.STRUCTURED_JSON)]), - self._step( - "execute_route_graph", - "Execute selected graph", - "execute_route_graph", - executor="graph", - graph_id="route", - depends_on=["collect_state"], - outputs=[self._out("graph_result", ArtifactType.STRUCTURED_JSON)], - gates=[self._gate("required_outputs")], - ), - self._step( - "finalize_graph_output", - "Finalize graph output", - "finalize_graph_output", - depends_on=["execute_route_graph"], - outputs=[self._out("final_answer", ArtifactType.TEXT, required=False)], - gates=[self._gate("non_empty_answer_or_changeset")], - ), - ] - return self._plan(task, "general_qa_v1", steps, [self._gate("non_empty_answer_or_changeset")]) - - def _project_qa(self, task: TaskSpec) -> ExecutionPlan: - steps = [ - self._step("collect_state", "Collect state", "collect_state", outputs=[self._out("agent_state", ArtifactType.STRUCTURED_JSON)]), - self._step( - "execute_code_qa_runtime", - "Execute CODE_QA runtime", - "execute_code_qa_runtime", - executor="graph", - graph_id="project_qa/code_qa_runtime", - depends_on=["collect_state"], - outputs=[ - self._out("code_qa_result", ArtifactType.STRUCTURED_JSON), - self._out("final_answer", ArtifactType.TEXT), - ], - gates=[self._gate("non_empty_answer_or_changeset")], - ), - ] - return self._plan(task, "project_qa_reasoning_v1", steps, [self._gate("non_empty_answer_or_changeset")]) - - def _explain(self, task: TaskSpec) -> ExecutionPlan: - if task.routing.domain_id == "project" and task.routing.process_id == "qa": - return self._project_qa(task) - steps = [ - self._step("collect_sources", "Collect sources", "collect_sources", outputs=[self._out("sources", ArtifactType.STRUCTURED_JSON)]), - self._step("extract_logic", "Extract logic", "extract_logic", depends_on=["collect_sources"], outputs=[self._out("logic_model", ArtifactType.STRUCTURED_JSON)]), - self._step("summarize", "Summarize", "summarize", depends_on=["extract_logic"], outputs=[self._out("final_answer", ArtifactType.TEXT)]), - ] - return self._plan(task, "explain_part_v1", steps, [self._gate("evidence_required"), self._gate("non_empty_answer_or_changeset")]) - - def _review(self, task: TaskSpec) -> ExecutionPlan: - steps = [ - self._step("fetch_source_doc", "Fetch source doc", "fetch_source_doc", outputs=[self._out("source_doc_raw", ArtifactType.TEXT)], side_effect="external"), - self._step("normalize_document", "Normalize document", "normalize_document", depends_on=["fetch_source_doc"], outputs=[self._out("source_doc_text", ArtifactType.TEXT)]), - self._step("structural_check", "Structural check", "structural_check", depends_on=["normalize_document"], outputs=[self._out("structural_findings", ArtifactType.STRUCTURED_JSON)]), - self._step("semantic_consistency_check", "Semantic check", "semantic_consistency_check", depends_on=["normalize_document"], outputs=[self._out("semantic_findings", ArtifactType.STRUCTURED_JSON)]), - self._step("architecture_fit_check", "Architecture fit", "architecture_fit_check", depends_on=["normalize_document"], outputs=[self._out("architecture_findings", ArtifactType.STRUCTURED_JSON)]), - self._step("optimization_check", "Optimization check", "optimization_check", depends_on=["normalize_document"], outputs=[self._out("optimization_findings", ArtifactType.STRUCTURED_JSON)]), - self._step( - "compose_review_report", - "Compose review report", - "compose_review_report", - depends_on=["structural_check", "semantic_consistency_check", "architecture_fit_check", "optimization_check"], - outputs=[self._out("review_report", ArtifactType.REVIEW_REPORT), self._out("final_answer", ArtifactType.TEXT)], - gates=[self._gate("review_report_schema")], - ), - ] - return self._plan(task, "analytics_review_v1", steps, [self._gate("evidence_required"), self._gate("non_empty_answer_or_changeset")]) - - def _docs(self, task: TaskSpec) -> ExecutionPlan: - steps = [ - self._step("fetch_source_doc", "Fetch source doc", "fetch_source_doc", outputs=[self._out("source_doc_raw", ArtifactType.TEXT)], side_effect="external"), - self._step("normalize_document", "Normalize document", "normalize_document", depends_on=["fetch_source_doc"], outputs=[self._out("source_doc_text", ArtifactType.TEXT)]), - self._step("extract_change_intents", "Extract intents", "extract_change_intents", depends_on=["normalize_document"], outputs=[self._out("change_intents", ArtifactType.STRUCTURED_JSON)]), - self._step("map_to_doc_tree", "Map to doc tree", "map_to_doc_tree", depends_on=["extract_change_intents"], outputs=[self._out("doc_targets", ArtifactType.STRUCTURED_JSON)]), - self._step("load_current_docs_context", "Load current docs", "load_current_docs_context", depends_on=["map_to_doc_tree"], outputs=[self._out("current_docs_context", ArtifactType.STRUCTURED_JSON)]), - self._step("generate_doc_updates", "Generate doc updates", "generate_doc_updates", depends_on=["load_current_docs_context"], outputs=[self._out("generated_doc_bundle", ArtifactType.DOC_BUNDLE)], side_effect="write"), - self._step("cross_file_validation", "Cross-file validation", "cross_file_validation", depends_on=["generate_doc_updates"], outputs=[self._out("consistency_report", ArtifactType.STRUCTURED_JSON)], gates=[self._gate("cross_file_consistency")]), - self._step("build_changeset", "Build changeset", "build_changeset", depends_on=["cross_file_validation"], outputs=[self._out("final_changeset", ArtifactType.CHANGESET)], side_effect="write"), - self._step("compose_summary", "Compose summary", "compose_summary", depends_on=["build_changeset"], outputs=[self._out("final_answer", ArtifactType.TEXT)]), - ] - return self._plan(task, "docs_from_analytics_v1", steps, [self._gate("changeset_required_for_write"), self._gate("changeset_schema")]) - - def _edit(self, task: TaskSpec) -> ExecutionPlan: - steps = [ - self._step("resolve_target", "Resolve target", "resolve_target", outputs=[self._out("resolved_target", ArtifactType.STRUCTURED_JSON)], gates=[self._gate("target_path_must_exist_or_be_allowed")]), - self._step("load_target_context", "Load target context", "load_target_context", depends_on=["resolve_target"], outputs=[self._out("target_context", ArtifactType.STRUCTURED_JSON)]), - self._step("plan_minimal_patch", "Plan minimal patch", "plan_minimal_patch", depends_on=["load_target_context"], outputs=[self._out("patch_plan", ArtifactType.STRUCTURED_JSON)]), - self._step("generate_patch", "Generate patch", "generate_patch", depends_on=["plan_minimal_patch"], outputs=[self._out("raw_changeset", ArtifactType.CHANGESET)], side_effect="write"), - self._step("validate_patch_safety", "Validate patch", "validate_patch_safety", depends_on=["generate_patch"], outputs=[self._out("patch_validation_report", ArtifactType.STRUCTURED_JSON)], gates=[self._gate("minimal_patch_policy")]), - self._step("finalize_changeset", "Finalize changeset", "finalize_changeset", depends_on=["validate_patch_safety"], outputs=[self._out("final_changeset", ArtifactType.CHANGESET)], side_effect="write"), - self._step("compose_edit_summary", "Compose summary", "compose_edit_summary", depends_on=["finalize_changeset"], outputs=[self._out("final_answer", ArtifactType.TEXT)]), - ] - return self._plan(task, "targeted_edit_v1", steps, [self._gate("changeset_required_for_write"), self._gate("changeset_schema")]) - - def _gherkin(self, task: TaskSpec) -> ExecutionPlan: - steps = [ - self._step("fetch_source_doc", "Fetch source doc", "fetch_source_doc", outputs=[self._out("source_doc_raw", ArtifactType.TEXT)], side_effect="external"), - self._step("normalize_document", "Normalize document", "normalize_document", depends_on=["fetch_source_doc"], outputs=[self._out("source_doc_text", ArtifactType.TEXT)]), - self._step("extract_increment_scope", "Extract increment scope", "extract_increment_scope", depends_on=["normalize_document"], outputs=[self._out("increment_scope", ArtifactType.STRUCTURED_JSON)]), - self._step("partition_features", "Partition features", "partition_features", depends_on=["extract_increment_scope"], outputs=[self._out("feature_groups", ArtifactType.STRUCTURED_JSON)]), - self._step("generate_gherkin_bundle", "Generate gherkin", "generate_gherkin_bundle", depends_on=["partition_features"], outputs=[self._out("gherkin_bundle", ArtifactType.GHERKIN_BUNDLE)], side_effect="write"), - self._step("lint_gherkin", "Lint gherkin", "lint_gherkin", depends_on=["generate_gherkin_bundle"], outputs=[self._out("gherkin_lint_report", ArtifactType.STRUCTURED_JSON)], gates=[self._gate("gherkin_syntax_lint")]), - self._step("validate_coverage", "Validate coverage", "validate_coverage", depends_on=["generate_gherkin_bundle"], outputs=[self._out("coverage_report", ArtifactType.STRUCTURED_JSON)], gates=[self._gate("coverage_of_change_intents")]), - self._step("compose_test_model_summary", "Compose summary", "compose_test_model_summary", depends_on=["lint_gherkin", "validate_coverage"], outputs=[self._out("final_answer", ArtifactType.TEXT), self._out("final_changeset", ArtifactType.CHANGESET)], side_effect="write"), - ] - return self._plan(task, "gherkin_model_v1", steps, [self._gate("changeset_schema"), self._gate("non_empty_answer_or_changeset")]) - - def _plan(self, task: TaskSpec, template_id: str, steps: list[PlanStep], gates: list[QualityGateRef]) -> ExecutionPlan: - return ExecutionPlan( - plan_id=f"{task.task_id}:{template_id}", - task_id=task.task_id, - scenario=task.scenario, - template_id=template_id, - template_version="1.0", - steps=steps, - global_gates=gates, - ) - - def _step( - self, - step_id: str, - title: str, - action_id: str, - *, - executor: str = "function", - graph_id: str | None = None, - depends_on: list[str] | None = None, - outputs: list[ArtifactSpec] | None = None, - gates: list[QualityGateRef] | None = None, - side_effect: str = "read", - ) -> PlanStep: - return PlanStep( - step_id=step_id, - title=title, - action_id=action_id, - executor=executor, - graph_id=graph_id, - depends_on=depends_on or [], - outputs=outputs or [], - quality_gates=gates or [], - side_effect=side_effect, - ) - - def _out(self, key: str, artifact_type: ArtifactType, *, required: bool = True) -> ArtifactSpec: - return ArtifactSpec(key=key, type=artifact_type, required=required) - - def _gate(self, gate_id: str, *, blocking: bool = True) -> QualityGateRef: - return QualityGateRef(gate_id=gate_id, blocking=blocking) diff --git a/src/app/modules/agent/engine/router/__init__.py b/src/app/modules/agent/engine/router/__init__.py deleted file mode 100644 index 141d95a..0000000 --- a/src/app/modules/agent/engine/router/__init__.py +++ /dev/null @@ -1,55 +0,0 @@ -from pathlib import Path -from typing import TYPE_CHECKING - -from app.modules.agent.llm import AgentLlmService -from app.modules.contracts import RagRetriever - -if TYPE_CHECKING: - from app.modules.agent.repository import AgentRepository - from app.modules.agent.engine.router.router_service import RouterService - - -def build_router_service(llm: AgentLlmService, agent_repository: "AgentRepository", rag: RagRetriever) -> "RouterService": - from app.modules.agent.engine.graphs import ( - BaseGraphFactory, - CodeQaGraphFactory, - DocsGraphFactory, - ProjectEditsGraphFactory, - ProjectQaAnalysisGraphFactory, - ProjectQaAnswerGraphFactory, - ProjectQaClassificationGraphFactory, - ProjectQaConversationGraphFactory, - ProjectQaGraphFactory, - ProjectQaRetrievalGraphFactory, - ) - from app.modules.agent.engine.router.context_store import RouterContextStore - from app.modules.agent.engine.router.intent_classifier import IntentClassifier - from app.modules.agent.engine.router.intent_switch_detector import IntentSwitchDetector - from app.modules.agent.engine.router.registry import IntentRegistry - from app.modules.agent.engine.router.router_service import RouterService - - registry_path = Path(__file__).resolve().parent / "intents_registry.yaml" - registry = IntentRegistry(registry_path=registry_path) - registry.register("default", "general", BaseGraphFactory(llm).build) - registry.register("project", "qa", ProjectQaGraphFactory(llm).build) - registry.register("project_qa", "code_qa_runtime", CodeQaGraphFactory(llm).build) - registry.register("project", "edits", ProjectEditsGraphFactory(llm).build) - registry.register("docs", "generation", DocsGraphFactory(llm).build) - registry.register("project_qa", "conversation_understanding", ProjectQaConversationGraphFactory(llm).build) - registry.register("project_qa", "question_classification", ProjectQaClassificationGraphFactory(llm).build) - registry.register("project_qa", "context_retrieval", ProjectQaRetrievalGraphFactory(rag, llm).build) - registry.register("project_qa", "context_analysis", ProjectQaAnalysisGraphFactory(llm).build) - registry.register("project_qa", "answer_composition", ProjectQaAnswerGraphFactory(llm).build) - - classifier = IntentClassifier(llm) - switch_detector = IntentSwitchDetector() - context_store = RouterContextStore(agent_repository) - return RouterService( - registry=registry, - classifier=classifier, - context_store=context_store, - switch_detector=switch_detector, - ) - - -__all__ = ["build_router_service"] diff --git a/src/app/modules/agent/engine/router/context_store.py b/src/app/modules/agent/engine/router/context_store.py deleted file mode 100644 index a514fc4..0000000 --- a/src/app/modules/agent/engine/router/context_store.py +++ /dev/null @@ -1,31 +0,0 @@ -from app.modules.agent.repository import AgentRepository -from app.modules.agent.engine.router.schemas import RouterContext - - -class RouterContextStore: - def __init__(self, repository: AgentRepository) -> None: - self._repo = repository - - def get(self, conversation_key: str) -> RouterContext: - return self._repo.get_router_context(conversation_key) - - def update( - self, - conversation_key: str, - *, - domain_id: str, - process_id: str, - user_message: str, - assistant_message: str, - decision_type: str = "start", - max_history: int = 10, - ) -> None: - self._repo.update_router_context( - conversation_key, - domain_id=domain_id, - process_id=process_id, - user_message=user_message, - assistant_message=assistant_message, - decision_type=decision_type, - max_history=max_history, - ) diff --git a/src/app/modules/agent/engine/router/intent_classifier.py b/src/app/modules/agent/engine/router/intent_classifier.py deleted file mode 100644 index 4e73c9b..0000000 --- a/src/app/modules/agent/engine/router/intent_classifier.py +++ /dev/null @@ -1,196 +0,0 @@ -import json -import re - -from app.modules.agent.engine.router.schemas import RouteDecision, RouterContext -from app.modules.agent.llm import AgentLlmService - - -class IntentClassifier: - _short_confirmations = {"да", "ок", "делай", "поехали", "запускай"} - _route_mapping = { - "default/general": ("default", "general"), - "project/qa": ("project", "qa"), - "project/edits": ("project", "edits"), - "docs/generation": ("docs", "generation"), - } - - def __init__(self, llm: AgentLlmService) -> None: - self._llm = llm - - def classify_new_intent(self, user_message: str, context: RouterContext) -> RouteDecision: - text = (user_message or "").strip().lower() - if text in self._short_confirmations and context.last_routing: - return RouteDecision( - domain_id=context.last_routing["domain_id"], - process_id=context.last_routing["process_id"], - confidence=1.0, - reason="short_confirmation", - use_previous=True, - decision_type="continue", - ) - - deterministic = self._deterministic_route(text) - if deterministic: - return deterministic - - llm_decision = self._classify_with_llm(user_message, context) - if llm_decision: - return llm_decision - - return RouteDecision( - domain_id="default", - process_id="general", - confidence=0.8, - reason="default", - decision_type="start", - ) - - def from_mode(self, mode: str) -> RouteDecision | None: - mapping = { - "project_qa": ("project", "qa"), - "project_edits": ("project", "edits"), - "docs_generation": ("docs", "generation"), - # Legacy aliases kept for API compatibility. - "analytics_review": ("project", "qa"), - "code_change": ("project", "edits"), - "qa": ("default", "general"), - } - route = mapping.get((mode or "auto").strip().lower()) - if not route: - return None - return RouteDecision( - domain_id=route[0], - process_id=route[1], - confidence=1.0, - reason=f"mode_override:{mode}", - decision_type="switch", - explicit_switch=True, - ) - - def _classify_with_llm(self, user_message: str, context: RouterContext) -> RouteDecision | None: - history = context.message_history[-8:] - user_input = json.dumps( - { - "message": user_message, - "history": history, - "allowed_routes": list(self._route_mapping.keys()), - }, - ensure_ascii=False, - ) - try: - raw = self._llm.generate("router_intent", user_input).strip() - except Exception: - return None - - payload = self._parse_llm_payload(raw) - if not payload: - return None - - route = self._route_mapping.get(payload["route"]) - if not route: - return None - - confidence = self._normalize_confidence(payload.get("confidence")) - return RouteDecision( - domain_id=route[0], - process_id=route[1], - confidence=confidence, - reason=f"llm_router:{payload.get('reason', 'ok')}", - decision_type="start", - ) - - def _parse_llm_payload(self, raw: str) -> dict[str, str | float] | None: - candidate = self._strip_code_fence(raw.strip()) - if not candidate: - return None - try: - parsed = json.loads(candidate) - except json.JSONDecodeError: - return None - if not isinstance(parsed, dict): - return None - route = str(parsed.get("route", "")).strip().lower() - if not route: - return None - return { - "route": route, - "confidence": parsed.get("confidence"), - "reason": str(parsed.get("reason", "ok")).strip().lower(), - } - - def _normalize_confidence(self, value: object) -> float: - if isinstance(value, (float, int)): - return max(0.0, min(1.0, float(value))) - return 0.75 - - def _strip_code_fence(self, text: str) -> str: - if not text.startswith("```"): - return text - lines = text.splitlines() - if len(lines) < 3: - return text - if lines[-1].strip() != "```": - return text - return "\n".join(lines[1:-1]).strip() - - def _deterministic_route(self, text: str) -> RouteDecision | None: - if self._is_targeted_file_edit_request(text): - return RouteDecision( - domain_id="project", - process_id="edits", - confidence=0.97, - reason="deterministic_targeted_file_edit", - decision_type="switch", - explicit_switch=True, - ) - if self._is_broad_docs_request(text): - return RouteDecision( - domain_id="docs", - process_id="generation", - confidence=0.95, - reason="deterministic_docs_generation", - decision_type="switch", - explicit_switch=True, - ) - return None - - def _is_targeted_file_edit_request(self, text: str) -> bool: - if not text: - return False - edit_markers = ( - "добавь", - "добавить", - "измени", - "исправь", - "обнови", - "удали", - "замени", - "вставь", - "в конец", - "в начале", - "append", - "update", - "edit", - "remove", - "replace", - ) - has_edit_marker = any(marker in text for marker in edit_markers) - has_file_marker = ( - "readme" in text - or bool(re.search(r"\b[\w.\-/]+\.(md|txt|rst|yaml|yml|json|toml|ini|cfg)\b", text)) - ) - return has_edit_marker and has_file_marker - - def _is_broad_docs_request(self, text: str) -> bool: - if not text: - return False - docs_markers = ( - "подготовь документац", - "сгенерируй документац", - "создай документац", - "опиши документац", - "generate documentation", - "write documentation", - "docs/", - ) - return any(marker in text for marker in docs_markers) diff --git a/src/app/modules/agent/engine/router/intent_switch_detector.py b/src/app/modules/agent/engine/router/intent_switch_detector.py deleted file mode 100644 index 151b57c..0000000 --- a/src/app/modules/agent/engine/router/intent_switch_detector.py +++ /dev/null @@ -1,81 +0,0 @@ -from __future__ import annotations - -import re - -from app.modules.agent.engine.router.schemas import RouterContext - - -class IntentSwitchDetector: - _EXPLICIT_SWITCH_MARKERS = ( - "теперь", - "а теперь", - "давай теперь", - "переключись", - "переключаемся", - "сейчас другое", - "новая задача", - "new task", - "switch to", - "now do", - "instead", - ) - _FOLLOW_UP_MARKERS = ( - "а еще", - "а ещё", - "подробнее", - "почему", - "зачем", - "что если", - "и еще", - "и ещё", - "покажи подробнее", - "можешь подробнее", - ) - - def should_switch(self, user_message: str, context: RouterContext) -> bool: - if not context.dialog_started or context.active_intent is None: - return False - text = " ".join((user_message or "").strip().lower().split()) - if not text: - return False - if self._is_follow_up(text): - return False - if any(marker in text for marker in self._EXPLICIT_SWITCH_MARKERS): - return True - return self._is_strong_targeted_edit_request(text) or self._is_strong_docs_request(text) - - def _is_follow_up(self, text: str) -> bool: - return any(marker in text for marker in self._FOLLOW_UP_MARKERS) - - def _is_strong_targeted_edit_request(self, text: str) -> bool: - edit_markers = ( - "добавь", - "добавить", - "измени", - "исправь", - "обнови", - "удали", - "замени", - "append", - "update", - "edit", - "remove", - "replace", - ) - has_edit_marker = any(marker in text for marker in edit_markers) - has_file_marker = ( - "readme" in text - or bool(re.search(r"\b[\w.\-/]+\.(md|txt|rst|yaml|yml|json|toml|ini|cfg|py)\b", text)) - ) - return has_edit_marker and has_file_marker - - def _is_strong_docs_request(self, text: str) -> bool: - docs_markers = ( - "подготовь документац", - "сгенерируй документац", - "создай документац", - "опиши документац", - "generate documentation", - "write documentation", - ) - return any(marker in text for marker in docs_markers) diff --git a/src/app/modules/agent/engine/router/intents_registry.yaml b/src/app/modules/agent/engine/router/intents_registry.yaml deleted file mode 100644 index e41c5f3..0000000 --- a/src/app/modules/agent/engine/router/intents_registry.yaml +++ /dev/null @@ -1,17 +0,0 @@ -intents: - - domain_id: "default" - process_id: "general" - description: "General Q&A" - priority: 1 - - domain_id: "project" - process_id: "qa" - description: "Project-specific Q&A with RAG and confluence context" - priority: 2 - - domain_id: "project" - process_id: "edits" - description: "Project file edits from user request with conservative changeset generation" - priority: 3 - - domain_id: "docs" - process_id: "generation" - description: "Documentation generation as changeset" - priority: 2 diff --git a/src/app/modules/agent/engine/router/registry.py b/src/app/modules/agent/engine/router/registry.py deleted file mode 100644 index 9af1818..0000000 --- a/src/app/modules/agent/engine/router/registry.py +++ /dev/null @@ -1,46 +0,0 @@ -from collections.abc import Callable -from pathlib import Path -from typing import Any - -import yaml - - -class IntentRegistry: - def __init__(self, registry_path: Path) -> None: - self._registry_path = registry_path - self._factories: dict[tuple[str, str], Callable[..., Any]] = {} - - def register(self, domain_id: str, process_id: str, factory: Callable[..., Any]) -> None: - self._factories[(domain_id, process_id)] = factory - - def get_factory(self, domain_id: str, process_id: str) -> Callable[..., Any] | None: - return self._factories.get((domain_id, process_id)) - - def is_valid(self, domain_id: str, process_id: str) -> bool: - return self.get_factory(domain_id, process_id) is not None - - def load_intents(self) -> list[dict[str, Any]]: - if not self._registry_path.is_file(): - return [] - with self._registry_path.open("r", encoding="utf-8") as fh: - payload = yaml.safe_load(fh) or {} - intents = payload.get("intents") - if not isinstance(intents, list): - return [] - output: list[dict[str, Any]] = [] - for item in intents: - if not isinstance(item, dict): - continue - domain_id = item.get("domain_id") - process_id = item.get("process_id") - if not isinstance(domain_id, str) or not isinstance(process_id, str): - continue - output.append( - { - "domain_id": domain_id, - "process_id": process_id, - "description": str(item.get("description") or ""), - "priority": int(item.get("priority") or 0), - } - ) - return output diff --git a/src/app/modules/agent/engine/router/router_service.py b/src/app/modules/agent/engine/router/router_service.py deleted file mode 100644 index 2ff7c7e..0000000 --- a/src/app/modules/agent/engine/router/router_service.py +++ /dev/null @@ -1,114 +0,0 @@ -from app.modules.agent.engine.router.context_store import RouterContextStore -from app.modules.agent.engine.router.intent_classifier import IntentClassifier -from app.modules.agent.engine.router.intent_switch_detector import IntentSwitchDetector -from app.modules.agent.engine.router.registry import IntentRegistry -from app.modules.agent.engine.router.schemas import RouteDecision, RouteResolution - - -class RouterService: - def __init__( - self, - registry: IntentRegistry, - classifier: IntentClassifier, - context_store: RouterContextStore, - switch_detector: IntentSwitchDetector | None = None, - min_confidence: float = 0.7, - ) -> None: - self._registry = registry - self._classifier = classifier - self._ctx = context_store - self._switch_detector = switch_detector or IntentSwitchDetector() - self._min_confidence = min_confidence - - def resolve(self, user_message: str, conversation_key: str, mode: str = "auto") -> RouteResolution: - context = self._ctx.get(conversation_key) - forced = self._classifier.from_mode(mode) - if forced: - return self._resolution(forced) - - if not context.dialog_started or context.active_intent is None: - decision = self._classifier.classify_new_intent(user_message, context) - if not self._is_acceptable(decision): - return self._fallback("low_confidence") - return self._resolution( - decision.model_copy( - update={ - "decision_type": "start", - "explicit_switch": False, - } - ) - ) - - if self._switch_detector.should_switch(user_message, context): - decision = self._classifier.classify_new_intent(user_message, context) - if self._is_acceptable(decision): - return self._resolution( - decision.model_copy( - update={ - "decision_type": "switch", - "explicit_switch": True, - } - ) - ) - return self._continue_current(context, "explicit_switch_unresolved_keep_current") - - return self._continue_current(context, "continue_current_intent") - - def persist_context( - self, - conversation_key: str, - *, - domain_id: str, - process_id: str, - user_message: str, - assistant_message: str, - decision_type: str = "start", - ) -> None: - self._ctx.update( - conversation_key, - domain_id=domain_id, - process_id=process_id, - user_message=user_message, - assistant_message=assistant_message, - decision_type=decision_type, - ) - - def graph_factory(self, domain_id: str, process_id: str): - return self._registry.get_factory(domain_id, process_id) - - def _fallback(self, reason: str) -> RouteResolution: - return RouteResolution( - domain_id="default", - process_id="general", - confidence=0.0, - reason=reason, - fallback_used=True, - decision_type="start", - explicit_switch=False, - ) - - def _continue_current(self, context, reason: str) -> RouteResolution: - active = context.active_intent or context.last_routing or {"domain_id": "default", "process_id": "general"} - return RouteResolution( - domain_id=str(active["domain_id"]), - process_id=str(active["process_id"]), - confidence=1.0, - reason=reason, - fallback_used=False, - decision_type="continue", - explicit_switch=False, - ) - - def _is_acceptable(self, decision: RouteDecision) -> bool: - return decision.confidence >= self._min_confidence and self._registry.is_valid(decision.domain_id, decision.process_id) - - def _resolution(self, decision: RouteDecision) -> RouteResolution: - return RouteResolution( - domain_id=decision.domain_id, - process_id=decision.process_id, - confidence=decision.confidence, - reason=decision.reason, - fallback_used=False, - decision_type=decision.decision_type, - explicit_switch=decision.explicit_switch, - ) diff --git a/src/app/modules/agent/engine/router/schemas.py b/src/app/modules/agent/engine/router/schemas.py deleted file mode 100644 index 233d4fa..0000000 --- a/src/app/modules/agent/engine/router/schemas.py +++ /dev/null @@ -1,34 +0,0 @@ -from pydantic import BaseModel, Field, field_validator - - -class RouteDecision(BaseModel): - domain_id: str = "default" - process_id: str = "general" - confidence: float = 0.0 - reason: str = "" - use_previous: bool = False - decision_type: str = "start" - explicit_switch: bool = False - - @field_validator("confidence") - @classmethod - def clamp_confidence(cls, value: float) -> float: - return max(0.0, min(1.0, float(value))) - - -class RouteResolution(BaseModel): - domain_id: str - process_id: str - confidence: float - reason: str - fallback_used: bool = False - decision_type: str = "start" - explicit_switch: bool = False - - -class RouterContext(BaseModel): - last_routing: dict[str, str] | None = None - message_history: list[dict[str, str]] = Field(default_factory=list) - active_intent: dict[str, str] | None = None - dialog_started: bool = False - turn_index: int = 0 diff --git a/src/app/modules/agent/module.py b/src/app/modules/agent/module.py deleted file mode 100644 index e0bebe6..0000000 --- a/src/app/modules/agent/module.py +++ /dev/null @@ -1,64 +0,0 @@ -from __future__ import annotations - -from fastapi import APIRouter -from pydantic import BaseModel, HttpUrl -from typing import TYPE_CHECKING - -from app.modules.agent.changeset_validator import ChangeSetValidator -from app.modules.agent.confluence_service import ConfluenceService -from app.modules.agent.llm import AgentLlmService -from app.modules.agent.prompt_loader import PromptLoader -from app.modules.agent.story_context_repository import StoryContextRepository -from app.modules.agent.story_session_recorder import StorySessionRecorder -from app.modules.agent.service import GraphAgentRuntime -from app.modules.agent.repository import AgentRepository -from app.modules.contracts import RagRetriever -from app.modules.shared.gigachat.client import GigaChatClient -from app.modules.shared.gigachat.settings import GigaChatSettings -from app.modules.shared.gigachat.token_provider import GigaChatTokenProvider - - -class ConfluenceFetchRequest(BaseModel): - url: HttpUrl - - -if TYPE_CHECKING: - from app.modules.rag.explain.retriever_v2 import CodeExplainRetrieverV2 - - -class AgentModule: - def __init__( - self, - rag_retriever: RagRetriever, - agent_repository: AgentRepository, - story_context_repository: StoryContextRepository, - code_explain_retriever: CodeExplainRetrieverV2 | None = None, - ) -> None: - self.confluence = ConfluenceService() - self.changeset_validator = ChangeSetValidator() - self.story_context_repository = story_context_repository - settings = GigaChatSettings.from_env() - token_provider = GigaChatTokenProvider(settings) - client = GigaChatClient(settings, token_provider) - prompt_loader = PromptLoader() - llm = AgentLlmService(client=client, prompts=prompt_loader) - self.llm = llm - story_recorder = StorySessionRecorder(story_context_repository) - self.runtime = GraphAgentRuntime( - rag=rag_retriever, - confluence=self.confluence, - changeset_validator=self.changeset_validator, - llm=self.llm, - agent_repository=agent_repository, - story_recorder=story_recorder, - code_explain_retriever=code_explain_retriever, - ) - - def internal_router(self) -> APIRouter: - router = APIRouter(prefix="/internal/tools", tags=["internal-tools"]) - - @router.post("/confluence/fetch") - async def fetch_page(request: ConfluenceFetchRequest) -> dict: - return await self.confluence.fetch_page(str(request.url)) - - return router diff --git a/src/app/modules/agent/prompts/docs_detect.txt b/src/app/modules/agent/prompts/docs_detect.txt deleted file mode 100644 index ca790a9..0000000 --- a/src/app/modules/agent/prompts/docs_detect.txt +++ /dev/null @@ -1,18 +0,0 @@ -Ты анализируешь, есть ли в проекте существующая документация, в которую нужно встраиваться. - -Оцени входные данные: -- User request -- Requested target path -- Detected documentation candidates (пути и сниппеты) - -Критерии EXISTS=yes: -- Есть хотя бы один релевантный doc-файл, и -- Он по смыслу подходит под запрос пользователя. - -Критерии EXISTS=no: -- Нет релевантных doc-файлов, или -- Есть только нерелевантные/пустые заготовки. - -Верни строго две строки: -EXISTS: yes|no -SUMMARY: <короткое объяснение на 1-2 предложения> diff --git a/src/app/modules/agent/prompts/docs_examples/from_scratch_example.md b/src/app/modules/agent/prompts/docs_examples/from_scratch_example.md deleted file mode 100644 index 89ca114..0000000 --- a/src/app/modules/agent/prompts/docs_examples/from_scratch_example.md +++ /dev/null @@ -1,27 +0,0 @@ -# Feature X Documentation - -## Goal -Describe how Feature X works and how to integrate it safely. - -## Architecture Overview -- Input enters through HTTP endpoint. -- Request is validated and transformed. -- Worker executes business logic and persists result. - -## Data Flow -1. Client sends request payload. -2. Service validates payload. -3. Domain layer computes output. -4. Repository stores entities. - -## Configuration -- Required environment variables. -- Optional tuning parameters. - -## Deployment Notes -- Migration prerequisites. -- Rollback strategy. - -## Risks and Constraints -- Throughput is bounded by downstream API limits. -- Partial failures require retry-safe handlers. diff --git a/src/app/modules/agent/prompts/docs_examples/incremental_update_example.md b/src/app/modules/agent/prompts/docs_examples/incremental_update_example.md deleted file mode 100644 index 5a00054..0000000 --- a/src/app/modules/agent/prompts/docs_examples/incremental_update_example.md +++ /dev/null @@ -1,21 +0,0 @@ -# API Client Module - -## Purpose -This document explains how the API client authenticates and retries requests. - -## Current Behavior -- Access token is fetched before outbound request. -- Retry policy uses exponential backoff for transient failures. - -## Recent Increment (v2) -### Added cache for tokens -- Token is cached in memory for a short TTL. -- Cache invalidates on 401 responses. - -### Operational impact -- Reduced auth latency for repetitive calls. -- Fewer token endpoint requests. - -## Limitations -- Single-process cache only. -- No distributed cache synchronization. diff --git a/src/app/modules/agent/prompts/docs_execution_summary.txt b/src/app/modules/agent/prompts/docs_execution_summary.txt deleted file mode 100644 index f36f539..0000000 --- a/src/app/modules/agent/prompts/docs_execution_summary.txt +++ /dev/null @@ -1,12 +0,0 @@ -Ты технический писатель и готовишь краткий итог по выполненной задаче документации. - -Верни только markdown-текст без JSON и без лишних вступлений. -Структура ответа: -1) "Что сделано" — 3-6 коротких пунктов по основным частям пользовательского запроса. -2) "Измененные файлы" — список файлов с кратким описанием изменения по каждому файлу. -3) "Ограничения" — добавляй только если в данных есть явные пробелы или ограничения. - -Правила: -- Используй только входные данные. -- Не выдумывай изменения, которых нет в списке changed files. -- Пиши коротко и по делу. diff --git a/src/app/modules/agent/prompts/docs_generation.txt b/src/app/modules/agent/prompts/docs_generation.txt deleted file mode 100644 index c764dc1..0000000 --- a/src/app/modules/agent/prompts/docs_generation.txt +++ /dev/null @@ -1,53 +0,0 @@ -Ты senior technical writer и пишешь только проектную документацию в markdown. - -Твоя задача: -1) Если strategy=incremental_update, встроиться в существующую документацию и добавить только недостающий инкремент. -2) Если strategy=from_scratch, создать целостный документ с нуля. - -Правила: -- Опирайся только на входной контекст (request, plan, rag context, current file content, examples bundle). -- Не выдумывай факты о коде, которых нет во входных данных. -- Сохраняй стиль существующего документа при incremental_update. -- Если контекст неполный, отмечай ограничения явно и коротко в отдельном разделе "Ограничения". -- Структура должна быть логичной и пригодной для реального репозитория. -- Агент должен спроектировать структуру папок и файлов документации под правила ниже. -- Документация должна быть разделена минимум на 2 направления: - - отдельная папка для описания методов API; - - отдельная папка для описания логики/требований. -- В одном markdown-файле допускается описание только: - - одного метода API, или - - одного атомарного куска логики/требования. -- Для описания одного метода API используй структуру: - - название метода; - - параметры запроса; - - параметры ответа; - - use case (сценарий последовательности вызова метода); - - функциональные требования (если нужны технические детали). -- Для описания логики используй аналогичный подход: - - сценарий; - - ссылки из шагов сценария на функциональные требования; - - отдельные функциональные требования с техническими деталями. -- Правила для сценариев: - - без объемных шагов; - - каждый шаг краткий, не более 2 предложений; - - если нужны технические детали, вынеси их из шага в отдельное функциональное требование и дай ссылку на него из шага. - -Формат ответа: -- Верни только JSON-объект без пояснений и без markdown-оберток. -- Строгий формат: -{ - "files": [ - { - "path": "docs/api/.md", - "content": "<полное содержимое markdown-файла>", - "reason": "<кратко зачем создан/обновлен файл>" - }, - { - "path": "docs/logic/.md", - "content": "<полное содержимое markdown-файла>", - "reason": "<кратко зачем создан/обновлен файл>" - } - ] -} -- Для from_scratch сформируй несколько файлов и обязательно покрой обе папки: `docs/api` и `docs/logic`. -- Для incremental_update также соблюдай правило атомарности: один файл = один метод API или один атомарный кусок логики/требования. diff --git a/src/app/modules/agent/prompts/docs_plan_sections.txt b/src/app/modules/agent/prompts/docs_plan_sections.txt deleted file mode 100644 index a45c5e3..0000000 --- a/src/app/modules/agent/prompts/docs_plan_sections.txt +++ /dev/null @@ -1,25 +0,0 @@ -Ты составляешь план изменений документации перед генерацией текста. - -Вход: -- Strategy -- User request -- Target path -- Current target content (для incremental_update) -- RAG context по коду -- Examples bundle - -Требования к плану: -- Сначала спроектируй структуру папок и файлов документации под формат: - - отдельная папка для API-методов; - - отдельная папка для логики/требований; - - один файл = один метод API или один атомарный кусок логики/требования. -- Для API-файлов закладывай структуру: название метода, параметры запроса, параметры ответа, use case, функциональные требования. -- Для логики закладывай структуру: сценарий, ссылки из шагов на функциональные требования, отдельные функциональные требования. -- Для сценариев закладывай короткие шаги (не более 2 предложений на шаг), а технические детали выноси в функциональные требования. -- Дай нумерованный список разделов будущего документа. -- Для incremental_update отмечай, какие разделы добавить/обновить, не переписывая все целиком. -- Для from_scratch давай полный каркас документа. -- Каждый пункт должен включать краткую цель раздела. -- Если контекст частичный, включи пункт "Ограничения и допущения". - -Формат ответа: только план в markdown, без вступлений и без JSON. diff --git a/src/app/modules/agent/prompts/docs_self_check.txt b/src/app/modules/agent/prompts/docs_self_check.txt deleted file mode 100644 index 8f5fbb0..0000000 --- a/src/app/modules/agent/prompts/docs_self_check.txt +++ /dev/null @@ -1,22 +0,0 @@ -Ты валидатор качества документации. - -Проверь: -- Соответствие strategy и user request. -- Соответствие generated document плану секций. -- Отсутствие очевидных выдуманных фактов. -- Практическую применимость текста к проекту. -- Для incremental_update: минимально необходимый инкремент без лишнего переписывания. -- Проверку структуры документации: - - есть разбиение по папкам `docs/api` и `docs/logic`; - - один файл описывает только один API-метод или один атомарный кусок логики; - - сценарии состоят из коротких шагов, а технические детали вынесены в функциональные требования. - -Если документ приемлем: -PASS: yes -FEEDBACK: <коротко, что ок> - -Если документ неприемлем: -PASS: no -FEEDBACK: <коротко, что исправить в следующей попытке> - -Верни ровно две строки в этом формате. diff --git a/src/app/modules/agent/prompts/docs_strategy.txt b/src/app/modules/agent/prompts/docs_strategy.txt deleted file mode 100644 index f654ea6..0000000 --- a/src/app/modules/agent/prompts/docs_strategy.txt +++ /dev/null @@ -1,14 +0,0 @@ -Ты выбираешь стратегию генерации документации. - -Доступные стратегии: -- incremental_update: дописать недостающий инкремент в существующий документ. -- from_scratch: создать новый документ с нуля. - -Правила выбора: -- Если Existing docs detected=true и это не противоречит user request, выбирай incremental_update. -- Если Existing docs detected=false, выбирай from_scratch. -- Если пользователь явно просит "с нуля", приоритет у from_scratch. -- Если пользователь явно просит "дописать/обновить", приоритет у incremental_update. - -Верни строго одну строку: -STRATEGY: incremental_update|from_scratch diff --git a/src/app/modules/agent/prompts/general_answer.txt b/src/app/modules/agent/prompts/general_answer.txt deleted file mode 100644 index b9238c5..0000000 --- a/src/app/modules/agent/prompts/general_answer.txt +++ /dev/null @@ -1,3 +0,0 @@ -Ты инженерный AI-ассистент. Ответь по проекту коротко и по делу. -Если в контексте недостаточно данных, явно укажи пробелы. -Не выдумывай факты, используй только входные данные. diff --git a/src/app/modules/agent/prompts/project_answer.txt b/src/app/modules/agent/prompts/project_answer.txt deleted file mode 100644 index c082c1d..0000000 --- a/src/app/modules/agent/prompts/project_answer.txt +++ /dev/null @@ -1,9 +0,0 @@ -Ты инженерный AI-ассистент по текущему проекту. - -Сформируй точный ответ на вопрос пользователя, используя только входной контекст. -Приоритет источников: сначала RAG context, затем Confluence context. - -Правила: -- Не выдумывай факты и явно помечай пробелы в данных. -- Отвечай структурировано и коротко. -- Если пользователь просит шаги, дай практичный пошаговый план. diff --git a/src/app/modules/agent/prompts/project_edits_hunks.txt b/src/app/modules/agent/prompts/project_edits_hunks.txt deleted file mode 100644 index dad0294..0000000 --- a/src/app/modules/agent/prompts/project_edits_hunks.txt +++ /dev/null @@ -1,32 +0,0 @@ -Ты формируешь hunks строго по контракту правок. -На вход приходит JSON с request, contract, current_content, previous_validation_feedback, rag_context, confluence_context. - -Верни только JSON: -{ - "hunks": [ - { - "type": "append_end", - "new_text": "<текст для добавления в конец>" - } - ] -} - -Для replace_between: -{ - "type": "replace_between", - "start_anchor": "<точно как в contract>", - "end_anchor": "<точно как в contract>", - "new_text": "<новый текст между якорями>" -} - -Для replace_line_equals: -{ - "type": "replace_line_equals", - "old_line": "<точно как в contract>", - "new_text": "<новая строка/текст>" -} - -Критичные правила: -- Не выходи за рамки allowed_blocks. -- Не добавляй hunks, которых нет в контракте. -- Минимизируй изменения и не трогай нерелевантные части файла. diff --git a/src/app/modules/agent/prompts/project_edits_plan.txt b/src/app/modules/agent/prompts/project_edits_plan.txt deleted file mode 100644 index 217ba53..0000000 --- a/src/app/modules/agent/prompts/project_edits_plan.txt +++ /dev/null @@ -1,32 +0,0 @@ -Ты планируешь строго ограниченный контракт правок файла. -На вход приходит JSON с request, requested_path, context_files, contract_requirements. - -Верни только JSON: -{ - "files": [ - { - "path": "README.md", - "reason": "коротко зачем меняем", - "intent": "update", - "max_hunks": 1, - "max_changed_lines": 8, - "allowed_blocks": [ - { - "type": "append_end", - "max_changed_lines": 8 - } - ] - } - ] -} - -Поддерживаемые block type: -- append_end: добавить текст только в конец файла. -- replace_between: заменить текст только между start_anchor и end_anchor. -- replace_line_equals: заменить только строку old_line. - -Критичные правила: -- Обязательно задавай allowed_blocks для каждого файла. -- Не добавляй файлы, которых нет в запросе. -- Точечные запросы: max_hunks=1 и маленький max_changed_lines. -- Если запрос "добавь в конец", используй append_end. diff --git a/src/app/modules/agent/prompts/project_edits_self_check.txt b/src/app/modules/agent/prompts/project_edits_self_check.txt deleted file mode 100644 index 4cc5ddf..0000000 --- a/src/app/modules/agent/prompts/project_edits_self_check.txt +++ /dev/null @@ -1,13 +0,0 @@ -Ты валидируешь changeset правок файла. -На вход приходит JSON с request, contracts и changeset (op, path, reason). - -Проверь: -1) изменения соответствуют запросу, -1.1) изменения соответствуют контракту (разрешенные блоки и лимиты), -2) нет лишних нерелевантных правок, -3) изменены только действительно нужные файлы, -4) нет косметических правок (пробелы/форматирование без смысла), -5) нет добавления новых секций/заголовков, если это не запрошено явно. - -Верни только JSON: -{"pass": true|false, "feedback": ""} diff --git a/src/app/modules/agent/prompts/router_intent.txt b/src/app/modules/agent/prompts/router_intent.txt deleted file mode 100644 index 9f8accb..0000000 --- a/src/app/modules/agent/prompts/router_intent.txt +++ /dev/null @@ -1,23 +0,0 @@ -Ты классификатор маршрутов агента. -На вход ты получаешь JSON с полями: -- message: текущий запрос пользователя -- history: последние сообщения диалога -- allowed_routes: допустимые маршруты - -Выбери ровно один маршрут из allowed_routes. -Верни только JSON без markdown и пояснений. - -Строгий формат ответа: -{"route":"","confidence":,"reason":""} - -Правила маршрутизации: -- project/qa: пользователь задает вопросы про текущий проект, его код, архитектуру, модули, поведение, ограничения. -- project/edits: пользователь просит внести правки в существующие файлы проекта (контент, конфиги, тексты, шаблоны), без реализации новой кодовой логики. -- docs/generation: пользователь просит подготовить/обновить документацию, инструкции, markdown-материалы. -- default/general: остальные случаи, включая общие вопросы и консультации. - -Приоритет: -- Если в запросе есть явная команда правки конкретного файла (например `README.md`, путь к файлу, "добавь в конец файла"), выбирай project/edits. -- docs/generation выбирай для задач подготовки документации в целом, а не для точечной правки одного файла. - -Если есть сомнения, выбирай default/general и confidence <= 0.6. diff --git a/src/app/modules/agent/repository.py b/src/app/modules/agent/repository.py deleted file mode 100644 index 2e764e0..0000000 --- a/src/app/modules/agent/repository.py +++ /dev/null @@ -1,286 +0,0 @@ -from __future__ import annotations - -import json - -from sqlalchemy import text - -from app.modules.agent.engine.router.schemas import RouterContext -from app.modules.shared.db import get_engine - - -class AgentRepository: - def ensure_tables(self) -> None: - with get_engine().connect() as conn: - conn.execute( - text( - """ - CREATE TABLE IF NOT EXISTS router_context ( - conversation_key VARCHAR(64) PRIMARY KEY, - last_domain_id VARCHAR(64) NULL, - last_process_id VARCHAR(64) NULL, - active_domain_id VARCHAR(64) NULL, - active_process_id VARCHAR(64) NULL, - dialog_started BOOLEAN NOT NULL DEFAULT FALSE, - turn_index INTEGER NOT NULL DEFAULT 0, - message_history_json TEXT NOT NULL DEFAULT '[]', - updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP - ) - """ - ) - ) - conn.execute( - text( - """ - CREATE TABLE IF NOT EXISTS agent_quality_metrics ( - id BIGSERIAL PRIMARY KEY, - task_id VARCHAR(64) NOT NULL, - dialog_session_id VARCHAR(64) NOT NULL, - rag_session_id VARCHAR(64) NOT NULL, - scenario VARCHAR(64) NOT NULL, - domain_id VARCHAR(64) NOT NULL, - process_id VARCHAR(64) NOT NULL, - faithfulness_score DOUBLE PRECISION NOT NULL, - coverage_score DOUBLE PRECISION NOT NULL, - faithfulness_claims_total INTEGER NOT NULL, - faithfulness_claims_supported INTEGER NOT NULL, - coverage_required_items INTEGER NOT NULL, - coverage_covered_items INTEGER NOT NULL, - quality_status VARCHAR(32) NOT NULL, - metrics_json JSONB NOT NULL, - created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP - ) - """ - ) - ) - conn.execute( - text( - """ - CREATE INDEX IF NOT EXISTS idx_agent_quality_metrics_task - ON agent_quality_metrics(task_id, created_at DESC) - """ - ) - ) - conn.execute( - text( - """ - CREATE INDEX IF NOT EXISTS idx_agent_quality_metrics_scenario - ON agent_quality_metrics(scenario, created_at DESC) - """ - ) - ) - self._ensure_router_context_columns(conn) - conn.commit() - - def _ensure_router_context_columns(self, conn) -> None: - for statement in ( - "ALTER TABLE router_context ADD COLUMN IF NOT EXISTS active_domain_id VARCHAR(64) NULL", - "ALTER TABLE router_context ADD COLUMN IF NOT EXISTS active_process_id VARCHAR(64) NULL", - "ALTER TABLE router_context ADD COLUMN IF NOT EXISTS dialog_started BOOLEAN NOT NULL DEFAULT FALSE", - "ALTER TABLE router_context ADD COLUMN IF NOT EXISTS turn_index INTEGER NOT NULL DEFAULT 0", - ): - conn.execute(text(statement)) - - def get_router_context(self, conversation_key: str) -> RouterContext: - with get_engine().connect() as conn: - row = conn.execute( - text( - """ - SELECT last_domain_id, last_process_id, active_domain_id, active_process_id, dialog_started, turn_index, message_history_json - FROM router_context - WHERE conversation_key = :key - """ - ), - {"key": conversation_key}, - ).fetchone() - - if not row: - return RouterContext() - - history_raw = row[6] or "[]" - try: - history = json.loads(history_raw) - except json.JSONDecodeError: - history = [] - - last = None - if row[0] and row[1]: - last = {"domain_id": str(row[0]), "process_id": str(row[1])} - active = None - if row[2] and row[3]: - active = {"domain_id": str(row[2]), "process_id": str(row[3])} - - clean_history = [] - for item in history if isinstance(history, list) else []: - if not isinstance(item, dict): - continue - role = str(item.get("role") or "") - content = str(item.get("content") or "") - if role in {"user", "assistant"} and content: - clean_history.append({"role": role, "content": content}) - - return RouterContext( - last_routing=last, - message_history=clean_history, - active_intent=active or last, - dialog_started=bool(row[4]), - turn_index=int(row[5] or 0), - ) - - def update_router_context( - self, - conversation_key: str, - *, - domain_id: str, - process_id: str, - user_message: str, - assistant_message: str, - decision_type: str, - max_history: int, - ) -> None: - current = self.get_router_context(conversation_key) - history = list(current.message_history) - if user_message: - history.append({"role": "user", "content": user_message}) - if assistant_message: - history.append({"role": "assistant", "content": assistant_message}) - if max_history > 0: - history = history[-max_history:] - current_active = current.active_intent or current.last_routing or {"domain_id": domain_id, "process_id": process_id} - next_active = ( - {"domain_id": domain_id, "process_id": process_id} - if decision_type in {"start", "switch"} - else current_active - ) - next_turn_index = max(0, int(current.turn_index or 0)) + (1 if user_message else 0) - - with get_engine().connect() as conn: - conn.execute( - text( - """ - INSERT INTO router_context ( - conversation_key, last_domain_id, last_process_id, active_domain_id, active_process_id, - dialog_started, turn_index, message_history_json - ) VALUES (:key, :domain, :process, :active_domain, :active_process, :dialog_started, :turn_index, :history) - ON CONFLICT (conversation_key) DO UPDATE SET - last_domain_id = EXCLUDED.last_domain_id, - last_process_id = EXCLUDED.last_process_id, - active_domain_id = EXCLUDED.active_domain_id, - active_process_id = EXCLUDED.active_process_id, - dialog_started = EXCLUDED.dialog_started, - turn_index = EXCLUDED.turn_index, - message_history_json = EXCLUDED.message_history_json, - updated_at = CURRENT_TIMESTAMP - """ - ), - { - "key": conversation_key, - "domain": domain_id, - "process": process_id, - "active_domain": str(next_active["domain_id"]), - "active_process": str(next_active["process_id"]), - "dialog_started": True, - "turn_index": next_turn_index, - "history": json.dumps(history, ensure_ascii=False), - }, - ) - conn.commit() - - def save_quality_metrics( - self, - *, - task_id: str, - dialog_session_id: str, - rag_session_id: str, - scenario: str, - domain_id: str, - process_id: str, - quality: dict, - ) -> None: - faithfulness = quality.get("faithfulness", {}) if isinstance(quality, dict) else {} - coverage = quality.get("coverage", {}) if isinstance(quality, dict) else {} - status = str(quality.get("status", "unknown")) if isinstance(quality, dict) else "unknown" - with get_engine().connect() as conn: - conn.execute( - text( - """ - INSERT INTO agent_quality_metrics ( - task_id, - dialog_session_id, - rag_session_id, - scenario, - domain_id, - process_id, - faithfulness_score, - coverage_score, - faithfulness_claims_total, - faithfulness_claims_supported, - coverage_required_items, - coverage_covered_items, - quality_status, - metrics_json - ) VALUES ( - :task_id, - :dialog_session_id, - :rag_session_id, - :scenario, - :domain_id, - :process_id, - :faithfulness_score, - :coverage_score, - :faithfulness_claims_total, - :faithfulness_claims_supported, - :coverage_required_items, - :coverage_covered_items, - :quality_status, - CAST(:metrics_json AS JSONB) - ) - """ - ), - { - "task_id": task_id, - "dialog_session_id": dialog_session_id, - "rag_session_id": rag_session_id, - "scenario": scenario, - "domain_id": domain_id, - "process_id": process_id, - "faithfulness_score": float(faithfulness.get("score", 0.0) or 0.0), - "coverage_score": float(coverage.get("score", 0.0) or 0.0), - "faithfulness_claims_total": int(faithfulness.get("claims_total", 0) or 0), - "faithfulness_claims_supported": int(faithfulness.get("claims_supported", 0) or 0), - "coverage_required_items": int(coverage.get("required_count", 0) or 0), - "coverage_covered_items": int(coverage.get("covered_count", 0) or 0), - "quality_status": status, - "metrics_json": json.dumps(quality if isinstance(quality, dict) else {}, ensure_ascii=False), - }, - ) - conn.commit() - - def get_quality_metrics(self, *, limit: int = 50, scenario: str | None = None) -> list[dict]: - query = """ - SELECT - task_id, - dialog_session_id, - rag_session_id, - scenario, - domain_id, - process_id, - faithfulness_score, - coverage_score, - faithfulness_claims_total, - faithfulness_claims_supported, - coverage_required_items, - coverage_covered_items, - quality_status, - metrics_json, - created_at - FROM agent_quality_metrics - """ - params: dict = {"limit": max(1, int(limit))} - if scenario: - query += " WHERE scenario = :scenario" - params["scenario"] = scenario - query += " ORDER BY created_at DESC LIMIT :limit" - - with get_engine().connect() as conn: - rows = conn.execute(text(query), params).mappings().fetchall() - return [dict(row) for row in rows] diff --git a/src/app/modules/agent/service.py b/src/app/modules/agent/service.py deleted file mode 100644 index 7885ed4..0000000 --- a/src/app/modules/agent/service.py +++ /dev/null @@ -1,483 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass, field -from collections.abc import Awaitable, Callable -import inspect -import logging -import re -from typing import TYPE_CHECKING - -from app.modules.agent.engine.orchestrator import OrchestratorService, TaskSpecBuilder -from app.modules.agent.engine.orchestrator.metrics_persister import MetricsPersister -from app.modules.agent.engine.orchestrator.models import RoutingMeta -from app.modules.agent.engine.orchestrator.step_registry import StepRegistry -from app.modules.agent.engine.router import build_router_service -from app.modules.agent.llm import AgentLlmService -from app.modules.agent.story_session_recorder import StorySessionRecorder -from app.modules.agent.changeset_validator import ChangeSetValidator -from app.modules.agent.confluence_service import ConfluenceService -from app.modules.agent.repository import AgentRepository -from app.modules.contracts import RagRetriever -from app.modules.shared.checkpointer import get_checkpointer -from app.schemas.changeset import ChangeItem -from app.schemas.chat import TaskResultType -from app.core.exceptions import AppError -from app.schemas.common import ModuleName - -LOGGER = logging.getLogger(__name__) - -if TYPE_CHECKING: - from app.modules.rag.explain.retriever_v2 import CodeExplainRetrieverV2 - - -def _truncate_for_log(text: str | None, max_chars: int = 1500) -> str: - value = (text or "").replace("\n", "\\n").strip() - if len(value) <= max_chars: - return value - return value[:max_chars].rstrip() + "...[truncated]" - - -@dataclass -class AgentResult: - result_type: TaskResultType - answer: str | None = None - changeset: list[ChangeItem] = field(default_factory=list) - meta: dict = field(default_factory=dict) - - -class GraphAgentRuntime: - def __init__( - self, - rag: RagRetriever, - confluence: ConfluenceService, - changeset_validator: ChangeSetValidator, - llm: AgentLlmService, - agent_repository: AgentRepository, - story_recorder: StorySessionRecorder | None = None, - code_explain_retriever: CodeExplainRetrieverV2 | None = None, - ) -> None: - self._rag = rag - self._confluence = confluence - self._changeset_validator = changeset_validator - self._router = build_router_service(llm, agent_repository, rag) - self._task_spec_builder = TaskSpecBuilder() - self._orchestrator = OrchestratorService(step_registry=StepRegistry(code_explain_retriever)) - self._metrics_persister = MetricsPersister(agent_repository) - self._story_recorder = story_recorder - self._checkpointer = None - - async def run( - self, - *, - task_id: str, - dialog_session_id: str, - rag_session_id: str, - mode: str, - message: str, - attachments: list[dict], - files: list[dict], - progress_cb: Callable[[str, str, str, dict | None], Awaitable[None] | None] | None = None, - ) -> AgentResult: - LOGGER.info( - "GraphAgentRuntime.run started: task_id=%s dialog_session_id=%s mode=%s", - task_id, - dialog_session_id, - mode, - ) - await self._emit_progress(progress_cb, "agent.route", "Определяю тип запроса и подбираю граф.", meta={"mode": mode}) - route = self._router.resolve(message, dialog_session_id, mode=mode) - LOGGER.warning( - "router decision: task_id=%s dialog_session_id=%s mode=%s route=%s/%s reason=%s confidence=%s fallback_used=%s", - task_id, - dialog_session_id, - mode, - route.domain_id, - route.process_id, - route.reason, - route.confidence, - route.fallback_used, - ) - await self._emit_progress( - progress_cb, - "agent.route.resolved", - "Маршрут выбран, готовлю контекст для выполнения.", - meta={"domain_id": route.domain_id, "process_id": route.process_id}, - ) - files_map = self._build_files_map(files) - rag_ctx: list[dict] = [] - await self._emit_progress(progress_cb, "agent.attachments", "Обрабатываю дополнительные вложения.") - conf_pages = await self._fetch_confluence_pages(attachments) - route_meta = RoutingMeta( - domain_id=route.domain_id, - process_id=route.process_id, - confidence=route.confidence, - reason=route.reason, - fallback_used=route.fallback_used, - ) - task_spec = self._task_spec_builder.build( - task_id=task_id, - dialog_session_id=dialog_session_id, - rag_session_id=rag_session_id, - mode=mode, - message=message, - route=route_meta, - attachments=attachments, - files=files, - rag_items=rag_ctx, - rag_context=self._format_rag(rag_ctx), - confluence_context=self._format_confluence(conf_pages), - files_map=files_map, - ) - - await self._emit_progress(progress_cb, "agent.orchestrator", "Строю и выполняю план оркестрации.") - orchestrator_result = await self._orchestrator.run( - task=task_spec, - graph_resolver=self._resolve_graph, - graph_invoker=self._invoke_graph, - progress_cb=progress_cb, - ) - await self._emit_progress(progress_cb, "agent.orchestrator.done", "Оркестратор завершил выполнение плана.") - answer = orchestrator_result.answer - changeset = orchestrator_result.changeset or [] - orchestrator_meta = orchestrator_result.meta or {} - quality_meta = self._extract_quality_meta(orchestrator_meta) - orchestrator_steps = [item.model_dump(mode="json") for item in orchestrator_result.steps] - self._record_session_story_artifacts( - dialog_session_id=dialog_session_id, - rag_session_id=rag_session_id, - scenario=str(orchestrator_meta.get("scenario", task_spec.scenario.value)), - attachments=[a.model_dump(mode="json") for a in task_spec.attachments], - answer=answer, - changeset=changeset, - ) - if changeset: - await self._emit_progress(progress_cb, "agent.changeset", "Проверяю и валидирую предложенные изменения.") - changeset = self._enrich_changeset_hashes(changeset, files_map) - changeset = self._sanitize_changeset(changeset, files_map) - if not changeset: - final_answer = (answer or "").strip() or "Предложенные правки были отброшены как нерелевантные или косметические." - await self._emit_progress(progress_cb, "agent.answer", "После фильтрации правок формирую ответ без changeset.") - self._router.persist_context( - dialog_session_id, - domain_id=route.domain_id, - process_id=route.process_id, - user_message=message, - assistant_message=final_answer, - decision_type=route.decision_type, - ) - LOGGER.info( - "final agent answer: task_id=%s route=%s/%s answer=%s", - task_id, - route.domain_id, - route.process_id, - _truncate_for_log(final_answer), - ) - self._persist_quality_metrics( - task_id=task_id, - dialog_session_id=dialog_session_id, - rag_session_id=rag_session_id, - route=route, - scenario=str(orchestrator_meta.get("scenario", task_spec.scenario.value)), - quality=quality_meta, - ) - return AgentResult( - result_type=TaskResultType.ANSWER, - answer=final_answer, - meta={ - "route": route.model_dump(), - "used_rag": False, - "used_confluence": bool(conf_pages), - "changeset_filtered_out": True, - "orchestrator": orchestrator_meta, - "orchestrator_steps": orchestrator_steps, - }, - ) - validated = self._changeset_validator.validate(task_id, changeset) - final_answer = (answer or "").strip() or None - self._router.persist_context( - dialog_session_id, - domain_id=route.domain_id, - process_id=route.process_id, - user_message=message, - assistant_message=final_answer or f"changeset:{len(validated)}", - decision_type=route.decision_type, - ) - final = AgentResult( - result_type=TaskResultType.CHANGESET, - answer=final_answer, - changeset=validated, - meta={ - "route": route.model_dump(), - "used_rag": False, - "used_confluence": bool(conf_pages), - "orchestrator": orchestrator_meta, - "orchestrator_steps": orchestrator_steps, - }, - ) - self._persist_quality_metrics( - task_id=task_id, - dialog_session_id=dialog_session_id, - rag_session_id=rag_session_id, - route=route, - scenario=str(orchestrator_meta.get("scenario", task_spec.scenario.value)), - quality=quality_meta, - ) - LOGGER.info( - "GraphAgentRuntime.run completed: task_id=%s route=%s/%s result_type=%s changeset_items=%s", - task_id, - route.domain_id, - route.process_id, - final.result_type.value, - len(final.changeset), - ) - LOGGER.info( - "final agent answer: task_id=%s route=%s/%s answer=%s", - task_id, - route.domain_id, - route.process_id, - _truncate_for_log(final.answer), - ) - return final - - final_answer = answer or "" - await self._emit_progress(progress_cb, "agent.answer", "Формирую финальный ответ.") - self._router.persist_context( - dialog_session_id, - domain_id=route.domain_id, - process_id=route.process_id, - user_message=message, - assistant_message=final_answer, - decision_type=route.decision_type, - ) - final = AgentResult( - result_type=TaskResultType.ANSWER, - answer=final_answer, - meta={ - "route": route.model_dump(), - "used_rag": False, - "used_confluence": bool(conf_pages), - "orchestrator": orchestrator_meta, - "orchestrator_steps": orchestrator_steps, - }, - ) - self._persist_quality_metrics( - task_id=task_id, - dialog_session_id=dialog_session_id, - rag_session_id=rag_session_id, - route=route, - scenario=str(orchestrator_meta.get("scenario", task_spec.scenario.value)), - quality=quality_meta, - ) - LOGGER.info( - "GraphAgentRuntime.run completed: task_id=%s route=%s/%s result_type=%s answer_len=%s", - task_id, - route.domain_id, - route.process_id, - final.result_type.value, - len(final.answer or ""), - ) - LOGGER.info( - "final agent answer: task_id=%s route=%s/%s answer=%s", - task_id, - route.domain_id, - route.process_id, - _truncate_for_log(final.answer), - ) - return final - - def _extract_quality_meta(self, orchestrator_meta: dict) -> dict: - if not isinstance(orchestrator_meta, dict): - return {} - quality = orchestrator_meta.get("quality") - return quality if isinstance(quality, dict) else {} - - def _persist_quality_metrics( - self, - *, - task_id: str, - dialog_session_id: str, - rag_session_id: str, - route, - scenario: str, - quality: dict, - ) -> None: - if not quality: - return - self._metrics_persister.save( - task_id=task_id, - dialog_session_id=dialog_session_id, - rag_session_id=rag_session_id, - scenario=scenario, - domain_id=str(route.domain_id), - process_id=str(route.process_id), - quality=quality, - ) - - def _record_session_story_artifacts( - self, - *, - dialog_session_id: str, - rag_session_id: str, - scenario: str, - attachments: list[dict], - answer: str | None, - changeset: list[ChangeItem], - ) -> None: - if self._story_recorder is None: - return - try: - self._story_recorder.record_run( - dialog_session_id=dialog_session_id, - rag_session_id=rag_session_id, - scenario=scenario, - attachments=attachments, - answer=answer, - changeset=changeset, - ) - except Exception: # noqa: BLE001 - LOGGER.exception("story session artifact recording failed") - - async def _emit_progress( - self, - progress_cb: Callable[[str, str, str, dict | None], Awaitable[None] | None] | None, - stage: str, - message: str, - *, - kind: str = "task_progress", - meta: dict | None = None, - ) -> None: - if progress_cb is None: - return - result = progress_cb(stage, message, kind, meta or {}) - if inspect.isawaitable(result): - await result - - def _resolve_graph(self, domain_id: str, process_id: str): - if self._checkpointer is None: - self._checkpointer = get_checkpointer() - factory = self._router.graph_factory(domain_id, process_id) - if factory is None: - factory = self._router.graph_factory("default", "general") - if factory is None: - raise RuntimeError("No graph factory configured") - LOGGER.debug("_resolve_graph resolved: domain_id=%s process_id=%s", domain_id, process_id) - return factory(self._checkpointer) - - def _invoke_graph(self, graph, state: dict, dialog_session_id: str): - return graph.invoke( - state, - config={"configurable": {"thread_id": dialog_session_id}}, - ) - - async def _fetch_confluence_pages(self, attachments: list[dict]) -> list[dict]: - pages: list[dict] = [] - for item in attachments: - if item.get("type") == "confluence_url": - pages.append(await self._confluence.fetch_page(item["url"])) - LOGGER.info("_fetch_confluence_pages completed: pages=%s", len(pages)) - return pages - - def _format_rag(self, items: list[dict]) -> str: - blocks: list[str] = [] - for item in items: - source = str(item.get("source", "") or item.get("path", "") or "") - layer = str(item.get("layer", "") or "").strip() - title = str(item.get("title", "") or "").strip() - metadata = item.get("metadata", {}) or {} - lines = [] - if source: - lines.append(f"Source: {source}") - if layer: - lines.append(f"Layer: {layer}") - if title: - lines.append(f"Title: {title}") - if metadata: - hints = [] - for key in ("module_id", "qname", "predicate", "entry_type", "framework", "section_path"): - value = metadata.get(key) - if value: - hints.append(f"{key}={value}") - if hints: - lines.append("Meta: " + ", ".join(hints)) - content = str(item.get("content", "")).strip() - if content: - lines.append(content) - if lines: - blocks.append("\n".join(lines)) - return "\n\n".join(blocks) - - def _format_confluence(self, pages: list[dict]) -> str: - return "\n".join(str(x.get("content_markdown", "")) for x in pages) - - def _build_files_map(self, files: list[dict]) -> dict[str, dict]: - output: dict[str, dict] = {} - for item in files: - path = str(item.get("path", "")).replace("\\", "/").strip() - if not path: - continue - output[path] = { - "path": path, - "content": str(item.get("content", "")), - "content_hash": str(item.get("content_hash", "")), - } - LOGGER.debug("_build_files_map completed: files=%s", len(output)) - return output - - def _lookup_file(self, files_map: dict[str, dict], path: str) -> dict | None: - normalized = (path or "").replace("\\", "/") - if normalized in files_map: - return files_map[normalized] - low = normalized.lower() - for key, value in files_map.items(): - if key.lower() == low: - return value - return None - - def _enrich_changeset_hashes(self, items: list[ChangeItem], files_map: dict[str, dict]) -> list[ChangeItem]: - enriched: list[ChangeItem] = [] - for item in items: - if item.op.value == "update": - source = self._lookup_file(files_map, item.path) - if not source or not source.get("content_hash"): - raise AppError( - "missing_base_hash", - f"Cannot build update for {item.path}: no file hash in request context", - ModuleName.AGENT, - ) - item.base_hash = str(source["content_hash"]) - enriched.append(item) - LOGGER.debug("_enrich_changeset_hashes completed: items=%s", len(enriched)) - return enriched - - def _sanitize_changeset(self, items: list[ChangeItem], files_map: dict[str, dict]) -> list[ChangeItem]: - sanitized: list[ChangeItem] = [] - dropped_noop = 0 - dropped_ws = 0 - for item in items: - if item.op.value != "update": - sanitized.append(item) - continue - source = self._lookup_file(files_map, item.path) - if not source: - sanitized.append(item) - continue - original = str(source.get("content", "")) - proposed = item.proposed_content or "" - if proposed == original: - dropped_noop += 1 - continue - if self._collapse_whitespace(proposed) == self._collapse_whitespace(original): - dropped_ws += 1 - continue - sanitized.append(item) - if dropped_noop or dropped_ws: - LOGGER.info( - "_sanitize_changeset dropped items: noop=%s whitespace_only=%s kept=%s", - dropped_noop, - dropped_ws, - len(sanitized), - ) - return sanitized - - def _collapse_whitespace(self, text: str) -> str: - return re.sub(r"\s+", " ", (text or "").strip()) diff --git a/src/app/modules/agent/story_session_recorder.py b/src/app/modules/agent/story_session_recorder.py deleted file mode 100644 index 4171e60..0000000 --- a/src/app/modules/agent/story_session_recorder.py +++ /dev/null @@ -1,106 +0,0 @@ -from __future__ import annotations - -from typing import Protocol - -from app.schemas.changeset import ChangeItem, ChangeOp - - -class SessionArtifactWriter(Protocol): - def add_session_artifact(self, **kwargs) -> None: ... - - -class StorySessionRecorder: - def __init__(self, repository: SessionArtifactWriter) -> None: - self._repo = repository - - def record_run( - self, - *, - dialog_session_id: str, - rag_session_id: str, - scenario: str, - attachments: list[dict], - answer: str | None, - changeset: list[ChangeItem], - actor: str | None = None, - ) -> None: - self._record_input_sources( - session_id=dialog_session_id, - project_id=rag_session_id, - attachments=attachments, - actor=actor, - ) - self._record_outputs( - session_id=dialog_session_id, - project_id=rag_session_id, - scenario=scenario, - answer=answer, - changeset=changeset, - actor=actor, - ) - - def _record_input_sources(self, *, session_id: str, project_id: str, attachments: list[dict], actor: str | None) -> None: - for item in attachments: - value = str(item.get("value") or "").strip() - if not value: - continue - if item.get("type") not in {"confluence_url", "http_url"}: - continue - self._repo.add_session_artifact( - session_id=session_id, - project_id=project_id, - artifact_role="analysis", - source_ref=value, - summary="Input analytics document", - change_type="linked", - created_by=actor, - ) - - def _record_outputs( - self, - *, - session_id: str, - project_id: str, - scenario: str, - answer: str | None, - changeset: list[ChangeItem], - actor: str | None, - ) -> None: - role = self._role_for_scenario(scenario) - if answer and answer.strip(): - self._repo.add_session_artifact( - session_id=session_id, - project_id=project_id, - artifact_role=role, - summary=answer.strip()[:4000], - created_by=actor, - ) - - for item in changeset: - self._repo.add_session_artifact( - session_id=session_id, - project_id=project_id, - artifact_role=role, - path=item.path, - summary=item.reason, - change_type=self._change_type(item.op), - created_by=actor, - ) - - def _role_for_scenario(self, scenario: str) -> str: - mapping = { - "docs_from_analytics": "doc_change", - "targeted_edit": "doc_change", - "gherkin_model": "test_model", - "analytics_review": "analysis", - "explain_part": "note", - "general_qa": "note", - } - return mapping.get(scenario, "note") - - def _change_type(self, op: ChangeOp) -> str: - if op == ChangeOp.CREATE: - return "added" - if op == ChangeOp.DELETE: - return "removed" - return "updated" diff --git a/src/app/modules/application.py b/src/app/modules/application.py index 380bc56..8ce4902 100644 --- a/src/app/modules/application.py +++ b/src/app/modules/application.py @@ -1,6 +1,8 @@ -from app.modules.agent.module import AgentModule -from app.modules.agent.repository import AgentRepository -from app.modules.agent.story_context_repository import StoryContextRepository, StoryContextSchemaRepository +from app.modules.agent.code_qa_runtime import CodeQaRuntimeExecutor +from app.modules.agent.code_qa_runtime.retrieval_adapter import CodeQaRetrievalAdapter +from app.modules.agent.code_qa_runner_adapter import CodeQaRunnerAdapter +from app.modules.agent.llm import AgentLlmService +from app.modules.agent.prompt_loader import PromptLoader from app.modules.chat.direct_service import CodeExplainChatService from app.modules.chat.dialog_store import DialogSessionStore from app.modules.chat.repository import ChatRepository @@ -8,6 +10,7 @@ from app.modules.chat.module import ChatModule from app.modules.chat.session_resolver import ChatSessionResolver from app.modules.chat.task_store import TaskStore from app.modules.rag.persistence.repository import RagRepository +from app.modules.rag.persistence.story_context_repository import StoryContextRepository, StoryContextSchemaRepository from app.modules.rag.explain import CodeExplainRetrieverV2, CodeGraphRepository, LayeredRetrievalGateway from app.modules.rag.module import RagModule, RagRepoModule from app.modules.shared.bootstrap import bootstrap_database @@ -21,7 +24,6 @@ class ModularApplication: self.retry = RetryExecutor() self.rag_repository = RagRepository() self.chat_repository = ChatRepository() - self.agent_repository = AgentRepository() self.story_context_schema_repository = StoryContextSchemaRepository() self.story_context_repository = StoryContextRepository() self.chat_tasks = TaskStore() @@ -35,15 +37,20 @@ class ModularApplication: gateway=LayeredRetrievalGateway(self.rag_repository, self.rag.embedder), graph_repository=CodeGraphRepository(), ) - self.agent = AgentModule( - rag_retriever=self.rag.rag, - agent_repository=self.agent_repository, - story_context_repository=self.story_context_repository, - code_explain_retriever=self.code_explain_retriever, - ) + from app.modules.shared.gigachat.client import GigaChatClient + from app.modules.shared.gigachat.settings import GigaChatSettings + from app.modules.shared.gigachat.token_provider import GigaChatTokenProvider + + _giga_settings = GigaChatSettings.from_env() + _giga_client = GigaChatClient(_giga_settings, GigaChatTokenProvider(_giga_settings)) + _prompt_loader = PromptLoader() + self._agent_llm = AgentLlmService(client=_giga_client, prompts=_prompt_loader) + _retrieval = CodeQaRetrievalAdapter(self.rag_repository) + _executor = CodeQaRuntimeExecutor(llm=self._agent_llm, retrieval=_retrieval) + self._agent_runner = CodeQaRunnerAdapter(_executor) self.direct_chat = CodeExplainChatService( retriever=self.code_explain_retriever, - llm=self.agent.llm, + llm=self._agent_llm, session_resolver=ChatSessionResolver( dialogs=DialogSessionStore(self.chat_repository), rag_session_exists=lambda rag_session_id: self.rag.sessions.get(rag_session_id) is not None, @@ -52,7 +59,7 @@ class ModularApplication: message_sink=self.chat_repository.add_message, ) self.chat = ChatModule( - agent_runner=self.agent.runtime, + agent_runner=self._agent_runner, event_bus=self.events, retry=self.retry, rag_sessions=self.rag.sessions, @@ -65,6 +72,5 @@ class ModularApplication: bootstrap_database( self.rag_repository, self.chat_repository, - self.agent_repository, self.story_context_schema_repository, ) diff --git a/src/app/modules/rag/module.py b/src/app/modules/rag/module.py index d1ed1ca..475cc33 100644 --- a/src/app/modules/rag/module.py +++ b/src/app/modules/rag/module.py @@ -36,7 +36,7 @@ from app.schemas.rag_sessions import ( ) if TYPE_CHECKING: - from app.modules.agent.story_context_repository import StoryContextRepository + from app.modules.rag.persistence.story_context_repository import StoryContextRepository class RagModule: diff --git a/src/app/modules/agent/story_context_repository.py b/src/app/modules/rag/persistence/story_context_repository.py similarity index 99% rename from src/app/modules/agent/story_context_repository.py rename to src/app/modules/rag/persistence/story_context_repository.py index 81f6dfd..f6bf93c 100644 --- a/src/app/modules/agent/story_context_repository.py +++ b/src/app/modules/rag/persistence/story_context_repository.py @@ -1,3 +1,5 @@ +"""Репозиторий Story-контекста и схемы таблиц. Перенесён из agent для отвязки от legacy-стека.""" + from __future__ import annotations import json diff --git a/src/app/modules/shared/bootstrap.py b/src/app/modules/shared/bootstrap.py index d62259d..34ea8ba 100644 --- a/src/app/modules/shared/bootstrap.py +++ b/src/app/modules/shared/bootstrap.py @@ -3,13 +3,12 @@ import time from app.modules.shared.checkpointer import get_checkpointer -def bootstrap_database(rag_repository, chat_repository, agent_repository, story_context_repository) -> None: +def bootstrap_database(rag_repository, chat_repository, story_context_repository) -> None: last_error: Exception | None = None for attempt in range(1, 16): try: rag_repository.ensure_tables() chat_repository.ensure_tables() - agent_repository.ensure_tables() story_context_repository.ensure_tables() get_checkpointer() return diff --git a/tests/__pycache__/conftest.cpython-312-pytest-9.0.2.pyc b/tests/__pycache__/conftest.cpython-312-pytest-9.0.2.pyc index f3bdba3..57094ca 100644 Binary files a/tests/__pycache__/conftest.cpython-312-pytest-9.0.2.pyc and b/tests/__pycache__/conftest.cpython-312-pytest-9.0.2.pyc differ diff --git a/tests/conftest.py b/tests/conftest.py index fdcbc1f..15998fb 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -5,5 +5,7 @@ from pathlib import Path ROOT = Path(__file__).resolve().parents[1] SRC = ROOT / "src" +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) if str(SRC) not in sys.path: sys.path.insert(0, str(SRC)) diff --git a/tests/unit_tests/agent/engine/router/test_router_service_intent_policy.py b/tests/unit_tests/agent/engine/router/test_router_service_intent_policy.py deleted file mode 100644 index 7a7ec95..0000000 --- a/tests/unit_tests/agent/engine/router/test_router_service_intent_policy.py +++ /dev/null @@ -1,181 +0,0 @@ -import sys -import types - -sqlalchemy = types.ModuleType("sqlalchemy") -sqlalchemy.text = lambda value: value -sqlalchemy.create_engine = lambda *args, **kwargs: object() -sys.modules.setdefault("sqlalchemy", sqlalchemy) - -sqlalchemy_engine = types.ModuleType("sqlalchemy.engine") -sqlalchemy_engine.Engine = object -sys.modules.setdefault("sqlalchemy.engine", sqlalchemy_engine) - -sqlalchemy_orm = types.ModuleType("sqlalchemy.orm") -sqlalchemy_orm.sessionmaker = lambda *args, **kwargs: object() -sys.modules.setdefault("sqlalchemy.orm", sqlalchemy_orm) - -sqlalchemy_pool = types.ModuleType("sqlalchemy.pool") -sqlalchemy_pool.NullPool = object -sys.modules.setdefault("sqlalchemy.pool", sqlalchemy_pool) - -from app.modules.agent.engine.router.router_service import RouterService -from app.modules.agent.engine.router.schemas import RouteDecision, RouterContext - - -class _FakeRegistry: - def is_valid(self, domain_id: str, process_id: str) -> bool: - return (domain_id, process_id) in { - ("default", "general"), - ("project", "qa"), - ("project", "edits"), - ("docs", "generation"), - } - - def get_factory(self, domain_id: str, process_id: str): - return object() - - -class _FakeClassifier: - def __init__(self, decision: RouteDecision | None = None, forced: RouteDecision | None = None) -> None: - self._decision = decision or RouteDecision(domain_id="project", process_id="qa", confidence=0.95, reason="new_intent") - self._forced = forced - self.calls = 0 - - def from_mode(self, mode: str) -> RouteDecision | None: - return self._forced if mode != "auto" else None - - def classify_new_intent(self, user_message: str, context: RouterContext) -> RouteDecision: - self.calls += 1 - return self._decision - - -class _FakeContextStore: - def __init__(self, context: RouterContext) -> None: - self._context = context - self.updated: list[dict] = [] - - def get(self, conversation_key: str) -> RouterContext: - return self._context - - def update(self, conversation_key: str, **kwargs) -> None: - self.updated.append({"conversation_key": conversation_key, **kwargs}) - - -class _FakeSwitchDetector: - def __init__(self, should_switch: bool) -> None: - self._should_switch = should_switch - - def should_switch(self, user_message: str, context: RouterContext) -> bool: - return self._should_switch - - -def test_router_service_classifies_first_message() -> None: - service = RouterService( - registry=_FakeRegistry(), - classifier=_FakeClassifier(), - context_store=_FakeContextStore(RouterContext()), - switch_detector=_FakeSwitchDetector(False), - ) - - route = service.resolve("Объясни как работает endpoint", "dialog-1") - - assert route.domain_id == "project" - assert route.process_id == "qa" - assert route.decision_type == "start" - - -def test_router_service_keeps_current_intent_for_follow_up() -> None: - context = RouterContext( - active_intent={"domain_id": "project", "process_id": "qa"}, - last_routing={"domain_id": "project", "process_id": "qa"}, - dialog_started=True, - turn_index=1, - ) - classifier = _FakeClassifier( - decision=RouteDecision(domain_id="docs", process_id="generation", confidence=0.99, reason="should_not_run") - ) - service = RouterService( - registry=_FakeRegistry(), - classifier=classifier, - context_store=_FakeContextStore(context), - switch_detector=_FakeSwitchDetector(False), - ) - - route = service.resolve("Покажи подробнее", "dialog-1") - - assert route.domain_id == "project" - assert route.process_id == "qa" - assert route.decision_type == "continue" - assert classifier.calls == 0 - - -def test_router_service_switches_only_on_explicit_new_intent() -> None: - context = RouterContext( - active_intent={"domain_id": "project", "process_id": "qa"}, - last_routing={"domain_id": "project", "process_id": "qa"}, - dialog_started=True, - turn_index=2, - ) - classifier = _FakeClassifier( - decision=RouteDecision(domain_id="project", process_id="edits", confidence=0.96, reason="explicit_edit") - ) - service = RouterService( - registry=_FakeRegistry(), - classifier=classifier, - context_store=_FakeContextStore(context), - switch_detector=_FakeSwitchDetector(True), - ) - - route = service.resolve("Теперь измени файл README.md", "dialog-1") - - assert route.domain_id == "project" - assert route.process_id == "edits" - assert route.decision_type == "switch" - assert route.explicit_switch is True - assert classifier.calls == 1 - - -def test_router_service_keeps_current_when_explicit_switch_is_unresolved() -> None: - context = RouterContext( - active_intent={"domain_id": "project", "process_id": "qa"}, - last_routing={"domain_id": "project", "process_id": "qa"}, - dialog_started=True, - turn_index=2, - ) - classifier = _FakeClassifier( - decision=RouteDecision(domain_id="docs", process_id="generation", confidence=0.2, reason="low_confidence") - ) - service = RouterService( - registry=_FakeRegistry(), - classifier=classifier, - context_store=_FakeContextStore(context), - switch_detector=_FakeSwitchDetector(True), - ) - - route = service.resolve("Теперь сделай что-то другое", "dialog-1") - - assert route.domain_id == "project" - assert route.process_id == "qa" - assert route.decision_type == "continue" - assert route.reason == "explicit_switch_unresolved_keep_current" - - -def test_router_service_persists_decision_type() -> None: - store = _FakeContextStore(RouterContext()) - service = RouterService( - registry=_FakeRegistry(), - classifier=_FakeClassifier(), - context_store=store, - switch_detector=_FakeSwitchDetector(False), - ) - - service.persist_context( - "dialog-1", - domain_id="project", - process_id="qa", - user_message="Объясни", - assistant_message="Ответ", - decision_type="continue", - ) - - assert store.updated[0]["decision_type"] == "continue" diff --git a/tests/unit_tests/agent/orchestrator/test_code_explain_actions.py b/tests/unit_tests/agent/orchestrator/test_code_explain_actions.py deleted file mode 100644 index 052df7b..0000000 --- a/tests/unit_tests/agent/orchestrator/test_code_explain_actions.py +++ /dev/null @@ -1,59 +0,0 @@ -from app.modules.agent.engine.orchestrator.actions.code_explain_actions import CodeExplainActions -from app.modules.agent.engine.orchestrator.execution_context import ExecutionContext -from app.modules.agent.engine.orchestrator.models import ( - ArtifactType, - ExecutionPlan, - OutputContract, - RoutingMeta, - Scenario, - TaskConstraints, - TaskSpec, -) -from app.modules.rag.explain.models import ExplainIntent, ExplainPack - - -class _FakeRetriever: - def build_pack(self, rag_session_id: str, user_query: str, *, file_candidates: list[dict] | None = None) -> ExplainPack: - assert rag_session_id == "rag-1" - assert "endpoint" in user_query - assert file_candidates == [{"path": "app/api/users.py", "content": "..." }] - return ExplainPack(intent=ExplainIntent(raw_query=user_query, normalized_query=user_query)) - - -def _ctx() -> ExecutionContext: - task = TaskSpec( - task_id="task-1", - dialog_session_id="dialog-1", - rag_session_id="rag-1", - user_message="Explain endpoint get_user", - scenario=Scenario.EXPLAIN_PART, - routing=RoutingMeta(domain_id="project", process_id="qa", confidence=0.9, reason="test"), - constraints=TaskConstraints(), - output_contract=OutputContract(result_type="answer"), - metadata={"rag_context": "", "confluence_context": "", "files_map": {}}, - ) - plan = ExecutionPlan( - plan_id="plan-1", - task_id="task-1", - scenario=Scenario.EXPLAIN_PART, - template_id="tpl", - template_version="1", - steps=[], - ) - ctx = ExecutionContext(task=task, plan=plan, graph_resolver=lambda *_: None, graph_invoker=lambda *_: {}) - ctx.artifacts.put( - key="source_bundle", - artifact_type=ArtifactType.STRUCTURED_JSON, - content={"file_candidates": [{"path": "app/api/users.py", "content": "..."}]}, - ) - return ctx - - -def test_code_explain_actions_store_explain_pack() -> None: - ctx = _ctx() - actions = CodeExplainActions(_FakeRetriever()) - - actions.build_code_explain_pack(ctx) - - stored = ctx.artifacts.get_content("explain_pack", {}) - assert stored["intent"]["raw_query"] == "Explain endpoint get_user" diff --git a/tests/unit_tests/agent/orchestrator/test_edit_actions_case_insensitive_path.py b/tests/unit_tests/agent/orchestrator/test_edit_actions_case_insensitive_path.py deleted file mode 100644 index a46fe5e..0000000 --- a/tests/unit_tests/agent/orchestrator/test_edit_actions_case_insensitive_path.py +++ /dev/null @@ -1,61 +0,0 @@ -from __future__ import annotations - -from app.modules.agent.engine.orchestrator.actions.edit_actions import EditActions -from app.modules.agent.engine.orchestrator.execution_context import ExecutionContext -from app.modules.agent.engine.orchestrator.models import ( - ExecutionPlan, - OutputContract, - RoutingMeta, - Scenario, - TaskConstraints, - TaskSpec, -) - - -def _ctx() -> ExecutionContext: - task = TaskSpec( - task_id="task-1", - dialog_session_id="dialog-1", - rag_session_id="rag-1", - mode="auto", - user_message="Добавь в readme.md в конце строку про автора", - scenario=Scenario.TARGETED_EDIT, - routing=RoutingMeta(domain_id="project", process_id="edits", confidence=0.95, reason="test"), - constraints=TaskConstraints(allow_writes=True), - output_contract=OutputContract(result_type="changeset"), - metadata={ - "files_map": { - "README.md": { - "path": "README.md", - "content": "# Title\n", - "content_hash": "hash123", - } - } - }, - ) - plan = ExecutionPlan( - plan_id="plan-1", - task_id="task-1", - scenario=Scenario.TARGETED_EDIT, - template_id="targeted_edit_v1", - template_version="1.0", - steps=[], - ) - return ExecutionContext(task=task, plan=plan, graph_resolver=lambda *_: None, graph_invoker=lambda *_: {}) - - -def test_edit_actions_resolve_path_case_insensitive_and_keep_update() -> None: - actions = EditActions() - ctx = _ctx() - - actions.resolve_target(ctx) - actions.load_target_context(ctx) - actions.plan_minimal_patch(ctx) - actions.generate_patch(ctx) - - target = ctx.artifacts.get_content("target_context", {}) - changeset = ctx.artifacts.get_content("raw_changeset", []) - - assert target["path"] == "README.md" - assert changeset[0]["path"] == "README.md" - assert changeset[0]["op"] == "update" diff --git a/tests/unit_tests/agent/orchestrator/test_eval_suite.py b/tests/unit_tests/agent/orchestrator/test_eval_suite.py deleted file mode 100644 index 62dd548..0000000 --- a/tests/unit_tests/agent/orchestrator/test_eval_suite.py +++ /dev/null @@ -1,56 +0,0 @@ -import asyncio - -import pytest - -from app.modules.agent.engine.orchestrator.models import OutputContract, RoutingMeta, Scenario, TaskConstraints, TaskSpec -from app.modules.agent.engine.orchestrator.service import OrchestratorService - - -@pytest.mark.parametrize( - "scenario,expect_changeset", - [ - (Scenario.EXPLAIN_PART, False), - (Scenario.ANALYTICS_REVIEW, False), - (Scenario.DOCS_FROM_ANALYTICS, True), - (Scenario.TARGETED_EDIT, True), - (Scenario.GHERKIN_MODEL, True), - ], -) -def test_eval_suite_scenarios_run(scenario: Scenario, expect_changeset: bool) -> None: - service = OrchestratorService() - - task = TaskSpec( - task_id=f"task-{scenario.value}", - dialog_session_id="dialog-1", - rag_session_id="rag-1", - mode="auto", - user_message="Please process this scenario using project docs and requirements.", - scenario=scenario, - routing=RoutingMeta(domain_id="project", process_id="qa", confidence=0.95, reason="eval"), - constraints=TaskConstraints( - allow_writes=scenario in {Scenario.DOCS_FROM_ANALYTICS, Scenario.TARGETED_EDIT, Scenario.GHERKIN_MODEL}, - max_steps=20, - max_retries_per_step=2, - step_timeout_sec=90, - ), - output_contract=OutputContract(result_type="answer"), - attachments=[{"type": "http_url", "value": "https://example.com/doc"}], - metadata={ - "rag_context": "Requirements context is available.", - "confluence_context": "", - "files_map": {"docs/api/increment.md": {"content": "old", "content_hash": "h1"}}, - }, - ) - - result = asyncio.run( - service.run( - task=task, - graph_resolver=lambda _domain, _process: object(), - graph_invoker=lambda _graph, _state, _dialog: {"answer": "fallback", "changeset": []}, - ) - ) - - assert result.meta["plan"]["status"] in {"completed", "partial"} - assert bool(result.changeset) is expect_changeset - if not expect_changeset: - assert result.answer diff --git a/tests/unit_tests/agent/orchestrator/test_explain_actions.py b/tests/unit_tests/agent/orchestrator/test_explain_actions.py deleted file mode 100644 index 4cd41c9..0000000 --- a/tests/unit_tests/agent/orchestrator/test_explain_actions.py +++ /dev/null @@ -1,131 +0,0 @@ -from app.modules.agent.engine.orchestrator.actions.explain_actions import ExplainActions -from app.modules.agent.engine.orchestrator.execution_context import ExecutionContext -from app.modules.agent.engine.orchestrator.models import ( - ExecutionPlan, - OutputContract, - RoutingMeta, - Scenario, - TaskConstraints, - TaskSpec, -) - - -def _ctx(rag_items: list[dict]) -> ExecutionContext: - task = TaskSpec( - task_id="task-1", - dialog_session_id="dialog-1", - rag_session_id="rag-1", - user_message="Объясни по коду как работает task_processor", - scenario=Scenario.EXPLAIN_PART, - routing=RoutingMeta(domain_id="project", process_id="qa", confidence=0.9, reason="test"), - constraints=TaskConstraints(), - output_contract=OutputContract(result_type="answer"), - metadata={ - "rag_items": rag_items, - "rag_context": "", - "confluence_context": "", - "files_map": {}, - }, - ) - plan = ExecutionPlan( - plan_id="plan-1", - task_id="task-1", - scenario=Scenario.EXPLAIN_PART, - template_id="tpl", - template_version="1", - steps=[], - ) - return ExecutionContext(task=task, plan=plan, graph_resolver=lambda *_: None, graph_invoker=lambda *_: {}) - - -def test_explain_actions_switch_to_code_profile_when_code_layers_present() -> None: - ctx = _ctx( - [ - { - "source": "app/task_processor.py", - "layer": "C1_SYMBOL_CATALOG", - "title": "task_processor.process_task", - "content": "function task_processor.process_task(task)", - "metadata": {"qname": "task_processor.process_task", "kind": "function"}, - }, - { - "source": "app/task_processor.py", - "layer": "C2_DEPENDENCY_GRAPH", - "title": "task_processor.process_task:calls", - "content": "task_processor.process_task calls queue.publish", - "metadata": {"edge_type": "calls"}, - }, - ] - ) - actions = ExplainActions() - - actions.collect_sources(ctx) - actions.extract_logic(ctx) - actions.summarize(ctx) - - sources = ctx.artifacts.get_content("sources", {}) - assert sources["source_profile"] == "code" - answer = str(ctx.artifacts.get_content("final_answer", "")) - assert "кодовых слоев индекса" not in answer - assert "CodeRAG" not in answer - assert "app/task_processor.py" in answer - assert "requirements/docs context" not in answer - - -def test_explain_actions_add_code_details_block() -> None: - ctx = _ctx( - [ - { - "source": "src/config_manager/__init__.py", - "layer": "C1_SYMBOL_CATALOG", - "title": "ConfigManager", - "content": "const ConfigManager\nConfigManager = config_manager.v2.ConfigManagerV2", - "metadata": { - "qname": "ConfigManager", - "kind": "const", - "lang_payload": {"imported_from": "v2.ConfigManagerV2", "import_alias": True}, - }, - }, - { - "source": "src/config_manager/v2/control/base.py", - "layer": "C1_SYMBOL_CATALOG", - "title": "ControlChannel", - "content": "class ControlChannel\nControlChannel(ABC)", - "metadata": {"qname": "ControlChannel", "kind": "class"}, - }, - { - "source": "src/config_manager/v2/core/control_bridge.py", - "layer": "C1_SYMBOL_CATALOG", - "title": "ControlChannelBridge", - "content": "class ControlChannelBridge\nПредоставляет halt и status как обработчики start/stop/status", - "metadata": {"qname": "ControlChannelBridge", "kind": "class"}, - }, - { - "source": "src/config_manager/v2/core/control_bridge.py", - "layer": "C2_DEPENDENCY_GRAPH", - "title": "ControlChannelBridge.on_start:calls", - "content": "ControlChannelBridge.on_start calls self._start_runtime", - "metadata": {"src_qname": "ControlChannelBridge.on_start", "dst_ref": "self._start_runtime"}, - }, - { - "source": "src/config_manager/v2/__init__.py", - "layer": "C0_SOURCE_CHUNKS", - "title": "src/config_manager/v2/__init__.py:1-6", - "content": '"""Контракт: управление через API (config.yaml, секция management)."""', - "metadata": {}, - }, - ] - ) - actions = ExplainActions() - - actions.collect_sources(ctx) - actions.extract_logic(ctx) - actions.summarize(ctx) - - answer = str(ctx.artifacts.get_content("final_answer", "")) - assert "### Что видно по коду" in answer - assert "ConfigManager` в проекте доступен как alias" in answer - assert "ControlChannelBridge.on_start" in answer - assert "### Где смотреть в проекте" in answer - assert "В индексе нет точного символа" not in answer - assert "отдельный интерфейс управления" in answer diff --git a/tests/unit_tests/agent/orchestrator/test_orchestrator_service.py b/tests/unit_tests/agent/orchestrator/test_orchestrator_service.py deleted file mode 100644 index 5b33578..0000000 --- a/tests/unit_tests/agent/orchestrator/test_orchestrator_service.py +++ /dev/null @@ -1,175 +0,0 @@ -import asyncio - -from app.modules.agent.engine.orchestrator.models import ( - OutputContract, - RoutingMeta, - Scenario, - TaskConstraints, - TaskSpec, -) -from app.modules.agent.engine.orchestrator.service import OrchestratorService - - -class DummyGraph: - pass - - -def _task(scenario: Scenario, *, domain_id: str = "project", process_id: str = "qa") -> TaskSpec: - allow_writes = scenario in {Scenario.DOCS_FROM_ANALYTICS, Scenario.TARGETED_EDIT, Scenario.GHERKIN_MODEL} - return TaskSpec( - task_id="task-1", - dialog_session_id="dialog-1", - rag_session_id="rag-1", - mode="auto", - user_message="Explain this module", - scenario=scenario, - routing=RoutingMeta(domain_id=domain_id, process_id=process_id, confidence=0.95, reason="unit-test"), - constraints=TaskConstraints(allow_writes=allow_writes, max_steps=16, max_retries_per_step=2, step_timeout_sec=90), - output_contract=OutputContract(result_type="answer"), - metadata={ - "rag_context": "RAG", - "confluence_context": "", - "files_map": {}, - }, - ) - - -def test_orchestrator_service_returns_answer() -> None: - service = OrchestratorService() - - def graph_resolver(domain_id: str, process_id: str): - assert domain_id == "default" - assert process_id == "general" - return DummyGraph() - - def graph_invoker(_graph, state: dict, dialog_session_id: str): - assert state["message"] == "Explain this module" - assert dialog_session_id == "dialog-1" - return {"answer": "It works.", "changeset": []} - - result = asyncio.run( - service.run( - task=_task(Scenario.GENERAL_QA, domain_id="default", process_id="general"), - graph_resolver=graph_resolver, - graph_invoker=graph_invoker, - ) - ) - assert result.answer == "It works." - assert result.meta["plan"]["status"] == "completed" - - -def test_orchestrator_service_generates_changeset_for_docs_scenario() -> None: - service = OrchestratorService() - - def graph_resolver(_domain_id: str, _process_id: str): - return DummyGraph() - - def graph_invoker(_graph, _state: dict, _dialog_session_id: str): - return {"answer": "unused", "changeset": []} - - result = asyncio.run( - service.run( - task=_task(Scenario.DOCS_FROM_ANALYTICS), - graph_resolver=graph_resolver, - graph_invoker=graph_invoker, - ) - ) - assert result.meta["plan"]["status"] == "completed" - assert len(result.changeset) > 0 - - -def test_orchestrator_service_uses_project_qa_reasoning_without_graph() -> None: - service = OrchestratorService() - requested_graphs: list[tuple[str, str]] = [] - - def graph_resolver(domain_id: str, process_id: str): - requested_graphs.append((domain_id, process_id)) - return DummyGraph() - - def graph_invoker(_graph, state: dict, _dialog_session_id: str): - if "resolved_request" not in state: - return { - "resolved_request": { - "original_message": state["message"], - "normalized_message": state["message"], - "subject_hint": "", - "source_hint": "code", - "russian": True, - } - } - if "question_profile" not in state: - return { - "question_profile": { - "domain": "code", - "intent": "inventory", - "terms": ["control", "channel"], - "entities": [], - "russian": True, - } - } - if "source_bundle" not in state: - return { - "source_bundle": { - "profile": state["question_profile"], - "rag_items": [], - "file_candidates": [ - {"path": "src/config_manager/v2/control/base.py", "content": "class ControlChannel: pass"}, - {"path": "src/config_manager/v2/control/http_channel.py", "content": "class HttpControlChannel(ControlChannel): pass # http api"}, - ], - "rag_total": 0, - "files_total": 2, - } - } - if "analysis_brief" not in state: - return { - "analysis_brief": { - "subject": "management channels", - "findings": ["В коде найдены конкретные реализации каналов управления: http channel (`src/config_manager/v2/control/http_channel.py`)."], - "evidence": ["src/config_manager/v2/control/http_channel.py"], - "gaps": [], - "answer_mode": "inventory", - } - } - return { - "answer_brief": { - "question_profile": state["question_profile"], - "resolved_subject": "management channels", - "key_findings": ["В коде найдены конкретные реализации каналов управления: http channel (`src/config_manager/v2/control/http_channel.py`)."], - "supporting_evidence": ["src/config_manager/v2/control/http_channel.py"], - "missing_evidence": [], - "answer_mode": "inventory", - }, - "final_answer": "## Кратко\n### Что реализовано\n- В коде найдены конкретные реализации каналов управления: http channel (`src/config_manager/v2/control/http_channel.py`).", - } - - task = _task(Scenario.GENERAL_QA).model_copy( - update={ - "user_message": "Какие каналы управления уже реализованы?", - "metadata": { - "rag_context": "", - "confluence_context": "", - "files_map": { - "src/config_manager/v2/control/base.py": { - "content": "class ControlChannel:\n async def start(self):\n ..." - }, - "src/config_manager/v2/control/http_channel.py": { - "content": "class HttpControlChannel(ControlChannel):\n async def start(self):\n ...\n# http api" - }, - }, - "rag_items": [], - }, - } - ) - - result = asyncio.run(service.run(task=task, graph_resolver=graph_resolver, graph_invoker=graph_invoker)) - - assert "Что реализовано" in result.answer - assert "http channel" in result.answer.lower() - assert result.meta["plan"]["status"] == "completed" - assert requested_graphs == [ - ("project_qa", "conversation_understanding"), - ("project_qa", "question_classification"), - ("project_qa", "context_retrieval"), - ("project_qa", "context_analysis"), - ("project_qa", "answer_composition"), - ] diff --git a/tests/unit_tests/agent/orchestrator/test_plan_validator.py b/tests/unit_tests/agent/orchestrator/test_plan_validator.py deleted file mode 100644 index d8d893d..0000000 --- a/tests/unit_tests/agent/orchestrator/test_plan_validator.py +++ /dev/null @@ -1,49 +0,0 @@ -from app.modules.agent.engine.orchestrator.models import ( - ExecutionPlan, - OutputContract, - PlanStep, - RetryPolicy, - RoutingMeta, - Scenario, - TaskConstraints, - TaskSpec, -) -from app.modules.agent.engine.orchestrator.plan_validator import PlanValidator - - -def _task(*, allow_writes: bool) -> TaskSpec: - return TaskSpec( - task_id="t1", - dialog_session_id="d1", - rag_session_id="r1", - mode="auto", - user_message="hello", - scenario=Scenario.GENERAL_QA, - routing=RoutingMeta(domain_id="default", process_id="general", confidence=0.9, reason="test"), - constraints=TaskConstraints(allow_writes=allow_writes, max_steps=10, max_retries_per_step=2, step_timeout_sec=60), - output_contract=OutputContract(result_type="answer"), - ) - - -def test_plan_validator_rejects_write_step_when_not_allowed() -> None: - plan = ExecutionPlan( - plan_id="p1", - task_id="t1", - scenario=Scenario.GENERAL_QA, - template_id="tmp", - template_version="1.0", - steps=[ - PlanStep( - step_id="s1", - title="write", - action_id="collect_state", - executor="function", - side_effect="write", - retry=RetryPolicy(max_attempts=1), - ) - ], - ) - - errors = PlanValidator().validate(plan, _task(allow_writes=False)) - - assert "write_step_not_allowed:s1" in errors diff --git a/tests/unit_tests/agent/orchestrator/test_project_qa_actions.py b/tests/unit_tests/agent/orchestrator/test_project_qa_actions.py deleted file mode 100644 index ca35e1f..0000000 --- a/tests/unit_tests/agent/orchestrator/test_project_qa_actions.py +++ /dev/null @@ -1,71 +0,0 @@ -from app.modules.agent.engine.orchestrator.actions.project_qa_actions import ProjectQaActions -from app.modules.agent.engine.orchestrator.execution_context import ExecutionContext -from app.modules.agent.engine.orchestrator.models import ( - ExecutionPlan, - OutputContract, - RoutingMeta, - Scenario, - TaskConstraints, - TaskSpec, -) - - -def _ctx(message: str, rag_items: list[dict], files_map: dict[str, dict]) -> ExecutionContext: - task = TaskSpec( - task_id="task-1", - dialog_session_id="dialog-1", - rag_session_id="rag-1", - user_message=message, - scenario=Scenario.GENERAL_QA, - routing=RoutingMeta(domain_id="project", process_id="qa", confidence=0.9, reason="test"), - constraints=TaskConstraints(), - output_contract=OutputContract(result_type="answer"), - metadata={ - "rag_items": rag_items, - "rag_context": "", - "confluence_context": "", - "files_map": files_map, - }, - ) - plan = ExecutionPlan( - plan_id="plan-1", - task_id="task-1", - scenario=Scenario.GENERAL_QA, - template_id="tpl", - template_version="1", - steps=[], - ) - return ExecutionContext(task=task, plan=plan, graph_resolver=lambda *_: None, graph_invoker=lambda *_: {}) - - -def test_project_qa_actions_build_inventory_answer_from_code_sources() -> None: - ctx = _ctx( - "Какие каналы управления уже реализованы?", - [], - { - "src/config_manager/v2/control/base.py": {"content": "class ControlChannel:\n async def start(self):\n ..."}, - "src/config_manager/v2/core/control_bridge.py": { - "content": "class ControlChannelBridge:\n async def on_start(self):\n ...\n async def on_status(self):\n ..." - }, - "src/config_manager/v2/control/http_channel.py": { - "content": "class HttpControlChannel(ControlChannel):\n async def start(self):\n ...\n# http api" - }, - "src/config_manager/v2/control/telegram_channel.py": { - "content": "class TelegramControlChannel(ControlChannel):\n async def start(self):\n ...\n# telegram bot" - }, - }, - ) - actions = ProjectQaActions() - - actions.classify_project_question(ctx) - actions.collect_project_sources(ctx) - actions.analyze_project_sources(ctx) - actions.build_project_answer_brief(ctx) - actions.compose_project_answer(ctx) - - answer = str(ctx.artifacts.get_content("final_answer", "")) - assert "### Что реализовано" in answer - assert "http channel" in answer.lower() - assert "telegram channel" in answer.lower() - assert "### Где смотреть в проекте" in answer - diff --git a/tests/unit_tests/agent/orchestrator/test_project_qa_answer_graph_v2.py b/tests/unit_tests/agent/orchestrator/test_project_qa_answer_graph_v2.py deleted file mode 100644 index 769557d..0000000 --- a/tests/unit_tests/agent/orchestrator/test_project_qa_answer_graph_v2.py +++ /dev/null @@ -1,74 +0,0 @@ -import sys -import types - -langgraph = types.ModuleType("langgraph") -langgraph_graph = types.ModuleType("langgraph.graph") -langgraph_graph.END = "END" -langgraph_graph.START = "START" -langgraph_graph.StateGraph = object -sys.modules.setdefault("langgraph", langgraph) -sys.modules.setdefault("langgraph.graph", langgraph_graph) - -from app.modules.agent.engine.graphs.project_qa_step_graphs import ProjectQaAnswerGraphFactory - - -class _FakeLlm: - def __init__(self) -> None: - self.calls: list[tuple[str, str, str | None]] = [] - - def generate(self, prompt_name: str, user_input: str, *, log_context: str | None = None) -> str: - self.calls.append((prompt_name, user_input, log_context)) - return "## Summary\n[entrypoint_1] [excerpt_1]" - - -def test_project_qa_answer_graph_uses_v2_prompt_when_explain_pack_present() -> None: - llm = _FakeLlm() - factory = ProjectQaAnswerGraphFactory(llm) - - result = factory._compose_answer( - { - "message": "Explain endpoint get_user", - "question_profile": {"russian": False}, - "analysis_brief": {"findings": [], "evidence": [], "gaps": [], "answer_mode": "summary"}, - "explain_pack": { - "intent": { - "raw_query": "Explain endpoint get_user", - "normalized_query": "Explain endpoint get_user", - "keywords": ["get_user"], - "hints": {"paths": [], "symbols": [], "endpoints": [], "commands": []}, - "expected_entry_types": ["http"], - "depth": "medium", - }, - "selected_entrypoints": [], - "seed_symbols": [], - "trace_paths": [], - "evidence_index": { - "entrypoint_1": { - "evidence_id": "entrypoint_1", - "kind": "entrypoint", - "summary": "/users/{id}", - "location": {"path": "app/api/users.py", "start_line": 10, "end_line": 10}, - "supports": ["handler-1"], - } - }, - "code_excerpts": [ - { - "evidence_id": "excerpt_1", - "symbol_id": "handler-1", - "title": "get_user", - "path": "app/api/users.py", - "start_line": 10, - "end_line": 18, - "content": "async def get_user():\n return 1", - "focus": "overview", - } - ], - "missing": [], - "conflicts": [], - }, - } - ) - - assert result["final_answer"].startswith("## Summary") - assert llm.calls[0][0] == "code_explain_answer_v2" - assert '"evidence_id": "excerpt_1"' in llm.calls[0][1] diff --git a/tests/unit_tests/agent/orchestrator/test_project_qa_retrieval_graph_v2_only.py b/tests/unit_tests/agent/orchestrator/test_project_qa_retrieval_graph_v2_only.py deleted file mode 100644 index 739f8d9..0000000 --- a/tests/unit_tests/agent/orchestrator/test_project_qa_retrieval_graph_v2_only.py +++ /dev/null @@ -1,49 +0,0 @@ -import sys -import types - -langgraph = types.ModuleType("langgraph") -langgraph_graph = types.ModuleType("langgraph.graph") -langgraph_graph.END = "END" -langgraph_graph.START = "START" -langgraph_graph.StateGraph = object -sys.modules.setdefault("langgraph", langgraph) -sys.modules.setdefault("langgraph.graph", langgraph_graph) - -from app.modules.agent.engine.graphs.project_qa_step_graphs import ProjectQaRetrievalGraphFactory - - -class _FailingRag: - async def retrieve(self, rag_session_id: str, query: str): - raise AssertionError("legacy rag should not be called for explain_part") - - -def test_project_qa_retrieval_skips_legacy_rag_for_explain_part() -> None: - factory = ProjectQaRetrievalGraphFactory(_FailingRag()) - - result = factory._retrieve_context( - { - "scenario": "explain_part", - "project_id": "rag-1", - "resolved_request": { - "original_message": "Explain how ConfigManager works", - "normalized_message": "Explain how ConfigManager works", - }, - "question_profile": { - "domain": "code", - "intent": "explain", - "terms": ["configmanager"], - "entities": ["ConfigManager"], - "russian": False, - }, - "files_map": { - "src/config_manager/__init__.py": { - "content": "from .v2 import ConfigManagerV2 as ConfigManager", - "content_hash": "hash-1", - } - }, - } - ) - - bundle = result["source_bundle"] - assert bundle["rag_items"] == [] - assert bundle["files_total"] >= 1 diff --git a/tests/unit_tests/agent/orchestrator/test_quality_metrics.py b/tests/unit_tests/agent/orchestrator/test_quality_metrics.py deleted file mode 100644 index 53b7b46..0000000 --- a/tests/unit_tests/agent/orchestrator/test_quality_metrics.py +++ /dev/null @@ -1,42 +0,0 @@ -import asyncio - -from app.modules.agent.engine.orchestrator.models import OutputContract, OutputSection, RoutingMeta, Scenario, TaskConstraints, TaskSpec -from app.modules.agent.engine.orchestrator.service import OrchestratorService - - -def test_quality_metrics_present_and_scored() -> None: - service = OrchestratorService() - task = TaskSpec( - task_id="quality-1", - dialog_session_id="dialog-1", - rag_session_id="rag-1", - mode="auto", - user_message="Explain architecture", - scenario=Scenario.EXPLAIN_PART, - routing=RoutingMeta(domain_id="project", process_id="qa", confidence=0.9, reason="test"), - constraints=TaskConstraints(allow_writes=False), - output_contract=OutputContract( - result_type="answer", - sections=[ - OutputSection(name="sequence_diagram", format="mermaid"), - OutputSection(name="use_cases", format="markdown"), - OutputSection(name="summary", format="markdown"), - ], - ), - metadata={"rag_context": "A\nB", "confluence_context": "", "files_map": {}}, - ) - - result = asyncio.run( - service.run( - task=task, - graph_resolver=lambda _d, _p: object(), - graph_invoker=lambda _g, _s, _id: {"answer": "unused", "changeset": []}, - ) - ) - - quality = result.meta.get("quality", {}) - assert quality - assert quality.get("faithfulness", {}).get("score") is not None - assert quality.get("coverage", {}).get("score") is not None - assert quality.get("status") in {"ok", "needs_review", "fail"} - assert quality.get("coverage", {}).get("covered_count", 0) >= 1 diff --git a/tests/unit_tests/agent/orchestrator/test_quality_metrics_gate_expectations.py b/tests/unit_tests/agent/orchestrator/test_quality_metrics_gate_expectations.py deleted file mode 100644 index a9daf61..0000000 --- a/tests/unit_tests/agent/orchestrator/test_quality_metrics_gate_expectations.py +++ /dev/null @@ -1,50 +0,0 @@ -from app.modules.agent.engine.orchestrator.models import ( - ArtifactType, - OutputContract, - OutputSection, - RoutingMeta, - Scenario, - TaskConstraints, - TaskSpec, -) -from app.modules.agent.engine.orchestrator.quality_metrics import QualityMetricsCalculator -from app.modules.agent.engine.orchestrator.template_registry import ScenarioTemplateRegistry -from app.modules.agent.engine.orchestrator.execution_context import ExecutionContext -from app.modules.agent.engine.orchestrator.models import PlanStatus - - -def test_quality_metrics_coverage_reflects_missing_required_sections() -> None: - task = TaskSpec( - task_id="quality-2", - dialog_session_id="dialog-1", - rag_session_id="rag-1", - mode="auto", - user_message="Explain architecture", - scenario=Scenario.EXPLAIN_PART, - routing=RoutingMeta(domain_id="project", process_id="qa", confidence=0.9, reason="test"), - constraints=TaskConstraints(allow_writes=False), - output_contract=OutputContract( - result_type="answer", - sections=[ - OutputSection(name="sequence_diagram", format="mermaid"), - OutputSection(name="use_cases", format="markdown"), - OutputSection(name="summary", format="markdown"), - ], - ), - metadata={"rag_context": "A", "confluence_context": "", "files_map": {}}, - ) - - plan = ScenarioTemplateRegistry().build(task) - plan.status = PlanStatus.COMPLETED - ctx = ExecutionContext( - task=task, - plan=plan, - graph_resolver=lambda _d, _p: object(), - graph_invoker=lambda _g, _s, _id: {}, - ) - ctx.artifacts.put(key="final_answer", artifact_type=ArtifactType.TEXT, content="Only summary text") - - metrics = QualityMetricsCalculator().build(ctx, step_results=[]) - - assert metrics["coverage"]["score"] < 1.0 - assert "sequence_diagram" in metrics["coverage"]["missing_items"] diff --git a/tests/unit_tests/agent/orchestrator/test_template_registry.py b/tests/unit_tests/agent/orchestrator/test_template_registry.py deleted file mode 100644 index 8572107..0000000 --- a/tests/unit_tests/agent/orchestrator/test_template_registry.py +++ /dev/null @@ -1,48 +0,0 @@ -from app.modules.agent.engine.orchestrator.models import OutputContract, RoutingMeta, Scenario, TaskConstraints, TaskSpec -from app.modules.agent.engine.orchestrator.template_registry import ScenarioTemplateRegistry - - -def _task(scenario: Scenario) -> TaskSpec: - return TaskSpec( - task_id="t1", - dialog_session_id="d1", - rag_session_id="r1", - mode="auto", - user_message="run scenario", - scenario=scenario, - routing=RoutingMeta(domain_id="project", process_id="qa", confidence=0.9, reason="test"), - constraints=TaskConstraints( - allow_writes=scenario in {Scenario.DOCS_FROM_ANALYTICS, Scenario.TARGETED_EDIT, Scenario.GHERKIN_MODEL} - ), - output_contract=OutputContract(result_type="answer"), - metadata={"rag_context": "ctx", "confluence_context": "", "files_map": {}}, - ) - - -def test_template_registry_has_multi_step_review_docs_edit_gherkin() -> None: - registry = ScenarioTemplateRegistry() - - review_steps = [step.step_id for step in registry.build(_task(Scenario.ANALYTICS_REVIEW)).steps] - docs_steps = [step.step_id for step in registry.build(_task(Scenario.DOCS_FROM_ANALYTICS)).steps] - edit_steps = [step.step_id for step in registry.build(_task(Scenario.TARGETED_EDIT)).steps] - gherkin_steps = [step.step_id for step in registry.build(_task(Scenario.GHERKIN_MODEL)).steps] - - assert "structural_check" in review_steps and "compose_review_report" in review_steps - assert "extract_change_intents" in docs_steps and "build_changeset" in docs_steps - assert "resolve_target" in edit_steps and "finalize_changeset" in edit_steps - assert "generate_gherkin_bundle" in gherkin_steps and "validate_coverage" in gherkin_steps - - assert len(review_steps) >= 7 - assert len(docs_steps) >= 9 - assert len(edit_steps) >= 7 - assert len(gherkin_steps) >= 8 - - -def test_template_registry_adds_code_explain_pack_step_for_project_explain() -> None: - registry = ScenarioTemplateRegistry() - - steps = [step.step_id for step in registry.build(_task(Scenario.EXPLAIN_PART)).steps] - - assert "code_explain_pack_step" in steps - assert steps.index("code_explain_pack_step") > steps.index("context_retrieval") - assert steps.index("code_explain_pack_step") < steps.index("context_analysis") diff --git a/tests/unit_tests/agent/test_story_session_recorder.py b/tests/unit_tests/agent/test_story_session_recorder.py deleted file mode 100644 index 4d00f92..0000000 --- a/tests/unit_tests/agent/test_story_session_recorder.py +++ /dev/null @@ -1,48 +0,0 @@ -from __future__ import annotations - -from app.modules.agent.story_session_recorder import StorySessionRecorder -from app.schemas.changeset import ChangeItem, ChangeOp - - -class FakeStoryRepo: - def __init__(self) -> None: - self.calls: list[dict] = [] - - def add_session_artifact(self, **kwargs) -> None: - self.calls.append(kwargs) - - -def test_record_run_stores_attachment_and_changeset_artifacts() -> None: - repo = FakeStoryRepo() - recorder = StorySessionRecorder(repo) - - recorder.record_run( - dialog_session_id="dialog-1", - rag_session_id="rag-1", - scenario="docs_from_analytics", - attachments=[ - {"type": "confluence_url", "value": "https://example.org/doc"}, - {"type": "file_ref", "value": "local.md"}, - ], - answer="Generated docs update summary", - changeset=[ - ChangeItem( - op=ChangeOp.UPDATE, - path="docs/api.md", - base_hash="abc", - proposed_content="new", - reason="sync endpoint section", - ) - ], - ) - - assert len(repo.calls) == 3 - assert repo.calls[0]["artifact_role"] == "analysis" - assert repo.calls[0]["source_ref"] == "https://example.org/doc" - - assert repo.calls[1]["artifact_role"] == "doc_change" - assert repo.calls[1]["summary"] == "Generated docs update summary" - - assert repo.calls[2]["artifact_role"] == "doc_change" - assert repo.calls[2]["path"] == "docs/api.md" - assert repo.calls[2]["change_type"] == "updated"