Удаление легаси
This commit is contained in:
@@ -31,7 +31,6 @@ def create_app() -> FastAPI:
|
|||||||
app.include_router(modules.rag.public_router())
|
app.include_router(modules.rag.public_router())
|
||||||
app.include_router(modules.rag.internal_router())
|
app.include_router(modules.rag.internal_router())
|
||||||
app.include_router(modules.rag_repo.internal_router())
|
app.include_router(modules.rag_repo.internal_router())
|
||||||
app.include_router(modules.agent.internal_router())
|
|
||||||
|
|
||||||
register_error_handlers(app)
|
register_error_handlers(app)
|
||||||
|
|
||||||
|
|||||||
@@ -1,91 +1,37 @@
|
|||||||
# Модуль agent
|
# Модуль agent
|
||||||
|
|
||||||
## 1. Функции модуля
|
## 1. Назначение
|
||||||
- Оркестрация выполнения пользовательского запроса поверх роутера интентов и графов.
|
Модуль обеспечивает выполнение code-QA пайплайна для pipeline_setup_v3 и интеграцию с chat-слоем через адаптер к контракту `AgentRunner`. Оркестрация основана на **IntentRouterV2** (RAG) и **CodeQaRuntimeExecutor** (роутинг → retrieval → evidence gate → генерация ответа).
|
||||||
- Формирование `TaskSpec`, запуск оркестратора шагов и сборка финального результата.
|
|
||||||
- Реализация необходимых для агента tools и их интеграция с остальной логикой выполнения.
|
|
||||||
- Сохранение quality-метрик и session-артефактов для последующей привязки к Story.
|
|
||||||
|
|
||||||
## 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
|
```mermaid
|
||||||
classDiagram
|
classDiagram
|
||||||
class AgentModule
|
class CodeQaRuntimeExecutor
|
||||||
class GraphAgentRuntime
|
class CodeQaRunnerAdapter
|
||||||
class OrchestratorService
|
class AgentLlmService
|
||||||
class TaskSpecBuilder
|
class PromptLoader
|
||||||
class StorySessionRecorder
|
class IntentRouterV2
|
||||||
class StoryContextRepository
|
class CodeQaRetrievalAdapter
|
||||||
class ConfluenceService
|
|
||||||
class AgentRepository
|
|
||||||
|
|
||||||
AgentModule --> GraphAgentRuntime
|
CodeQaRunnerAdapter --> CodeQaRuntimeExecutor
|
||||||
AgentModule --> ConfluenceService
|
CodeQaRuntimeExecutor --> AgentLlmService
|
||||||
AgentModule --> StorySessionRecorder
|
CodeQaRuntimeExecutor --> IntentRouterV2
|
||||||
StorySessionRecorder --> StoryContextRepository
|
CodeQaRuntimeExecutor --> CodeQaRetrievalAdapter
|
||||||
GraphAgentRuntime --> OrchestratorService
|
AgentLlmService --> PromptLoader
|
||||||
GraphAgentRuntime --> TaskSpecBuilder
|
|
||||||
GraphAgentRuntime --> AgentRepository
|
|
||||||
GraphAgentRuntime --> ConfluenceService
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 3. Описание классов
|
## 4. Точки входа
|
||||||
- `AgentModule`: собирает runtime и публикует внутренние tools-роуты.
|
- **Тесты pipeline_setup_v3**: `AgentRuntimeAdapter` импортирует `CodeQaRuntimeExecutor`, `IntentRouterV2`, `CodeQaRepoContextFactory`, `CodeQaRetrievalAdapter`, `AgentLlmService`, `PromptLoader` напрямую из соответствующих пакетов.
|
||||||
Методы: `__init__` — связывает зависимости модуля; `internal_router` — регистрирует internal API tools.
|
- **Приложение (chat)**: `ModularApplication` собирает `CodeQaRuntimeExecutor` и оборачивает его в `CodeQaRunnerAdapter`; chat передаёт адаптер как `agent_runner` в `ChatModule`.
|
||||||
- `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. Сиквенс-диаграммы API
|
## 5. Промпты
|
||||||
|
Используются только промпты, загружаемые из `prompts/`:
|
||||||
### POST /internal/tools/confluence/fetch
|
- **code_qa_*** — ответы по sub_intent (architecture, explain, find_entrypoints, find_tests, general, open_file, trace_flow, degraded, repair).
|
||||||
Назначение: загружает страницу Confluence по URL и возвращает ее контент для дальнейшего использования в сценариях агента.
|
- **rag_intent_router_v2** — классификация интента в IntentRouterV2.
|
||||||
```mermaid
|
- **code_explain_answer_v2** — прямой code-explain в chat (direct_service).
|
||||||
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
|
|
||||||
```
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
70
src/app/modules/agent/code_qa_runner_adapter.py
Normal file
70
src/app/modules/agent/code_qa_runner_adapter.py
Normal file
@@ -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,
|
||||||
|
)
|
||||||
@@ -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(),
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
@@ -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
|
|
||||||
@@ -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"),
|
|
||||||
}
|
|
||||||
@@ -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()
|
|
||||||
@@ -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
|
|
||||||
@@ -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)
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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()
|
|
||||||
@@ -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))
|
|
||||||
@@ -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 "")),
|
|
||||||
)
|
|
||||||
@@ -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 ""
|
|
||||||
@@ -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)
|
|
||||||
@@ -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)
|
|
||||||
@@ -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
|
|
||||||
@@ -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()
|
|
||||||
@@ -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
|
|
||||||
@@ -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",
|
|
||||||
]
|
|
||||||
@@ -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",
|
|
||||||
]
|
|
||||||
@@ -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"))]
|
|
||||||
@@ -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
|
|
||||||
@@ -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)]
|
|
||||||
@@ -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<!-- orchestrator note: {plan.get('instruction', '')[:100]} -->\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
|
|
||||||
@@ -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
|
|
||||||
@@ -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),
|
|
||||||
]
|
|
||||||
@@ -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))]
|
|
||||||
@@ -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
|
|
||||||
@@ -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)
|
|
||||||
@@ -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),
|
|
||||||
]
|
|
||||||
@@ -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
|
|
||||||
@@ -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)
|
|
||||||
@@ -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()
|
|
||||||
@@ -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 "",
|
|
||||||
)
|
|
||||||
@@ -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)
|
|
||||||
@@ -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",
|
|
||||||
]
|
|
||||||
@@ -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)
|
|
||||||
@@ -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)
|
|
||||||
@@ -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)
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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"]
|
|
||||||
@@ -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)
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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")])
|
|
||||||
@@ -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)
|
|
||||||
@@ -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"]
|
|
||||||
@@ -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,
|
|
||||||
)
|
|
||||||
@@ -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)
|
|
||||||
@@ -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)
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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,
|
|
||||||
)
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
Ты анализируешь, есть ли в проекте существующая документация, в которую нужно встраиваться.
|
|
||||||
|
|
||||||
Оцени входные данные:
|
|
||||||
- User request
|
|
||||||
- Requested target path
|
|
||||||
- Detected documentation candidates (пути и сниппеты)
|
|
||||||
|
|
||||||
Критерии EXISTS=yes:
|
|
||||||
- Есть хотя бы один релевантный doc-файл, и
|
|
||||||
- Он по смыслу подходит под запрос пользователя.
|
|
||||||
|
|
||||||
Критерии EXISTS=no:
|
|
||||||
- Нет релевантных doc-файлов, или
|
|
||||||
- Есть только нерелевантные/пустые заготовки.
|
|
||||||
|
|
||||||
Верни строго две строки:
|
|
||||||
EXISTS: yes|no
|
|
||||||
SUMMARY: <короткое объяснение на 1-2 предложения>
|
|
||||||
@@ -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.
|
|
||||||
@@ -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.
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
Ты технический писатель и готовишь краткий итог по выполненной задаче документации.
|
|
||||||
|
|
||||||
Верни только markdown-текст без JSON и без лишних вступлений.
|
|
||||||
Структура ответа:
|
|
||||||
1) "Что сделано" — 3-6 коротких пунктов по основным частям пользовательского запроса.
|
|
||||||
2) "Измененные файлы" — список файлов с кратким описанием изменения по каждому файлу.
|
|
||||||
3) "Ограничения" — добавляй только если в данных есть явные пробелы или ограничения.
|
|
||||||
|
|
||||||
Правила:
|
|
||||||
- Используй только входные данные.
|
|
||||||
- Не выдумывай изменения, которых нет в списке changed files.
|
|
||||||
- Пиши коротко и по делу.
|
|
||||||
@@ -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/<file>.md",
|
|
||||||
"content": "<полное содержимое markdown-файла>",
|
|
||||||
"reason": "<кратко зачем создан/обновлен файл>"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "docs/logic/<file>.md",
|
|
||||||
"content": "<полное содержимое markdown-файла>",
|
|
||||||
"reason": "<кратко зачем создан/обновлен файл>"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
- Для from_scratch сформируй несколько файлов и обязательно покрой обе папки: `docs/api` и `docs/logic`.
|
|
||||||
- Для incremental_update также соблюдай правило атомарности: один файл = один метод API или один атомарный кусок логики/требования.
|
|
||||||
@@ -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.
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
Ты валидатор качества документации.
|
|
||||||
|
|
||||||
Проверь:
|
|
||||||
- Соответствие strategy и user request.
|
|
||||||
- Соответствие generated document плану секций.
|
|
||||||
- Отсутствие очевидных выдуманных фактов.
|
|
||||||
- Практическую применимость текста к проекту.
|
|
||||||
- Для incremental_update: минимально необходимый инкремент без лишнего переписывания.
|
|
||||||
- Проверку структуры документации:
|
|
||||||
- есть разбиение по папкам `docs/api` и `docs/logic`;
|
|
||||||
- один файл описывает только один API-метод или один атомарный кусок логики;
|
|
||||||
- сценарии состоят из коротких шагов, а технические детали вынесены в функциональные требования.
|
|
||||||
|
|
||||||
Если документ приемлем:
|
|
||||||
PASS: yes
|
|
||||||
FEEDBACK: <коротко, что ок>
|
|
||||||
|
|
||||||
Если документ неприемлем:
|
|
||||||
PASS: no
|
|
||||||
FEEDBACK: <коротко, что исправить в следующей попытке>
|
|
||||||
|
|
||||||
Верни ровно две строки в этом формате.
|
|
||||||
@@ -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
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
Ты инженерный AI-ассистент. Ответь по проекту коротко и по делу.
|
|
||||||
Если в контексте недостаточно данных, явно укажи пробелы.
|
|
||||||
Не выдумывай факты, используй только входные данные.
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
Ты инженерный AI-ассистент по текущему проекту.
|
|
||||||
|
|
||||||
Сформируй точный ответ на вопрос пользователя, используя только входной контекст.
|
|
||||||
Приоритет источников: сначала RAG context, затем Confluence context.
|
|
||||||
|
|
||||||
Правила:
|
|
||||||
- Не выдумывай факты и явно помечай пробелы в данных.
|
|
||||||
- Отвечай структурировано и коротко.
|
|
||||||
- Если пользователь просит шаги, дай практичный пошаговый план.
|
|
||||||
@@ -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, которых нет в контракте.
|
|
||||||
- Минимизируй изменения и не трогай нерелевантные части файла.
|
|
||||||
@@ -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.
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
Ты валидируешь changeset правок файла.
|
|
||||||
На вход приходит JSON с request, contracts и changeset (op, path, reason).
|
|
||||||
|
|
||||||
Проверь:
|
|
||||||
1) изменения соответствуют запросу,
|
|
||||||
1.1) изменения соответствуют контракту (разрешенные блоки и лимиты),
|
|
||||||
2) нет лишних нерелевантных правок,
|
|
||||||
3) изменены только действительно нужные файлы,
|
|
||||||
4) нет косметических правок (пробелы/форматирование без смысла),
|
|
||||||
5) нет добавления новых секций/заголовков, если это не запрошено явно.
|
|
||||||
|
|
||||||
Верни только JSON:
|
|
||||||
{"pass": true|false, "feedback": "<short reason>"}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
Ты классификатор маршрутов агента.
|
|
||||||
На вход ты получаешь JSON с полями:
|
|
||||||
- message: текущий запрос пользователя
|
|
||||||
- history: последние сообщения диалога
|
|
||||||
- allowed_routes: допустимые маршруты
|
|
||||||
|
|
||||||
Выбери ровно один маршрут из allowed_routes.
|
|
||||||
Верни только JSON без markdown и пояснений.
|
|
||||||
|
|
||||||
Строгий формат ответа:
|
|
||||||
{"route":"<one_of_allowed_routes>","confidence":<number_0_to_1>,"reason":"<short_reason>"}
|
|
||||||
|
|
||||||
Правила маршрутизации:
|
|
||||||
- project/qa: пользователь задает вопросы про текущий проект, его код, архитектуру, модули, поведение, ограничения.
|
|
||||||
- project/edits: пользователь просит внести правки в существующие файлы проекта (контент, конфиги, тексты, шаблоны), без реализации новой кодовой логики.
|
|
||||||
- docs/generation: пользователь просит подготовить/обновить документацию, инструкции, markdown-материалы.
|
|
||||||
- default/general: остальные случаи, включая общие вопросы и консультации.
|
|
||||||
|
|
||||||
Приоритет:
|
|
||||||
- Если в запросе есть явная команда правки конкретного файла (например `README.md`, путь к файлу, "добавь в конец файла"), выбирай project/edits.
|
|
||||||
- docs/generation выбирай для задач подготовки документации в целом, а не для точечной правки одного файла.
|
|
||||||
|
|
||||||
Если есть сомнения, выбирай default/general и confidence <= 0.6.
|
|
||||||
@@ -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]
|
|
||||||
@@ -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())
|
|
||||||
@@ -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"
|
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
from app.modules.agent.module import AgentModule
|
from app.modules.agent.code_qa_runtime import CodeQaRuntimeExecutor
|
||||||
from app.modules.agent.repository import AgentRepository
|
from app.modules.agent.code_qa_runtime.retrieval_adapter import CodeQaRetrievalAdapter
|
||||||
from app.modules.agent.story_context_repository import StoryContextRepository, StoryContextSchemaRepository
|
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.direct_service import CodeExplainChatService
|
||||||
from app.modules.chat.dialog_store import DialogSessionStore
|
from app.modules.chat.dialog_store import DialogSessionStore
|
||||||
from app.modules.chat.repository import ChatRepository
|
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.session_resolver import ChatSessionResolver
|
||||||
from app.modules.chat.task_store import TaskStore
|
from app.modules.chat.task_store import TaskStore
|
||||||
from app.modules.rag.persistence.repository import RagRepository
|
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.explain import CodeExplainRetrieverV2, CodeGraphRepository, LayeredRetrievalGateway
|
||||||
from app.modules.rag.module import RagModule, RagRepoModule
|
from app.modules.rag.module import RagModule, RagRepoModule
|
||||||
from app.modules.shared.bootstrap import bootstrap_database
|
from app.modules.shared.bootstrap import bootstrap_database
|
||||||
@@ -21,7 +24,6 @@ class ModularApplication:
|
|||||||
self.retry = RetryExecutor()
|
self.retry = RetryExecutor()
|
||||||
self.rag_repository = RagRepository()
|
self.rag_repository = RagRepository()
|
||||||
self.chat_repository = ChatRepository()
|
self.chat_repository = ChatRepository()
|
||||||
self.agent_repository = AgentRepository()
|
|
||||||
self.story_context_schema_repository = StoryContextSchemaRepository()
|
self.story_context_schema_repository = StoryContextSchemaRepository()
|
||||||
self.story_context_repository = StoryContextRepository()
|
self.story_context_repository = StoryContextRepository()
|
||||||
self.chat_tasks = TaskStore()
|
self.chat_tasks = TaskStore()
|
||||||
@@ -35,15 +37,20 @@ class ModularApplication:
|
|||||||
gateway=LayeredRetrievalGateway(self.rag_repository, self.rag.embedder),
|
gateway=LayeredRetrievalGateway(self.rag_repository, self.rag.embedder),
|
||||||
graph_repository=CodeGraphRepository(),
|
graph_repository=CodeGraphRepository(),
|
||||||
)
|
)
|
||||||
self.agent = AgentModule(
|
from app.modules.shared.gigachat.client import GigaChatClient
|
||||||
rag_retriever=self.rag.rag,
|
from app.modules.shared.gigachat.settings import GigaChatSettings
|
||||||
agent_repository=self.agent_repository,
|
from app.modules.shared.gigachat.token_provider import GigaChatTokenProvider
|
||||||
story_context_repository=self.story_context_repository,
|
|
||||||
code_explain_retriever=self.code_explain_retriever,
|
_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(
|
self.direct_chat = CodeExplainChatService(
|
||||||
retriever=self.code_explain_retriever,
|
retriever=self.code_explain_retriever,
|
||||||
llm=self.agent.llm,
|
llm=self._agent_llm,
|
||||||
session_resolver=ChatSessionResolver(
|
session_resolver=ChatSessionResolver(
|
||||||
dialogs=DialogSessionStore(self.chat_repository),
|
dialogs=DialogSessionStore(self.chat_repository),
|
||||||
rag_session_exists=lambda rag_session_id: self.rag.sessions.get(rag_session_id) is not None,
|
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,
|
message_sink=self.chat_repository.add_message,
|
||||||
)
|
)
|
||||||
self.chat = ChatModule(
|
self.chat = ChatModule(
|
||||||
agent_runner=self.agent.runtime,
|
agent_runner=self._agent_runner,
|
||||||
event_bus=self.events,
|
event_bus=self.events,
|
||||||
retry=self.retry,
|
retry=self.retry,
|
||||||
rag_sessions=self.rag.sessions,
|
rag_sessions=self.rag.sessions,
|
||||||
@@ -65,6 +72,5 @@ class ModularApplication:
|
|||||||
bootstrap_database(
|
bootstrap_database(
|
||||||
self.rag_repository,
|
self.rag_repository,
|
||||||
self.chat_repository,
|
self.chat_repository,
|
||||||
self.agent_repository,
|
|
||||||
self.story_context_schema_repository,
|
self.story_context_schema_repository,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ from app.schemas.rag_sessions import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from app.modules.agent.story_context_repository import StoryContextRepository
|
from app.modules.rag.persistence.story_context_repository import StoryContextRepository
|
||||||
|
|
||||||
|
|
||||||
class RagModule:
|
class RagModule:
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
"""Репозиторий Story-контекста и схемы таблиц. Перенесён из agent для отвязки от legacy-стека."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
@@ -3,13 +3,12 @@ import time
|
|||||||
from app.modules.shared.checkpointer import get_checkpointer
|
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
|
last_error: Exception | None = None
|
||||||
for attempt in range(1, 16):
|
for attempt in range(1, 16):
|
||||||
try:
|
try:
|
||||||
rag_repository.ensure_tables()
|
rag_repository.ensure_tables()
|
||||||
chat_repository.ensure_tables()
|
chat_repository.ensure_tables()
|
||||||
agent_repository.ensure_tables()
|
|
||||||
story_context_repository.ensure_tables()
|
story_context_repository.ensure_tables()
|
||||||
get_checkpointer()
|
get_checkpointer()
|
||||||
return
|
return
|
||||||
|
|||||||
Binary file not shown.
@@ -5,5 +5,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[1]
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
SRC = ROOT / "src"
|
SRC = ROOT / "src"
|
||||||
|
if str(ROOT) not in sys.path:
|
||||||
|
sys.path.insert(0, str(ROOT))
|
||||||
if str(SRC) not in sys.path:
|
if str(SRC) not in sys.path:
|
||||||
sys.path.insert(0, str(SRC))
|
sys.path.insert(0, str(SRC))
|
||||||
|
|||||||
@@ -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"
|
|
||||||
@@ -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"
|
|
||||||
@@ -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"
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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"),
|
|
||||||
]
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
|
|
||||||
@@ -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]
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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"]
|
|
||||||
@@ -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")
|
|
||||||
@@ -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"
|
|
||||||
Reference in New Issue
Block a user