Compare commits
10 Commits
main
...
codex/orch
| Author | SHA1 | Date | |
|---|---|---|---|
| 15586f9a8c | |||
| 9066c292de | |||
| b1f825e6b9 | |||
| 095d354112 | |||
| 6ba0a18ac9 | |||
| 417b8b6f72 | |||
| 1ef0b4d68c | |||
| 2728c07ba9 | |||
| 9f1c67a751 | |||
| e8805ffe29 |
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
.env
|
||||||
|
.venv
|
||||||
|
__pycache__
|
||||||
271
README.DB.STORY_PLAN.md
Normal file
271
README.DB.STORY_PLAN.md
Normal file
@@ -0,0 +1,271 @@
|
|||||||
|
# План доработки БД для хранения контекста Story и метаданных RAG
|
||||||
|
|
||||||
|
## Цель
|
||||||
|
Зафиксировать проект миграции, который:
|
||||||
|
- добавляет в таблицу чанков признаки артефакта (тип, источник, контекст),
|
||||||
|
- вводит отдельный контур хранения инкремента по `story_id`,
|
||||||
|
- не зависит от выбранного режима RAG (общий/сессионный/гибридный).
|
||||||
|
|
||||||
|
## Границы
|
||||||
|
- Документ описывает план и целевую схему.
|
||||||
|
- Реализация SQL-миграций и backfill выполняется отдельным шагом после согласования.
|
||||||
|
|
||||||
|
## 1) Метаданные чанков (RAG-слой)
|
||||||
|
|
||||||
|
### 1.1. Что добавить
|
||||||
|
Для таблицы `rag_chunks` (или эквивалента таблицы чанков) добавить поля:
|
||||||
|
- `artifact_type` (`REQ|ARCH|API|DB|UI|CODE|OTHER`)
|
||||||
|
- `path` (нормализованный относительный путь файла)
|
||||||
|
- `section` (заголовок/логический раздел документа)
|
||||||
|
- `doc_id` (стабильный идентификатор документа)
|
||||||
|
- `doc_version` (версия документа/ревизия)
|
||||||
|
- `owner` (ответственная команда/человек)
|
||||||
|
- `system_component` (система/подсистема/компонент)
|
||||||
|
- `last_modified` (время последнего изменения источника)
|
||||||
|
- `staleness_score` (0..1, в первую очередь для `CODE`)
|
||||||
|
|
||||||
|
### 1.2. Ограничения и индексы
|
||||||
|
- `CHECK` для `artifact_type` и диапазона `staleness_score`.
|
||||||
|
- Индексы:
|
||||||
|
- `(artifact_type)`
|
||||||
|
- `(doc_id, doc_version)`
|
||||||
|
- `(system_component)`
|
||||||
|
- `(path)`
|
||||||
|
- GIN/BTREE по потребности для фильтрации в retrieval.
|
||||||
|
|
||||||
|
## 2) Контур Story (отдельно от чанков)
|
||||||
|
|
||||||
|
### 2.1. Таблица `story_records`
|
||||||
|
Карточка Story:
|
||||||
|
- `story_id` (PK, строковый уникальный идентификатор)
|
||||||
|
- `project_id` (идентификатор проекта/репозитория)
|
||||||
|
- `title`
|
||||||
|
- `status` (`draft|in_progress|review|done|archived`)
|
||||||
|
- `baseline_commit_sha` (базовый снимок)
|
||||||
|
- `snapshot_id` (опционально для session-RAG)
|
||||||
|
- `created_at`, `updated_at`
|
||||||
|
- `created_by`, `updated_by`
|
||||||
|
|
||||||
|
Индексы:
|
||||||
|
- `(project_id)`
|
||||||
|
- `(status)`
|
||||||
|
- `(updated_at)`
|
||||||
|
|
||||||
|
### 2.2. Таблица `story_artifacts`
|
||||||
|
Связь Story с артефактами изменений:
|
||||||
|
- `id` (PK)
|
||||||
|
- `story_id` (FK -> `story_records.story_id`)
|
||||||
|
- `artifact_role` (`requirement|analysis|doc_change|test_model|note|decision|risk`)
|
||||||
|
- `doc_id`
|
||||||
|
- `doc_version`
|
||||||
|
- `path`
|
||||||
|
- `section`
|
||||||
|
- `chunk_id` (nullable; ссылка на chunk если стабильно поддерживается)
|
||||||
|
- `change_type` (`added|updated|removed|linked`)
|
||||||
|
- `summary` (краткое описание изменения)
|
||||||
|
- `source_ref` (ссылка/внешний id)
|
||||||
|
- `created_at`
|
||||||
|
- `created_by`
|
||||||
|
|
||||||
|
Уникальность (черновик):
|
||||||
|
- `UNIQUE(story_id, artifact_role, COALESCE(doc_id,''), COALESCE(path,''), COALESCE(section,''), COALESCE(change_type,''))`
|
||||||
|
|
||||||
|
Индексы:
|
||||||
|
- `(story_id, artifact_role)`
|
||||||
|
- `(story_id, change_type)`
|
||||||
|
- `(doc_id, doc_version)`
|
||||||
|
- `(path)`
|
||||||
|
|
||||||
|
### 2.3. Таблица `story_links`
|
||||||
|
Связи Story с внешними сущностями и Story-to-Story:
|
||||||
|
- `id` (PK)
|
||||||
|
- `story_id` (FK)
|
||||||
|
- `link_type` (`story|adr|ticket|pr|commit|doc|external`)
|
||||||
|
- `target_ref` (идентификатор/ссылка)
|
||||||
|
- `description`
|
||||||
|
- `created_at`
|
||||||
|
|
||||||
|
Индексы:
|
||||||
|
- `(story_id, link_type)`
|
||||||
|
- `(target_ref)`
|
||||||
|
|
||||||
|
## 3) Почему `story_id` не в чанках
|
||||||
|
- Один чанк может относиться к нескольким Story.
|
||||||
|
- Чанки нестабильны при переиндексации.
|
||||||
|
- Разделение слоев упрощает поддержку и не привязывает модель к типу RAG.
|
||||||
|
|
||||||
|
Итог: связь Story и чанков/документов хранить в `story_artifacts`, а не в `rag_chunks`.
|
||||||
|
|
||||||
|
## 4) Целевая модель RAG: Hybrid-Lite
|
||||||
|
Выбранный вектор на текущем этапе: `Session-first + Shared Cache + Story Ledger`.
|
||||||
|
|
||||||
|
### 4.1. Принципы
|
||||||
|
- Рабочий retrieval выполняется из сессионного индекса (видит незакоммиченные изменения).
|
||||||
|
- Общий кэш чанков/эмбеддингов используется только для ускорения индексации.
|
||||||
|
- Источник правды по инкременту Story находится в Story-таблицах, а не в RAG-индексе.
|
||||||
|
|
||||||
|
### 4.2. Что хранить дополнительно
|
||||||
|
- `rag_blob_cache`: кэш файловых blob по `repo_id + blob_sha`.
|
||||||
|
- `rag_chunk_cache`: кэш чанков/эмбеддингов, привязанный к `blob_sha`.
|
||||||
|
- `rag_session_chunk_map`: привязка сессии к используемым chunk (чтобы retrieval был изолированным).
|
||||||
|
- `session_artifacts`: временные артефакты сессии до появления `story_id` (late binding).
|
||||||
|
|
||||||
|
### 4.3. Алгоритм индексации (delta-only)
|
||||||
|
1. На старте сессии сканировать рабочее дерево и считать `blob_sha` для файлов индексации.
|
||||||
|
2. Для каждого файла:
|
||||||
|
- `cache hit`: взять chunk/embedding из кэша и связать с текущей сессией.
|
||||||
|
- `cache miss`: выполнить chunk+embed и записать результат в кэш.
|
||||||
|
3. Для retrieval использовать `rag_session_chunk_map` как первичный источник.
|
||||||
|
4. При необходимости делать fallback к cache-scoped данным по `repo_id` (опционально, под флагом).
|
||||||
|
|
||||||
|
### 4.4. Почему это подходит
|
||||||
|
- Нет необходимости в сложном ACL общего RAG на уровне приложения.
|
||||||
|
- Нет обязательной зависимости от ручного commit, индекс отражает локальные изменения.
|
||||||
|
- Снижается время загрузки сессии за счет переиспользования эмбеддингов.
|
||||||
|
- История Story не теряется и не зависит от режима RAG.
|
||||||
|
|
||||||
|
### 4.5. Late binding `story_id` (целевой процесс)
|
||||||
|
1. Аналитик запускает работу только со ссылкой на документ (без `story_id`).
|
||||||
|
2. Агент обрабатывает задачу в `session-RAG` и сохраняет все изменения в `session_artifacts`.
|
||||||
|
3. Аналитик вручную делает commit и указывает `story_id`.
|
||||||
|
4. Вебхук на commit:
|
||||||
|
- извлекает `story_id` из commit metadata/message,
|
||||||
|
- обновляет репозиторный RAG,
|
||||||
|
- выполняет `bind session -> story`: переносит/привязывает `session_artifacts` к `story_artifacts`,
|
||||||
|
- фиксирует связь `story_id <-> commit_sha <-> changed_files`.
|
||||||
|
5. Исходный документ аналитики тоже попадает в контекст Story ретроспективно, даже если изначально был без `story_id`.
|
||||||
|
|
||||||
|
## 5) Черновик DDL (PostgreSQL)
|
||||||
|
```sql
|
||||||
|
-- 0. Enum-like checks можно заменить на справочники при необходимости
|
||||||
|
|
||||||
|
-- A) Session artifacts (временный слой до появления story_id)
|
||||||
|
CREATE TABLE IF NOT EXISTS session_artifacts (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
session_id TEXT NOT NULL,
|
||||||
|
project_id TEXT NOT NULL,
|
||||||
|
artifact_role TEXT NOT NULL,
|
||||||
|
source_ref TEXT,
|
||||||
|
doc_id TEXT,
|
||||||
|
doc_version TEXT,
|
||||||
|
path TEXT,
|
||||||
|
section TEXT,
|
||||||
|
chunk_id TEXT,
|
||||||
|
change_type TEXT,
|
||||||
|
summary TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
created_by TEXT,
|
||||||
|
CONSTRAINT chk_session_artifact_role CHECK (artifact_role IN (
|
||||||
|
'analysis','doc_change','note','decision','risk','test_model'
|
||||||
|
)),
|
||||||
|
CONSTRAINT chk_session_change_type CHECK (change_type IS NULL OR change_type IN (
|
||||||
|
'added','updated','removed','linked'
|
||||||
|
))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_session_artifacts_session ON session_artifacts(session_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_session_artifacts_project ON session_artifacts(project_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_session_artifacts_role ON session_artifacts(artifact_role);
|
||||||
|
|
||||||
|
-- 1) Story records
|
||||||
|
CREATE TABLE IF NOT EXISTS story_records (
|
||||||
|
story_id TEXT PRIMARY KEY,
|
||||||
|
project_id TEXT NOT NULL,
|
||||||
|
title TEXT,
|
||||||
|
status TEXT NOT NULL DEFAULT 'draft',
|
||||||
|
baseline_commit_sha TEXT,
|
||||||
|
snapshot_id TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
created_by TEXT,
|
||||||
|
updated_by TEXT,
|
||||||
|
CONSTRAINT chk_story_status CHECK (status IN (
|
||||||
|
'draft','in_progress','review','done','archived'
|
||||||
|
))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_story_records_project ON story_records(project_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_story_records_status ON story_records(status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_story_records_updated_at ON story_records(updated_at DESC);
|
||||||
|
|
||||||
|
-- 2) Story artifacts
|
||||||
|
CREATE TABLE IF NOT EXISTS story_artifacts (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
story_id TEXT NOT NULL REFERENCES story_records(story_id) ON DELETE CASCADE,
|
||||||
|
artifact_role TEXT NOT NULL,
|
||||||
|
doc_id TEXT,
|
||||||
|
doc_version TEXT,
|
||||||
|
path TEXT,
|
||||||
|
section TEXT,
|
||||||
|
chunk_id TEXT,
|
||||||
|
change_type TEXT,
|
||||||
|
summary TEXT,
|
||||||
|
source_ref TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
created_by TEXT,
|
||||||
|
CONSTRAINT chk_story_artifact_role CHECK (artifact_role IN (
|
||||||
|
'requirement','analysis','doc_change','test_model','note','decision','risk'
|
||||||
|
)),
|
||||||
|
CONSTRAINT chk_story_change_type CHECK (change_type IS NULL OR change_type IN (
|
||||||
|
'added','updated','removed','linked'
|
||||||
|
))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_story_artifacts_story_role ON story_artifacts(story_id, artifact_role);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_story_artifacts_story_change ON story_artifacts(story_id, change_type);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_story_artifacts_doc ON story_artifacts(doc_id, doc_version);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_story_artifacts_path ON story_artifacts(path);
|
||||||
|
|
||||||
|
-- Вариант уникальности можно уточнить после согласования процессов
|
||||||
|
|
||||||
|
-- 3) Story links
|
||||||
|
CREATE TABLE IF NOT EXISTS story_links (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
story_id TEXT NOT NULL REFERENCES story_records(story_id) ON DELETE CASCADE,
|
||||||
|
link_type TEXT NOT NULL,
|
||||||
|
target_ref TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
CONSTRAINT chk_story_link_type CHECK (link_type IN (
|
||||||
|
'story','adr','ticket','pr','commit','doc','external'
|
||||||
|
))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_story_links_story_type ON story_links(story_id, link_type);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_story_links_target_ref ON story_links(target_ref);
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6) План внедрения (после согласования)
|
||||||
|
1. Подтвердить перечень полей и enum-значений.
|
||||||
|
2. Подготовить SQL-миграцию `Vxxx__story_context.sql`.
|
||||||
|
3. Обновить bootstrap/инициализацию схемы.
|
||||||
|
4. Обновить репозитории для `story_records/story_artifacts/story_links`.
|
||||||
|
5. Добавить таблицу и репозиторий `session_artifacts` (session-scoped артефакты без `story_id`).
|
||||||
|
6. Добавить запись session-артефактов в оркестраторе во время работы аналитика.
|
||||||
|
7. Добавить webhook-обработчик `bind session -> story` при появлении commit со `story_id`.
|
||||||
|
8. Добавить API/сервисный метод `get_story_context(story_id)` для повторного входа в Story.
|
||||||
|
9. Добавить тесты:
|
||||||
|
- unit на репозитории,
|
||||||
|
- интеграционные на happy-path записи/чтения,
|
||||||
|
- регресс на отсутствие зависимости от типа RAG.
|
||||||
|
10. Добавить миграцию для `rag_blob_cache/rag_chunk_cache/rag_session_chunk_map`.
|
||||||
|
11. Внедрить `delta-only` индексацию для session-RAG с переиспользованием кэша.
|
||||||
|
|
||||||
|
## 7) Открытые вопросы
|
||||||
|
- Нужен ли отдельный справочник для `artifact_type`, `artifact_role`, `link_type`.
|
||||||
|
- Что считать `doc_version`: semver, дата, commit, hash файла.
|
||||||
|
- Нужна ли soft-delete политика для Story.
|
||||||
|
- Требуется ли аудит (кто/когда менял `summary` и связи).
|
||||||
|
- Какой уровень обязательности `chunk_id` (опционален по умолчанию).
|
||||||
|
- Нужна ли TTL/очистка для `rag_blob_cache/rag_chunk_cache`.
|
||||||
|
- Делать ли fallback к репозиторному кэшу по умолчанию или только при explicit-флаге.
|
||||||
|
- Как определять соответствие `session_id` и commit в webhook (1:1, последний активный, explicit token).
|
||||||
|
- Как долго хранить `session_artifacts` до bind/cleanup.
|
||||||
|
|
||||||
|
## 8) Критерии готовности
|
||||||
|
- По `story_id` можно восстановить инкремент без исходной сессии.
|
||||||
|
- История изменений не теряется при переиндексации RAG.
|
||||||
|
- Аналитик и тестировщик используют один `story_id` как общий ключ контекста.
|
||||||
|
- Схема работает при любом выбранном режиме RAG.
|
||||||
|
- Session-RAG поднимается быстрее за счет cache hit по неизмененным файлам.
|
||||||
|
- Артефакты аналитика, созданные до появления `story_id`, корректно попадают в Story после commit/webhook bind.
|
||||||
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.
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,11 +0,0 @@
|
|||||||
from app.modules.agent.engine.graphs.base_graph import BaseGraphFactory
|
|
||||||
from app.modules.agent.engine.graphs.docs_graph import DocsGraphFactory
|
|
||||||
from app.modules.agent.engine.graphs.project_edits_graph import ProjectEditsGraphFactory
|
|
||||||
from app.modules.agent.engine.graphs.project_qa_graph import ProjectQaGraphFactory
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"BaseGraphFactory",
|
|
||||||
"DocsGraphFactory",
|
|
||||||
"ProjectEditsGraphFactory",
|
|
||||||
"ProjectQaGraphFactory",
|
|
||||||
]
|
|
||||||
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,58 +0,0 @@
|
|||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
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="Контекст собран, перехожу к формированию ответа.",
|
|
||||||
)
|
|
||||||
return {"rag_context": rag, "confluence_context": conf}
|
|
||||||
|
|
||||||
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="Черновик ответа подготовлен.",
|
|
||||||
)
|
|
||||||
return {"answer": answer}
|
|
||||||
@@ -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,79 +0,0 @@
|
|||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
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="Собираю контекст и релевантные файлы для правок.",
|
|
||||||
)
|
|
||||||
return self._logic.collect_context(state)
|
|
||||||
|
|
||||||
def _plan_changes(self, state: AgentGraphState) -> dict:
|
|
||||||
emit_progress_sync(
|
|
||||||
state,
|
|
||||||
stage="graph.project_edits.plan_changes",
|
|
||||||
message="Определяю, что именно нужно изменить и в каких файлах.",
|
|
||||||
)
|
|
||||||
return self._logic.plan_changes(state)
|
|
||||||
|
|
||||||
def _generate_changeset(self, state: AgentGraphState) -> dict:
|
|
||||||
emit_progress_sync(
|
|
||||||
state,
|
|
||||||
stage="graph.project_edits.generate_changeset",
|
|
||||||
message="Формирую предлагаемые правки по выбранным файлам.",
|
|
||||||
)
|
|
||||||
return self._logic.generate_changeset(state)
|
|
||||||
|
|
||||||
def _self_check(self, state: AgentGraphState) -> dict:
|
|
||||||
emit_progress_sync(
|
|
||||||
state,
|
|
||||||
stage="graph.project_edits.self_check",
|
|
||||||
message="Проверяю, что правки соответствуют запросу и не трогают лишнее.",
|
|
||||||
)
|
|
||||||
return self._logic.self_check(state)
|
|
||||||
|
|
||||||
def _build_result(self, state: AgentGraphState) -> dict:
|
|
||||||
emit_progress_sync(
|
|
||||||
state,
|
|
||||||
stage="graph.project_edits.build_result",
|
|
||||||
message="Формирую итоговый changeset и краткий обзор.",
|
|
||||||
)
|
|
||||||
return self._logic.build_result(state)
|
|
||||||
|
|
||||||
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"
|
|
||||||
@@ -1,271 +0,0 @@
|
|||||||
import json
|
|
||||||
from difflib import SequenceMatcher
|
|
||||||
import re
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
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 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 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 similarity(self, original: str, updated: str) -> float:
|
|
||||||
return SequenceMatcher(None, original or "", updated or "").ratio()
|
|
||||||
|
|
||||||
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 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)
|
|
||||||
|
|
||||||
|
|
||||||
class ProjectEditsLogic:
|
|
||||||
def __init__(self, llm: AgentLlmService) -> None:
|
|
||||||
self._llm = llm
|
|
||||||
self._targeting = FileTargeting()
|
|
||||||
self._support = ProjectEditsSupport()
|
|
||||||
|
|
||||||
def collect_context(self, state: AgentGraphState) -> dict:
|
|
||||||
message = state.get("message", "")
|
|
||||||
files_map = state.get("files_map", {}) or {}
|
|
||||||
requested_path = self._targeting.extract_target_path(message)
|
|
||||||
preferred = self._targeting.lookup_file(files_map, requested_path) if requested_path else None
|
|
||||||
candidates = self._support.pick_relevant_files(message, files_map)
|
|
||||||
if preferred and not any(x["path"] == preferred.get("path") for x in candidates):
|
|
||||||
candidates.insert(0, self._support.as_candidate(preferred))
|
|
||||||
return {
|
|
||||||
"edits_requested_path": str((preferred or {}).get("path") or (requested_path or "")).strip(),
|
|
||||||
"edits_context_files": candidates[:12],
|
|
||||||
"validation_attempts": 0,
|
|
||||||
}
|
|
||||||
|
|
||||||
def plan_changes(self, state: AgentGraphState) -> dict:
|
|
||||||
context_files = state.get("edits_context_files", []) or []
|
|
||||||
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", ""))),
|
|
||||||
}
|
|
||||||
for item in context_files
|
|
||||||
],
|
|
||||||
},
|
|
||||||
ensure_ascii=False,
|
|
||||||
)
|
|
||||||
parsed = self._support.parse_json(self._llm.generate("project_edits_plan", user_input))
|
|
||||||
files = parsed.get("files", []) if isinstance(parsed, dict) else []
|
|
||||||
planned: list[dict] = []
|
|
||||||
for item in files[:8] if isinstance(files, list) else []:
|
|
||||||
if not isinstance(item, dict):
|
|
||||||
continue
|
|
||||||
path = str(item.get("path", "")).replace("\\", "/").strip()
|
|
||||||
if not path:
|
|
||||||
continue
|
|
||||||
planned.append(
|
|
||||||
{
|
|
||||||
"path": path,
|
|
||||||
"reason": str(item.get("reason", "")).strip() or "Requested user adjustment.",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
if not planned:
|
|
||||||
fallback_path = state.get("edits_requested_path", "").strip() or "docs/REQUESTED_UPDATES.md"
|
|
||||||
planned = [{"path": fallback_path, "reason": "Fallback path from user request."}]
|
|
||||||
return {"edits_plan": planned}
|
|
||||||
|
|
||||||
def generate_changeset(self, state: AgentGraphState) -> dict:
|
|
||||||
files_map = state.get("files_map", {}) or {}
|
|
||||||
planned = state.get("edits_plan", []) or []
|
|
||||||
changeset: list[ChangeItem] = []
|
|
||||||
for item in planned:
|
|
||||||
path = str(item.get("path", "")).replace("\\", "/").strip()
|
|
||||||
if not path:
|
|
||||||
continue
|
|
||||||
current = self._targeting.lookup_file(files_map, path)
|
|
||||||
current_content = str((current or {}).get("content", ""))
|
|
||||||
user_input = json.dumps(
|
|
||||||
{
|
|
||||||
"request": state.get("message", ""),
|
|
||||||
"path": path,
|
|
||||||
"reason": item.get("reason", ""),
|
|
||||||
"current_content": current_content,
|
|
||||||
"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),
|
|
||||||
"instruction": "Modify only required parts and preserve unrelated content unchanged.",
|
|
||||||
},
|
|
||||||
ensure_ascii=False,
|
|
||||||
)
|
|
||||||
raw = self._llm.generate("project_edits_apply", user_input).strip()
|
|
||||||
normalized = self._support.normalize_file_output(raw)
|
|
||||||
if not normalized:
|
|
||||||
continue
|
|
||||||
if current:
|
|
||||||
if normalized == current_content:
|
|
||||||
continue
|
|
||||||
if self._support.collapse_whitespace(normalized) == self._support.collapse_whitespace(current_content):
|
|
||||||
continue
|
|
||||||
reason = str(item.get("reason", "")).strip() or "User-requested update."
|
|
||||||
if current and current.get("content_hash"):
|
|
||||||
changeset.append(
|
|
||||||
ChangeItem(
|
|
||||||
op="update",
|
|
||||||
path=str(current.get("path") or path),
|
|
||||||
base_hash=str(current.get("content_hash", "")),
|
|
||||||
proposed_content=normalized,
|
|
||||||
reason=reason,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
changeset.append(ChangeItem(op="create", path=path, proposed_content=normalized, reason=reason))
|
|
||||||
return {"changeset": changeset}
|
|
||||||
|
|
||||||
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 {}
|
|
||||||
is_broad_rewrite = self._is_broad_rewrite_request(str(state.get("message", "")))
|
|
||||||
if not changeset:
|
|
||||||
return {"validation_attempts": attempts, "validation_passed": False, "validation_feedback": "Generated changeset is empty."}
|
|
||||||
|
|
||||||
for item in changeset:
|
|
||||||
if item.op.value != "update":
|
|
||||||
continue
|
|
||||||
source = self._targeting.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)
|
|
||||||
headings_added = self._support.added_headings(original, proposed)
|
|
||||||
min_similarity = 0.75 if is_broad_rewrite else 0.9
|
|
||||||
max_change_ratio = 0.7 if is_broad_rewrite 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 is_broad_rewrite and headings_added > 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", ""),
|
|
||||||
"changeset": [{"op": x.op.value, "path": x.path, "reason": x.reason} for x in changeset[:20]],
|
|
||||||
"rule": "Changes must match request and avoid unrelated modifications.",
|
|
||||||
}
|
|
||||||
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 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 _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,38 +0,0 @@
|
|||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
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="Ответ по проекту сформирован.",
|
|
||||||
)
|
|
||||||
return {"answer": answer}
|
|
||||||
@@ -1,32 +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]
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from app.modules.agent.engine.graphs import (
|
|
||||||
BaseGraphFactory,
|
|
||||||
DocsGraphFactory,
|
|
||||||
ProjectEditsGraphFactory,
|
|
||||||
ProjectQaGraphFactory,
|
|
||||||
)
|
|
||||||
from app.modules.agent.repository import AgentRepository
|
|
||||||
from app.modules.agent.llm import AgentLlmService
|
|
||||||
from app.modules.agent.engine.router.context_store import RouterContextStore
|
|
||||||
from app.modules.agent.engine.router.intent_classifier import IntentClassifier
|
|
||||||
from app.modules.agent.engine.router.registry import IntentRegistry
|
|
||||||
from app.modules.agent.engine.router.router_service import RouterService
|
|
||||||
|
|
||||||
|
|
||||||
def build_router_service(llm: AgentLlmService, agent_repository: AgentRepository) -> RouterService:
|
|
||||||
registry_path = Path(__file__).resolve().parent / "intents_registry.yaml"
|
|
||||||
registry = IntentRegistry(registry_path=registry_path)
|
|
||||||
registry.register("default", "general", BaseGraphFactory(llm).build)
|
|
||||||
registry.register("project", "qa", ProjectQaGraphFactory(llm).build)
|
|
||||||
registry.register("project", "edits", ProjectEditsGraphFactory(llm).build)
|
|
||||||
registry.register("docs", "generation", DocsGraphFactory(llm).build)
|
|
||||||
|
|
||||||
classifier = IntentClassifier(llm)
|
|
||||||
context_store = RouterContextStore(agent_repository)
|
|
||||||
return RouterService(
|
|
||||||
registry=registry,
|
|
||||||
classifier=classifier,
|
|
||||||
context_store=context_store,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
__all__ = ["build_router_service", "IntentRegistry", "RouterService"]
|
|
||||||
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,29 +0,0 @@
|
|||||||
from app.modules.agent.repository import AgentRepository
|
|
||||||
from app.modules.agent.engine.router.schemas import RouterContext
|
|
||||||
|
|
||||||
|
|
||||||
class RouterContextStore:
|
|
||||||
def __init__(self, repository: AgentRepository) -> None:
|
|
||||||
self._repo = repository
|
|
||||||
|
|
||||||
def get(self, conversation_key: str) -> RouterContext:
|
|
||||||
return self._repo.get_router_context(conversation_key)
|
|
||||||
|
|
||||||
def update(
|
|
||||||
self,
|
|
||||||
conversation_key: str,
|
|
||||||
*,
|
|
||||||
domain_id: str,
|
|
||||||
process_id: str,
|
|
||||||
user_message: str,
|
|
||||||
assistant_message: str,
|
|
||||||
max_history: int = 10,
|
|
||||||
) -> None:
|
|
||||||
self._repo.update_router_context(
|
|
||||||
conversation_key,
|
|
||||||
domain_id=domain_id,
|
|
||||||
process_id=process_id,
|
|
||||||
user_message=user_message,
|
|
||||||
assistant_message=assistant_message,
|
|
||||||
max_history=max_history,
|
|
||||||
)
|
|
||||||
@@ -1,191 +0,0 @@
|
|||||||
import json
|
|
||||||
import re
|
|
||||||
|
|
||||||
from app.modules.agent.engine.router.schemas import RouteDecision, RouterContext
|
|
||||||
from app.modules.agent.llm import AgentLlmService
|
|
||||||
|
|
||||||
|
|
||||||
class IntentClassifier:
|
|
||||||
_short_confirmations = {"да", "ок", "делай", "поехали", "запускай"}
|
|
||||||
_route_mapping = {
|
|
||||||
"default/general": ("default", "general"),
|
|
||||||
"project/qa": ("project", "qa"),
|
|
||||||
"project/edits": ("project", "edits"),
|
|
||||||
"docs/generation": ("docs", "generation"),
|
|
||||||
}
|
|
||||||
|
|
||||||
def __init__(self, llm: AgentLlmService) -> None:
|
|
||||||
self._llm = llm
|
|
||||||
|
|
||||||
def classify(self, user_message: str, context: RouterContext, mode: str = "auto") -> RouteDecision:
|
|
||||||
forced = self._from_mode(mode)
|
|
||||||
if forced:
|
|
||||||
return forced
|
|
||||||
|
|
||||||
text = (user_message or "").strip().lower()
|
|
||||||
if text in self._short_confirmations and context.last_routing:
|
|
||||||
return RouteDecision(
|
|
||||||
domain_id=context.last_routing["domain_id"],
|
|
||||||
process_id=context.last_routing["process_id"],
|
|
||||||
confidence=1.0,
|
|
||||||
reason="short_confirmation",
|
|
||||||
use_previous=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
deterministic = self._deterministic_route(text)
|
|
||||||
if deterministic:
|
|
||||||
return deterministic
|
|
||||||
|
|
||||||
llm_decision = self._classify_with_llm(user_message, context)
|
|
||||||
if llm_decision:
|
|
||||||
return llm_decision
|
|
||||||
|
|
||||||
return RouteDecision(
|
|
||||||
domain_id="default",
|
|
||||||
process_id="general",
|
|
||||||
confidence=0.8,
|
|
||||||
reason="default",
|
|
||||||
)
|
|
||||||
|
|
||||||
def _from_mode(self, mode: str) -> RouteDecision | None:
|
|
||||||
mapping = {
|
|
||||||
"project_qa": ("project", "qa"),
|
|
||||||
"project_edits": ("project", "edits"),
|
|
||||||
"docs_generation": ("docs", "generation"),
|
|
||||||
# Legacy aliases kept for API compatibility.
|
|
||||||
"analytics_review": ("project", "qa"),
|
|
||||||
"code_change": ("project", "edits"),
|
|
||||||
"qa": ("default", "general"),
|
|
||||||
}
|
|
||||||
route = mapping.get((mode or "auto").strip().lower())
|
|
||||||
if not route:
|
|
||||||
return None
|
|
||||||
return RouteDecision(
|
|
||||||
domain_id=route[0],
|
|
||||||
process_id=route[1],
|
|
||||||
confidence=1.0,
|
|
||||||
reason=f"mode_override:{mode}",
|
|
||||||
)
|
|
||||||
|
|
||||||
def _classify_with_llm(self, user_message: str, context: RouterContext) -> RouteDecision | None:
|
|
||||||
history = context.message_history[-8:]
|
|
||||||
user_input = json.dumps(
|
|
||||||
{
|
|
||||||
"message": user_message,
|
|
||||||
"history": history,
|
|
||||||
"allowed_routes": list(self._route_mapping.keys()),
|
|
||||||
},
|
|
||||||
ensure_ascii=False,
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
raw = self._llm.generate("router_intent", user_input).strip()
|
|
||||||
except Exception:
|
|
||||||
return None
|
|
||||||
|
|
||||||
payload = self._parse_llm_payload(raw)
|
|
||||||
if not payload:
|
|
||||||
return None
|
|
||||||
|
|
||||||
route = self._route_mapping.get(payload["route"])
|
|
||||||
if not route:
|
|
||||||
return None
|
|
||||||
|
|
||||||
confidence = self._normalize_confidence(payload.get("confidence"))
|
|
||||||
return RouteDecision(
|
|
||||||
domain_id=route[0],
|
|
||||||
process_id=route[1],
|
|
||||||
confidence=confidence,
|
|
||||||
reason=f"llm_router:{payload.get('reason', 'ok')}",
|
|
||||||
)
|
|
||||||
|
|
||||||
def _parse_llm_payload(self, raw: str) -> dict[str, str | float] | None:
|
|
||||||
candidate = self._strip_code_fence(raw.strip())
|
|
||||||
if not candidate:
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
parsed = json.loads(candidate)
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
return None
|
|
||||||
if not isinstance(parsed, dict):
|
|
||||||
return None
|
|
||||||
route = str(parsed.get("route", "")).strip().lower()
|
|
||||||
if not route:
|
|
||||||
return None
|
|
||||||
return {
|
|
||||||
"route": route,
|
|
||||||
"confidence": parsed.get("confidence"),
|
|
||||||
"reason": str(parsed.get("reason", "ok")).strip().lower(),
|
|
||||||
}
|
|
||||||
|
|
||||||
def _normalize_confidence(self, value: object) -> float:
|
|
||||||
if isinstance(value, (float, int)):
|
|
||||||
return max(0.0, min(1.0, float(value)))
|
|
||||||
return 0.75
|
|
||||||
|
|
||||||
def _strip_code_fence(self, text: str) -> str:
|
|
||||||
if not text.startswith("```"):
|
|
||||||
return text
|
|
||||||
lines = text.splitlines()
|
|
||||||
if len(lines) < 3:
|
|
||||||
return text
|
|
||||||
if lines[-1].strip() != "```":
|
|
||||||
return text
|
|
||||||
return "\n".join(lines[1:-1]).strip()
|
|
||||||
|
|
||||||
def _deterministic_route(self, text: str) -> RouteDecision | None:
|
|
||||||
if self._is_targeted_file_edit_request(text):
|
|
||||||
return RouteDecision(
|
|
||||||
domain_id="project",
|
|
||||||
process_id="edits",
|
|
||||||
confidence=0.97,
|
|
||||||
reason="deterministic_targeted_file_edit",
|
|
||||||
)
|
|
||||||
if self._is_broad_docs_request(text):
|
|
||||||
return RouteDecision(
|
|
||||||
domain_id="docs",
|
|
||||||
process_id="generation",
|
|
||||||
confidence=0.95,
|
|
||||||
reason="deterministic_docs_generation",
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
|
|
||||||
def _is_targeted_file_edit_request(self, text: str) -> bool:
|
|
||||||
if not text:
|
|
||||||
return False
|
|
||||||
edit_markers = (
|
|
||||||
"добавь",
|
|
||||||
"добавить",
|
|
||||||
"измени",
|
|
||||||
"исправь",
|
|
||||||
"обнови",
|
|
||||||
"удали",
|
|
||||||
"замени",
|
|
||||||
"вставь",
|
|
||||||
"в конец",
|
|
||||||
"в начале",
|
|
||||||
"append",
|
|
||||||
"update",
|
|
||||||
"edit",
|
|
||||||
"remove",
|
|
||||||
"replace",
|
|
||||||
)
|
|
||||||
has_edit_marker = any(marker in text for marker in edit_markers)
|
|
||||||
has_file_marker = (
|
|
||||||
"readme" in text
|
|
||||||
or bool(re.search(r"\b[\w.\-/]+\.(md|txt|rst|yaml|yml|json|toml|ini|cfg)\b", text))
|
|
||||||
)
|
|
||||||
return has_edit_marker and has_file_marker
|
|
||||||
|
|
||||||
def _is_broad_docs_request(self, text: str) -> bool:
|
|
||||||
if not text:
|
|
||||||
return False
|
|
||||||
docs_markers = (
|
|
||||||
"подготовь документац",
|
|
||||||
"сгенерируй документац",
|
|
||||||
"создай документац",
|
|
||||||
"опиши документац",
|
|
||||||
"generate documentation",
|
|
||||||
"write documentation",
|
|
||||||
"docs/",
|
|
||||||
)
|
|
||||||
return any(marker in text for marker in docs_markers)
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
intents:
|
|
||||||
- domain_id: "default"
|
|
||||||
process_id: "general"
|
|
||||||
description: "General Q&A"
|
|
||||||
priority: 1
|
|
||||||
- domain_id: "project"
|
|
||||||
process_id: "qa"
|
|
||||||
description: "Project-specific Q&A with RAG and confluence context"
|
|
||||||
priority: 2
|
|
||||||
- domain_id: "project"
|
|
||||||
process_id: "edits"
|
|
||||||
description: "Project file edits from user request with conservative changeset generation"
|
|
||||||
priority: 3
|
|
||||||
- domain_id: "docs"
|
|
||||||
process_id: "generation"
|
|
||||||
description: "Documentation generation as changeset"
|
|
||||||
priority: 2
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
from collections.abc import Callable
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
import yaml
|
|
||||||
|
|
||||||
|
|
||||||
class IntentRegistry:
|
|
||||||
def __init__(self, registry_path: Path) -> None:
|
|
||||||
self._registry_path = registry_path
|
|
||||||
self._factories: dict[tuple[str, str], Callable[..., Any]] = {}
|
|
||||||
|
|
||||||
def register(self, domain_id: str, process_id: str, factory: Callable[..., Any]) -> None:
|
|
||||||
self._factories[(domain_id, process_id)] = factory
|
|
||||||
|
|
||||||
def get_factory(self, domain_id: str, process_id: str) -> Callable[..., Any] | None:
|
|
||||||
return self._factories.get((domain_id, process_id))
|
|
||||||
|
|
||||||
def is_valid(self, domain_id: str, process_id: str) -> bool:
|
|
||||||
return self.get_factory(domain_id, process_id) is not None
|
|
||||||
|
|
||||||
def load_intents(self) -> list[dict[str, Any]]:
|
|
||||||
if not self._registry_path.is_file():
|
|
||||||
return []
|
|
||||||
with self._registry_path.open("r", encoding="utf-8") as fh:
|
|
||||||
payload = yaml.safe_load(fh) or {}
|
|
||||||
intents = payload.get("intents")
|
|
||||||
if not isinstance(intents, list):
|
|
||||||
return []
|
|
||||||
output: list[dict[str, Any]] = []
|
|
||||||
for item in intents:
|
|
||||||
if not isinstance(item, dict):
|
|
||||||
continue
|
|
||||||
domain_id = item.get("domain_id")
|
|
||||||
process_id = item.get("process_id")
|
|
||||||
if not isinstance(domain_id, str) or not isinstance(process_id, str):
|
|
||||||
continue
|
|
||||||
output.append(
|
|
||||||
{
|
|
||||||
"domain_id": domain_id,
|
|
||||||
"process_id": process_id,
|
|
||||||
"description": str(item.get("description") or ""),
|
|
||||||
"priority": int(item.get("priority") or 0),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return output
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
from app.modules.agent.engine.router.context_store import RouterContextStore
|
|
||||||
from app.modules.agent.engine.router.intent_classifier import IntentClassifier
|
|
||||||
from app.modules.agent.engine.router.registry import IntentRegistry
|
|
||||||
from app.modules.agent.engine.router.schemas import RouteResolution
|
|
||||||
|
|
||||||
|
|
||||||
class RouterService:
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
registry: IntentRegistry,
|
|
||||||
classifier: IntentClassifier,
|
|
||||||
context_store: RouterContextStore,
|
|
||||||
min_confidence: float = 0.7,
|
|
||||||
) -> None:
|
|
||||||
self._registry = registry
|
|
||||||
self._classifier = classifier
|
|
||||||
self._ctx = context_store
|
|
||||||
self._min_confidence = min_confidence
|
|
||||||
|
|
||||||
def resolve(self, user_message: str, conversation_key: str, mode: str = "auto") -> RouteResolution:
|
|
||||||
context = self._ctx.get(conversation_key)
|
|
||||||
decision = self._classifier.classify(user_message, context, mode=mode)
|
|
||||||
if decision.confidence < self._min_confidence:
|
|
||||||
return self._fallback("low_confidence")
|
|
||||||
if not self._registry.is_valid(decision.domain_id, decision.process_id):
|
|
||||||
return self._fallback("invalid_route")
|
|
||||||
return RouteResolution(
|
|
||||||
domain_id=decision.domain_id,
|
|
||||||
process_id=decision.process_id,
|
|
||||||
confidence=decision.confidence,
|
|
||||||
reason=decision.reason,
|
|
||||||
fallback_used=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
def persist_context(
|
|
||||||
self,
|
|
||||||
conversation_key: str,
|
|
||||||
*,
|
|
||||||
domain_id: str,
|
|
||||||
process_id: str,
|
|
||||||
user_message: str,
|
|
||||||
assistant_message: str,
|
|
||||||
) -> None:
|
|
||||||
self._ctx.update(
|
|
||||||
conversation_key,
|
|
||||||
domain_id=domain_id,
|
|
||||||
process_id=process_id,
|
|
||||||
user_message=user_message,
|
|
||||||
assistant_message=assistant_message,
|
|
||||||
)
|
|
||||||
|
|
||||||
def graph_factory(self, domain_id: str, process_id: str):
|
|
||||||
return self._registry.get_factory(domain_id, process_id)
|
|
||||||
|
|
||||||
def _fallback(self, reason: str) -> RouteResolution:
|
|
||||||
return RouteResolution(
|
|
||||||
domain_id="default",
|
|
||||||
process_id="general",
|
|
||||||
confidence=0.0,
|
|
||||||
reason=reason,
|
|
||||||
fallback_used=True,
|
|
||||||
)
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
from pydantic import BaseModel, Field, field_validator
|
|
||||||
|
|
||||||
|
|
||||||
class RouteDecision(BaseModel):
|
|
||||||
domain_id: str = "default"
|
|
||||||
process_id: str = "general"
|
|
||||||
confidence: float = 0.0
|
|
||||||
reason: str = ""
|
|
||||||
use_previous: bool = False
|
|
||||||
|
|
||||||
@field_validator("confidence")
|
|
||||||
@classmethod
|
|
||||||
def clamp_confidence(cls, value: float) -> float:
|
|
||||||
return max(0.0, min(1.0, float(value)))
|
|
||||||
|
|
||||||
|
|
||||||
class RouteResolution(BaseModel):
|
|
||||||
domain_id: str
|
|
||||||
process_id: str
|
|
||||||
confidence: float
|
|
||||||
reason: str
|
|
||||||
fallback_used: bool = False
|
|
||||||
|
|
||||||
|
|
||||||
class RouterContext(BaseModel):
|
|
||||||
last_routing: dict[str, str] | None = None
|
|
||||||
message_history: list[dict[str, str]] = Field(default_factory=list)
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
from app.modules.agent.llm.service import AgentLlmService
|
|
||||||
|
|
||||||
__all__ = ["AgentLlmService"]
|
|
||||||
Binary file not shown.
Binary file not shown.
@@ -1,14 +0,0 @@
|
|||||||
from app.modules.agent.prompt_loader import PromptLoader
|
|
||||||
from app.modules.shared.gigachat.client import GigaChatClient
|
|
||||||
|
|
||||||
|
|
||||||
class AgentLlmService:
|
|
||||||
def __init__(self, client: GigaChatClient, prompts: PromptLoader) -> None:
|
|
||||||
self._client = client
|
|
||||||
self._prompts = prompts
|
|
||||||
|
|
||||||
def generate(self, prompt_name: str, user_input: str) -> str:
|
|
||||||
system_prompt = self._prompts.load(prompt_name)
|
|
||||||
if not system_prompt:
|
|
||||||
system_prompt = "You are a helpful assistant."
|
|
||||||
return self._client.complete(system_prompt=system_prompt, user_prompt=user_input)
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
from fastapi import APIRouter
|
|
||||||
from pydantic import BaseModel, HttpUrl
|
|
||||||
|
|
||||||
from app.modules.agent.changeset_validator import ChangeSetValidator
|
|
||||||
from app.modules.agent.confluence_service import ConfluenceService
|
|
||||||
from app.modules.agent.llm import AgentLlmService
|
|
||||||
from app.modules.agent.prompt_loader import PromptLoader
|
|
||||||
from app.modules.agent.service import GraphAgentRuntime
|
|
||||||
from app.modules.agent.repository import AgentRepository
|
|
||||||
from app.modules.contracts import RagRetriever
|
|
||||||
from app.modules.shared.gigachat.client import GigaChatClient
|
|
||||||
from app.modules.shared.gigachat.settings import GigaChatSettings
|
|
||||||
from app.modules.shared.gigachat.token_provider import GigaChatTokenProvider
|
|
||||||
|
|
||||||
|
|
||||||
class ConfluenceFetchRequest(BaseModel):
|
|
||||||
url: HttpUrl
|
|
||||||
|
|
||||||
|
|
||||||
class AgentModule:
|
|
||||||
def __init__(self, rag_retriever: RagRetriever, agent_repository: AgentRepository) -> None:
|
|
||||||
self.confluence = ConfluenceService()
|
|
||||||
self.changeset_validator = ChangeSetValidator()
|
|
||||||
settings = GigaChatSettings.from_env()
|
|
||||||
token_provider = GigaChatTokenProvider(settings)
|
|
||||||
client = GigaChatClient(settings, token_provider)
|
|
||||||
prompt_loader = PromptLoader()
|
|
||||||
llm = AgentLlmService(client=client, prompts=prompt_loader)
|
|
||||||
self.runtime = GraphAgentRuntime(
|
|
||||||
rag=rag_retriever,
|
|
||||||
confluence=self.confluence,
|
|
||||||
changeset_validator=self.changeset_validator,
|
|
||||||
llm=llm,
|
|
||||||
agent_repository=agent_repository,
|
|
||||||
)
|
|
||||||
|
|
||||||
def internal_router(self) -> APIRouter:
|
|
||||||
router = APIRouter(prefix="/internal/tools/confluence", tags=["internal-confluence"])
|
|
||||||
|
|
||||||
@router.post("/fetch")
|
|
||||||
async def fetch_page(request: ConfluenceFetchRequest) -> dict:
|
|
||||||
return await self.confluence.fetch_page(str(request.url))
|
|
||||||
|
|
||||||
return router
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
from pathlib import Path
|
|
||||||
import os
|
|
||||||
|
|
||||||
|
|
||||||
class PromptLoader:
|
|
||||||
def __init__(self, prompts_dir: Path | None = None) -> None:
|
|
||||||
base = prompts_dir or Path(__file__).resolve().parent / "prompts"
|
|
||||||
env_override = os.getenv("AGENT_PROMPTS_DIR", "").strip()
|
|
||||||
self._dir = Path(env_override) if env_override else base
|
|
||||||
|
|
||||||
def load(self, name: str) -> str:
|
|
||||||
path = self._dir / f"{name}.txt"
|
|
||||||
if not path.is_file():
|
|
||||||
return ""
|
|
||||||
return path.read_text(encoding="utf-8").strip()
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
Ты анализируешь, есть ли в проекте существующая документация, в которую нужно встраиваться.
|
|
||||||
|
|
||||||
Оцени входные данные:
|
|
||||||
- User request
|
|
||||||
- Requested target path
|
|
||||||
- Detected documentation candidates (пути и сниппеты)
|
|
||||||
|
|
||||||
Критерии EXISTS=yes:
|
|
||||||
- Есть хотя бы один релевантный doc-файл, и
|
|
||||||
- Он по смыслу подходит под запрос пользователя.
|
|
||||||
|
|
||||||
Критерии EXISTS=no:
|
|
||||||
- Нет релевантных doc-файлов, или
|
|
||||||
- Есть только нерелевантные/пустые заготовки.
|
|
||||||
|
|
||||||
Верни строго две строки:
|
|
||||||
EXISTS: yes|no
|
|
||||||
SUMMARY: <короткое объяснение на 1-2 предложения>
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
# Feature X Documentation
|
|
||||||
|
|
||||||
## Goal
|
|
||||||
Describe how Feature X works and how to integrate it safely.
|
|
||||||
|
|
||||||
## Architecture Overview
|
|
||||||
- Input enters through HTTP endpoint.
|
|
||||||
- Request is validated and transformed.
|
|
||||||
- Worker executes business logic and persists result.
|
|
||||||
|
|
||||||
## Data Flow
|
|
||||||
1. Client sends request payload.
|
|
||||||
2. Service validates payload.
|
|
||||||
3. Domain layer computes output.
|
|
||||||
4. Repository stores entities.
|
|
||||||
|
|
||||||
## Configuration
|
|
||||||
- Required environment variables.
|
|
||||||
- Optional tuning parameters.
|
|
||||||
|
|
||||||
## Deployment Notes
|
|
||||||
- Migration prerequisites.
|
|
||||||
- Rollback strategy.
|
|
||||||
|
|
||||||
## Risks and Constraints
|
|
||||||
- Throughput is bounded by downstream API limits.
|
|
||||||
- Partial failures require retry-safe handlers.
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
# API Client Module
|
|
||||||
|
|
||||||
## Purpose
|
|
||||||
This document explains how the API client authenticates and retries requests.
|
|
||||||
|
|
||||||
## Current Behavior
|
|
||||||
- Access token is fetched before outbound request.
|
|
||||||
- Retry policy uses exponential backoff for transient failures.
|
|
||||||
|
|
||||||
## Recent Increment (v2)
|
|
||||||
### Added cache for tokens
|
|
||||||
- Token is cached in memory for a short TTL.
|
|
||||||
- Cache invalidates on 401 responses.
|
|
||||||
|
|
||||||
### Operational impact
|
|
||||||
- Reduced auth latency for repetitive calls.
|
|
||||||
- Fewer token endpoint requests.
|
|
||||||
|
|
||||||
## Limitations
|
|
||||||
- Single-process cache only.
|
|
||||||
- No distributed cache synchronization.
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
Ты технический писатель и готовишь краткий итог по выполненной задаче документации.
|
|
||||||
|
|
||||||
Верни только markdown-текст без JSON и без лишних вступлений.
|
|
||||||
Структура ответа:
|
|
||||||
1) "Что сделано" — 3-6 коротких пунктов по основным частям пользовательского запроса.
|
|
||||||
2) "Измененные файлы" — список файлов с кратким описанием изменения по каждому файлу.
|
|
||||||
3) "Ограничения" — добавляй только если в данных есть явные пробелы или ограничения.
|
|
||||||
|
|
||||||
Правила:
|
|
||||||
- Используй только входные данные.
|
|
||||||
- Не выдумывай изменения, которых нет в списке changed files.
|
|
||||||
- Пиши коротко и по делу.
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
Ты senior technical writer и пишешь только проектную документацию в markdown.
|
|
||||||
|
|
||||||
Твоя задача:
|
|
||||||
1) Если strategy=incremental_update, встроиться в существующую документацию и добавить только недостающий инкремент.
|
|
||||||
2) Если strategy=from_scratch, создать целостный документ с нуля.
|
|
||||||
|
|
||||||
Правила:
|
|
||||||
- Опирайся только на входной контекст (request, plan, rag context, current file content, examples bundle).
|
|
||||||
- Не выдумывай факты о коде, которых нет во входных данных.
|
|
||||||
- Сохраняй стиль существующего документа при incremental_update.
|
|
||||||
- Если контекст неполный, отмечай ограничения явно и коротко в отдельном разделе "Ограничения".
|
|
||||||
- Структура должна быть логичной и пригодной для реального репозитория.
|
|
||||||
- Агент должен спроектировать структуру папок и файлов документации под правила ниже.
|
|
||||||
- Документация должна быть разделена минимум на 2 направления:
|
|
||||||
- отдельная папка для описания методов API;
|
|
||||||
- отдельная папка для описания логики/требований.
|
|
||||||
- В одном markdown-файле допускается описание только:
|
|
||||||
- одного метода API, или
|
|
||||||
- одного атомарного куска логики/требования.
|
|
||||||
- Для описания одного метода API используй структуру:
|
|
||||||
- название метода;
|
|
||||||
- параметры запроса;
|
|
||||||
- параметры ответа;
|
|
||||||
- use case (сценарий последовательности вызова метода);
|
|
||||||
- функциональные требования (если нужны технические детали).
|
|
||||||
- Для описания логики используй аналогичный подход:
|
|
||||||
- сценарий;
|
|
||||||
- ссылки из шагов сценария на функциональные требования;
|
|
||||||
- отдельные функциональные требования с техническими деталями.
|
|
||||||
- Правила для сценариев:
|
|
||||||
- без объемных шагов;
|
|
||||||
- каждый шаг краткий, не более 2 предложений;
|
|
||||||
- если нужны технические детали, вынеси их из шага в отдельное функциональное требование и дай ссылку на него из шага.
|
|
||||||
|
|
||||||
Формат ответа:
|
|
||||||
- Верни только JSON-объект без пояснений и без markdown-оберток.
|
|
||||||
- Строгий формат:
|
|
||||||
{
|
|
||||||
"files": [
|
|
||||||
{
|
|
||||||
"path": "docs/api/<file>.md",
|
|
||||||
"content": "<полное содержимое markdown-файла>",
|
|
||||||
"reason": "<кратко зачем создан/обновлен файл>"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "docs/logic/<file>.md",
|
|
||||||
"content": "<полное содержимое markdown-файла>",
|
|
||||||
"reason": "<кратко зачем создан/обновлен файл>"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
- Для from_scratch сформируй несколько файлов и обязательно покрой обе папки: `docs/api` и `docs/logic`.
|
|
||||||
- Для incremental_update также соблюдай правило атомарности: один файл = один метод API или один атомарный кусок логики/требования.
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
Ты составляешь план изменений документации перед генерацией текста.
|
|
||||||
|
|
||||||
Вход:
|
|
||||||
- Strategy
|
|
||||||
- User request
|
|
||||||
- Target path
|
|
||||||
- Current target content (для incremental_update)
|
|
||||||
- RAG context по коду
|
|
||||||
- Examples bundle
|
|
||||||
|
|
||||||
Требования к плану:
|
|
||||||
- Сначала спроектируй структуру папок и файлов документации под формат:
|
|
||||||
- отдельная папка для API-методов;
|
|
||||||
- отдельная папка для логики/требований;
|
|
||||||
- один файл = один метод API или один атомарный кусок логики/требования.
|
|
||||||
- Для API-файлов закладывай структуру: название метода, параметры запроса, параметры ответа, use case, функциональные требования.
|
|
||||||
- Для логики закладывай структуру: сценарий, ссылки из шагов на функциональные требования, отдельные функциональные требования.
|
|
||||||
- Для сценариев закладывай короткие шаги (не более 2 предложений на шаг), а технические детали выноси в функциональные требования.
|
|
||||||
- Дай нумерованный список разделов будущего документа.
|
|
||||||
- Для incremental_update отмечай, какие разделы добавить/обновить, не переписывая все целиком.
|
|
||||||
- Для from_scratch давай полный каркас документа.
|
|
||||||
- Каждый пункт должен включать краткую цель раздела.
|
|
||||||
- Если контекст частичный, включи пункт "Ограничения и допущения".
|
|
||||||
|
|
||||||
Формат ответа: только план в markdown, без вступлений и без JSON.
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
Ты валидатор качества документации.
|
|
||||||
|
|
||||||
Проверь:
|
|
||||||
- Соответствие strategy и user request.
|
|
||||||
- Соответствие generated document плану секций.
|
|
||||||
- Отсутствие очевидных выдуманных фактов.
|
|
||||||
- Практическую применимость текста к проекту.
|
|
||||||
- Для incremental_update: минимально необходимый инкремент без лишнего переписывания.
|
|
||||||
- Проверку структуры документации:
|
|
||||||
- есть разбиение по папкам `docs/api` и `docs/logic`;
|
|
||||||
- один файл описывает только один API-метод или один атомарный кусок логики;
|
|
||||||
- сценарии состоят из коротких шагов, а технические детали вынесены в функциональные требования.
|
|
||||||
|
|
||||||
Если документ приемлем:
|
|
||||||
PASS: yes
|
|
||||||
FEEDBACK: <коротко, что ок>
|
|
||||||
|
|
||||||
Если документ неприемлем:
|
|
||||||
PASS: no
|
|
||||||
FEEDBACK: <коротко, что исправить в следующей попытке>
|
|
||||||
|
|
||||||
Верни ровно две строки в этом формате.
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
Ты выбираешь стратегию генерации документации.
|
|
||||||
|
|
||||||
Доступные стратегии:
|
|
||||||
- incremental_update: дописать недостающий инкремент в существующий документ.
|
|
||||||
- from_scratch: создать новый документ с нуля.
|
|
||||||
|
|
||||||
Правила выбора:
|
|
||||||
- Если Existing docs detected=true и это не противоречит user request, выбирай incremental_update.
|
|
||||||
- Если Existing docs detected=false, выбирай from_scratch.
|
|
||||||
- Если пользователь явно просит "с нуля", приоритет у from_scratch.
|
|
||||||
- Если пользователь явно просит "дописать/обновить", приоритет у incremental_update.
|
|
||||||
|
|
||||||
Верни строго одну строку:
|
|
||||||
STRATEGY: incremental_update|from_scratch
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
Ты инженерный AI-ассистент. Ответь по проекту коротко и по делу.
|
|
||||||
Если в контексте недостаточно данных, явно укажи пробелы.
|
|
||||||
Не выдумывай факты, используй только входные данные.
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
Ты инженерный AI-ассистент по текущему проекту.
|
|
||||||
|
|
||||||
Сформируй точный ответ на вопрос пользователя, используя только входной контекст.
|
|
||||||
Приоритет источников: сначала RAG context, затем Confluence context.
|
|
||||||
|
|
||||||
Правила:
|
|
||||||
- Не выдумывай факты и явно помечай пробелы в данных.
|
|
||||||
- Отвечай структурировано и коротко.
|
|
||||||
- Если пользователь просит шаги, дай практичный пошаговый план.
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
Ты вносишь правку в один файл по запросу пользователя.
|
|
||||||
На вход приходит JSON с request, path, reason, current_content, previous_validation_feedback, rag_context, confluence_context.
|
|
||||||
|
|
||||||
Верни только полное итоговое содержимое файла (без JSON).
|
|
||||||
|
|
||||||
Критичные правила:
|
|
||||||
- Измени только те части, которые нужны по запросу.
|
|
||||||
- Не переписывай файл целиком без необходимости.
|
|
||||||
- Сохрани структуру, стиль и все нерелевантные разделы без изменений.
|
|
||||||
- Если данных недостаточно, внеси минимально безопасную правку и явно отрази ограничение в тексте файла.
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
Ты анализируешь запрос на правки файлов проекта (не про написание нового кода).
|
|
||||||
На вход приходит JSON с request, requested_path, context_files.
|
|
||||||
|
|
||||||
Верни только JSON:
|
|
||||||
{
|
|
||||||
"files": [
|
|
||||||
{"path": "<path>", "reason": "<why this file should be edited>"}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
Правила:
|
|
||||||
- Выбирай только файлы, реально нужные для выполнения запроса.
|
|
||||||
- Не добавляй лишние файлы.
|
|
||||||
- Обычно 1-3 файла, максимум 8.
|
|
||||||
- Если в request указан конкретный файл, включи его в первую очередь.
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
Ты валидируешь changeset правок файла.
|
|
||||||
На вход приходит JSON с request и changeset (op, path, reason).
|
|
||||||
|
|
||||||
Проверь:
|
|
||||||
1) изменения соответствуют запросу,
|
|
||||||
2) нет лишних нерелевантных правок,
|
|
||||||
3) изменены только действительно нужные файлы,
|
|
||||||
4) нет косметических правок (пробелы/форматирование без смысла),
|
|
||||||
5) нет добавления новых секций/заголовков, если это не запрошено явно.
|
|
||||||
|
|
||||||
Верни только JSON:
|
|
||||||
{"pass": true|false, "feedback": "<short reason>"}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
Ты классификатор маршрутов агента.
|
|
||||||
На вход ты получаешь JSON с полями:
|
|
||||||
- message: текущий запрос пользователя
|
|
||||||
- history: последние сообщения диалога
|
|
||||||
- allowed_routes: допустимые маршруты
|
|
||||||
|
|
||||||
Выбери ровно один маршрут из allowed_routes.
|
|
||||||
Верни только JSON без markdown и пояснений.
|
|
||||||
|
|
||||||
Строгий формат ответа:
|
|
||||||
{"route":"<one_of_allowed_routes>","confidence":<number_0_to_1>,"reason":"<short_reason>"}
|
|
||||||
|
|
||||||
Правила маршрутизации:
|
|
||||||
- project/qa: пользователь задает вопросы про текущий проект, его код, архитектуру, модули, поведение, ограничения.
|
|
||||||
- project/edits: пользователь просит внести правки в существующие файлы проекта (контент, конфиги, тексты, шаблоны), без реализации новой кодовой логики.
|
|
||||||
- docs/generation: пользователь просит подготовить/обновить документацию, инструкции, markdown-материалы.
|
|
||||||
- default/general: остальные случаи, включая общие вопросы и консультации.
|
|
||||||
|
|
||||||
Приоритет:
|
|
||||||
- Если в запросе есть явная команда правки конкретного файла (например `README.md`, путь к файлу, "добавь в конец файла"), выбирай project/edits.
|
|
||||||
- docs/generation выбирай для задач подготовки документации в целом, а не для точечной правки одного файла.
|
|
||||||
|
|
||||||
Если есть сомнения, выбирай default/general и confidence <= 0.6.
|
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
|
|
||||||
from sqlalchemy import text
|
|
||||||
|
|
||||||
from app.modules.agent.engine.router.schemas import RouterContext
|
|
||||||
from app.modules.shared.db import get_engine
|
|
||||||
|
|
||||||
|
|
||||||
class AgentRepository:
|
|
||||||
def ensure_tables(self) -> None:
|
|
||||||
with get_engine().connect() as conn:
|
|
||||||
conn.execute(
|
|
||||||
text(
|
|
||||||
"""
|
|
||||||
CREATE TABLE IF NOT EXISTS router_context (
|
|
||||||
conversation_key VARCHAR(64) PRIMARY KEY,
|
|
||||||
last_domain_id VARCHAR(64) NULL,
|
|
||||||
last_process_id VARCHAR(64) NULL,
|
|
||||||
message_history_json TEXT NOT NULL DEFAULT '[]',
|
|
||||||
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
|
|
||||||
)
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
)
|
|
||||||
conn.commit()
|
|
||||||
|
|
||||||
def get_router_context(self, conversation_key: str) -> RouterContext:
|
|
||||||
with get_engine().connect() as conn:
|
|
||||||
row = conn.execute(
|
|
||||||
text(
|
|
||||||
"""
|
|
||||||
SELECT last_domain_id, last_process_id, message_history_json
|
|
||||||
FROM router_context
|
|
||||||
WHERE conversation_key = :key
|
|
||||||
"""
|
|
||||||
),
|
|
||||||
{"key": conversation_key},
|
|
||||||
).fetchone()
|
|
||||||
|
|
||||||
if not row:
|
|
||||||
return RouterContext()
|
|
||||||
|
|
||||||
history_raw = row[2] or "[]"
|
|
||||||
try:
|
|
||||||
history = json.loads(history_raw)
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
history = []
|
|
||||||
|
|
||||||
last = None
|
|
||||||
if row[0] and row[1]:
|
|
||||||
last = {"domain_id": str(row[0]), "process_id": str(row[1])}
|
|
||||||
|
|
||||||
clean_history = []
|
|
||||||
for item in history if isinstance(history, list) else []:
|
|
||||||
if not isinstance(item, dict):
|
|
||||||
continue
|
|
||||||
role = str(item.get("role") or "")
|
|
||||||
content = str(item.get("content") or "")
|
|
||||||
if role in {"user", "assistant"} and content:
|
|
||||||
clean_history.append({"role": role, "content": content})
|
|
||||||
|
|
||||||
return RouterContext(last_routing=last, message_history=clean_history)
|
|
||||||
|
|
||||||
def update_router_context(
|
|
||||||
self,
|
|
||||||
conversation_key: str,
|
|
||||||
*,
|
|
||||||
domain_id: str,
|
|
||||||
process_id: str,
|
|
||||||
user_message: str,
|
|
||||||
assistant_message: str,
|
|
||||||
max_history: int,
|
|
||||||
) -> None:
|
|
||||||
current = self.get_router_context(conversation_key)
|
|
||||||
history = list(current.message_history)
|
|
||||||
if user_message:
|
|
||||||
history.append({"role": "user", "content": user_message})
|
|
||||||
if assistant_message:
|
|
||||||
history.append({"role": "assistant", "content": assistant_message})
|
|
||||||
if max_history > 0:
|
|
||||||
history = history[-max_history:]
|
|
||||||
|
|
||||||
with get_engine().connect() as conn:
|
|
||||||
conn.execute(
|
|
||||||
text(
|
|
||||||
"""
|
|
||||||
INSERT INTO router_context (
|
|
||||||
conversation_key, last_domain_id, last_process_id, message_history_json
|
|
||||||
) VALUES (:key, :domain, :process, :history)
|
|
||||||
ON CONFLICT (conversation_key) DO UPDATE SET
|
|
||||||
last_domain_id = EXCLUDED.last_domain_id,
|
|
||||||
last_process_id = EXCLUDED.last_process_id,
|
|
||||||
message_history_json = EXCLUDED.message_history_json,
|
|
||||||
updated_at = CURRENT_TIMESTAMP
|
|
||||||
"""
|
|
||||||
),
|
|
||||||
{
|
|
||||||
"key": conversation_key,
|
|
||||||
"domain": domain_id,
|
|
||||||
"process": process_id,
|
|
||||||
"history": json.dumps(history, ensure_ascii=False),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
conn.commit()
|
|
||||||
@@ -1,296 +0,0 @@
|
|||||||
from dataclasses import dataclass, field
|
|
||||||
from collections.abc import Awaitable, Callable
|
|
||||||
import inspect
|
|
||||||
import asyncio
|
|
||||||
import logging
|
|
||||||
import re
|
|
||||||
|
|
||||||
from app.modules.agent.engine.router import build_router_service
|
|
||||||
from app.modules.agent.engine.graphs.progress_registry import progress_registry
|
|
||||||
from app.modules.agent.llm import AgentLlmService
|
|
||||||
from app.modules.agent.changeset_validator import ChangeSetValidator
|
|
||||||
from app.modules.agent.confluence_service import ConfluenceService
|
|
||||||
from app.modules.agent.repository import AgentRepository
|
|
||||||
from app.modules.contracts import RagRetriever
|
|
||||||
from app.modules.shared.checkpointer import get_checkpointer
|
|
||||||
from app.schemas.changeset import ChangeItem
|
|
||||||
from app.schemas.chat import TaskResultType
|
|
||||||
from app.core.exceptions import AppError
|
|
||||||
from app.schemas.common import ModuleName
|
|
||||||
|
|
||||||
LOGGER = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class AgentResult:
|
|
||||||
result_type: TaskResultType
|
|
||||||
answer: str | None = None
|
|
||||||
changeset: list[ChangeItem] = field(default_factory=list)
|
|
||||||
meta: dict = field(default_factory=dict)
|
|
||||||
|
|
||||||
|
|
||||||
class GraphAgentRuntime:
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
rag: RagRetriever,
|
|
||||||
confluence: ConfluenceService,
|
|
||||||
changeset_validator: ChangeSetValidator,
|
|
||||||
llm: AgentLlmService,
|
|
||||||
agent_repository: AgentRepository,
|
|
||||||
) -> None:
|
|
||||||
self._rag = rag
|
|
||||||
self._confluence = confluence
|
|
||||||
self._changeset_validator = changeset_validator
|
|
||||||
self._router = build_router_service(llm, agent_repository)
|
|
||||||
self._checkpointer = None
|
|
||||||
|
|
||||||
async def run(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
task_id: str,
|
|
||||||
dialog_session_id: str,
|
|
||||||
rag_session_id: str,
|
|
||||||
mode: str,
|
|
||||||
message: str,
|
|
||||||
attachments: list[dict],
|
|
||||||
files: list[dict],
|
|
||||||
progress_cb: Callable[[str, str, str, dict | None], Awaitable[None] | None] | None = None,
|
|
||||||
) -> AgentResult:
|
|
||||||
LOGGER.warning(
|
|
||||||
"GraphAgentRuntime.run started: task_id=%s dialog_session_id=%s mode=%s",
|
|
||||||
task_id,
|
|
||||||
dialog_session_id,
|
|
||||||
mode,
|
|
||||||
)
|
|
||||||
await self._emit_progress(progress_cb, "agent.route", "Определяю тип запроса и подбираю граф.", meta={"mode": mode})
|
|
||||||
route = self._router.resolve(message, dialog_session_id, mode=mode)
|
|
||||||
await self._emit_progress(
|
|
||||||
progress_cb,
|
|
||||||
"agent.route.resolved",
|
|
||||||
"Маршрут выбран, готовлю контекст для выполнения.",
|
|
||||||
meta={"domain_id": route.domain_id, "process_id": route.process_id},
|
|
||||||
)
|
|
||||||
graph = self._resolve_graph(route.domain_id, route.process_id)
|
|
||||||
files_map = self._build_files_map(files)
|
|
||||||
|
|
||||||
await self._emit_progress(progress_cb, "agent.rag", "Собираю релевантный контекст из RAG.")
|
|
||||||
rag_ctx = await self._rag.retrieve(rag_session_id, message)
|
|
||||||
await self._emit_progress(progress_cb, "agent.attachments", "Обрабатываю дополнительные вложения.")
|
|
||||||
conf_pages = await self._fetch_confluence_pages(attachments)
|
|
||||||
state = {
|
|
||||||
"task_id": task_id,
|
|
||||||
"project_id": rag_session_id,
|
|
||||||
"message": message,
|
|
||||||
"progress_key": task_id,
|
|
||||||
"rag_context": self._format_rag(rag_ctx),
|
|
||||||
"confluence_context": self._format_confluence(conf_pages),
|
|
||||||
"files_map": files_map,
|
|
||||||
}
|
|
||||||
|
|
||||||
await self._emit_progress(progress_cb, "agent.graph", "Запускаю выполнение графа.")
|
|
||||||
if progress_cb is not None:
|
|
||||||
progress_registry.register(task_id, progress_cb)
|
|
||||||
try:
|
|
||||||
result = await asyncio.to_thread(
|
|
||||||
self._invoke_graph,
|
|
||||||
graph,
|
|
||||||
state,
|
|
||||||
dialog_session_id,
|
|
||||||
)
|
|
||||||
finally:
|
|
||||||
if progress_cb is not None:
|
|
||||||
progress_registry.unregister(task_id)
|
|
||||||
await self._emit_progress(progress_cb, "agent.graph.done", "Граф завершил обработку результата.")
|
|
||||||
answer = result.get("answer")
|
|
||||||
changeset = result.get("changeset") or []
|
|
||||||
if changeset:
|
|
||||||
await self._emit_progress(progress_cb, "agent.changeset", "Проверяю и валидирую предложенные изменения.")
|
|
||||||
changeset = self._enrich_changeset_hashes(changeset, files_map)
|
|
||||||
changeset = self._sanitize_changeset(changeset, files_map)
|
|
||||||
if not changeset:
|
|
||||||
final_answer = (answer or "").strip() or "Предложенные правки были отброшены как нерелевантные или косметические."
|
|
||||||
await self._emit_progress(progress_cb, "agent.answer", "После фильтрации правок формирую ответ без changeset.")
|
|
||||||
self._router.persist_context(
|
|
||||||
dialog_session_id,
|
|
||||||
domain_id=route.domain_id,
|
|
||||||
process_id=route.process_id,
|
|
||||||
user_message=message,
|
|
||||||
assistant_message=final_answer,
|
|
||||||
)
|
|
||||||
return AgentResult(
|
|
||||||
result_type=TaskResultType.ANSWER,
|
|
||||||
answer=final_answer,
|
|
||||||
meta={
|
|
||||||
"route": route.model_dump(),
|
|
||||||
"used_rag": True,
|
|
||||||
"used_confluence": bool(conf_pages),
|
|
||||||
"changeset_filtered_out": True,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
validated = self._changeset_validator.validate(task_id, changeset)
|
|
||||||
final_answer = (answer or "").strip() or None
|
|
||||||
self._router.persist_context(
|
|
||||||
dialog_session_id,
|
|
||||||
domain_id=route.domain_id,
|
|
||||||
process_id=route.process_id,
|
|
||||||
user_message=message,
|
|
||||||
assistant_message=final_answer or f"changeset:{len(validated)}",
|
|
||||||
)
|
|
||||||
final = AgentResult(
|
|
||||||
result_type=TaskResultType.CHANGESET,
|
|
||||||
answer=final_answer,
|
|
||||||
changeset=validated,
|
|
||||||
meta={"route": route.model_dump(), "used_rag": True, "used_confluence": bool(conf_pages)},
|
|
||||||
)
|
|
||||||
LOGGER.warning(
|
|
||||||
"GraphAgentRuntime.run completed: task_id=%s route=%s/%s result_type=%s changeset_items=%s",
|
|
||||||
task_id,
|
|
||||||
route.domain_id,
|
|
||||||
route.process_id,
|
|
||||||
final.result_type.value,
|
|
||||||
len(final.changeset),
|
|
||||||
)
|
|
||||||
return final
|
|
||||||
|
|
||||||
final_answer = answer or ""
|
|
||||||
await self._emit_progress(progress_cb, "agent.answer", "Формирую финальный ответ.")
|
|
||||||
self._router.persist_context(
|
|
||||||
dialog_session_id,
|
|
||||||
domain_id=route.domain_id,
|
|
||||||
process_id=route.process_id,
|
|
||||||
user_message=message,
|
|
||||||
assistant_message=final_answer,
|
|
||||||
)
|
|
||||||
final = AgentResult(
|
|
||||||
result_type=TaskResultType.ANSWER,
|
|
||||||
answer=final_answer,
|
|
||||||
meta={"route": route.model_dump(), "used_rag": True, "used_confluence": bool(conf_pages)},
|
|
||||||
)
|
|
||||||
LOGGER.warning(
|
|
||||||
"GraphAgentRuntime.run completed: task_id=%s route=%s/%s result_type=%s answer_len=%s",
|
|
||||||
task_id,
|
|
||||||
route.domain_id,
|
|
||||||
route.process_id,
|
|
||||||
final.result_type.value,
|
|
||||||
len(final.answer or ""),
|
|
||||||
)
|
|
||||||
return final
|
|
||||||
|
|
||||||
async def _emit_progress(
|
|
||||||
self,
|
|
||||||
progress_cb: Callable[[str, str, str, dict | None], Awaitable[None] | None] | None,
|
|
||||||
stage: str,
|
|
||||||
message: str,
|
|
||||||
*,
|
|
||||||
kind: str = "task_progress",
|
|
||||||
meta: dict | None = None,
|
|
||||||
) -> None:
|
|
||||||
if progress_cb is None:
|
|
||||||
return
|
|
||||||
result = progress_cb(stage, message, kind, meta or {})
|
|
||||||
if inspect.isawaitable(result):
|
|
||||||
await result
|
|
||||||
|
|
||||||
def _resolve_graph(self, domain_id: str, process_id: str):
|
|
||||||
if self._checkpointer is None:
|
|
||||||
self._checkpointer = get_checkpointer()
|
|
||||||
factory = self._router.graph_factory(domain_id, process_id)
|
|
||||||
if factory is None:
|
|
||||||
factory = self._router.graph_factory("default", "general")
|
|
||||||
if factory is None:
|
|
||||||
raise RuntimeError("No graph factory configured")
|
|
||||||
LOGGER.warning("_resolve_graph resolved: domain_id=%s process_id=%s", domain_id, process_id)
|
|
||||||
return factory(self._checkpointer)
|
|
||||||
|
|
||||||
def _invoke_graph(self, graph, state: dict, dialog_session_id: str):
|
|
||||||
return graph.invoke(
|
|
||||||
state,
|
|
||||||
config={"configurable": {"thread_id": dialog_session_id}},
|
|
||||||
)
|
|
||||||
|
|
||||||
async def _fetch_confluence_pages(self, attachments: list[dict]) -> list[dict]:
|
|
||||||
pages: list[dict] = []
|
|
||||||
for item in attachments:
|
|
||||||
if item.get("type") == "confluence_url":
|
|
||||||
pages.append(await self._confluence.fetch_page(item["url"]))
|
|
||||||
LOGGER.warning("_fetch_confluence_pages completed: pages=%s", len(pages))
|
|
||||||
return pages
|
|
||||||
|
|
||||||
def _format_rag(self, items: list[dict]) -> str:
|
|
||||||
return "\n".join(str(x.get("content", "")) for x in items)
|
|
||||||
|
|
||||||
def _format_confluence(self, pages: list[dict]) -> str:
|
|
||||||
return "\n".join(str(x.get("content_markdown", "")) for x in pages)
|
|
||||||
|
|
||||||
def _build_files_map(self, files: list[dict]) -> dict[str, dict]:
|
|
||||||
output: dict[str, dict] = {}
|
|
||||||
for item in files:
|
|
||||||
path = str(item.get("path", "")).replace("\\", "/").strip()
|
|
||||||
if not path:
|
|
||||||
continue
|
|
||||||
output[path] = {
|
|
||||||
"path": path,
|
|
||||||
"content": str(item.get("content", "")),
|
|
||||||
"content_hash": str(item.get("content_hash", "")),
|
|
||||||
}
|
|
||||||
LOGGER.warning("_build_files_map completed: files=%s", len(output))
|
|
||||||
return output
|
|
||||||
|
|
||||||
def _lookup_file(self, files_map: dict[str, dict], path: str) -> dict | None:
|
|
||||||
normalized = (path or "").replace("\\", "/")
|
|
||||||
if normalized in files_map:
|
|
||||||
return files_map[normalized]
|
|
||||||
low = normalized.lower()
|
|
||||||
for key, value in files_map.items():
|
|
||||||
if key.lower() == low:
|
|
||||||
return value
|
|
||||||
return None
|
|
||||||
|
|
||||||
def _enrich_changeset_hashes(self, items: list[ChangeItem], files_map: dict[str, dict]) -> list[ChangeItem]:
|
|
||||||
enriched: list[ChangeItem] = []
|
|
||||||
for item in items:
|
|
||||||
if item.op.value == "update":
|
|
||||||
source = self._lookup_file(files_map, item.path)
|
|
||||||
if not source or not source.get("content_hash"):
|
|
||||||
raise AppError(
|
|
||||||
"missing_base_hash",
|
|
||||||
f"Cannot build update for {item.path}: no file hash in request context",
|
|
||||||
ModuleName.AGENT,
|
|
||||||
)
|
|
||||||
item.base_hash = str(source["content_hash"])
|
|
||||||
enriched.append(item)
|
|
||||||
LOGGER.warning("_enrich_changeset_hashes completed: items=%s", len(enriched))
|
|
||||||
return enriched
|
|
||||||
|
|
||||||
def _sanitize_changeset(self, items: list[ChangeItem], files_map: dict[str, dict]) -> list[ChangeItem]:
|
|
||||||
sanitized: list[ChangeItem] = []
|
|
||||||
dropped_noop = 0
|
|
||||||
dropped_ws = 0
|
|
||||||
for item in items:
|
|
||||||
if item.op.value != "update":
|
|
||||||
sanitized.append(item)
|
|
||||||
continue
|
|
||||||
source = self._lookup_file(files_map, item.path)
|
|
||||||
if not source:
|
|
||||||
sanitized.append(item)
|
|
||||||
continue
|
|
||||||
original = str(source.get("content", ""))
|
|
||||||
proposed = item.proposed_content or ""
|
|
||||||
if proposed == original:
|
|
||||||
dropped_noop += 1
|
|
||||||
continue
|
|
||||||
if self._collapse_whitespace(proposed) == self._collapse_whitespace(original):
|
|
||||||
dropped_ws += 1
|
|
||||||
continue
|
|
||||||
sanitized.append(item)
|
|
||||||
if dropped_noop or dropped_ws:
|
|
||||||
LOGGER.warning(
|
|
||||||
"_sanitize_changeset dropped items: noop=%s whitespace_only=%s kept=%s",
|
|
||||||
dropped_noop,
|
|
||||||
dropped_ws,
|
|
||||||
len(sanitized),
|
|
||||||
)
|
|
||||||
return sanitized
|
|
||||||
|
|
||||||
def _collapse_whitespace(self, text: str) -> str:
|
|
||||||
return re.sub(r"\s+", " ", (text or "").strip())
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
from app.modules.agent.module import AgentModule
|
|
||||||
from app.modules.agent.repository import AgentRepository
|
|
||||||
from app.modules.chat.repository import ChatRepository
|
|
||||||
from app.modules.chat.module import ChatModule
|
|
||||||
from app.modules.rag.repository import RagRepository
|
|
||||||
from app.modules.rag.module import RagModule
|
|
||||||
from app.modules.shared.bootstrap import bootstrap_database
|
|
||||||
from app.modules.shared.event_bus import EventBus
|
|
||||||
from app.modules.shared.retry_executor import RetryExecutor
|
|
||||||
|
|
||||||
|
|
||||||
class ModularApplication:
|
|
||||||
def __init__(self) -> None:
|
|
||||||
self.events = EventBus()
|
|
||||||
self.retry = RetryExecutor()
|
|
||||||
self.rag_repository = RagRepository()
|
|
||||||
self.chat_repository = ChatRepository()
|
|
||||||
self.agent_repository = AgentRepository()
|
|
||||||
|
|
||||||
self.rag = RagModule(event_bus=self.events, retry=self.retry, repository=self.rag_repository)
|
|
||||||
self.agent = AgentModule(rag_retriever=self.rag.rag, agent_repository=self.agent_repository)
|
|
||||||
self.chat = ChatModule(
|
|
||||||
agent_runner=self.agent.runtime,
|
|
||||||
event_bus=self.events,
|
|
||||||
retry=self.retry,
|
|
||||||
rag_sessions=self.rag.sessions,
|
|
||||||
repository=self.chat_repository,
|
|
||||||
)
|
|
||||||
|
|
||||||
def startup(self) -> None:
|
|
||||||
bootstrap_database(self.rag_repository, self.chat_repository, self.agent_repository)
|
|
||||||
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.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user