Compare commits
8 Commits
9f1c67a751
...
codex/orch
| Author | SHA1 | Date | |
|---|---|---|---|
| 15586f9a8c | |||
| 9066c292de | |||
| b1f825e6b9 | |||
| 095d354112 | |||
| 6ba0a18ac9 | |||
| 417b8b6f72 | |||
| 1ef0b4d68c | |||
| 2728c07ba9 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1 +1,3 @@
|
|||||||
.env
|
.env
|
||||||
|
.venv
|
||||||
|
__pycache__
|
||||||
155
README.ARCH.md
155
README.ARCH.md
@@ -1,155 +0,0 @@
|
|||||||
# Архитектура приложения
|
|
||||||
|
|
||||||
Документ описывает модульную архитектуру backend-приложения, связи между модулями и контрактные границы.
|
|
||||||
|
|
||||||
## 1. Диаграмма модулей и взаимосвязей
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
flowchart LR
|
|
||||||
UI["Клиент / Frontend"] --> API["FastAPI (app/main.py)"]
|
|
||||||
|
|
||||||
API --> CHAT["chat модуль"]
|
|
||||||
API --> RAG["rag модуль"]
|
|
||||||
API --> AGENT_INTERNAL["internal tools API"]
|
|
||||||
|
|
||||||
CHAT -->|AgentRunner| AGENT["agent модуль"]
|
|
||||||
AGENT -->|RagRetriever| RAG
|
|
||||||
AGENT --> DB[(PostgreSQL + pgvector)]
|
|
||||||
CHAT --> DB
|
|
||||||
RAG --> DB
|
|
||||||
|
|
||||||
AGENT --> GIGA["GigaChat API"]
|
|
||||||
RAG --> GIGA
|
|
||||||
|
|
||||||
AGENT_INTERNAL --> AGENT
|
|
||||||
|
|
||||||
APP["ModularApplication\n(app/modules/application.py)"] --> CHAT
|
|
||||||
APP --> AGENT
|
|
||||||
APP --> RAG
|
|
||||||
```
|
|
||||||
|
|
||||||
### Внутренние слои `agent`
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
flowchart TB
|
|
||||||
ROUTER["Router\n(intent + context)"] --> ORCH["Orchestrator\n(task spec + plan + execution + quality)"]
|
|
||||||
ORCH --> GRAPHS["Action Graphs\nLangGraph"]
|
|
||||||
|
|
||||||
ORCH --> METRICS["Quality Metrics\n(faithfulness/coverage)"]
|
|
||||||
METRICS --> DB[(agent_quality_metrics)]
|
|
||||||
```
|
|
||||||
|
|
||||||
## 2. Описание модулей
|
|
||||||
|
|
||||||
### Модуль `app/modules/chat`
|
|
||||||
|
|
||||||
- Цель: принять пользовательский запрос, управлять задачей обработки, отдать результат и прогресс.
|
|
||||||
- Кратко о реализации:
|
|
||||||
- `ChatModule` публикует HTTP API (`/api/chat/*`, `/api/tasks/*`, `/api/events`).
|
|
||||||
- `ChatOrchestrator` запускает обработку асинхронной задачи, публикует SSE-события, обрабатывает retry и ошибки.
|
|
||||||
- `TaskStore`, `DialogSessionStore`, `IdempotencyStore` держат состояние задач/диалогов.
|
|
||||||
- С кем взаимодействует:
|
|
||||||
- с `agent` через контракт `AgentRunner`.
|
|
||||||
- с `rag` для проверки `rag_session_id`.
|
|
||||||
- с `shared/event_bus` для стриминга прогресса.
|
|
||||||
- с БД через `ChatRepository` (`dialog_sessions`, `chat_messages`).
|
|
||||||
- Контракты:
|
|
||||||
- потребляет `AgentRunner.run(...)` из `app/modules/contracts.py`.
|
|
||||||
|
|
||||||
### Модуль `app/modules/agent`
|
|
||||||
|
|
||||||
- Цель: интеллектуальная обработка запроса, маршрутизация, оркестрация сценария, генерация ответа/changeset.
|
|
||||||
- Кратко о реализации:
|
|
||||||
- `GraphAgentRuntime` выполняет pipeline: route -> task spec -> orchestrator -> post-processing.
|
|
||||||
- Router (`engine/router/*`) выбирает `domain/process` и хранит routing-context.
|
|
||||||
- Orchestrator (`engine/orchestrator/*`) строит и валидирует plan, исполняет шаги, запускает графовые/функциональные actions.
|
|
||||||
- Graphs (`engine/graphs/*`) выполняют целевые действия (QA, edits, docs).
|
|
||||||
- Рассчитывает quality-метрики (`faithfulness`, `coverage`) и сохраняет их в БД.
|
|
||||||
- Поддерживает внутренний инструмент получения страниц Confluence (`/internal/tools/confluence/fetch`).
|
|
||||||
- С кем взаимодействует:
|
|
||||||
- с `rag` через контракт `RagRetriever`.
|
|
||||||
- с `shared/checkpointer` (LangGraph checkpoints в PostgreSQL).
|
|
||||||
- с GigaChat (LLM-запросы, промпты).
|
|
||||||
- с БД через `AgentRepository` (`router_context`, `agent_quality_metrics`).
|
|
||||||
- Контракты:
|
|
||||||
- реализует `AgentRunner` (используется `chat`).
|
|
||||||
- потребляет `RagRetriever` (реализуется `rag`).
|
|
||||||
|
|
||||||
### Модуль `app/modules/rag`
|
|
||||||
|
|
||||||
- Цель: индексация проектных файлов и retrieval релевантного контекста.
|
|
||||||
- Кратко о реализации:
|
|
||||||
- API для snapshot/changes индексации и retrieval.
|
|
||||||
- Индексация хранит чанки, эмбеддинги и состояние job.
|
|
||||||
- Retrieval ищет релевантные куски в `rag_chunks` (pgvector).
|
|
||||||
- С кем взаимодействует:
|
|
||||||
- с `agent` (выдача контекста через `RagRetriever`).
|
|
||||||
- с БД (`rag_sessions`, `rag_chunks`, `rag_index_jobs`).
|
|
||||||
- с GigaChat Embeddings.
|
|
||||||
- Контракты:
|
|
||||||
- реализует `RagRetriever` и `RagIndexer` из `app/modules/contracts.py`.
|
|
||||||
|
|
||||||
### Модуль `app/modules/shared`
|
|
||||||
|
|
||||||
- Цель: общие инфраструктурные компоненты, переиспользуемые всеми модулями.
|
|
||||||
- Кратко о реализации:
|
|
||||||
- `db.py`: engine/session factory.
|
|
||||||
- `event_bus.py`: pub/sub для SSE.
|
|
||||||
- `retry_executor.py`: общий retry.
|
|
||||||
- `checkpointer.py`: PostgresSaver для LangGraph.
|
|
||||||
- `bootstrap.py`: инициализация схем БД на старте.
|
|
||||||
- С кем взаимодействует:
|
|
||||||
- со всеми бизнес-модулями (`chat`, `agent`, `rag`).
|
|
||||||
- Контракты:
|
|
||||||
- внутренние инфраструктурные API без отдельного публичного контракта уровня `contracts.py`.
|
|
||||||
|
|
||||||
### Модуль `app/modules/contracts.py`
|
|
||||||
|
|
||||||
- Цель: зафиксировать межмодульные интерфейсы и отделить реализацию от потребителей.
|
|
||||||
- Кратко о реализации:
|
|
||||||
- `AgentRunner`, `RagRetriever`, `RagIndexer` определены как `Protocol`.
|
|
||||||
- С кем взаимодействует:
|
|
||||||
- используется `chat` (как потребитель `AgentRunner`), `agent` (как потребитель `RagRetriever`), `rag` (как реализация `RagRetriever`/`RagIndexer`).
|
|
||||||
- Контракты:
|
|
||||||
- это и есть контрактный слой.
|
|
||||||
|
|
||||||
### Модуль композиции `app/modules/application.py`
|
|
||||||
|
|
||||||
- Цель: централизованный wiring зависимостей.
|
|
||||||
- Кратко о реализации:
|
|
||||||
- `ModularApplication` создаёт `EventBus`, `RetryExecutor`, репозитории и модули.
|
|
||||||
- На `startup()` выполняет bootstrap БД.
|
|
||||||
- С кем взаимодействует:
|
|
||||||
- со всеми модулями, но не содержит бизнес-логики.
|
|
||||||
- Контракты:
|
|
||||||
- использует `contracts.py` для сборки зависимостей без жёсткого сцепления по реализациям.
|
|
||||||
|
|
||||||
## 3. Ключевые контрактные границы
|
|
||||||
|
|
||||||
- `chat -> agent`: только через `AgentRunner`.
|
|
||||||
- `agent -> rag`: только через `RagRetriever`.
|
|
||||||
- `rag`: не зависит от `agent` internals.
|
|
||||||
- `application.py`: единственная точка связывания реализаций.
|
|
||||||
|
|
||||||
## 4. Схема данных (кратко)
|
|
||||||
|
|
||||||
- `chat`: `dialog_sessions`, `chat_messages`.
|
|
||||||
- `rag`: `rag_sessions`, `rag_chunks`, `rag_index_jobs`.
|
|
||||||
- `agent`:
|
|
||||||
- `router_context` — контекст маршрутизации по диалогу.
|
|
||||||
- `agent_quality_metrics` — пер-сценарные quality-метрики для отчетности:
|
|
||||||
- `faithfulness_score`, `coverage_score`, `quality_status`, `metrics_json`, `created_at`.
|
|
||||||
- `story_records` — карточка Story (статус, владелец, метаданные).
|
|
||||||
- `story_artifacts` — артефакты по Story (analytics/doc_increment/test_model и версии).
|
|
||||||
- `story_links` — внешние связи Story (тикеты, документы, URL).
|
|
||||||
|
|
||||||
## 5. Поток обработки запроса
|
|
||||||
|
|
||||||
1. Пользователь отправляет сообщение в `chat`.
|
|
||||||
2. `chat` создаёт task и вызывает `AgentRunner.run(...)`.
|
|
||||||
3. `agent/router` выбирает маршрут (domain/process).
|
|
||||||
4. `agent/orchestrator` строит `TaskSpec` и `ExecutionPlan`.
|
|
||||||
5. Выполняются шаги плана (function/actions/graph steps).
|
|
||||||
6. Формируется `answer` или `changeset`.
|
|
||||||
7. Считаются `faithfulness/coverage`, сохраняются в `agent_quality_metrics`.
|
|
||||||
8. `chat` возвращает результат и стримит прогресс через SSE.
|
|
||||||
161
README_old.md
Normal file
161
README_old.md
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
# Агент для работы с проектной документацией
|
||||||
|
|
||||||
|
## 1. Общее описание
|
||||||
|
Приложение представляет собой backend агентного режима для работы с документацией и кодом проекта.
|
||||||
|
|
||||||
|
Система решает следующие задачи:
|
||||||
|
- индексирует локальную копию проекта в `rag_session` и использует ее как основной рабочий контекст пользователя;
|
||||||
|
- принимает webhook коммитов репозитория в `rag_repo` и фиксирует контекст изменений по `story_id`;
|
||||||
|
- ускоряет построение `rag_session` за счет переиспользования кэша чанков и эмбеддингов из `rag_repo`;
|
||||||
|
- обрабатывает пользовательские запросы через `chat`, `agent`, оркестратор и специализированные графы;
|
||||||
|
- сохраняет quality-метрики, Story-контекст и артефакты сессии в PostgreSQL.
|
||||||
|
|
||||||
|
Ключевая идея архитектуры:
|
||||||
|
- `rag_session` отвечает за пользовательскую рабочую сессию и всегда остается основным источником retrieval;
|
||||||
|
- `rag_repo` не участвует напрямую в пользовательском ответе, а служит фоновым источником кэша и контекста коммитов;
|
||||||
|
- `story_id` связывает изменения аналитика, документацию и последующую работу тестировщика.
|
||||||
|
|
||||||
|
## 2. Архитектура
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
User["Пользователь"]
|
||||||
|
Git["Git репозиторий\n(Gitea / Bitbucket)"]
|
||||||
|
Chat["Модуль chat"]
|
||||||
|
Agent["Модуль agent"]
|
||||||
|
RagSession["Модуль rag_session"]
|
||||||
|
RagRepo["Модуль rag_repo"]
|
||||||
|
Shared["Модуль shared"]
|
||||||
|
DB["PostgreSQL + pgvector"]
|
||||||
|
Giga["GigaChat API"]
|
||||||
|
|
||||||
|
User --> Chat
|
||||||
|
Chat --> Agent
|
||||||
|
Agent --> RagSession
|
||||||
|
Agent --> Shared
|
||||||
|
RagSession --> Shared
|
||||||
|
RagRepo --> Shared
|
||||||
|
Chat --> DB
|
||||||
|
Agent --> DB
|
||||||
|
RagSession --> DB
|
||||||
|
RagRepo --> DB
|
||||||
|
RagSession --> Giga
|
||||||
|
Agent --> Giga
|
||||||
|
Git --> RagRepo
|
||||||
|
RagRepo -.кэш и контекст коммитов.-> RagSession
|
||||||
|
```
|
||||||
|
|
||||||
|
Кратко по ролям модулей:
|
||||||
|
- `chat` — внешний API чата, фоновые задачи, SSE события, диалоги.
|
||||||
|
- `agent` — роутер интентов, оркестратор, графы, tools, генерация ответа и changeset.
|
||||||
|
- `rag_session` — создание и сопровождение пользовательского RAG индекса по локальным файлам.
|
||||||
|
- `rag_repo` — прием webhook коммитов, определение `story_id`, фиксация контекста коммита и заполнение repo-cache.
|
||||||
|
- `shared` — инфраструктурный слой: БД, retry, event bus, GigaChat client, настройки.
|
||||||
|
|
||||||
|
## 3. Типичный флоу
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
actor User as Пользователь
|
||||||
|
participant Git as Git репозиторий
|
||||||
|
participant RagRepo as Модуль rag_repo
|
||||||
|
participant DB as PostgreSQL
|
||||||
|
participant RagSession as Модуль rag_session
|
||||||
|
participant Chat as Модуль chat
|
||||||
|
participant Agent as Модуль agent
|
||||||
|
|
||||||
|
Note over User,RagSession: Первая рабочая сессия: кэша репозитория еще нет
|
||||||
|
User->>RagSession: Создание rag_session и загрузка файлов проекта
|
||||||
|
RagSession->>DB: Проверка cache hit/miss
|
||||||
|
DB-->>RagSession: Кэш пуст
|
||||||
|
RagSession->>RagSession: Чанкинг и расчет embeddings без repo-cache
|
||||||
|
RagSession->>DB: Сохранение rag_chunks и rag_index_jobs
|
||||||
|
User->>Chat: Отправка запроса в чат
|
||||||
|
Chat->>Agent: Передача задачи
|
||||||
|
Agent->>RagSession: Retrieval контекста
|
||||||
|
RagSession->>DB: Чтение rag_chunks
|
||||||
|
DB-->>RagSession: Релевантные чанки
|
||||||
|
RagSession-->>Agent: Контекст
|
||||||
|
Agent-->>Chat: Ответ / changeset
|
||||||
|
|
||||||
|
Note over User,Git: Пользователь вручную делает commit и push
|
||||||
|
Git->>RagRepo: Webhook push
|
||||||
|
RagRepo->>RagRepo: Определение provider, commit_sha, changed_files, story_id
|
||||||
|
RagRepo->>DB: Запись story_records/story_links/story_artifacts
|
||||||
|
RagRepo->>DB: Запись rag_blob_cache/rag_chunk_cache/rag_session_chunk_map
|
||||||
|
|
||||||
|
Note over User,RagSession: Вторая рабочая сессия: repo-cache уже существует
|
||||||
|
User->>RagSession: Создание новой rag_session и загрузка файлов проекта
|
||||||
|
RagSession->>DB: Проверка cache hit/miss
|
||||||
|
DB-->>RagSession: Найдены cache hit по части файлов
|
||||||
|
RagSession->>RagSession: Переиспользование чанков из rag_repo cache
|
||||||
|
RagSession->>RagSession: Пересчет embeddings только для cache miss файлов
|
||||||
|
RagSession->>DB: Сохранение rag_chunks, rag_session_chunk_map, rag_index_jobs
|
||||||
|
User->>Chat: Новый запрос
|
||||||
|
Chat->>Agent: Передача задачи
|
||||||
|
Agent->>RagSession: Retrieval по новой сессии
|
||||||
|
RagSession-->>Agent: Контекст из session-RAG
|
||||||
|
Agent-->>Chat: Ответ / changeset
|
||||||
|
```
|
||||||
|
|
||||||
|
Что важно в этом сценарии:
|
||||||
|
- первый запуск индексации может быть полностью без кэша;
|
||||||
|
- после коммита `rag_repo` фиксирует контекст изменений и наполняет cache-таблицы;
|
||||||
|
- во второй и последующих сессиях `rag_session` использует `cache_hit_files`, чтобы уменьшить объем новых embeddings.
|
||||||
|
|
||||||
|
## 4. Инструкции к запуску
|
||||||
|
|
||||||
|
### Локальный запуск
|
||||||
|
```bash
|
||||||
|
python3 -m venv .venv
|
||||||
|
source .venv/bin/activate
|
||||||
|
pip install -r requirements.txt
|
||||||
|
uvicorn app.main:app --reload --port 15000
|
||||||
|
```
|
||||||
|
|
||||||
|
### Запуск через Docker Compose
|
||||||
|
1. Создать `.env` на основе примера:
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Заполнить как минимум `GIGACHAT_TOKEN`.
|
||||||
|
|
||||||
|
3. Запустить сервисы:
|
||||||
|
```bash
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Проверить доступность backend:
|
||||||
|
```bash
|
||||||
|
curl http://localhost:15000/health
|
||||||
|
```
|
||||||
|
|
||||||
|
Ожидаемый ответ:
|
||||||
|
```json
|
||||||
|
{"status":"ok"}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Основные адреса
|
||||||
|
- Backend API: `http://localhost:15000`
|
||||||
|
- PostgreSQL + pgvector: `localhost:5432`
|
||||||
|
- Webhook репозитория: `POST /internal/rag-repo/webhook`
|
||||||
|
|
||||||
|
### Базовый сценарий проверки
|
||||||
|
1. Создать `rag_session` через `POST /api/rag/sessions`.
|
||||||
|
2. Дождаться завершения index-job через `GET /api/rag/sessions/{rag_session_id}/jobs/{index_job_id}`.
|
||||||
|
3. Создать диалог через `POST /api/chat/dialogs`.
|
||||||
|
4. Отправить сообщение через `POST /api/chat/messages`.
|
||||||
|
5. Настроить webhook репозитория на `POST /internal/rag-repo/webhook`.
|
||||||
|
6. Сделать commit с `story_id` в сообщении, например `FEAT-1 ...`.
|
||||||
|
7. Проверить заполнение таблиц:
|
||||||
|
- `story_records`, `story_links`, `story_artifacts`
|
||||||
|
- `rag_blob_cache`, `rag_chunk_cache`, `rag_session_chunk_map`
|
||||||
|
8. Во второй сессии индексации проверить поля job-статуса:
|
||||||
|
- `indexed_files`
|
||||||
|
- `cache_hit_files`
|
||||||
|
- `cache_miss_files`
|
||||||
|
|
||||||
|
### Полезные замечания
|
||||||
|
- Текущая chat-модель: `GigaChat`.
|
||||||
|
- Основной retrieval всегда идет из `rag_session`.
|
||||||
|
- `rag_repo` используется как фоновый источник кэша и контекста коммитов.
|
||||||
|
- Если в webhook не найден `story_id`, commit-контекст Story не будет привязан, но cache-таблицы все равно должны наполняться.
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,60 +0,0 @@
|
|||||||
# Модуль agent
|
|
||||||
|
|
||||||
## 1. Функции модуля
|
|
||||||
- Оркестрация выполнения пользовательского запроса поверх роутера интентов и графов.
|
|
||||||
- Формирование `TaskSpec`, запуск оркестратора шагов и сборка финального результата.
|
|
||||||
- Реализация необходимых для агента tools и их интеграция с остальной логикой выполнения.
|
|
||||||
- Сохранение quality-метрик и session-артефактов для последующей привязки к Story.
|
|
||||||
|
|
||||||
## 2. Диаграмма классов и взаимосвязей
|
|
||||||
```mermaid
|
|
||||||
classDiagram
|
|
||||||
class AgentModule
|
|
||||||
class GraphAgentRuntime
|
|
||||||
class OrchestratorService
|
|
||||||
class TaskSpecBuilder
|
|
||||||
class StorySessionRecorder
|
|
||||||
class StoryContextRepository
|
|
||||||
class ConfluenceService
|
|
||||||
class AgentRepository
|
|
||||||
|
|
||||||
AgentModule --> GraphAgentRuntime
|
|
||||||
AgentModule --> ConfluenceService
|
|
||||||
AgentModule --> StorySessionRecorder
|
|
||||||
StorySessionRecorder --> StoryContextRepository
|
|
||||||
GraphAgentRuntime --> OrchestratorService
|
|
||||||
GraphAgentRuntime --> TaskSpecBuilder
|
|
||||||
GraphAgentRuntime --> AgentRepository
|
|
||||||
GraphAgentRuntime --> ConfluenceService
|
|
||||||
```
|
|
||||||
|
|
||||||
## 3. Описание классов
|
|
||||||
- `AgentModule`: собирает runtime и публикует внутренние tools-роуты.
|
|
||||||
Методы: `__init__` — связывает зависимости модуля; `internal_router` — регистрирует internal API tools.
|
|
||||||
- `GraphAgentRuntime`: основной исполнитель агентного запроса.
|
|
||||||
Методы: `run` — выполняет цикл route -> retrieval -> orchestration -> ответ/changeset.
|
|
||||||
- `OrchestratorService`: управляет планом шагов и выполнением quality gates.
|
|
||||||
Методы: `run` — строит, валидирует и исполняет execution plan.
|
|
||||||
- `TaskSpecBuilder`: формирует спецификацию задачи для оркестратора.
|
|
||||||
Методы: `build` — собирает `TaskSpec` из route, контекстов и ограничений.
|
|
||||||
- `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
|
|
||||||
|
|
||||||
### POST /internal/tools/confluence/fetch
|
|
||||||
Назначение: загружает страницу Confluence по URL и возвращает ее контент для дальнейшего использования в сценариях агента.
|
|
||||||
```mermaid
|
|
||||||
sequenceDiagram
|
|
||||||
participant Router as AgentModule.APIRouter
|
|
||||||
participant Confluence as ConfluenceService
|
|
||||||
|
|
||||||
Router->>Confluence: fetch_page(url)
|
|
||||||
Confluence-->>Router: page(content_markdown, metadata)
|
|
||||||
```
|
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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
|
|
||||||
@@ -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(),
|
|
||||||
}
|
|
||||||
Binary file not shown.
@@ -1,26 +0,0 @@
|
|||||||
__all__ = [
|
|
||||||
"BaseGraphFactory",
|
|
||||||
"DocsGraphFactory",
|
|
||||||
"ProjectEditsGraphFactory",
|
|
||||||
"ProjectQaGraphFactory",
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def __getattr__(name: str):
|
|
||||||
if name == "BaseGraphFactory":
|
|
||||||
from app.modules.agent.engine.graphs.base_graph import BaseGraphFactory
|
|
||||||
|
|
||||||
return BaseGraphFactory
|
|
||||||
if name == "DocsGraphFactory":
|
|
||||||
from app.modules.agent.engine.graphs.docs_graph import DocsGraphFactory
|
|
||||||
|
|
||||||
return DocsGraphFactory
|
|
||||||
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)
|
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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)
|
|
||||||
emit_progress_sync(
|
|
||||||
state,
|
|
||||||
stage="graph.default.answer.done",
|
|
||||||
message="Черновик ответа подготовлен.",
|
|
||||||
)
|
|
||||||
result = {"answer": answer}
|
|
||||||
LOGGER.warning(
|
|
||||||
"graph step result: graph=default step=answer answer_len=%s",
|
|
||||||
len(answer or ""),
|
|
||||||
)
|
|
||||||
return result
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
from pathlib import Path
|
|
||||||
import os
|
|
||||||
|
|
||||||
|
|
||||||
class DocsExamplesLoader:
|
|
||||||
def __init__(self, prompts_dir: Path | None = None) -> None:
|
|
||||||
base = prompts_dir or Path(__file__).resolve().parents[2] / "prompts"
|
|
||||||
env_override = os.getenv("AGENT_PROMPTS_DIR", "").strip()
|
|
||||||
root = Path(env_override) if env_override else base
|
|
||||||
self._examples_dir = root / "docs_examples"
|
|
||||||
|
|
||||||
def load_bundle(self, *, max_files: int = 6, max_chars_per_file: int = 1800) -> str:
|
|
||||||
if not self._examples_dir.is_dir():
|
|
||||||
return ""
|
|
||||||
files = sorted(
|
|
||||||
[p for p in self._examples_dir.iterdir() if p.is_file() and p.suffix.lower() in {".md", ".txt"}],
|
|
||||||
key=lambda p: p.name.lower(),
|
|
||||||
)[:max_files]
|
|
||||||
chunks: list[str] = []
|
|
||||||
for path in files:
|
|
||||||
content = path.read_text(encoding="utf-8", errors="ignore").strip()
|
|
||||||
if not content:
|
|
||||||
continue
|
|
||||||
excerpt = content[:max_chars_per_file].strip()
|
|
||||||
chunks.append(f"### Example: {path.name}\n{excerpt}")
|
|
||||||
return "\n\n".join(chunks).strip()
|
|
||||||
@@ -1,128 +0,0 @@
|
|||||||
from langgraph.graph import END, START, StateGraph
|
|
||||||
import logging
|
|
||||||
|
|
||||||
from app.modules.agent.engine.graphs.file_targeting import FileTargeting
|
|
||||||
from app.modules.agent.engine.graphs.docs_graph_logic import DocsContentComposer, DocsContextAnalyzer
|
|
||||||
from app.modules.agent.engine.graphs.progress import emit_progress_sync
|
|
||||||
from app.modules.agent.engine.graphs.state import AgentGraphState
|
|
||||||
from app.modules.agent.llm import AgentLlmService
|
|
||||||
|
|
||||||
LOGGER = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class DocsGraphFactory:
|
|
||||||
_max_validation_attempts = 2
|
|
||||||
|
|
||||||
def __init__(self, llm: AgentLlmService) -> None:
|
|
||||||
self._targeting = FileTargeting()
|
|
||||||
self._analyzer = DocsContextAnalyzer(llm, self._targeting)
|
|
||||||
self._composer = DocsContentComposer(llm, self._targeting)
|
|
||||||
|
|
||||||
def build(self, checkpointer=None):
|
|
||||||
graph = StateGraph(AgentGraphState)
|
|
||||||
graph.add_node("collect_code_context", self._collect_code_context)
|
|
||||||
graph.add_node("detect_existing_docs", self._detect_existing_docs)
|
|
||||||
graph.add_node("decide_strategy", self._decide_strategy)
|
|
||||||
graph.add_node("load_rules_and_examples", self._load_rules_and_examples)
|
|
||||||
graph.add_node("plan_incremental_changes", self._plan_incremental_changes)
|
|
||||||
graph.add_node("plan_new_document", self._plan_new_document)
|
|
||||||
graph.add_node("generate_doc_content", self._generate_doc_content)
|
|
||||||
graph.add_node("self_check", self._self_check)
|
|
||||||
graph.add_node("build_changeset", self._build_changeset)
|
|
||||||
graph.add_node("summarize_result", self._summarize_result)
|
|
||||||
|
|
||||||
graph.add_edge(START, "collect_code_context")
|
|
||||||
graph.add_edge("collect_code_context", "detect_existing_docs")
|
|
||||||
graph.add_edge("detect_existing_docs", "decide_strategy")
|
|
||||||
graph.add_edge("decide_strategy", "load_rules_and_examples")
|
|
||||||
graph.add_conditional_edges(
|
|
||||||
"load_rules_and_examples",
|
|
||||||
self._route_after_rules_loading,
|
|
||||||
{
|
|
||||||
"incremental": "plan_incremental_changes",
|
|
||||||
"from_scratch": "plan_new_document",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
graph.add_edge("plan_incremental_changes", "generate_doc_content")
|
|
||||||
graph.add_edge("plan_new_document", "generate_doc_content")
|
|
||||||
graph.add_edge("generate_doc_content", "self_check")
|
|
||||||
graph.add_conditional_edges(
|
|
||||||
"self_check",
|
|
||||||
self._route_after_self_check,
|
|
||||||
{"retry": "generate_doc_content", "ready": "build_changeset"},
|
|
||||||
)
|
|
||||||
graph.add_edge("build_changeset", "summarize_result")
|
|
||||||
graph.add_edge("summarize_result", END)
|
|
||||||
return graph.compile(checkpointer=checkpointer)
|
|
||||||
|
|
||||||
def _collect_code_context(self, state: AgentGraphState) -> dict:
|
|
||||||
return self._run_node(state, "collect_code_context", "Собираю контекст кода и файлов.", self._analyzer.collect_code_context)
|
|
||||||
|
|
||||||
def _detect_existing_docs(self, state: AgentGraphState) -> dict:
|
|
||||||
return self._run_node(
|
|
||||||
state,
|
|
||||||
"detect_existing_docs",
|
|
||||||
"Определяю, есть ли существующая документация проекта.",
|
|
||||||
self._analyzer.detect_existing_docs,
|
|
||||||
)
|
|
||||||
|
|
||||||
def _decide_strategy(self, state: AgentGraphState) -> dict:
|
|
||||||
return self._run_node(state, "decide_strategy", "Выбираю стратегию: инкремент или генерация с нуля.", self._analyzer.decide_strategy)
|
|
||||||
|
|
||||||
def _load_rules_and_examples(self, state: AgentGraphState) -> dict:
|
|
||||||
return self._run_node(
|
|
||||||
state,
|
|
||||||
"load_rules_and_examples",
|
|
||||||
"Загружаю правила и примеры формата документации.",
|
|
||||||
self._composer.load_rules_and_examples,
|
|
||||||
)
|
|
||||||
|
|
||||||
def _plan_incremental_changes(self, state: AgentGraphState) -> dict:
|
|
||||||
return self._run_node(
|
|
||||||
state,
|
|
||||||
"plan_incremental_changes",
|
|
||||||
"Планирую точечные изменения в существующей документации.",
|
|
||||||
lambda st: self._composer.plan_incremental_changes(st, self._analyzer),
|
|
||||||
)
|
|
||||||
|
|
||||||
def _plan_new_document(self, state: AgentGraphState) -> dict:
|
|
||||||
return self._run_node(state, "plan_new_document", "Проектирую структуру новой документации.", self._composer.plan_new_document)
|
|
||||||
|
|
||||||
def _generate_doc_content(self, state: AgentGraphState) -> dict:
|
|
||||||
return self._run_node(state, "generate_doc_content", "Генерирую содержимое документации.", self._composer.generate_doc_content)
|
|
||||||
|
|
||||||
def _self_check(self, state: AgentGraphState) -> dict:
|
|
||||||
return self._run_node(state, "self_check", "Проверяю соответствие результата правилам.", self._composer.self_check)
|
|
||||||
|
|
||||||
def _build_changeset(self, state: AgentGraphState) -> dict:
|
|
||||||
return self._run_node(state, "build_changeset", "Формирую итоговый набор изменений файлов.", self._composer.build_changeset)
|
|
||||||
|
|
||||||
def _summarize_result(self, state: AgentGraphState) -> dict:
|
|
||||||
return self._run_node(
|
|
||||||
state,
|
|
||||||
"summarize_result",
|
|
||||||
"Формирую краткий обзор выполненных действий и измененных файлов.",
|
|
||||||
self._composer.build_execution_summary,
|
|
||||||
)
|
|
||||||
|
|
||||||
def _route_after_rules_loading(self, state: AgentGraphState) -> str:
|
|
||||||
if state.get("docs_strategy") == "incremental_update":
|
|
||||||
return "incremental"
|
|
||||||
return "from_scratch"
|
|
||||||
|
|
||||||
def _route_after_self_check(self, state: AgentGraphState) -> str:
|
|
||||||
if state.get("validation_passed"):
|
|
||||||
return "ready"
|
|
||||||
attempts = int(state.get("validation_attempts", 0) or 0)
|
|
||||||
return "ready" if attempts >= self._max_validation_attempts else "retry"
|
|
||||||
|
|
||||||
def _run_node(self, state: AgentGraphState, node_name: str, message: str, fn):
|
|
||||||
emit_progress_sync(state, stage=f"graph.docs.{node_name}", message=message)
|
|
||||||
try:
|
|
||||||
result = fn(state)
|
|
||||||
emit_progress_sync(state, stage=f"graph.docs.{node_name}.done", message=f"Шаг '{node_name}' завершен.")
|
|
||||||
LOGGER.warning("docs graph node completed: node=%s keys=%s", node_name, sorted(result.keys()))
|
|
||||||
return result
|
|
||||||
except Exception:
|
|
||||||
LOGGER.exception("docs graph node failed: node=%s", node_name)
|
|
||||||
raise
|
|
||||||
@@ -1,519 +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)
|
|
||||||
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)
|
|
||||||
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)
|
|
||||||
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)
|
|
||||||
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)
|
|
||||||
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)
|
|
||||||
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.warning(
|
|
||||||
"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).strip()
|
|
||||||
except Exception:
|
|
||||||
summary = ""
|
|
||||||
if not summary:
|
|
||||||
summary = self._build_fallback_summary(state, file_lines)
|
|
||||||
return {"answer": summary}
|
|
||||||
|
|
||||||
def _build_fallback_bundle_from_text(self, text: str) -> list[dict]:
|
|
||||||
content = (text or "").strip()
|
|
||||||
if not content:
|
|
||||||
content = (
|
|
||||||
"# Project Documentation Draft\n\n"
|
|
||||||
"## Overview\n"
|
|
||||||
"Documentation draft was generated, but structured sections require уточнение.\n"
|
|
||||||
)
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
"path": "docs/logic/project_overview.md",
|
|
||||||
"content": content,
|
|
||||||
"reason": "Fallback: generated structured logic document from non-JSON model output.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "docs/api/README.md",
|
|
||||||
"content": (
|
|
||||||
"# API Methods\n\n"
|
|
||||||
"This file is a fallback placeholder for API method documentation.\n\n"
|
|
||||||
"## Next Step\n"
|
|
||||||
"- Add one file per API method under `docs/api/`.\n"
|
|
||||||
),
|
|
||||||
"reason": "Fallback: ensure required docs/api structure exists.",
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
def _format_changed_files(self, changeset: list[ChangeItem]) -> str:
|
|
||||||
lines: list[str] = []
|
|
||||||
for item in changeset[:30]:
|
|
||||||
lines.append(f"- {item.op.value} {item.path}: {item.reason}")
|
|
||||||
return "\n".join(lines)
|
|
||||||
|
|
||||||
def _build_fallback_summary(self, state: AgentGraphState, file_lines: str) -> str:
|
|
||||||
request = (state.get("message", "") or "").strip()
|
|
||||||
return "\n".join(
|
|
||||||
[
|
|
||||||
"Выполненные действия:",
|
|
||||||
f"- Обработан запрос: {request or '(пустой запрос)'}",
|
|
||||||
f"- Применена стратегия документации: {state.get('docs_strategy', 'from_scratch')}",
|
|
||||||
"- Сформирован и проверен changeset для документации.",
|
|
||||||
"",
|
|
||||||
"Измененные файлы:",
|
|
||||||
file_lines or "- (нет изменений)",
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
def _is_broad_rewrite_request(self, message: str) -> bool:
|
|
||||||
low = (message or "").lower()
|
|
||||||
markers = (
|
|
||||||
"перепиши",
|
|
||||||
"полностью",
|
|
||||||
"целиком",
|
|
||||||
"с нуля",
|
|
||||||
"full rewrite",
|
|
||||||
"rewrite all",
|
|
||||||
"реорганизуй",
|
|
||||||
)
|
|
||||||
return any(marker in low for marker in markers)
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
import re
|
|
||||||
|
|
||||||
|
|
||||||
class FileTargeting:
|
|
||||||
_path_pattern = re.compile(r"([A-Za-z0-9_.\-/]+?\.[A-Za-z0-9_]+)")
|
|
||||||
|
|
||||||
def extract_target_path(self, message: str) -> str | None:
|
|
||||||
text = (message or "").replace("\\", "/")
|
|
||||||
candidates = self._path_pattern.findall(text)
|
|
||||||
if not candidates:
|
|
||||||
return None
|
|
||||||
for candidate in candidates:
|
|
||||||
cleaned = candidate.strip("`'\".,:;()[]{}")
|
|
||||||
if "/" in cleaned or cleaned.startswith("."):
|
|
||||||
return cleaned
|
|
||||||
return candidates[0].strip("`'\".,:;()[]{}")
|
|
||||||
|
|
||||||
def lookup_file(self, files_map: dict[str, dict], path: str | None) -> dict | None:
|
|
||||||
if not path:
|
|
||||||
return None
|
|
||||||
normalized = path.replace("\\", "/")
|
|
||||||
if normalized in files_map:
|
|
||||||
return files_map[normalized]
|
|
||||||
low = normalized.lower()
|
|
||||||
for key, value in files_map.items():
|
|
||||||
if key.lower() == low:
|
|
||||||
return value
|
|
||||||
return None
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
from collections.abc import Awaitable, Callable
|
|
||||||
import inspect
|
|
||||||
import asyncio
|
|
||||||
|
|
||||||
from app.modules.agent.engine.graphs.progress_registry import progress_registry
|
|
||||||
from app.modules.agent.engine.graphs.state import AgentGraphState
|
|
||||||
|
|
||||||
ProgressCallback = Callable[[str, str, str, dict | None], Awaitable[None] | None]
|
|
||||||
|
|
||||||
|
|
||||||
async def emit_progress(
|
|
||||||
state: AgentGraphState,
|
|
||||||
*,
|
|
||||||
stage: str,
|
|
||||||
message: str,
|
|
||||||
kind: str = "task_progress",
|
|
||||||
meta: dict | None = None,
|
|
||||||
) -> None:
|
|
||||||
callback = progress_registry.get(state.get("progress_key"))
|
|
||||||
if callback is None:
|
|
||||||
return
|
|
||||||
result = callback(stage, message, kind, meta or {})
|
|
||||||
if inspect.isawaitable(result):
|
|
||||||
await result
|
|
||||||
|
|
||||||
|
|
||||||
def emit_progress_sync(
|
|
||||||
state: AgentGraphState,
|
|
||||||
*,
|
|
||||||
stage: str,
|
|
||||||
message: str,
|
|
||||||
kind: str = "task_progress",
|
|
||||||
meta: dict | None = None,
|
|
||||||
) -> None:
|
|
||||||
callback = progress_registry.get(state.get("progress_key"))
|
|
||||||
if callback is None:
|
|
||||||
return
|
|
||||||
result = callback(stage, message, kind, meta or {})
|
|
||||||
if inspect.isawaitable(result):
|
|
||||||
try:
|
|
||||||
loop = asyncio.get_running_loop()
|
|
||||||
loop.create_task(result)
|
|
||||||
except RuntimeError:
|
|
||||||
pass
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
from collections.abc import Awaitable, Callable
|
|
||||||
from threading import Lock
|
|
||||||
|
|
||||||
ProgressCallback = Callable[[str, str, str, dict | None], Awaitable[None] | None]
|
|
||||||
|
|
||||||
|
|
||||||
class ProgressRegistry:
|
|
||||||
def __init__(self) -> None:
|
|
||||||
self._items: dict[str, ProgressCallback] = {}
|
|
||||||
self._lock = Lock()
|
|
||||||
|
|
||||||
def register(self, key: str, callback: ProgressCallback) -> None:
|
|
||||||
with self._lock:
|
|
||||||
self._items[key] = callback
|
|
||||||
|
|
||||||
def get(self, key: str | None) -> ProgressCallback | None:
|
|
||||||
if not key:
|
|
||||||
return None
|
|
||||||
with self._lock:
|
|
||||||
return self._items.get(key)
|
|
||||||
|
|
||||||
def unregister(self, key: str) -> None:
|
|
||||||
with self._lock:
|
|
||||||
self._items.pop(key, None)
|
|
||||||
|
|
||||||
|
|
||||||
progress_registry = ProgressRegistry()
|
|
||||||
@@ -1,171 +0,0 @@
|
|||||||
import re
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class BlockContract:
|
|
||||||
type: str
|
|
||||||
max_changed_lines: int = 6
|
|
||||||
start_anchor: str = ""
|
|
||||||
end_anchor: str = ""
|
|
||||||
old_line: str = ""
|
|
||||||
|
|
||||||
def as_dict(self) -> dict:
|
|
||||||
return {
|
|
||||||
"type": self.type,
|
|
||||||
"max_changed_lines": self.max_changed_lines,
|
|
||||||
"start_anchor": self.start_anchor,
|
|
||||||
"end_anchor": self.end_anchor,
|
|
||||||
"old_line": self.old_line,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class FileEditContract:
|
|
||||||
path: str
|
|
||||||
reason: str
|
|
||||||
intent: str = "update"
|
|
||||||
max_hunks: int = 1
|
|
||||||
max_changed_lines: int = 8
|
|
||||||
allowed_blocks: list[BlockContract] = field(default_factory=list)
|
|
||||||
|
|
||||||
def as_dict(self) -> dict:
|
|
||||||
return {
|
|
||||||
"path": self.path,
|
|
||||||
"reason": self.reason,
|
|
||||||
"intent": self.intent,
|
|
||||||
"max_hunks": self.max_hunks,
|
|
||||||
"max_changed_lines": self.max_changed_lines,
|
|
||||||
"allowed_blocks": [block.as_dict() for block in self.allowed_blocks],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class ContractParser:
|
|
||||||
_supported_block_types = {"append_end", "replace_between", "replace_line_equals"}
|
|
||||||
|
|
||||||
def parse(self, payload: dict, *, request: str, requested_path: str) -> list[dict]:
|
|
||||||
files = payload.get("files", []) if isinstance(payload, dict) else []
|
|
||||||
parsed: list[FileEditContract] = []
|
|
||||||
for item in files if isinstance(files, list) else []:
|
|
||||||
contract = self._parse_file_contract(item)
|
|
||||||
if contract:
|
|
||||||
parsed.append(contract)
|
|
||||||
|
|
||||||
if not parsed:
|
|
||||||
fallback = self._fallback_contract(request=request, requested_path=requested_path)
|
|
||||||
if fallback:
|
|
||||||
parsed.append(fallback)
|
|
||||||
|
|
||||||
return [item.as_dict() for item in parsed]
|
|
||||||
|
|
||||||
def _parse_file_contract(self, item: object) -> FileEditContract | None:
|
|
||||||
if not isinstance(item, dict):
|
|
||||||
return None
|
|
||||||
path = str(item.get("path", "")).replace("\\", "/").strip()
|
|
||||||
if not path:
|
|
||||||
return None
|
|
||||||
reason = str(item.get("reason", "")).strip() or "Requested user adjustment."
|
|
||||||
intent = str(item.get("intent", "update")).strip().lower() or "update"
|
|
||||||
if intent not in {"update", "create"}:
|
|
||||||
intent = "update"
|
|
||||||
max_hunks = self._clamp_int(item.get("max_hunks"), default=1, min_value=1, max_value=5)
|
|
||||||
max_changed_lines = self._clamp_int(item.get("max_changed_lines"), default=8, min_value=1, max_value=120)
|
|
||||||
blocks: list[BlockContract] = []
|
|
||||||
raw_blocks = item.get("allowed_blocks", [])
|
|
||||||
for raw in raw_blocks if isinstance(raw_blocks, list) else []:
|
|
||||||
block = self._parse_block(raw)
|
|
||||||
if block:
|
|
||||||
blocks.append(block)
|
|
||||||
if not blocks:
|
|
||||||
return None
|
|
||||||
return FileEditContract(
|
|
||||||
path=path,
|
|
||||||
reason=reason,
|
|
||||||
intent=intent,
|
|
||||||
max_hunks=max_hunks,
|
|
||||||
max_changed_lines=max_changed_lines,
|
|
||||||
allowed_blocks=blocks,
|
|
||||||
)
|
|
||||||
|
|
||||||
def _parse_block(self, raw: object) -> BlockContract | None:
|
|
||||||
if not isinstance(raw, dict):
|
|
||||||
return None
|
|
||||||
kind = self._normalize_block_type(str(raw.get("type", "")).strip().lower())
|
|
||||||
if kind not in self._supported_block_types:
|
|
||||||
return None
|
|
||||||
max_changed_lines = self._clamp_int(raw.get("max_changed_lines"), default=6, min_value=1, max_value=80)
|
|
||||||
block = BlockContract(
|
|
||||||
type=kind,
|
|
||||||
max_changed_lines=max_changed_lines,
|
|
||||||
start_anchor=str(raw.get("start_anchor", "")).strip(),
|
|
||||||
end_anchor=str(raw.get("end_anchor", "")).strip(),
|
|
||||||
old_line=str(raw.get("old_line", "")).strip(),
|
|
||||||
)
|
|
||||||
if block.type == "replace_between" and (not block.start_anchor or not block.end_anchor):
|
|
||||||
return None
|
|
||||||
if block.type == "replace_line_equals" and not block.old_line:
|
|
||||||
return None
|
|
||||||
return block
|
|
||||||
|
|
||||||
def _fallback_contract(self, *, request: str, requested_path: str) -> FileEditContract | None:
|
|
||||||
path = requested_path.strip()
|
|
||||||
if not path:
|
|
||||||
return None
|
|
||||||
low = (request or "").lower()
|
|
||||||
if any(marker in low for marker in ("в конец", "в самый конец", "append to end", "append at the end")):
|
|
||||||
return FileEditContract(
|
|
||||||
path=path,
|
|
||||||
reason="Append-only update inferred from user request.",
|
|
||||||
intent="update",
|
|
||||||
max_hunks=1,
|
|
||||||
max_changed_lines=8,
|
|
||||||
allowed_blocks=[BlockContract(type="append_end", max_changed_lines=8)],
|
|
||||||
)
|
|
||||||
quoted = self._extract_quoted_line(request)
|
|
||||||
if quoted:
|
|
||||||
return FileEditContract(
|
|
||||||
path=path,
|
|
||||||
reason="Single-line replacement inferred from quoted segment in user request.",
|
|
||||||
intent="update",
|
|
||||||
max_hunks=1,
|
|
||||||
max_changed_lines=4,
|
|
||||||
allowed_blocks=[BlockContract(type="replace_line_equals", old_line=quoted, max_changed_lines=4)],
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
|
|
||||||
def _extract_quoted_line(self, text: str) -> str:
|
|
||||||
value = (text or "").strip()
|
|
||||||
patterns = [
|
|
||||||
r"`([^`]+)`",
|
|
||||||
r"\"([^\"]+)\"",
|
|
||||||
r"'([^']+)'",
|
|
||||||
r"«([^»]+)»",
|
|
||||||
]
|
|
||||||
for pattern in patterns:
|
|
||||||
match = re.search(pattern, value)
|
|
||||||
if not match:
|
|
||||||
continue
|
|
||||||
candidate = match.group(1).strip()
|
|
||||||
if candidate:
|
|
||||||
return candidate
|
|
||||||
return ""
|
|
||||||
|
|
||||||
def _normalize_block_type(self, value: str) -> str:
|
|
||||||
mapping = {
|
|
||||||
"append": "append_end",
|
|
||||||
"append_eof": "append_end",
|
|
||||||
"end_append": "append_end",
|
|
||||||
"replace_block": "replace_between",
|
|
||||||
"replace_section": "replace_between",
|
|
||||||
"replace_range": "replace_between",
|
|
||||||
"replace_line": "replace_line_equals",
|
|
||||||
"line_equals": "replace_line_equals",
|
|
||||||
}
|
|
||||||
return mapping.get(value, value)
|
|
||||||
|
|
||||||
def _clamp_int(self, value: object, *, default: int, min_value: int, max_value: int) -> int:
|
|
||||||
try:
|
|
||||||
numeric = int(value) # type: ignore[arg-type]
|
|
||||||
except Exception:
|
|
||||||
numeric = default
|
|
||||||
return max(min_value, min(max_value, numeric))
|
|
||||||
@@ -1,102 +0,0 @@
|
|||||||
import logging
|
|
||||||
|
|
||||||
from langgraph.graph import END, START, StateGraph
|
|
||||||
|
|
||||||
from app.modules.agent.engine.graphs.progress import emit_progress_sync
|
|
||||||
from app.modules.agent.engine.graphs.project_edits_logic import ProjectEditsLogic
|
|
||||||
from app.modules.agent.engine.graphs.state import AgentGraphState
|
|
||||||
from app.modules.agent.llm import AgentLlmService
|
|
||||||
|
|
||||||
LOGGER = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class ProjectEditsGraphFactory:
|
|
||||||
_max_validation_attempts = 2
|
|
||||||
|
|
||||||
def __init__(self, llm: AgentLlmService) -> None:
|
|
||||||
self._logic = ProjectEditsLogic(llm)
|
|
||||||
|
|
||||||
def build(self, checkpointer=None):
|
|
||||||
graph = StateGraph(AgentGraphState)
|
|
||||||
graph.add_node("collect_context", self._collect_context)
|
|
||||||
graph.add_node("plan_changes", self._plan_changes)
|
|
||||||
graph.add_node("generate_changeset", self._generate_changeset)
|
|
||||||
graph.add_node("self_check", self._self_check)
|
|
||||||
graph.add_node("build_result", self._build_result)
|
|
||||||
|
|
||||||
graph.add_edge(START, "collect_context")
|
|
||||||
graph.add_edge("collect_context", "plan_changes")
|
|
||||||
graph.add_edge("plan_changes", "generate_changeset")
|
|
||||||
graph.add_edge("generate_changeset", "self_check")
|
|
||||||
graph.add_conditional_edges(
|
|
||||||
"self_check",
|
|
||||||
self._route_after_self_check,
|
|
||||||
{"retry": "generate_changeset", "ready": "build_result"},
|
|
||||||
)
|
|
||||||
graph.add_edge("build_result", END)
|
|
||||||
return graph.compile(checkpointer=checkpointer)
|
|
||||||
|
|
||||||
def _collect_context(self, state: AgentGraphState) -> dict:
|
|
||||||
emit_progress_sync(
|
|
||||||
state,
|
|
||||||
stage="graph.project_edits.collect_context",
|
|
||||||
message="Собираю контекст и релевантные файлы для правок.",
|
|
||||||
)
|
|
||||||
result = self._logic.collect_context(state)
|
|
||||||
self._log_step_result("collect_context", result)
|
|
||||||
return result
|
|
||||||
|
|
||||||
def _plan_changes(self, state: AgentGraphState) -> dict:
|
|
||||||
emit_progress_sync(
|
|
||||||
state,
|
|
||||||
stage="graph.project_edits.plan_changes",
|
|
||||||
message="Определяю, что именно нужно изменить и в каких файлах.",
|
|
||||||
)
|
|
||||||
result = self._logic.plan_changes(state)
|
|
||||||
self._log_step_result("plan_changes", result)
|
|
||||||
return result
|
|
||||||
|
|
||||||
def _generate_changeset(self, state: AgentGraphState) -> dict:
|
|
||||||
emit_progress_sync(
|
|
||||||
state,
|
|
||||||
stage="graph.project_edits.generate_changeset",
|
|
||||||
message="Формирую предлагаемые правки по выбранным файлам.",
|
|
||||||
)
|
|
||||||
result = self._logic.generate_changeset(state)
|
|
||||||
self._log_step_result("generate_changeset", result)
|
|
||||||
return result
|
|
||||||
|
|
||||||
def _self_check(self, state: AgentGraphState) -> dict:
|
|
||||||
emit_progress_sync(
|
|
||||||
state,
|
|
||||||
stage="graph.project_edits.self_check",
|
|
||||||
message="Проверяю, что правки соответствуют запросу и не трогают лишнее.",
|
|
||||||
)
|
|
||||||
result = self._logic.self_check(state)
|
|
||||||
self._log_step_result("self_check", result)
|
|
||||||
return result
|
|
||||||
|
|
||||||
def _build_result(self, state: AgentGraphState) -> dict:
|
|
||||||
emit_progress_sync(
|
|
||||||
state,
|
|
||||||
stage="graph.project_edits.build_result",
|
|
||||||
message="Формирую итоговый changeset и краткий обзор.",
|
|
||||||
)
|
|
||||||
result = self._logic.build_result(state)
|
|
||||||
self._log_step_result("build_result", result)
|
|
||||||
return result
|
|
||||||
|
|
||||||
def _route_after_self_check(self, state: AgentGraphState) -> str:
|
|
||||||
if state.get("validation_passed"):
|
|
||||||
return "ready"
|
|
||||||
attempts = int(state.get("validation_attempts", 0) or 0)
|
|
||||||
return "ready" if attempts >= self._max_validation_attempts else "retry"
|
|
||||||
|
|
||||||
def _log_step_result(self, step: str, result: dict) -> None:
|
|
||||||
LOGGER.warning(
|
|
||||||
"graph step result: graph=project_edits step=%s keys=%s changeset_items=%s answer_len=%s",
|
|
||||||
step,
|
|
||||||
sorted(result.keys()),
|
|
||||||
len(result.get("changeset", []) or []),
|
|
||||||
len(str(result.get("answer", "") or "")),
|
|
||||||
)
|
|
||||||
@@ -1,228 +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))
|
|
||||||
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)))
|
|
||||||
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))
|
|
||||||
parsed = self._support.parse_json(raw)
|
|
||||||
hunks = parsed.get("hunks", []) if isinstance(parsed, dict) else []
|
|
||||||
if not isinstance(hunks, list) or not hunks:
|
|
||||||
return [], "Model did not return contract hunks."
|
|
||||||
normalized: list[dict] = []
|
|
||||||
for hunk in hunks:
|
|
||||||
if not isinstance(hunk, dict):
|
|
||||||
continue
|
|
||||||
kind = str(hunk.get("type", "")).strip().lower()
|
|
||||||
if kind not in {"append_end", "replace_between", "replace_line_equals"}:
|
|
||||||
continue
|
|
||||||
normalized.append(
|
|
||||||
{
|
|
||||||
"type": kind,
|
|
||||||
"start_anchor": str(hunk.get("start_anchor", "")),
|
|
||||||
"end_anchor": str(hunk.get("end_anchor", "")),
|
|
||||||
"old_line": str(hunk.get("old_line", "")),
|
|
||||||
"new_text": str(hunk.get("new_text", "")),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
if not normalized:
|
|
||||||
return [], "Model hunks are empty or invalid."
|
|
||||||
return normalized, None
|
|
||||||
|
|
||||||
def _extract_path_hint(self, message: str) -> str:
|
|
||||||
words = (message or "").replace("\\", "/").split()
|
|
||||||
for token in words:
|
|
||||||
cleaned = token.strip("`'\".,:;()[]{}")
|
|
||||||
if "/" in cleaned and "." in cleaned:
|
|
||||||
return cleaned
|
|
||||||
if cleaned.lower().startswith("readme"):
|
|
||||||
return "README.md"
|
|
||||||
return ""
|
|
||||||
@@ -1,142 +0,0 @@
|
|||||||
from difflib import SequenceMatcher
|
|
||||||
|
|
||||||
|
|
||||||
class ContractPatcher:
|
|
||||||
def apply(self, current_content: str, contract: dict, hunks: list[dict]) -> tuple[str | None, str | None]:
|
|
||||||
if not hunks:
|
|
||||||
return None, "No hunks were generated."
|
|
||||||
|
|
||||||
max_hunks = int(contract.get("max_hunks", 1) or 1)
|
|
||||||
if len(hunks) > max_hunks:
|
|
||||||
return None, f"Too many hunks: got={len(hunks)} allowed={max_hunks}."
|
|
||||||
|
|
||||||
allowed_blocks = contract.get("allowed_blocks", [])
|
|
||||||
if not isinstance(allowed_blocks, list) or not allowed_blocks:
|
|
||||||
return None, "No allowed blocks in edit contract."
|
|
||||||
|
|
||||||
result = current_content
|
|
||||||
total_changed_lines = 0
|
|
||||||
for idx, hunk in enumerate(hunks, start=1):
|
|
||||||
applied, changed_lines, error = self._apply_hunk(result, hunk, allowed_blocks)
|
|
||||||
if error:
|
|
||||||
return None, f"Hunk {idx} rejected: {error}"
|
|
||||||
result = applied
|
|
||||||
total_changed_lines += changed_lines
|
|
||||||
|
|
||||||
max_changed_lines = int(contract.get("max_changed_lines", 8) or 8)
|
|
||||||
if total_changed_lines > max_changed_lines:
|
|
||||||
return (
|
|
||||||
None,
|
|
||||||
f"Changed lines exceed contract limit: changed={total_changed_lines} allowed={max_changed_lines}.",
|
|
||||||
)
|
|
||||||
return result, None
|
|
||||||
|
|
||||||
def _apply_hunk(
|
|
||||||
self,
|
|
||||||
content: str,
|
|
||||||
hunk: dict,
|
|
||||||
allowed_blocks: list[dict],
|
|
||||||
) -> tuple[str, int, str | None]:
|
|
||||||
if not isinstance(hunk, dict):
|
|
||||||
return content, 0, "Invalid hunk payload."
|
|
||||||
kind = str(hunk.get("type", "")).strip().lower()
|
|
||||||
if kind not in {"append_end", "replace_between", "replace_line_equals"}:
|
|
||||||
return content, 0, f"Unsupported hunk type: {kind or '(empty)'}."
|
|
||||||
|
|
||||||
block = self._find_matching_block(hunk, allowed_blocks)
|
|
||||||
if block is None:
|
|
||||||
return content, 0, "Hunk does not match allowed contract blocks."
|
|
||||||
|
|
||||||
if kind == "append_end":
|
|
||||||
return self._apply_append_end(content, hunk, block)
|
|
||||||
if kind == "replace_between":
|
|
||||||
return self._apply_replace_between(content, hunk, block)
|
|
||||||
return self._apply_replace_line_equals(content, hunk, block)
|
|
||||||
|
|
||||||
def _find_matching_block(self, hunk: dict, allowed_blocks: list[dict]) -> dict | None:
|
|
||||||
kind = str(hunk.get("type", "")).strip().lower()
|
|
||||||
for block in allowed_blocks:
|
|
||||||
if not isinstance(block, dict):
|
|
||||||
continue
|
|
||||||
block_type = str(block.get("type", "")).strip().lower()
|
|
||||||
if block_type != kind:
|
|
||||||
continue
|
|
||||||
if kind == "replace_between":
|
|
||||||
start = str(hunk.get("start_anchor", "")).strip()
|
|
||||||
end = str(hunk.get("end_anchor", "")).strip()
|
|
||||||
if start != str(block.get("start_anchor", "")).strip():
|
|
||||||
continue
|
|
||||||
if end != str(block.get("end_anchor", "")).strip():
|
|
||||||
continue
|
|
||||||
if kind == "replace_line_equals":
|
|
||||||
old_line = str(hunk.get("old_line", "")).strip()
|
|
||||||
if old_line != str(block.get("old_line", "")).strip():
|
|
||||||
continue
|
|
||||||
return block
|
|
||||||
return None
|
|
||||||
|
|
||||||
def _apply_append_end(self, content: str, hunk: dict, block: dict) -> tuple[str, int, str | None]:
|
|
||||||
new_text = str(hunk.get("new_text", ""))
|
|
||||||
if not new_text.strip():
|
|
||||||
return content, 0, "append_end new_text is empty."
|
|
||||||
changed_lines = self._changed_line_count("", new_text)
|
|
||||||
block_limit = int(block.get("max_changed_lines", 6) or 6)
|
|
||||||
if changed_lines > block_limit:
|
|
||||||
return content, 0, f"append_end is too large: changed={changed_lines} allowed={block_limit}."
|
|
||||||
base = content.rstrip("\n")
|
|
||||||
suffix = new_text.strip("\n")
|
|
||||||
if not suffix:
|
|
||||||
return content, 0, "append_end resolved to empty suffix."
|
|
||||||
merged = f"{base}\n\n{suffix}\n" if base else f"{suffix}\n"
|
|
||||||
return merged, changed_lines, None
|
|
||||||
|
|
||||||
def _apply_replace_between(self, content: str, hunk: dict, block: dict) -> tuple[str, int, str | None]:
|
|
||||||
start_anchor = str(hunk.get("start_anchor", "")).strip()
|
|
||||||
end_anchor = str(hunk.get("end_anchor", "")).strip()
|
|
||||||
new_text = str(hunk.get("new_text", ""))
|
|
||||||
if not start_anchor or not end_anchor:
|
|
||||||
return content, 0, "replace_between anchors are required."
|
|
||||||
start_pos = content.find(start_anchor)
|
|
||||||
if start_pos < 0:
|
|
||||||
return content, 0, "start_anchor not found in file."
|
|
||||||
middle_start = start_pos + len(start_anchor)
|
|
||||||
end_pos = content.find(end_anchor, middle_start)
|
|
||||||
if end_pos < 0:
|
|
||||||
return content, 0, "end_anchor not found after start_anchor."
|
|
||||||
old_segment = content[middle_start:end_pos]
|
|
||||||
changed_lines = self._changed_line_count(old_segment, new_text)
|
|
||||||
block_limit = int(block.get("max_changed_lines", 6) or 6)
|
|
||||||
if changed_lines > block_limit:
|
|
||||||
return content, 0, f"replace_between is too large: changed={changed_lines} allowed={block_limit}."
|
|
||||||
merged = content[:middle_start] + new_text + content[end_pos:]
|
|
||||||
return merged, changed_lines, None
|
|
||||||
|
|
||||||
def _apply_replace_line_equals(self, content: str, hunk: dict, block: dict) -> tuple[str, int, str | None]:
|
|
||||||
old_line = str(hunk.get("old_line", "")).strip()
|
|
||||||
new_text = str(hunk.get("new_text", ""))
|
|
||||||
if not old_line:
|
|
||||||
return content, 0, "replace_line_equals old_line is required."
|
|
||||||
lines = content.splitlines(keepends=True)
|
|
||||||
matches = [idx for idx, line in enumerate(lines) if line.rstrip("\n") == old_line]
|
|
||||||
if len(matches) != 1:
|
|
||||||
return content, 0, f"replace_line_equals expected exactly one match, got={len(matches)}."
|
|
||||||
replacement = new_text.rstrip("\n") + "\n"
|
|
||||||
changed_lines = self._changed_line_count(old_line + "\n", replacement)
|
|
||||||
block_limit = int(block.get("max_changed_lines", 6) or 6)
|
|
||||||
if changed_lines > block_limit:
|
|
||||||
return content, 0, f"replace_line_equals is too large: changed={changed_lines} allowed={block_limit}."
|
|
||||||
lines[matches[0] : matches[0] + 1] = [replacement]
|
|
||||||
return "".join(lines), changed_lines, None
|
|
||||||
|
|
||||||
def _changed_line_count(self, old_text: str, new_text: str) -> int:
|
|
||||||
old_lines = (old_text or "").splitlines()
|
|
||||||
new_lines = (new_text or "").splitlines()
|
|
||||||
if not old_lines and not new_lines:
|
|
||||||
return 0
|
|
||||||
matcher = SequenceMatcher(None, old_lines, new_lines)
|
|
||||||
changed = 0
|
|
||||||
for tag, i1, i2, j1, j2 in matcher.get_opcodes():
|
|
||||||
if tag == "equal":
|
|
||||||
continue
|
|
||||||
changed += max(i2 - i1, j2 - j1)
|
|
||||||
return max(changed, 1)
|
|
||||||
@@ -1,116 +0,0 @@
|
|||||||
import json
|
|
||||||
import re
|
|
||||||
from difflib import SequenceMatcher
|
|
||||||
|
|
||||||
from app.modules.agent.engine.graphs.file_targeting import FileTargeting
|
|
||||||
from app.modules.agent.engine.graphs.state import AgentGraphState
|
|
||||||
from app.schemas.changeset import ChangeItem
|
|
||||||
|
|
||||||
|
|
||||||
class ProjectEditsSupport:
|
|
||||||
def __init__(self, max_context_files: int = 12, max_preview_chars: int = 2500) -> None:
|
|
||||||
self._max_context_files = max_context_files
|
|
||||||
self._max_preview_chars = max_preview_chars
|
|
||||||
self._targeting = FileTargeting()
|
|
||||||
|
|
||||||
def pick_relevant_files(self, message: str, files_map: dict[str, dict]) -> list[dict]:
|
|
||||||
tokens = {x for x in (message or "").lower().replace("/", " ").split() if len(x) >= 4}
|
|
||||||
scored: list[tuple[int, dict]] = []
|
|
||||||
for path, payload in files_map.items():
|
|
||||||
content = str(payload.get("content", ""))
|
|
||||||
score = 0
|
|
||||||
low_path = path.lower()
|
|
||||||
low_content = content.lower()
|
|
||||||
for token in tokens:
|
|
||||||
if token in low_path:
|
|
||||||
score += 3
|
|
||||||
if token in low_content:
|
|
||||||
score += 1
|
|
||||||
scored.append((score, self.as_candidate(payload)))
|
|
||||||
scored.sort(key=lambda x: (-x[0], x[1]["path"]))
|
|
||||||
return [item for _, item in scored[: self._max_context_files]]
|
|
||||||
|
|
||||||
def as_candidate(self, payload: dict) -> dict:
|
|
||||||
return {
|
|
||||||
"path": str(payload.get("path", "")).replace("\\", "/"),
|
|
||||||
"content": str(payload.get("content", "")),
|
|
||||||
"content_hash": str(payload.get("content_hash", "")),
|
|
||||||
}
|
|
||||||
|
|
||||||
def normalize_file_output(self, text: str) -> str:
|
|
||||||
value = (text or "").strip()
|
|
||||||
if value.startswith("```") and value.endswith("```"):
|
|
||||||
lines = value.splitlines()
|
|
||||||
if len(lines) >= 3:
|
|
||||||
return "\n".join(lines[1:-1]).strip()
|
|
||||||
return value
|
|
||||||
|
|
||||||
def parse_json(self, raw: str):
|
|
||||||
text = self.normalize_file_output(raw)
|
|
||||||
try:
|
|
||||||
return json.loads(text)
|
|
||||||
except Exception:
|
|
||||||
return {}
|
|
||||||
|
|
||||||
def shorten(self, text: str, max_chars: int | None = None) -> str:
|
|
||||||
limit = max_chars or self._max_preview_chars
|
|
||||||
value = (text or "").strip()
|
|
||||||
if len(value) <= limit:
|
|
||||||
return value
|
|
||||||
return value[:limit].rstrip() + "\n...[truncated]"
|
|
||||||
|
|
||||||
def collapse_whitespace(self, text: str) -> str:
|
|
||||||
return re.sub(r"\s+", " ", (text or "").strip())
|
|
||||||
|
|
||||||
def similarity(self, original: str, updated: str) -> float:
|
|
||||||
return SequenceMatcher(None, original or "", updated or "").ratio()
|
|
||||||
|
|
||||||
def line_change_ratio(self, original: str, updated: str) -> float:
|
|
||||||
orig_lines = (original or "").splitlines()
|
|
||||||
new_lines = (updated or "").splitlines()
|
|
||||||
if not orig_lines and not new_lines:
|
|
||||||
return 0.0
|
|
||||||
matcher = SequenceMatcher(None, orig_lines, new_lines)
|
|
||||||
changed = 0
|
|
||||||
for tag, i1, i2, j1, j2 in matcher.get_opcodes():
|
|
||||||
if tag == "equal":
|
|
||||||
continue
|
|
||||||
changed += max(i2 - i1, j2 - j1)
|
|
||||||
total = max(len(orig_lines), len(new_lines), 1)
|
|
||||||
return changed / total
|
|
||||||
|
|
||||||
def added_headings(self, original: str, updated: str) -> int:
|
|
||||||
old_heads = {line.strip() for line in (original or "").splitlines() if line.strip().startswith("#")}
|
|
||||||
new_heads = {line.strip() for line in (updated or "").splitlines() if line.strip().startswith("#")}
|
|
||||||
return len(new_heads - old_heads)
|
|
||||||
|
|
||||||
def build_summary(self, state: AgentGraphState, changeset: list[ChangeItem]) -> str:
|
|
||||||
if not changeset:
|
|
||||||
return "Правки не сформированы: changeset пуст."
|
|
||||||
lines = [
|
|
||||||
"Выполненные действия:",
|
|
||||||
f"- Проанализирован запрос: {state.get('message', '')}",
|
|
||||||
"- Сформирован контракт правок с разрешенными блоками изменений.",
|
|
||||||
f"- Проведен self-check: {state.get('validation_feedback', 'без замечаний')}",
|
|
||||||
"",
|
|
||||||
"Измененные файлы:",
|
|
||||||
]
|
|
||||||
for item in changeset[:30]:
|
|
||||||
lines.append(f"- {item.op.value} {item.path}: {item.reason}")
|
|
||||||
return "\n".join(lines)
|
|
||||||
|
|
||||||
def is_broad_rewrite_request(self, message: str) -> bool:
|
|
||||||
low = (message or "").lower()
|
|
||||||
markers = (
|
|
||||||
"перепиши",
|
|
||||||
"полностью",
|
|
||||||
"целиком",
|
|
||||||
"с нуля",
|
|
||||||
"full rewrite",
|
|
||||||
"rewrite all",
|
|
||||||
"реорганизуй документ",
|
|
||||||
)
|
|
||||||
return any(marker in low for marker in markers)
|
|
||||||
|
|
||||||
def lookup_file(self, files_map: dict[str, dict], path: str) -> dict | None:
|
|
||||||
return self._targeting.lookup_file(files_map, path)
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
import logging
|
|
||||||
|
|
||||||
from langgraph.graph import END, START, StateGraph
|
|
||||||
|
|
||||||
from app.modules.agent.engine.graphs.progress import emit_progress_sync
|
|
||||||
from app.modules.agent.engine.graphs.state import AgentGraphState
|
|
||||||
from app.modules.agent.llm import AgentLlmService
|
|
||||||
|
|
||||||
LOGGER = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class ProjectQaGraphFactory:
|
|
||||||
def __init__(self, llm: AgentLlmService) -> None:
|
|
||||||
self._llm = llm
|
|
||||||
|
|
||||||
def build(self, checkpointer=None):
|
|
||||||
graph = StateGraph(AgentGraphState)
|
|
||||||
graph.add_node("answer", self._answer_node)
|
|
||||||
graph.add_edge(START, "answer")
|
|
||||||
graph.add_edge("answer", END)
|
|
||||||
return graph.compile(checkpointer=checkpointer)
|
|
||||||
|
|
||||||
def _answer_node(self, state: AgentGraphState) -> dict:
|
|
||||||
emit_progress_sync(
|
|
||||||
state,
|
|
||||||
stage="graph.project_qa.answer",
|
|
||||||
message="Готовлю ответ по контексту текущего проекта.",
|
|
||||||
)
|
|
||||||
user_input = "\n\n".join(
|
|
||||||
[
|
|
||||||
f"User request:\n{state.get('message', '')}",
|
|
||||||
f"RAG context:\n{state.get('rag_context', '')}",
|
|
||||||
f"Confluence context:\n{state.get('confluence_context', '')}",
|
|
||||||
]
|
|
||||||
)
|
|
||||||
answer = self._llm.generate("project_answer", user_input)
|
|
||||||
emit_progress_sync(
|
|
||||||
state,
|
|
||||||
stage="graph.project_qa.answer.done",
|
|
||||||
message="Ответ по проекту сформирован.",
|
|
||||||
)
|
|
||||||
result = {"answer": answer}
|
|
||||||
LOGGER.warning(
|
|
||||||
"graph step result: graph=project_qa step=answer answer_len=%s",
|
|
||||||
len(answer or ""),
|
|
||||||
)
|
|
||||||
return result
|
|
||||||
@@ -1,34 +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
|
|
||||||
answer: str
|
|
||||||
changeset: list[ChangeItem]
|
|
||||||
edits_requested_path: str
|
|
||||||
edits_context_files: list[dict]
|
|
||||||
edits_plan: list[dict]
|
|
||||||
edits_contracts: list[dict]
|
|
||||||
edits_generation_feedback: str
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
from app.modules.agent.engine.orchestrator.models import (
|
|
||||||
ExecutionPlan,
|
|
||||||
OrchestratorResult,
|
|
||||||
PlanStep,
|
|
||||||
Scenario,
|
|
||||||
StepResult,
|
|
||||||
TaskSpec,
|
|
||||||
)
|
|
||||||
from app.modules.agent.engine.orchestrator.service import OrchestratorService
|
|
||||||
from app.modules.agent.engine.orchestrator.task_spec_builder import TaskSpecBuilder
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"ExecutionPlan",
|
|
||||||
"OrchestratorResult",
|
|
||||||
"OrchestratorService",
|
|
||||||
"PlanStep",
|
|
||||||
"Scenario",
|
|
||||||
"StepResult",
|
|
||||||
"TaskSpec",
|
|
||||||
"TaskSpecBuilder",
|
|
||||||
]
|
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,13 +0,0 @@
|
|||||||
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.review_actions import ReviewActions
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"DocsActions",
|
|
||||||
"EditActions",
|
|
||||||
"ExplainActions",
|
|
||||||
"GherkinActions",
|
|
||||||
"ReviewActions",
|
|
||||||
]
|
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,26 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from uuid import uuid4
|
|
||||||
|
|
||||||
from app.modules.agent.engine.orchestrator.execution_context import ExecutionContext
|
|
||||||
from app.modules.agent.engine.orchestrator.models import ArtifactType, EvidenceItem
|
|
||||||
|
|
||||||
|
|
||||||
class ActionSupport:
|
|
||||||
def put(self, ctx: ExecutionContext, key: str, artifact_type: ArtifactType, value, *, meta: dict | None = None) -> str:
|
|
||||||
item = ctx.artifacts.put(key=key, artifact_type=artifact_type, content=value, meta=meta)
|
|
||||||
return item.artifact_id
|
|
||||||
|
|
||||||
def get(self, ctx: ExecutionContext, key: str, default=None):
|
|
||||||
return ctx.artifacts.get_content(key, default)
|
|
||||||
|
|
||||||
def add_evidence(self, ctx: ExecutionContext, *, source_type: str, source_ref: str, snippet: str, score: float = 0.8) -> str:
|
|
||||||
evidence = EvidenceItem(
|
|
||||||
evidence_id=f"evidence_{uuid4().hex}",
|
|
||||||
source_type=source_type,
|
|
||||||
source_ref=source_ref,
|
|
||||||
snippet=(snippet or "").strip()[:600],
|
|
||||||
score=max(0.0, min(1.0, float(score))),
|
|
||||||
)
|
|
||||||
ctx.evidences.put_many([evidence])
|
|
||||||
return evidence.evidence_id
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from app.modules.agent.engine.orchestrator.actions.common import ActionSupport
|
|
||||||
from app.modules.agent.engine.orchestrator.execution_context import ExecutionContext
|
|
||||||
from app.modules.agent.engine.orchestrator.models import ArtifactType
|
|
||||||
|
|
||||||
|
|
||||||
class DocsActions(ActionSupport):
|
|
||||||
def extract_change_intents(self, ctx: ExecutionContext) -> list[str]:
|
|
||||||
text = str(self.get(ctx, "source_doc_text", "") or ctx.task.user_message)
|
|
||||||
intents = {
|
|
||||||
"summary": text[:240],
|
|
||||||
"api": ["Update endpoint behavior contract"],
|
|
||||||
"logic": ["Adjust reusable business rules"],
|
|
||||||
"db": ["Reflect schema/table notes if needed"],
|
|
||||||
"ui": ["Adjust form behavior and validation"],
|
|
||||||
}
|
|
||||||
return [self.put(ctx, "change_intents", ArtifactType.STRUCTURED_JSON, intents)]
|
|
||||||
|
|
||||||
def map_to_doc_tree(self, ctx: ExecutionContext) -> list[str]:
|
|
||||||
targets = [
|
|
||||||
"docs/api/increment.md",
|
|
||||||
"docs/logic/increment.md",
|
|
||||||
"docs/db/increment.md",
|
|
||||||
"docs/ui/increment.md",
|
|
||||||
]
|
|
||||||
return [self.put(ctx, "doc_targets", ArtifactType.STRUCTURED_JSON, {"targets": targets})]
|
|
||||||
|
|
||||||
def load_current_docs_context(self, ctx: ExecutionContext) -> list[str]:
|
|
||||||
files_map = dict(ctx.task.metadata.get("files_map", {}) or {})
|
|
||||||
targets = (self.get(ctx, "doc_targets", {}) or {}).get("targets", [])
|
|
||||||
current = []
|
|
||||||
for path in targets:
|
|
||||||
current.append(
|
|
||||||
{
|
|
||||||
"path": path,
|
|
||||||
"content": str((files_map.get(path) or {}).get("content", "")),
|
|
||||||
"content_hash": str((files_map.get(path) or {}).get("content_hash", "")),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return [self.put(ctx, "current_docs_context", ArtifactType.STRUCTURED_JSON, {"files": current})]
|
|
||||||
|
|
||||||
def generate_doc_updates(self, ctx: ExecutionContext) -> list[str]:
|
|
||||||
intents = self.get(ctx, "change_intents", {}) or {}
|
|
||||||
targets = (self.get(ctx, "doc_targets", {}) or {}).get("targets", [])
|
|
||||||
bundle = []
|
|
||||||
for path in targets:
|
|
||||||
bundle.append(
|
|
||||||
{
|
|
||||||
"path": path,
|
|
||||||
"content": "\n".join(
|
|
||||||
[
|
|
||||||
f"# Increment Update: {path}",
|
|
||||||
"",
|
|
||||||
"## Scope",
|
|
||||||
str(intents.get("summary", "")),
|
|
||||||
"",
|
|
||||||
"## Changes",
|
|
||||||
"- Updated according to analytics increment.",
|
|
||||||
]
|
|
||||||
),
|
|
||||||
"reason": "align docs with analytics increment",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return [self.put(ctx, "generated_doc_bundle", ArtifactType.DOC_BUNDLE, bundle)]
|
|
||||||
|
|
||||||
def cross_file_validation(self, ctx: ExecutionContext) -> list[str]:
|
|
||||||
bundle = self.get(ctx, "generated_doc_bundle", []) or []
|
|
||||||
paths = [str(item.get("path", "")) for item in bundle if isinstance(item, dict)]
|
|
||||||
has_required = any(path.startswith("docs/api/") for path in paths) and any(path.startswith("docs/logic/") for path in paths)
|
|
||||||
report = {"paths": paths, "required_core_paths_present": has_required}
|
|
||||||
return [self.put(ctx, "consistency_report", ArtifactType.STRUCTURED_JSON, report)]
|
|
||||||
|
|
||||||
def build_changeset(self, ctx: ExecutionContext) -> list[str]:
|
|
||||||
bundle = self.get(ctx, "generated_doc_bundle", []) or []
|
|
||||||
changeset = []
|
|
||||||
for item in bundle:
|
|
||||||
if not isinstance(item, dict):
|
|
||||||
continue
|
|
||||||
changeset.append(
|
|
||||||
{
|
|
||||||
"op": "update",
|
|
||||||
"path": str(item.get("path", "")).strip(),
|
|
||||||
"base_hash": "orchestrator-generated",
|
|
||||||
"proposed_content": str(item.get("content", "")),
|
|
||||||
"reason": str(item.get("reason", "documentation update")),
|
|
||||||
"hunks": [],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return [self.put(ctx, "final_changeset", ArtifactType.CHANGESET, changeset)]
|
|
||||||
|
|
||||||
def compose_summary(self, ctx: ExecutionContext) -> list[str]:
|
|
||||||
count = len(self.get(ctx, "final_changeset", []) or [])
|
|
||||||
text = f"Prepared documentation changeset with {count} files updated."
|
|
||||||
return [self.put(ctx, "final_answer", ArtifactType.TEXT, text)]
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import re
|
|
||||||
|
|
||||||
from app.modules.agent.engine.orchestrator.actions.common import ActionSupport
|
|
||||||
from app.modules.agent.engine.orchestrator.execution_context import ExecutionContext
|
|
||||||
from app.modules.agent.engine.orchestrator.models import ArtifactType
|
|
||||||
|
|
||||||
|
|
||||||
class EditActions(ActionSupport):
|
|
||||||
def resolve_target(self, ctx: ExecutionContext) -> list[str]:
|
|
||||||
message = ctx.task.user_message
|
|
||||||
files_map = dict(ctx.task.metadata.get("files_map", {}) or {})
|
|
||||||
requested = self._extract_path(message)
|
|
||||||
matched = self._lookup_source(files_map, requested)
|
|
||||||
if matched:
|
|
||||||
requested = str(matched.get("path") or requested or "")
|
|
||||||
if not requested and files_map:
|
|
||||||
requested = next(iter(files_map.keys()))
|
|
||||||
payload = {"path": requested or "", "allowed": bool(requested)}
|
|
||||||
return [self.put(ctx, "resolved_target", ArtifactType.STRUCTURED_JSON, payload)]
|
|
||||||
|
|
||||||
def load_target_context(self, ctx: ExecutionContext) -> list[str]:
|
|
||||||
files_map = dict(ctx.task.metadata.get("files_map", {}) or {})
|
|
||||||
resolved = self.get(ctx, "resolved_target", {}) or {}
|
|
||||||
path = str(resolved.get("path", ""))
|
|
||||||
source = dict(self._lookup_source(files_map, path) or {})
|
|
||||||
current = {
|
|
||||||
"path": str(source.get("path", "")) or path,
|
|
||||||
"content": str(source.get("content", "")),
|
|
||||||
"content_hash": str(source.get("content_hash", "")),
|
|
||||||
}
|
|
||||||
return [self.put(ctx, "target_context", ArtifactType.STRUCTURED_JSON, current)]
|
|
||||||
|
|
||||||
def plan_minimal_patch(self, ctx: ExecutionContext) -> list[str]:
|
|
||||||
target = self.get(ctx, "target_context", {}) or {}
|
|
||||||
plan = {
|
|
||||||
"path": target.get("path", ""),
|
|
||||||
"intent": "minimal_update",
|
|
||||||
"instruction": ctx.task.user_message[:240],
|
|
||||||
}
|
|
||||||
return [self.put(ctx, "patch_plan", ArtifactType.STRUCTURED_JSON, plan)]
|
|
||||||
|
|
||||||
def generate_patch(self, ctx: ExecutionContext) -> list[str]:
|
|
||||||
target = self.get(ctx, "target_context", {}) or {}
|
|
||||||
plan = self.get(ctx, "patch_plan", {}) or {}
|
|
||||||
path = str(target.get("path", ""))
|
|
||||||
base = str(target.get("content_hash", "") or "orchestrator-generated")
|
|
||||||
original = str(target.get("content", ""))
|
|
||||||
note = f"\n\n<!-- orchestrator note: {plan.get('instruction', '')[:100]} -->\n"
|
|
||||||
proposed = (original + note).strip() if original else note.strip()
|
|
||||||
changeset = [
|
|
||||||
{
|
|
||||||
"op": "update" if original else "create",
|
|
||||||
"path": path,
|
|
||||||
"base_hash": base if original else None,
|
|
||||||
"proposed_content": proposed,
|
|
||||||
"reason": "targeted file update",
|
|
||||||
"hunks": [],
|
|
||||||
}
|
|
||||||
]
|
|
||||||
return [self.put(ctx, "raw_changeset", ArtifactType.CHANGESET, changeset)]
|
|
||||||
|
|
||||||
def validate_patch_safety(self, ctx: ExecutionContext) -> list[str]:
|
|
||||||
changeset = self.get(ctx, "raw_changeset", []) or []
|
|
||||||
safe = len(changeset) == 1
|
|
||||||
report = {"safe": safe, "items": len(changeset), "reason": "single-file patch expected"}
|
|
||||||
return [self.put(ctx, "patch_validation_report", ArtifactType.STRUCTURED_JSON, report)]
|
|
||||||
|
|
||||||
def finalize_changeset(self, ctx: ExecutionContext) -> list[str]:
|
|
||||||
report = self.get(ctx, "patch_validation_report", {}) or {}
|
|
||||||
if not report.get("safe"):
|
|
||||||
return [self.put(ctx, "final_changeset", ArtifactType.CHANGESET, [])]
|
|
||||||
changeset = self.get(ctx, "raw_changeset", []) or []
|
|
||||||
return [self.put(ctx, "final_changeset", ArtifactType.CHANGESET, changeset)]
|
|
||||||
|
|
||||||
def compose_edit_summary(self, ctx: ExecutionContext) -> list[str]:
|
|
||||||
count = len(self.get(ctx, "final_changeset", []) or [])
|
|
||||||
text = f"Prepared targeted edit changeset with {count} item(s)."
|
|
||||||
return [self.put(ctx, "final_answer", ArtifactType.TEXT, text)]
|
|
||||||
|
|
||||||
def _extract_path(self, text: str) -> str | None:
|
|
||||||
match = re.search(r"\b[\w./-]+\.(md|txt|rst|yaml|yml|json|toml|ini|cfg)\b", text or "", flags=re.IGNORECASE)
|
|
||||||
if not match:
|
|
||||||
return None
|
|
||||||
return match.group(0).replace("\\", "/").strip()
|
|
||||||
|
|
||||||
def _lookup_source(self, files_map: dict[str, dict], path: str | None) -> dict | None:
|
|
||||||
if not path:
|
|
||||||
return None
|
|
||||||
normalized = str(path).replace("\\", "/").strip()
|
|
||||||
if not normalized:
|
|
||||||
return None
|
|
||||||
source = files_map.get(normalized)
|
|
||||||
if source:
|
|
||||||
return source
|
|
||||||
normalized_low = normalized.lower()
|
|
||||||
for key, value in files_map.items():
|
|
||||||
if str(key).replace("\\", "/").lower() == normalized_low:
|
|
||||||
return value
|
|
||||||
return None
|
|
||||||
@@ -1,87 +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 ExplainActions(ActionSupport):
|
|
||||||
def collect_sources(self, ctx: ExecutionContext) -> list[str]:
|
|
||||||
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_context": rag_context,
|
|
||||||
"confluence_context": confluence_context,
|
|
||||||
"files_count": len(files_map),
|
|
||||||
}
|
|
||||||
evidence_ids: list[str] = []
|
|
||||||
if rag_context.strip():
|
|
||||||
evidence_ids.append(
|
|
||||||
self.add_evidence(
|
|
||||||
ctx,
|
|
||||||
source_type="rag_chunk",
|
|
||||||
source_ref=ctx.task.rag_session_id,
|
|
||||||
snippet=rag_context,
|
|
||||||
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
|
|
||||||
logic = {
|
|
||||||
"request": message,
|
|
||||||
"assumptions": ["requirements-first"],
|
|
||||||
"notes": "Use requirements/docs as primary source over code.",
|
|
||||||
"source_summary": sources,
|
|
||||||
}
|
|
||||||
return [self.put(ctx, "logic_model", ArtifactType.STRUCTURED_JSON, logic)]
|
|
||||||
|
|
||||||
def build_sequence(self, ctx: ExecutionContext) -> list[str]:
|
|
||||||
message = ctx.task.user_message
|
|
||||||
mermaid = "\n".join(
|
|
||||||
[
|
|
||||||
"```mermaid",
|
|
||||||
"sequenceDiagram",
|
|
||||||
"participant User",
|
|
||||||
"participant Agent",
|
|
||||||
"participant Docs",
|
|
||||||
"User->>Agent: " + message[:80],
|
|
||||||
"Agent->>Docs: Find relevant requirements",
|
|
||||||
"Docs-->>Agent: Relevant context",
|
|
||||||
"Agent-->>User: Structured explanation",
|
|
||||||
"```",
|
|
||||||
]
|
|
||||||
)
|
|
||||||
return [self.put(ctx, "sequence_diagram", ArtifactType.TEXT, mermaid)]
|
|
||||||
|
|
||||||
def build_use_cases(self, ctx: ExecutionContext) -> list[str]:
|
|
||||||
lines = [
|
|
||||||
"### Use Cases",
|
|
||||||
"- Analyze requirement fragments relevant to user question",
|
|
||||||
"- Reconstruct behavior flow and decision points",
|
|
||||||
"- Return user-focused explanation with constraints",
|
|
||||||
]
|
|
||||||
return [self.put(ctx, "use_cases", ArtifactType.TEXT, "\n".join(lines))]
|
|
||||||
|
|
||||||
def summarize(self, ctx: ExecutionContext) -> list[str]:
|
|
||||||
sequence = str(self.get(ctx, "sequence_diagram", "") or "")
|
|
||||||
use_cases = str(self.get(ctx, "use_cases", "") or "")
|
|
||||||
answer = "\n\n".join(
|
|
||||||
[
|
|
||||||
"## Summary",
|
|
||||||
"The requested project part is explained from requirements/docs context.",
|
|
||||||
sequence,
|
|
||||||
use_cases,
|
|
||||||
]
|
|
||||||
)
|
|
||||||
return [self.put(ctx, "final_answer", ArtifactType.TEXT, answer)]
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from app.modules.agent.engine.orchestrator.actions.common import ActionSupport
|
|
||||||
from app.modules.agent.engine.orchestrator.execution_context import ExecutionContext
|
|
||||||
from app.modules.agent.engine.orchestrator.models import ArtifactType
|
|
||||||
|
|
||||||
|
|
||||||
class GherkinActions(ActionSupport):
|
|
||||||
def extract_increment_scope(self, ctx: ExecutionContext) -> list[str]:
|
|
||||||
text = str(self.get(ctx, "source_doc_text", "") or ctx.task.user_message)
|
|
||||||
scope = {
|
|
||||||
"title": "Increment scope",
|
|
||||||
"summary": text[:220],
|
|
||||||
"entities": ["User", "System"],
|
|
||||||
}
|
|
||||||
return [self.put(ctx, "increment_scope", ArtifactType.STRUCTURED_JSON, scope)]
|
|
||||||
|
|
||||||
def partition_features(self, ctx: ExecutionContext) -> list[str]:
|
|
||||||
scope = self.get(ctx, "increment_scope", {}) or {}
|
|
||||||
groups = [
|
|
||||||
{"feature": "Main flow", "goal": scope.get("summary", "")},
|
|
||||||
{"feature": "Validation", "goal": "Input validation and error behavior"},
|
|
||||||
]
|
|
||||||
return [self.put(ctx, "feature_groups", ArtifactType.STRUCTURED_JSON, groups)]
|
|
||||||
|
|
||||||
def generate_gherkin_bundle(self, ctx: ExecutionContext) -> list[str]:
|
|
||||||
groups = self.get(ctx, "feature_groups", []) or []
|
|
||||||
files = []
|
|
||||||
for idx, group in enumerate(groups, start=1):
|
|
||||||
feature_name = str(group.get("feature", f"Feature {idx}"))
|
|
||||||
content = "\n".join(
|
|
||||||
[
|
|
||||||
f"Feature: {feature_name}",
|
|
||||||
" Scenario: Happy path",
|
|
||||||
" Given system is available",
|
|
||||||
" When user performs increment action",
|
|
||||||
" Then system applies expected increment behavior",
|
|
||||||
]
|
|
||||||
)
|
|
||||||
files.append({"path": f"tests/gherkin/feature_{idx}.feature", "content": content})
|
|
||||||
return [self.put(ctx, "gherkin_bundle", ArtifactType.GHERKIN_BUNDLE, files)]
|
|
||||||
|
|
||||||
def lint_gherkin(self, ctx: ExecutionContext) -> list[str]:
|
|
||||||
bundle = self.get(ctx, "gherkin_bundle", []) or []
|
|
||||||
invalid = []
|
|
||||||
for item in bundle:
|
|
||||||
content = str(item.get("content", "")) if isinstance(item, dict) else ""
|
|
||||||
if "Feature:" not in content or "Scenario:" not in content:
|
|
||||||
invalid.append(str(item.get("path", "unknown")))
|
|
||||||
report = {"valid": len(invalid) == 0, "invalid_files": invalid}
|
|
||||||
return [self.put(ctx, "gherkin_lint_report", ArtifactType.STRUCTURED_JSON, report)]
|
|
||||||
|
|
||||||
def validate_coverage(self, ctx: ExecutionContext) -> list[str]:
|
|
||||||
bundle = self.get(ctx, "gherkin_bundle", []) or []
|
|
||||||
report = {"covered": len(bundle) > 0, "feature_files": len(bundle)}
|
|
||||||
return [self.put(ctx, "coverage_report", ArtifactType.STRUCTURED_JSON, report)]
|
|
||||||
|
|
||||||
def compose_test_model_summary(self, ctx: ExecutionContext) -> list[str]:
|
|
||||||
bundle = self.get(ctx, "gherkin_bundle", []) or []
|
|
||||||
summary = f"Prepared gherkin model with {len(bundle)} feature file(s)."
|
|
||||||
changeset = [
|
|
||||||
{
|
|
||||||
"op": "create",
|
|
||||||
"path": str(item.get("path", "")),
|
|
||||||
"base_hash": None,
|
|
||||||
"proposed_content": str(item.get("content", "")),
|
|
||||||
"reason": "generated gherkin feature",
|
|
||||||
"hunks": [],
|
|
||||||
}
|
|
||||||
for item in bundle
|
|
||||||
if isinstance(item, dict)
|
|
||||||
]
|
|
||||||
return [
|
|
||||||
self.put(ctx, "final_answer", ArtifactType.TEXT, summary),
|
|
||||||
self.put(ctx, "final_changeset", ArtifactType.CHANGESET, changeset),
|
|
||||||
]
|
|
||||||
@@ -1,102 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from urllib.parse import urlparse
|
|
||||||
|
|
||||||
from app.modules.agent.engine.orchestrator.actions.common import ActionSupport
|
|
||||||
from app.modules.agent.engine.orchestrator.execution_context import ExecutionContext
|
|
||||||
from app.modules.agent.engine.orchestrator.models import ArtifactType
|
|
||||||
|
|
||||||
|
|
||||||
class ReviewActions(ActionSupport):
|
|
||||||
def fetch_source_doc(self, ctx: ExecutionContext) -> list[str]:
|
|
||||||
attachment = next((a for a in ctx.task.attachments if a.value), None)
|
|
||||||
if attachment is None:
|
|
||||||
text = ctx.task.user_message
|
|
||||||
source_ref = "inline:message"
|
|
||||||
else:
|
|
||||||
parsed = urlparse(attachment.value)
|
|
||||||
source_ref = attachment.value
|
|
||||||
text = f"Source: {parsed.netloc}\nPath: {parsed.path}\nRequest: {ctx.task.user_message}"
|
|
||||||
evidence_id = self.add_evidence(
|
|
||||||
ctx,
|
|
||||||
source_type="external_doc",
|
|
||||||
source_ref=source_ref,
|
|
||||||
snippet=text,
|
|
||||||
score=0.75,
|
|
||||||
)
|
|
||||||
return [
|
|
||||||
self.put(
|
|
||||||
ctx,
|
|
||||||
"source_doc_raw",
|
|
||||||
ArtifactType.TEXT,
|
|
||||||
text,
|
|
||||||
meta={"source_ref": source_ref, "evidence_ids": [evidence_id]},
|
|
||||||
)
|
|
||||||
]
|
|
||||||
|
|
||||||
def normalize_document(self, ctx: ExecutionContext) -> list[str]:
|
|
||||||
raw = str(self.get(ctx, "source_doc_raw", "") or "")
|
|
||||||
normalized = "\n".join(line.rstrip() for line in raw.splitlines()).strip()
|
|
||||||
return [self.put(ctx, "source_doc_text", ArtifactType.TEXT, normalized)]
|
|
||||||
|
|
||||||
def structural_check(self, ctx: ExecutionContext) -> list[str]:
|
|
||||||
text = str(self.get(ctx, "source_doc_text", "") or "")
|
|
||||||
required = ["цель", "границ", "риски", "api", "данные"]
|
|
||||||
found = [token for token in required if token in text.lower()]
|
|
||||||
findings = {
|
|
||||||
"required_sections": required,
|
|
||||||
"found_markers": found,
|
|
||||||
"missing_markers": [token for token in required if token not in found],
|
|
||||||
}
|
|
||||||
return [self.put(ctx, "structural_findings", ArtifactType.STRUCTURED_JSON, findings)]
|
|
||||||
|
|
||||||
def semantic_consistency_check(self, ctx: ExecutionContext) -> list[str]:
|
|
||||||
text = str(self.get(ctx, "source_doc_text", "") or "")
|
|
||||||
contradictions = []
|
|
||||||
if "без изменений" in text.lower() and "новый" in text.lower():
|
|
||||||
contradictions.append("Contains both 'no changes' and 'new behavior' markers.")
|
|
||||||
payload = {"contradictions": contradictions, "status": "ok" if not contradictions else "needs_attention"}
|
|
||||||
return [self.put(ctx, "semantic_findings", ArtifactType.STRUCTURED_JSON, payload)]
|
|
||||||
|
|
||||||
def architecture_fit_check(self, ctx: ExecutionContext) -> list[str]:
|
|
||||||
text = str(self.get(ctx, "source_doc_text", "") or "")
|
|
||||||
files_count = len(dict(ctx.task.metadata.get("files_map", {}) or {}))
|
|
||||||
payload = {
|
|
||||||
"architecture_fit": "medium" if files_count == 0 else "high",
|
|
||||||
"notes": "Evaluate fit against existing docs and interfaces.",
|
|
||||||
"markers": ["integration"] if "integr" in text.lower() else [],
|
|
||||||
}
|
|
||||||
return [self.put(ctx, "architecture_findings", ArtifactType.STRUCTURED_JSON, payload)]
|
|
||||||
|
|
||||||
def optimization_check(self, ctx: ExecutionContext) -> list[str]:
|
|
||||||
text = str(self.get(ctx, "source_doc_text", "") or "")
|
|
||||||
has_perf = any(token in text.lower() for token in ("latency", "performance", "оптим"))
|
|
||||||
payload = {
|
|
||||||
"optimization_considered": has_perf,
|
|
||||||
"recommendation": "Add explicit non-functional targets." if not has_perf else "Optimization criteria present.",
|
|
||||||
}
|
|
||||||
return [self.put(ctx, "optimization_findings", ArtifactType.STRUCTURED_JSON, payload)]
|
|
||||||
|
|
||||||
def compose_review_report(self, ctx: ExecutionContext) -> list[str]:
|
|
||||||
structural = self.get(ctx, "structural_findings", {}) or {}
|
|
||||||
semantic = self.get(ctx, "semantic_findings", {}) or {}
|
|
||||||
architecture = self.get(ctx, "architecture_findings", {}) or {}
|
|
||||||
optimization = self.get(ctx, "optimization_findings", {}) or {}
|
|
||||||
report = "\n".join(
|
|
||||||
[
|
|
||||||
"## Findings",
|
|
||||||
f"- Missing structure markers: {', '.join(structural.get('missing_markers', [])) or 'none'}",
|
|
||||||
f"- Contradictions: {len(semantic.get('contradictions', []))}",
|
|
||||||
f"- Architecture fit: {architecture.get('architecture_fit', 'unknown')}",
|
|
||||||
f"- Optimization: {optimization.get('recommendation', 'n/a')}",
|
|
||||||
"",
|
|
||||||
"## Recommendations",
|
|
||||||
"- Clarify boundaries and data contracts.",
|
|
||||||
"- Add explicit error and rollback behavior.",
|
|
||||||
"- Add measurable non-functional requirements.",
|
|
||||||
]
|
|
||||||
)
|
|
||||||
return [
|
|
||||||
self.put(ctx, "review_report", ArtifactType.REVIEW_REPORT, report),
|
|
||||||
self.put(ctx, "final_answer", ArtifactType.TEXT, report),
|
|
||||||
]
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from uuid import uuid4
|
|
||||||
|
|
||||||
from app.modules.agent.engine.orchestrator.models import ArtifactItem, ArtifactType
|
|
||||||
|
|
||||||
|
|
||||||
class ArtifactStore:
|
|
||||||
def __init__(self) -> None:
|
|
||||||
self._by_id: dict[str, ArtifactItem] = {}
|
|
||||||
self._by_key: dict[str, ArtifactItem] = {}
|
|
||||||
|
|
||||||
def put(self, *, key: str, artifact_type: ArtifactType, content=None, meta: dict | None = None) -> ArtifactItem:
|
|
||||||
item_meta = dict(meta or {})
|
|
||||||
if content is not None and not isinstance(content, str):
|
|
||||||
item_meta.setdefault("value", content)
|
|
||||||
item = ArtifactItem(
|
|
||||||
artifact_id=f"artifact_{uuid4().hex}",
|
|
||||||
key=key,
|
|
||||||
type=artifact_type,
|
|
||||||
content=self._as_content(content),
|
|
||||||
meta=item_meta,
|
|
||||||
)
|
|
||||||
self._by_id[item.artifact_id] = item
|
|
||||||
self._by_key[key] = item
|
|
||||||
return item
|
|
||||||
|
|
||||||
def get(self, key: str) -> ArtifactItem | None:
|
|
||||||
return self._by_key.get(key)
|
|
||||||
|
|
||||||
def get_content(self, key: str, default=None):
|
|
||||||
item = self.get(key)
|
|
||||||
if item is None:
|
|
||||||
return default
|
|
||||||
if item.content is not None:
|
|
||||||
return item.content
|
|
||||||
return item.meta.get("value", default)
|
|
||||||
|
|
||||||
def has(self, key: str) -> bool:
|
|
||||||
return key in self._by_key
|
|
||||||
|
|
||||||
def all_items(self) -> list[ArtifactItem]:
|
|
||||||
return list(self._by_id.values())
|
|
||||||
|
|
||||||
def _as_content(self, value):
|
|
||||||
if value is None:
|
|
||||||
return None
|
|
||||||
if isinstance(value, str):
|
|
||||||
return value
|
|
||||||
return None
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from app.modules.agent.engine.orchestrator.models import EvidenceItem
|
|
||||||
|
|
||||||
|
|
||||||
class EvidenceStore:
|
|
||||||
def __init__(self) -> None:
|
|
||||||
self._items: list[EvidenceItem] = []
|
|
||||||
|
|
||||||
def put_many(self, items: list[EvidenceItem]) -> None:
|
|
||||||
self._items.extend(items)
|
|
||||||
|
|
||||||
def all_items(self) -> list[EvidenceItem]:
|
|
||||||
return list(self._items)
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from collections.abc import Awaitable, Callable
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from app.modules.agent.engine.orchestrator.artifact_store import ArtifactStore
|
|
||||||
from app.modules.agent.engine.orchestrator.evidence_store import EvidenceStore
|
|
||||||
from app.modules.agent.engine.orchestrator.models import ExecutionPlan, TaskSpec
|
|
||||||
|
|
||||||
ProgressCallback = Callable[[str, str, str, dict | None], Awaitable[None] | None]
|
|
||||||
GraphResolver = Callable[[str, str], Any]
|
|
||||||
GraphInvoker = Callable[[Any, dict, str], dict]
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class ExecutionContext:
|
|
||||||
task: TaskSpec
|
|
||||||
plan: ExecutionPlan
|
|
||||||
graph_resolver: GraphResolver
|
|
||||||
graph_invoker: GraphInvoker
|
|
||||||
progress_cb: ProgressCallback | None = None
|
|
||||||
artifacts: ArtifactStore | None = None
|
|
||||||
evidences: EvidenceStore | None = None
|
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
|
||||||
if self.artifacts is None:
|
|
||||||
self.artifacts = ArtifactStore()
|
|
||||||
if self.evidences is None:
|
|
||||||
self.evidences = EvidenceStore()
|
|
||||||
@@ -1,115 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import inspect
|
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
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:
|
|
||||||
step_results.append(
|
|
||||||
StepResult(
|
|
||||||
step_id=step.step_id,
|
|
||||||
status=StepStatus.SKIPPED,
|
|
||||||
warnings=[dep_issue],
|
|
||||||
)
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
|
|
||||||
result = await self._run_with_retry(step, ctx)
|
|
||||||
step_results.append(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()
|
|
||||||
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
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
|
|
||||||
from app.modules.agent.repository import AgentRepository
|
|
||||||
|
|
||||||
LOGGER = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class MetricsPersister:
|
|
||||||
def __init__(self, repository: AgentRepository) -> None:
|
|
||||||
self._repository = repository
|
|
||||||
|
|
||||||
def save(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
task_id: str,
|
|
||||||
dialog_session_id: str,
|
|
||||||
rag_session_id: str,
|
|
||||||
scenario: str,
|
|
||||||
domain_id: str,
|
|
||||||
process_id: str,
|
|
||||||
quality: dict,
|
|
||||||
) -> None:
|
|
||||||
try:
|
|
||||||
self._repository.save_quality_metrics(
|
|
||||||
task_id=task_id,
|
|
||||||
dialog_session_id=dialog_session_id,
|
|
||||||
rag_session_id=rag_session_id,
|
|
||||||
scenario=scenario,
|
|
||||||
domain_id=domain_id,
|
|
||||||
process_id=process_id,
|
|
||||||
quality=quality,
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
LOGGER.exception("Failed to persist quality metrics: task_id=%s", task_id)
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
from app.modules.agent.engine.orchestrator.models.plan import (
|
|
||||||
ArtifactSpec,
|
|
||||||
ArtifactType,
|
|
||||||
ExecutionPlan,
|
|
||||||
PlanStatus,
|
|
||||||
PlanStep,
|
|
||||||
QualityGateRef,
|
|
||||||
RetryPolicy,
|
|
||||||
)
|
|
||||||
from app.modules.agent.engine.orchestrator.models.result import (
|
|
||||||
ArtifactItem,
|
|
||||||
EvidenceItem,
|
|
||||||
OrchestratorResult,
|
|
||||||
StepResult,
|
|
||||||
StepStatus,
|
|
||||||
)
|
|
||||||
from app.modules.agent.engine.orchestrator.models.task_spec import (
|
|
||||||
AttachmentRef,
|
|
||||||
FileRef,
|
|
||||||
OutputContract,
|
|
||||||
OutputSection,
|
|
||||||
RoutingMeta,
|
|
||||||
Scenario,
|
|
||||||
SourcePolicy,
|
|
||||||
TaskConstraints,
|
|
||||||
TaskSpec,
|
|
||||||
)
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"ArtifactItem",
|
|
||||||
"ArtifactSpec",
|
|
||||||
"ArtifactType",
|
|
||||||
"AttachmentRef",
|
|
||||||
"EvidenceItem",
|
|
||||||
"ExecutionPlan",
|
|
||||||
"FileRef",
|
|
||||||
"OrchestratorResult",
|
|
||||||
"OutputContract",
|
|
||||||
"OutputSection",
|
|
||||||
"PlanStatus",
|
|
||||||
"PlanStep",
|
|
||||||
"QualityGateRef",
|
|
||||||
"RetryPolicy",
|
|
||||||
"RoutingMeta",
|
|
||||||
"Scenario",
|
|
||||||
"SourcePolicy",
|
|
||||||
"StepResult",
|
|
||||||
"StepStatus",
|
|
||||||
"TaskConstraints",
|
|
||||||
"TaskSpec",
|
|
||||||
]
|
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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)
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user