Compare commits

...

3 Commits

Author SHA1 Message Date
15586f9a8c Рефакторинг 2026-03-12 23:33:51 +03:00
9066c292de Удаление легаси 2026-03-12 21:11:16 +03:00
b1f825e6b9 Фиксируем состояние 2026-03-12 20:40:29 +03:00
434 changed files with 25403 additions and 101496 deletions

View File

@@ -54,7 +54,7 @@
- **Target Architecture** описывает то, к чему проект идёт. - **Target Architecture** описывает то, к чему проект идёт.
- **MVP-now** описывает то, что реально доводится сейчас через тесты. - **MVP-now** описывает то, что реально доводится сейчас через тесты.
- **MVP-now** не включает UI-интеграцию и не требует полного runtime orchestration. - **MVP-now** не включает UI-интеграцию и использует единый stage-based runtime.
- **MVP-now** фокусируется на: - **MVP-now** фокусируется на:
- IntentRouterV2; - IntentRouterV2;
- code-first retrieval; - code-first retrieval;
@@ -72,9 +72,10 @@
**MVP-now** **MVP-now**
- isolated `CODE_QA` test pipeline; - единый `agent runtime`;
- IntentRouterV2 as canonical router; - `IntentRouterV2` как канонический router и retrieval planner;
- router-driven layered retrieval; - stage-based execution внутри `agent.runtime`;
- infrastructure `rag` только для indexing/retrieval/storage;
- evidence-first answer synthesis; - evidence-first answer synthesis;
- diagnostics-first tuning; - diagnostics-first tuning;
- no UI dependency; - no UI dependency;
@@ -870,9 +871,9 @@ flowchart TD
**Target Architecture** **Target Architecture**
- Router - Router
- Graphs / pipelines для `CODE`, `DOCS`, `CROSS_DOMAIN`, `GENERAL` - unified runtime
- CODE RAG + DOCS RAG - CODE RAG + DOCS RAG
- evidence gate - evidence gates
- synthesis layer - synthesis layer
- diagnostics - diagnostics
- генерация технической документации из code / docs / system analysis - генерация технической документации из code / docs / system analysis
@@ -880,8 +881,8 @@ flowchart TD
**MVP-now** **MVP-now**
- изолированный test-first пайплайн; - единый test-first runtime;
- цепочка: `user query → IntentRouterV2 → retrieval plan → layered retrieval → evidence gate → LLM answer → diagnostics`; - цепочка: `user query → IntentRouterV2 → retrieval planning → runtime retrieval → context normalization → evidence gate 1 → answer policy → LLM answer → evidence gate 2 → finalization/diagnostics`;
- основной домен: `CODE`; - основной домен: `CODE`;
- основные сценарии: - основные сценарии:
- `OPEN_FILE` - `OPEN_FILE`
@@ -889,35 +890,42 @@ flowchart TD
- `FIND_TESTS` - `FIND_TESTS`
- `FIND_ENTRYPOINTS` - `FIND_ENTRYPOINTS`
- `GENERAL_QA` - `GENERAL_QA`
- `TRACE_FLOW`
- `ARCHITECTURE`
- UI-интеграция не требуется для текущего этапа; - UI-интеграция не требуется для текущего этапа;
- docs retrieval не обязателен для текущего milestone; - docs retrieval не обязателен для текущего milestone;
- legacy `RouterService` не считается целевой архитектурой и в перспективе будет заменён. - legacy orchestration удалён из актуального execution path.
```mermaid ```mermaid
flowchart TD flowchart TD
U[User Query] --> R[IntentRouterV2] U[User Query] --> R[IntentRouterV2]
R --> P[Retrieval Plan] R --> P[Retrieval Planning]
P --> G[Layered Retrieval] P --> X[Runtime Retrieval]
G --> E[Evidence Gate] X --> C[Context Normalization]
E --> A[LLM Answer] C --> E1[Evidence Gate 1]
E --> D[Diagnostics] E1 --> AP[Answer Policy]
A --> D AP --> A[LLM Answer]
AP --> D[Diagnostics]
A --> E2[Evidence Gate 2]
E2 --> F[Finalization]
F --> D
``` ```
Текущий milestone — test-first и code-first; этот пайплайн настраивается изолированно до интеграции в полный agent runtime. Текущий milestone — test-first и code-first; этот runtime уже является каноническим execution path для MVP.
### 4.1.3. Канонический test-first пайплайн (CODE_QA) ### 4.1.3. Канонический MVP runtime (CODE-first)
Единая точка входа изолированного пайплайна — пакет `app.modules.rag.code_qa_pipeline`: Единая точка входа исполнения — пакет `app.modules.agent.runtime`:
- **Роутер:** только `IntentRouterV2`; legacy `RouterService` не используется. - **Роутер:** `app.modules.agent.intent_router_v2`; он отвечает и за routing, и за retrieval planning.
- **Контракты:** `RouterResult` (IntentRouterResult), `RetrievalRequest`, `RetrievalResult`, `EvidenceBundle`, `AnswerSynthesisInput`, `DiagnosticsReport`. - **LLM-слой:** `app.modules.agent.llm`; здесь живут `AgentLlmService`, `PromptLoader` и системные prompt assets.
- **Цепочка:** запрос → IntentRouterV2 → RetrievalRequest → layered retrieval (через адаптер) → нормализованный RetrievalResult → EvidenceBundle → evidence gate → AnswerSynthesisInput → diagnostics. - **Runtime:** `app.modules.agent.runtime`; внутри него stages разложены по подпакетам `retrieval`, `context`, `gates`, `answer_policy`, `generation`, `finalization`.
- **Evidence gate:** общая проверка достаточности evidence по сценарию (OPEN_FILE, EXPLAIN, FIND_TESTS, FIND_ENTRYPOINTS, GENERAL_QA); при недостатке — degraded/insufficient, без уверенного ответа. - **Цепочка:** запрос → `IntentRouterV2` → retrieval planning → runtime retrieval adapter → нормализованный context/evidence → evidence gate 1 → answer policy → LLM generation → evidence gate 2 → finalization → diagnostics.
- **Диагностика:** Level 1 (summary) и Level 2 (detail), машинно-читаемые коды причин (`failure_reasons`: `target_not_resolved`, `path_scope_empty`, `layer_c0_empty`, `insufficient_evidence`, `tests_not_found`, `entrypoints_not_found` и др.). - **Evidence gates:** pre/post проверки достаточности evidence и качества ответа по сценарию.
- **Запуск пайплайна в тестах:** `CodeQAPipelineRunner(router=..., retrieval_adapter=...)`; метод `run(user_query, rag_session_id)` возвращает `CodeQAPipelineResult` с полной диагностикой. - **Диагностика:** runtime возвращает machine-readable diagnostics и trace по стадиям.
- **RAG:** `app.modules.rag` больше не содержит agent use-case слоев; он остается инфраструктурой indexing/retrieval/storage.
Тесты: `tests/pipeline_setup/pipeline_intent_rag/test_canonical_code_qa_pipeline.py` (роутер → retrieval request, нормализованный результат, evidence gate, диагностика). Тесты: `pipeline_setup_v3` и связанные suite-ы проверяют канонический runtime и его stage-based execution.
## 4.2. Router ## 4.2. Router
@@ -926,7 +934,7 @@ Router определяет:
- intent; - intent;
- sub-intent; - sub-intent;
- confidence; - confidence;
- подходящий graph; - подходящий execution path;
- требования к retrieval plan. - требования к retrieval plan.
Целевые домены: Целевые домены:
@@ -935,9 +943,22 @@ Router определяет:
- `CROSS_DOMAIN` - `CROSS_DOMAIN`
- `GENERAL` - `GENERAL`
## 4.3. Graphs / pipelines ## 4.3. Runtime Stages
Graph — это специализированный сценарий обработки запроса. В текущем MVP execution path реализован не через graph engine, а через единый runtime с явными stage-компонентами.
Текущие стадии:
- `IntentRouterV2`
- `retrieval planning`
- `runtime retrieval`
- `context normalization`
- `evidence gate 1`
- `answer policy`
- `LLM generation`
- `evidence gate 2`
- `finalization + diagnostics`
Если сценарии в будущем начнут расходиться по структуре, а не только по policy-логике шагов, следующим шагом будет рассмотрен переход на graph-based orchestration.
Для MVP целесообразны как минимум: Для MVP целесообразны как минимум:
- `CodeOpenGraph` - `CodeOpenGraph`
@@ -1186,4 +1207,3 @@ DOCS и CROSS_DOMAIN остаются частью target architecture; в те
- богатые fact-индексы по всем доменам; - богатые fact-индексы по всем доменам;
- полный reference graph документации; - полный reference graph документации;
- глубокая автоматизация подготовки системной аналитики. - глубокая автоматизация подготовки системной аналитики.

View File

@@ -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)

View File

@@ -1,91 +1,37 @@
# Модуль agent # Модуль agent
## 1. Функции модуля ## 1. Назначение
- Оркестрация выполнения пользовательского запроса поверх роутера интентов и графов. Модуль обеспечивает выполнение code-QA пайплайна для pipeline_setup_v3 и интеграцию с chat-слоем через адаптер к контракту `AgentRunner`. Оркестрация основана на **IntentRouterV2** (RAG) и **AgentRuntimeExecutor** (роутинг → retrieval → evidence gate → генерация ответа).
- Формирование `TaskSpec`, запуск оркестратора шагов и сборка финального результата.
- Реализация необходимых для агента tools и их интеграция с остальной логикой выполнения.
- Сохранение quality-метрик и session-артефактов для последующей привязки к Story.
## 2. Диаграмма классов и взаимосвязей ## 2. Состав модуля
- **runtime/** — единственный orchestration-слой. На верхнем уровне содержит только файлы рантайма, а шаги исполнения вынесены в `runtime/steps/*` (`retrieval`, `context`, `gates`, `answer_policy`, `generation`, `finalization`, `explain`). Публичный API: `AgentRuntimeExecutor`, `RuntimeRetrievalAdapter`, `RuntimeRepoContextFactory`, модели `Runtime*`.
- **llm/** — сервис вызова LLM (GigaChat) с загрузкой системных промптов через `PromptLoader`.
- **llm/prompt_loader.py** — загрузка системных промптов из `llm/prompts.yml`.
- **runtime/code_qa_runner_adapter.py** — адаптер `AgentRuntimeExecutor` к протоколу `AgentRunner` для использования из chat (async `run` → sync `execute` в executor).
## 3. Диаграмма зависимостей
```mermaid ```mermaid
classDiagram classDiagram
class AgentModule class AgentRuntimeExecutor
class GraphAgentRuntime class CodeQaRunnerAdapter
class OrchestratorService class AgentLlmService
class TaskSpecBuilder class PromptLoader
class StorySessionRecorder class IntentRouterV2
class StoryContextRepository class RuntimeRetrievalAdapter
class ConfluenceService
class AgentRepository
AgentModule --> GraphAgentRuntime CodeQaRunnerAdapter --> AgentRuntimeExecutor
AgentModule --> ConfluenceService AgentRuntimeExecutor --> AgentLlmService
AgentModule --> StorySessionRecorder AgentRuntimeExecutor --> IntentRouterV2
StorySessionRecorder --> StoryContextRepository AgentRuntimeExecutor --> RuntimeRetrievalAdapter
GraphAgentRuntime --> OrchestratorService AgentLlmService --> PromptLoader
GraphAgentRuntime --> TaskSpecBuilder
GraphAgentRuntime --> AgentRepository
GraphAgentRuntime --> ConfluenceService
``` ```
## 3. Описание классов ## 4. Точки входа
- `AgentModule`: собирает runtime и публикует внутренние tools-роуты. - **Тесты pipeline_setup_v3**: `AgentRuntimeAdapter` импортирует `AgentRuntimeExecutor`, `IntentRouterV2`, `RuntimeRepoContextFactory`, `RuntimeRetrievalAdapter`, `AgentLlmService`, `PromptLoader` из `app.modules.agent.runtime` и соответствующих пакетов.
Методы: `__init__` — связывает зависимости модуля; `internal_router` — регистрирует internal API tools. - **Приложение (chat)**: `ModularApplication` собирает `AgentRuntimeExecutor` и оборачивает его в `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. Промпты
Используются только промпты, загружаемые из `llm/prompts.yml`:
### 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
```

View File

@@ -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

View File

@@ -1,15 +0,0 @@
from app.modules.agent.code_qa_runtime.executor import CodeQaRuntimeExecutor
from app.modules.agent.code_qa_runtime.models import (
CodeQaDraftAnswer,
CodeQaExecutionState,
CodeQaFinalResult,
CodeQaValidationResult,
)
__all__ = [
"CodeQaDraftAnswer",
"CodeQaExecutionState",
"CodeQaFinalResult",
"CodeQaRuntimeExecutor",
"CodeQaValidationResult",
]

View File

@@ -1,62 +0,0 @@
from __future__ import annotations
from dataclasses import dataclass
from app.modules.rag.code_qa_pipeline.evidence_gate import EvidenceGateDecision
from app.modules.rag.intent_router_v2.models import IntentRouterResult
@dataclass(slots=True, frozen=True)
class CodeQaPolicyDecision:
answer_mode: str
answer: str = ""
should_call_llm: bool = True
branch: str = "normal_answer"
reason: str = "evidence_sufficient"
class CodeQaAnswerPolicy:
def decide(
self,
*,
router_result: IntentRouterResult,
gate_decision: EvidenceGateDecision,
) -> CodeQaPolicyDecision:
sub_intent = router_result.query_plan.sub_intent.upper()
symbol_resolution = router_result.symbol_resolution
if sub_intent == "OPEN_FILE" and "path_scope_empty" in gate_decision.failure_reasons:
path_scope = list(getattr(router_result.retrieval_spec.filters, "path_scope", []) or [])
target = path_scope[0] if path_scope else "запрошенный файл"
return CodeQaPolicyDecision(
answer_mode="not_found",
answer=f"Файл {target} не найден.",
should_call_llm=False,
branch="open_file_not_found",
reason="path_scope_empty",
)
if sub_intent == "EXPLAIN" and symbol_resolution.status in {"not_found", "ambiguous"}:
return CodeQaPolicyDecision(
answer_mode="degraded",
answer=self._symbol_message(symbol_resolution.status, symbol_resolution.alternatives),
should_call_llm=False,
branch="explain_unresolved_symbol",
reason=f"symbol_resolution_{symbol_resolution.status}",
)
if not gate_decision.passed:
answer_mode = "insufficient" if "insufficient_evidence" in gate_decision.failure_reasons else "degraded"
reason = gate_decision.failure_reasons[0] if gate_decision.failure_reasons else "evidence_gate_failed"
return CodeQaPolicyDecision(
answer_mode=answer_mode,
answer=gate_decision.degraded_message,
should_call_llm=False,
branch="evidence_gate_short_circuit",
reason=reason,
)
return CodeQaPolicyDecision(answer_mode="normal", branch="normal_answer", reason="evidence_sufficient")
def _symbol_message(self, status: str, alternatives: list[str]) -> str:
if status == "ambiguous" and alternatives:
return f"Сущность не удалось однозначно разрешить. Близкие варианты: {', '.join(alternatives[:3])}."
if alternatives:
return f"Сущность не найдена в доступном коде. Ближайшие варианты: {', '.join(alternatives[:3])}."
return "Сущность не найдена в доступном коде."

View File

@@ -1,309 +0,0 @@
from __future__ import annotations
import logging
from time import perf_counter
from app.modules.agent.code_qa_runtime.answer_policy import CodeQaAnswerPolicy
from app.modules.agent.code_qa_runtime.models import CodeQaDraftAnswer, CodeQaExecutionState, CodeQaFinalResult
from app.modules.agent.code_qa_runtime.post_gate import CodeQaPostEvidenceGate
from app.modules.agent.code_qa_runtime.prompt_payload_builder import CodeQaPromptPayloadBuilder
from app.modules.agent.code_qa_runtime.prompt_selector import CodeQaPromptSelector
from app.modules.agent.code_qa_runtime.repair import CodeQaAnswerRepairService
from app.modules.agent.code_qa_runtime.repo_context import CodeQaRepoContextFactory
from app.modules.agent.code_qa_runtime.retrieval_adapter import CodeQaRetrievalAdapter
from app.modules.agent.llm import AgentLlmService
from app.modules.rag.code_qa_pipeline.answer_synthesis import build_answer_synthesis_input
from app.modules.rag.code_qa_pipeline.diagnostics import build_diagnostics_report
from app.modules.rag.code_qa_pipeline.evidence_bundle_builder import build_evidence_bundle
from app.modules.rag.code_qa_pipeline.evidence_gate import evaluate_evidence
from app.modules.rag.code_qa_pipeline.retrieval_request_builder import build_retrieval_request
from app.modules.rag.code_qa_pipeline.retrieval_result_builder import build_retrieval_result
from app.modules.rag.intent_router_v2 import ConversationState, IntentRouterV2
from app.modules.rag.intent_router_v2.models import SymbolResolution
LOGGER = logging.getLogger(__name__)
class CodeQaRuntimeExecutor:
def __init__(
self,
llm: AgentLlmService | None,
*,
router: IntentRouterV2 | None = None,
retrieval: CodeQaRetrievalAdapter | None = None,
repo_context_factory: CodeQaRepoContextFactory | None = None,
prompt_selector: CodeQaPromptSelector | None = None,
payload_builder: CodeQaPromptPayloadBuilder | None = None,
answer_policy: CodeQaAnswerPolicy | None = None,
post_gate: CodeQaPostEvidenceGate | None = None,
) -> None:
self._llm = llm
self._router = router or IntentRouterV2()
self._retrieval = retrieval or CodeQaRetrievalAdapter()
self._repo_context_factory = repo_context_factory or CodeQaRepoContextFactory()
self._prompt_selector = prompt_selector or CodeQaPromptSelector()
self._payload_builder = payload_builder or CodeQaPromptPayloadBuilder()
self._answer_policy = answer_policy or CodeQaAnswerPolicy()
self._post_gate = post_gate or CodeQaPostEvidenceGate()
self._repair = CodeQaAnswerRepairService(llm) if llm is not None else None
def execute(self, *, user_query: str, rag_session_id: str, files_map: dict[str, dict] | None = None) -> CodeQaFinalResult:
timings_ms: dict[str, int] = {}
runtime_trace: list[dict] = []
state = CodeQaExecutionState(
user_query=user_query,
rag_session_id=rag_session_id,
conversation_state=ConversationState(),
repo_context=self._repo_context_factory.build(files_map),
)
started = perf_counter()
state.router_result = self._router.route(user_query, state.conversation_state, state.repo_context)
timings_ms["router"] = self._elapsed_ms(started)
runtime_trace.append(
{
"step": "router",
"status": "completed",
"timings_ms": {"router": timings_ms["router"]},
"output": {
"intent": state.router_result.intent,
"sub_intent": state.router_result.query_plan.sub_intent,
"graph_id": state.router_result.graph_id,
"conversation_mode": state.router_result.conversation_mode,
},
}
)
state.retrieval_request = build_retrieval_request(state.router_result, rag_session_id)
started = perf_counter()
raw_rows = self._retrieve(state)
timings_ms["retrieval"] = self._elapsed_ms(started)
symbol_resolution = self._resolve_symbol(state.router_result.symbol_resolution.model_dump(), raw_rows)
state.router_result = state.router_result.model_copy(update={"symbol_resolution": SymbolResolution(**symbol_resolution)})
retrieval_report = self._retrieval.consume_retrieval_report()
state.retrieval_result = build_retrieval_result(raw_rows, retrieval_report, symbol_resolution)
if state.retrieval_request.sub_intent.upper() == "EXPLAIN" and symbol_resolution.get("status") in {"not_found", "ambiguous"}:
state.retrieval_result = build_retrieval_result([], retrieval_report, symbol_resolution)
runtime_trace.append(
{
"step": "retrieval",
"status": "completed",
"timings_ms": {"retrieval": timings_ms["retrieval"]},
"output": {
"rag_count": len(raw_rows),
"answer_path_rag_count": len(state.retrieval_result.raw_rows),
"resolved_symbol_status": symbol_resolution.get("status"),
"resolved_symbol": symbol_resolution.get("resolved_symbol"),
"requested_layers": list(state.retrieval_request.requested_layers or []),
},
"diagnostics": retrieval_report or {},
}
)
state.evidence_pack = build_evidence_bundle(state.retrieval_result, state.router_result)
if state.retrieval_request.sub_intent.upper() == "EXPLAIN" and symbol_resolution.get("status") in {"not_found", "ambiguous"}:
state.evidence_pack.sufficient = False
state.evidence_pack.failure_reasons = ["target_not_resolved"]
gate_decision = evaluate_evidence(state.evidence_pack)
timings_ms["pre_evidence_gate"] = 0
state.answer_mode = "normal" if gate_decision.passed else "degraded"
state.degraded_message = gate_decision.degraded_message
runtime_trace.append(
{
"step": "pre_evidence_gate",
"status": "passed" if gate_decision.passed else "blocked",
"timings_ms": {"pre_evidence_gate": timings_ms["pre_evidence_gate"]},
"output": {
"passed": gate_decision.passed,
"failure_reasons": list(gate_decision.failure_reasons),
"degraded_message": gate_decision.degraded_message,
},
}
)
decision = self._answer_policy.decide(router_result=state.router_result, gate_decision=gate_decision)
if not decision.should_call_llm:
state.answer_mode = decision.answer_mode
runtime_trace.append(
{
"step": "llm",
"status": "skipped",
"output": {
"reason": "policy_short_circuit",
"answer_mode": decision.answer_mode,
},
}
)
runtime_trace.append(
{
"step": "post_evidence_gate",
"status": "skipped",
"output": {"reason": "no_draft_answer"},
}
)
return self._finalize(
state,
draft=None,
final_answer=decision.answer,
repair_used=False,
llm_used=False,
timings_ms=timings_ms,
runtime_trace=runtime_trace,
)
if self._llm is None:
runtime_trace.append(
{
"step": "llm",
"status": "skipped",
"output": {"reason": "llm_unavailable"},
}
)
return self._finalize(
state,
draft=None,
final_answer="",
repair_used=False,
llm_used=False,
timings_ms=timings_ms,
runtime_trace=runtime_trace,
)
state.synthesis_input = build_answer_synthesis_input(user_query, state.evidence_pack)
prompt_name = self._prompt_selector.select(sub_intent=state.retrieval_request.sub_intent, answer_mode=state.answer_mode)
prompt_payload = self._payload_builder.build(
user_query=user_query,
synthesis_input=state.synthesis_input,
evidence_pack=state.evidence_pack,
answer_mode=state.answer_mode,
)
started = perf_counter()
draft = CodeQaDraftAnswer(
prompt_name=prompt_name,
prompt_payload=prompt_payload,
answer=self._llm.generate(prompt_name, prompt_payload, log_context="graph.project_qa.code_qa.answer").strip(),
)
timings_ms["llm"] = self._elapsed_ms(started)
runtime_trace.append(
{
"step": "llm",
"status": "completed",
"timings_ms": {"llm": timings_ms["llm"]},
"output": {
"prompt_name": prompt_name,
"answer_preview": draft.answer[:300],
},
}
)
validation = self._post_gate.validate(answer=draft.answer, answer_mode=state.answer_mode, degraded_message=state.degraded_message)
final_answer = draft.answer
repair_used = False
if not validation.passed and self._repair is not None:
started = perf_counter()
final_answer = self._repair.repair(draft_answer=draft.answer, validation=validation, prompt_payload=prompt_payload)
repair_used = True
timings_ms["repair"] = self._elapsed_ms(started)
validation = self._post_gate.validate(answer=final_answer, answer_mode=state.answer_mode, degraded_message=state.degraded_message)
if not validation.passed and state.degraded_message:
final_answer = state.degraded_message
runtime_trace.append(
{
"step": "post_evidence_gate",
"status": "passed" if validation.passed else "failed",
"timings_ms": {
"post_evidence_gate": 0,
"repair": timings_ms.get("repair", 0),
},
"output": {
"passed": validation.passed,
"reasons": list(validation.reasons),
"repair_used": repair_used,
},
}
)
return self._finalize(
state,
draft=draft,
final_answer=final_answer,
repair_used=repair_used,
llm_used=True,
validation=validation,
timings_ms=timings_ms,
runtime_trace=runtime_trace,
)
def _retrieve(self, state: CodeQaExecutionState) -> list[dict]:
assert state.retrieval_request is not None
if state.retrieval_request.sub_intent == "OPEN_FILE" and state.retrieval_request.path_scope:
return self._retrieval.retrieve_exact_files(
state.rag_session_id,
paths=state.retrieval_request.path_scope,
layers=["C0_SOURCE_CHUNKS"],
limit=200,
query=state.retrieval_request.query,
ranking_profile=str(getattr(state.retrieval_request.retrieval_spec, "rerank_profile", "") or ""),
)
return self._retrieval.retrieve_with_plan(
state.rag_session_id,
state.retrieval_request.query,
state.retrieval_request.retrieval_spec,
state.retrieval_request.retrieval_constraints,
query_plan=state.retrieval_request.query_plan,
)
def _resolve_symbol(self, initial: dict, rag_rows: list[dict]) -> dict:
if str(initial.get("status") or "") != "pending":
return initial
candidates = [str(item).strip() for item in initial.get("alternatives", []) if str(item).strip()]
found = [
str(row.get("title") or "").strip()
for row in rag_rows
if str(row.get("layer") or "") == "C1_SYMBOL_CATALOG" and str(row.get("title") or "").strip()
]
exact = next((item for item in found if item in candidates), None)
if exact:
return {"status": "resolved", "resolved_symbol": exact, "alternatives": found[:5], "confidence": 0.99}
if found:
return {"status": "ambiguous", "resolved_symbol": None, "alternatives": found[:5], "confidence": 0.55}
return {"status": "not_found", "resolved_symbol": None, "alternatives": [], "confidence": 0.0}
def _finalize(
self,
state: CodeQaExecutionState,
*,
draft: CodeQaDraftAnswer | None,
final_answer: str,
repair_used: bool,
llm_used: bool,
validation=None,
timings_ms: dict[str, int] | None = None,
runtime_trace: list[dict] | None = None,
) -> CodeQaFinalResult:
diagnostics = build_diagnostics_report(
router_result=state.router_result,
retrieval_request=state.retrieval_request,
retrieval_result=state.retrieval_result,
evidence_bundle=state.evidence_pack,
answer_mode=state.answer_mode,
timings_ms=timings_ms or {},
)
result = CodeQaFinalResult(
final_answer=final_answer.strip(),
answer_mode=state.answer_mode,
repair_used=repair_used,
llm_used=llm_used,
draft_answer=draft,
validation=validation or self._post_gate.validate(answer=final_answer, answer_mode=state.answer_mode, degraded_message=state.degraded_message),
router_result=state.router_result,
retrieval_request=state.retrieval_request,
retrieval_result=state.retrieval_result,
evidence_pack=state.evidence_pack,
diagnostics=diagnostics,
runtime_trace=list(runtime_trace or []),
)
LOGGER.warning(
"code qa runtime executed: intent=%s sub_intent=%s answer_mode=%s repair_used=%s llm_used=%s",
state.router_result.intent,
state.router_result.query_plan.sub_intent,
result.answer_mode,
result.repair_used,
result.llm_used,
)
return result
def _elapsed_ms(self, started: float) -> int:
return int((perf_counter() - started) * 1000)

View File

@@ -1,73 +0,0 @@
from __future__ import annotations
from typing import Any
from pydantic import BaseModel, ConfigDict, Field
from app.modules.rag.code_qa_pipeline.contracts import (
AnswerSynthesisInput as CodeQaAnswerSynthesisInput,
)
from app.modules.rag.code_qa_pipeline.contracts import (
DiagnosticsReport as CodeQaDiagnosticsReport,
)
from app.modules.rag.code_qa_pipeline.contracts import (
EvidenceBundle as CodeQaEvidencePack,
)
from app.modules.rag.code_qa_pipeline.contracts import (
RetrievalRequest as CodeQaRetrievalRequest,
)
from app.modules.rag.code_qa_pipeline.contracts import (
RetrievalResult as CodeQaRetrievalResult,
)
from app.modules.rag.intent_router_v2.models import ConversationState, IntentRouterResult, RepoContext
class CodeQaDraftAnswer(BaseModel):
model_config = ConfigDict(extra="forbid")
prompt_name: str
prompt_payload: str
answer: str = ""
class CodeQaValidationResult(BaseModel):
model_config = ConfigDict(extra="forbid")
passed: bool = False
action: str = "return"
reasons: list[str] = Field(default_factory=list)
class CodeQaFinalResult(BaseModel):
model_config = ConfigDict(extra="forbid")
final_answer: str
answer_mode: str = "normal"
repair_used: bool = False
llm_used: bool = False
draft_answer: CodeQaDraftAnswer | None = None
validation: CodeQaValidationResult = Field(default_factory=CodeQaValidationResult)
router_result: IntentRouterResult | None = None
retrieval_request: CodeQaRetrievalRequest | None = None
retrieval_result: CodeQaRetrievalResult | None = None
evidence_pack: CodeQaEvidencePack | None = None
diagnostics: CodeQaDiagnosticsReport
runtime_trace: list[dict[str, Any]] = Field(default_factory=list)
class CodeQaExecutionState(BaseModel):
model_config = ConfigDict(extra="forbid")
user_query: str
rag_session_id: str
conversation_state: ConversationState = Field(default_factory=ConversationState)
repo_context: RepoContext = Field(default_factory=RepoContext)
router_result: IntentRouterResult | None = None
retrieval_request: CodeQaRetrievalRequest | None = None
retrieval_result: CodeQaRetrievalResult | None = None
evidence_pack: CodeQaEvidencePack | None = None
synthesis_input: CodeQaAnswerSynthesisInput | None = None
diagnostics: CodeQaDiagnosticsReport | None = None
answer_mode: str = "normal"
degraded_message: str = ""
final_result: CodeQaFinalResult | None = None

View File

@@ -1,23 +0,0 @@
from __future__ import annotations
from app.modules.agent.code_qa_runtime.models import CodeQaValidationResult
class CodeQaPostEvidenceGate:
def validate(
self,
*,
answer: str,
answer_mode: str,
degraded_message: str,
) -> CodeQaValidationResult:
normalized = (answer or "").strip()
if not normalized:
return CodeQaValidationResult(passed=False, action="repair", reasons=["empty_answer"])
if answer_mode in {"degraded", "insufficient"} and "недостат" not in normalized.lower():
return CodeQaValidationResult(passed=False, action="repair", reasons=["degraded_answer_missing_guardrail"])
if answer_mode == "not_found" and "не найден" not in normalized.lower():
return CodeQaValidationResult(passed=False, action="repair", reasons=["not_found_answer_missing_phrase"])
if degraded_message and answer_mode != "normal" and len(normalized) < 24:
return CodeQaValidationResult(passed=False, action="repair", reasons=["answer_too_short"])
return CodeQaValidationResult(passed=True, action="return")

View File

@@ -1,37 +0,0 @@
from __future__ import annotations
import json
from app.modules.rag.code_qa_pipeline.contracts import AnswerSynthesisInput, EvidenceBundle
_LAYER_GUIDE = (
"- C0_SOURCE_CHUNKS: фактический код, это основной источник деталей.\n"
"- C1_SYMBOL_CATALOG: объявления и сигнатуры символов.\n"
"- C2_DEPENDENCY_GRAPH: связи вызовов и зависимостей.\n"
"- C3_ENTRYPOINTS: подтвержденные точки входа.\n"
"- C4_SEMANTIC_ROLES: вспомогательная роль компонента, использовать осторожно."
)
class CodeQaPromptPayloadBuilder:
def build(
self,
*,
user_query: str,
synthesis_input: AnswerSynthesisInput,
evidence_pack: EvidenceBundle,
answer_mode: str,
) -> str:
payload = {
"user_query": user_query,
"resolved_scenario": synthesis_input.resolved_scenario,
"resolved_target": synthesis_input.resolved_target,
"answer_mode": answer_mode,
"fast_context": synthesis_input.fast_context,
"deep_context": synthesis_input.deep_context,
"evidence_summary": synthesis_input.evidence_summary,
"diagnostic_hints": synthesis_input.diagnostic_hints,
"retrieval_summary": evidence_pack.retrieval_summary,
"layer_guide": _LAYER_GUIDE,
}
return json.dumps(payload, ensure_ascii=False, indent=2)

View File

@@ -1,33 +0,0 @@
from __future__ import annotations
import json
from app.modules.agent.code_qa_runtime.models import CodeQaValidationResult
from app.modules.agent.llm import AgentLlmService
class CodeQaAnswerRepairService:
def __init__(self, llm: AgentLlmService) -> None:
self._llm = llm
def repair(
self,
*,
draft_answer: str,
validation: CodeQaValidationResult,
prompt_payload: str,
) -> str:
repair_input = json.dumps(
{
"draft_answer": draft_answer,
"validation_reasons": validation.reasons,
"prompt_payload": prompt_payload,
},
ensure_ascii=False,
indent=2,
)
return self._llm.generate(
"code_qa_repair_answer",
repair_input,
log_context="graph.project_qa.code_qa.repair",
).strip()

View File

@@ -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(),
}

View File

@@ -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)

View File

@@ -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

View File

@@ -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"),
}

View File

@@ -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()

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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()

View File

@@ -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))

View File

@@ -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 "")),
)

View File

@@ -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 ""

View File

@@ -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)

View File

@@ -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)

View File

@@ -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

View File

@@ -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()

View File

@@ -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

View File

@@ -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",
]

View File

@@ -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",
]

View File

@@ -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"))]

View File

@@ -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

View File

@@ -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)]

View File

@@ -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

View File

@@ -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

View File

@@ -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),
]

View File

@@ -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))]

View File

@@ -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

View File

@@ -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)

View File

@@ -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),
]

View File

@@ -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

View File

@@ -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)

View File

@@ -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()

View File

@@ -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 "",
)

View File

@@ -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)

View File

@@ -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",
]

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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"]

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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")])

View File

@@ -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)

View File

@@ -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"]

View File

@@ -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,
)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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,
)

View File

@@ -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

View File

@@ -0,0 +1,19 @@
from app.modules.agent.intent_router_v2.models import (
ConversationState,
IntentDecision,
IntentRouterResult,
QueryAnchor,
QueryPlan,
RepoContext,
)
from app.modules.agent.intent_router_v2.router import IntentRouterV2
__all__ = [
"ConversationState",
"IntentDecision",
"IntentRouterResult",
"IntentRouterV2",
"QueryAnchor",
"QueryPlan",
"RepoContext",
]

View File

@@ -0,0 +1,4 @@
from app.modules.agent.intent_router_v2.analysis.normalization import QueryNormalizer
from app.modules.agent.intent_router_v2.analysis.query_plan_builder import QueryPlanBuilder
__all__ = ["QueryNormalizer", "QueryPlanBuilder"]

View File

@@ -2,10 +2,10 @@ from __future__ import annotations
import re import re
from app.modules.rag.intent_router_v2.models import AnchorSpan, QueryAnchor from app.modules.agent.intent_router_v2.models import AnchorSpan, QueryAnchor
from app.modules.rag.intent_router_v2.analysis.normalization_terms import KeyTermCanonicalizer from app.modules.agent.intent_router_v2.analysis.normalization_terms import KeyTermCanonicalizer
from app.modules.rag.intent_router_v2.analysis.symbol_rules import COMMON_PATH_SEGMENTS, PY_KEYWORDS from app.modules.agent.intent_router_v2.analysis.symbol_rules import COMMON_PATH_SEGMENTS, PY_KEYWORDS
from app.modules.rag.intent_router_v2.analysis.term_mapping import RuEnTermMapper from app.modules.agent.intent_router_v2.analysis.term_mapping import RuEnTermMapper
_FILE_PATTERN = re.compile(r"(?P<value>\b(?:[\w.-]+/)*[\w.-]+\.(?:py|md|rst|txt|yaml|yml|json|toml|ini|cfg)\b)") _FILE_PATTERN = re.compile(r"(?P<value>\b(?:[\w.-]+/)*[\w.-]+\.(?:py|md|rst|txt|yaml|yml|json|toml|ini|cfg)\b)")
_PATH_HINT_PATTERN = re.compile(r"(?P<value>\b(?:src|app|docs|tests)/[\w./-]*[\w-]\b)") _PATH_HINT_PATTERN = re.compile(r"(?P<value>\b(?:src|app|docs|tests)/[\w./-]*[\w-]\b)")

View File

@@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
from app.modules.rag.intent_router_v2.models import QueryAnchor from app.modules.agent.intent_router_v2.models import QueryAnchor
class AnchorSpanValidator: class AnchorSpanValidator:

View File

@@ -1,7 +1,7 @@
from __future__ import annotations from __future__ import annotations
from app.modules.rag.intent_router_v2.analysis.followup_detector import FollowUpDetector from app.modules.agent.intent_router_v2.analysis.followup_detector import FollowUpDetector
from app.modules.rag.intent_router_v2.models import ConversationState, QueryAnchor from app.modules.agent.intent_router_v2.models import ConversationState, QueryAnchor
class ConversationAnchorBuilder: class ConversationAnchorBuilder:

View File

@@ -2,8 +2,8 @@ from __future__ import annotations
import re import re
from app.modules.rag.intent_router_v2.analysis.normalization import FILE_PATH_RE from app.modules.agent.intent_router_v2.analysis.normalization import FILE_PATH_RE
from app.modules.rag.intent_router_v2.analysis.symbol_rules import COMMON_PATH_SEGMENTS, PY_KEYWORDS from app.modules.agent.intent_router_v2.analysis.symbol_rules import COMMON_PATH_SEGMENTS, PY_KEYWORDS
_IDENTIFIER_RE = re.compile(r"[A-Za-z_][A-Za-z0-9_]{2,}") _IDENTIFIER_RE = re.compile(r"[A-Za-z_][A-Za-z0-9_]{2,}")

View File

@@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
from app.modules.rag.intent_router_v2.models import QueryAnchor from app.modules.agent.intent_router_v2.models import QueryAnchor
class KeywordHintSanitizer: class KeywordHintSanitizer:

View File

@@ -13,7 +13,7 @@ SNAKE_RE = re.compile(r"(?<!\w)[a-z][a-z0-9]*(?:_[a-z0-9]+)+(?!\w)")
SPACE_BEFORE_PUNCT_RE = re.compile(r"\s+([,.:;?!])") SPACE_BEFORE_PUNCT_RE = re.compile(r"\s+([,.:;?!])")
SPACE_AFTER_PUNCT_RE = re.compile(r"([,.:;?!])(?=(?:[\"'(\[A-Za-zА-ЯЁа-яё]))") SPACE_AFTER_PUNCT_RE = re.compile(r"([,.:;?!])(?=(?:[\"'(\[A-Za-zА-ЯЁа-яё]))")
WS_RE = re.compile(r"\s+") WS_RE = re.compile(r"\s+")
QUOTE_TRANSLATION = str.maketrans({"«": '"', "»": '"', "": '"', "": '"', "": "'", "": "'"}) QUOTE_TRANSLATION = str.maketrans({"«": '"', "»": '"', "\u201c": '"', "\u201d": '"', "\u2018": "'", "\u2019": "'"})
class QueryNormalizer: class QueryNormalizer:

View File

@@ -0,0 +1,3 @@
from app.modules.agent.intent_router_v2.analysis.normalization import QueryNormalizer
__all__ = ["QueryNormalizer"]

View File

@@ -1,16 +1,16 @@
from __future__ import annotations from __future__ import annotations
from app.modules.rag.intent_router_v2.analysis.anchor_extractor import AnchorExtractor from app.modules.agent.intent_router_v2.analysis.anchor_extractor import AnchorExtractor
from app.modules.rag.intent_router_v2.analysis.anchor_span_validator import AnchorSpanValidator from app.modules.agent.intent_router_v2.analysis.anchor_span_validator import AnchorSpanValidator
from app.modules.rag.intent_router_v2.analysis.conversation_anchor_builder import ConversationAnchorBuilder from app.modules.agent.intent_router_v2.analysis.conversation_anchor_builder import ConversationAnchorBuilder
from app.modules.rag.intent_router_v2.analysis.keyword_hint_builder import KeywordHintBuilder from app.modules.agent.intent_router_v2.analysis.keyword_hint_builder import KeywordHintBuilder
from app.modules.rag.intent_router_v2.analysis.keyword_hint_sanitizer import KeywordHintSanitizer from app.modules.agent.intent_router_v2.analysis.keyword_hint_sanitizer import KeywordHintSanitizer
from app.modules.rag.intent_router_v2.models import ConversationState, QueryAnchor, QueryPlan from app.modules.agent.intent_router_v2.models import ConversationState, QueryAnchor, QueryPlan
from app.modules.rag.intent_router_v2.analysis.negation_detector import NegationDetector from app.modules.agent.intent_router_v2.analysis.negation_detector import NegationDetector
from app.modules.rag.intent_router_v2.analysis.normalization import QueryNormalizer from app.modules.agent.intent_router_v2.analysis.normalization import QueryNormalizer
from app.modules.rag.intent_router_v2.analysis.sub_intent_detector import SubIntentDetector from app.modules.agent.intent_router_v2.analysis.sub_intent_detector import SubIntentDetector
from app.modules.rag.intent_router_v2.analysis.test_signals import has_test_focus, is_negative_test_request, is_test_related_token from app.modules.agent.intent_router_v2.analysis.test_signals import has_test_focus, is_negative_test_request, is_test_related_token
from app.modules.rag.intent_router_v2.analysis.term_mapping import RuEnTermMapper from app.modules.agent.intent_router_v2.analysis.term_mapping import RuEnTermMapper
class QueryPlanBuilder: class QueryPlanBuilder:

View File

@@ -2,7 +2,7 @@ from __future__ import annotations
import re import re
from app.modules.rag.intent_router_v2.analysis.normalization_terms import KeyTermCanonicalizer from app.modules.agent.intent_router_v2.analysis.normalization_terms import KeyTermCanonicalizer
_WORD_RE = re.compile(r"[A-Za-zА-Яа-яЁё-]+") _WORD_RE = re.compile(r"[A-Za-zА-Яа-яЁё-]+")

View File

@@ -1,9 +1,9 @@
from __future__ import annotations from __future__ import annotations
from app.modules.agent.llm import AgentLlmService from app.modules.agent.llm import AgentLlmService
from app.modules.agent.prompt_loader import PromptLoader from app.modules.agent.llm.prompt_loader import PromptLoader
from app.modules.rag.intent_router_v2.intent.classifier import IntentClassifierV2 from app.modules.agent.intent_router_v2.intent.classifier import IntentClassifierV2
from app.modules.rag.intent_router_v2.router import IntentRouterV2 from app.modules.agent.intent_router_v2.router import IntentRouterV2
from app.modules.shared.env_loader import load_workspace_env from app.modules.shared.env_loader import load_workspace_env
from app.modules.shared.gigachat.client import GigaChatClient from app.modules.shared.gigachat.client import GigaChatClient
from app.modules.shared.gigachat.settings import GigaChatSettings from app.modules.shared.gigachat.settings import GigaChatSettings

View File

@@ -0,0 +1,5 @@
from app.modules.agent.intent_router_v2.intent.classifier import IntentClassifierV2
from app.modules.agent.intent_router_v2.intent.conversation_policy import ConversationPolicy
from app.modules.agent.intent_router_v2.intent.graph_id_resolver import GraphIdResolver
__all__ = ["IntentClassifierV2", "ConversationPolicy", "GraphIdResolver"]

View File

@@ -3,9 +3,9 @@ from __future__ import annotations
import json import json
import re import re
from app.modules.rag.intent_router_v2.models import ConversationState, IntentDecision from app.modules.agent.intent_router_v2.models import ConversationState, IntentDecision
from app.modules.rag.intent_router_v2.protocols import TextGenerator from app.modules.agent.intent_router_v2.protocols import TextGenerator
from app.modules.rag.intent_router_v2.analysis.test_signals import has_test_focus from app.modules.agent.intent_router_v2.analysis.test_signals import has_test_focus
_CODE_FILE_PATH_RE = re.compile( _CODE_FILE_PATH_RE = re.compile(
r"\b(?:[\w.-]+/)*[\w.-]+\.(?:py|js|jsx|ts|tsx|java|kt|go|rb|php|c|cc|cpp|h|hpp|cs|swift|rs)(?!\w)\b", r"\b(?:[\w.-]+/)*[\w.-]+\.(?:py|js|jsx|ts|tsx|java|kt|go|rb|php|c|cc|cpp|h|hpp|cs|swift|rs)(?!\w)\b",

View File

@@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
from app.modules.rag.intent_router_v2.models import ConversationState, IntentDecision from app.modules.agent.intent_router_v2.models import ConversationState, IntentDecision
class ConversationPolicy: class ConversationPolicy:

View File

@@ -2,8 +2,8 @@ from __future__ import annotations
import logging import logging
from app.modules.rag.intent_router_v2.models import ConversationState, IntentRouterResult, RepoContext from app.modules.agent.intent_router_v2.models import ConversationState, IntentRouterResult, RepoContext
from app.modules.rag.intent_router_v2.router import IntentRouterV2 from app.modules.agent.intent_router_v2.router import IntentRouterV2
LOGGER = logging.getLogger(__name__) LOGGER = logging.getLogger(__name__)

View File

@@ -3,7 +3,7 @@ from __future__ import annotations
import json import json
import logging import logging
from app.modules.rag.intent_router_v2.models import ConversationState, IntentRouterResult, RepoContext from app.modules.agent.intent_router_v2.models import ConversationState, IntentRouterResult, RepoContext
LOGGER = logging.getLogger(__name__) LOGGER = logging.getLogger(__name__)

View File

@@ -0,0 +1,4 @@
from app.modules.agent.intent_router_v2.retrieval_planning.retrieval_spec_factory import RetrievalSpecFactory
from app.modules.agent.intent_router_v2.retrieval_planning.retrieval_constraints_factory import RetrievalConstraintsFactory
__all__ = ["RetrievalSpecFactory", "RetrievalConstraintsFactory"]

View File

@@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
from app.modules.rag.intent_router_v2.models import EvidencePolicy from app.modules.agent.intent_router_v2.models import EvidencePolicy
class EvidencePolicyFactory: class EvidencePolicyFactory:

View File

@@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
from app.modules.rag.intent_router_v2.models import LayerQuery, RepoContext from app.modules.agent.intent_router_v2.models import LayerQuery, RepoContext
class LayerQueryBuilder: class LayerQueryBuilder:

View File

@@ -1,7 +1,7 @@
from __future__ import annotations from __future__ import annotations
from app.modules.rag.intent_router_v2.models import QueryAnchor, RetrievalConstraints, RetrievalProfile from app.modules.agent.intent_router_v2.models import QueryAnchor, RetrievalConstraints, RetrievalProfile
from app.modules.rag.intent_router_v2.analysis.test_signals import has_test_focus, is_negative_test_request from app.modules.agent.intent_router_v2.analysis.test_signals import has_test_focus, is_negative_test_request
class RetrievalConstraintsFactory: class RetrievalConstraintsFactory:

View File

@@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
from app.modules.rag.intent_router_v2.models import ( from app.modules.agent.intent_router_v2.models import (
CodeRetrievalFilters, CodeRetrievalFilters,
ConversationState, ConversationState,
DocsRetrievalFilters, DocsRetrievalFilters,
@@ -8,7 +8,7 @@ from app.modules.rag.intent_router_v2.models import (
QueryAnchor, QueryAnchor,
RepoContext, RepoContext,
) )
from app.modules.rag.intent_router_v2.analysis.test_signals import has_test_focus, is_negative_test_request, is_test_related_token from app.modules.agent.intent_router_v2.analysis.test_signals import has_test_focus, is_negative_test_request, is_test_related_token
class RetrievalFilterBuilder: class RetrievalFilterBuilder:

Some files were not shown because too many files have changed in this diff Show More