Compare commits
7 Commits
main
...
095d354112
| Author | SHA1 | Date | |
|---|---|---|---|
| 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.
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,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,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,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,
|
|
||||||
)
|
|
||||||
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,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,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,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.
@@ -1,261 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from sqlalchemy import text
|
|
||||||
|
|
||||||
from app.modules.shared.db import get_engine
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class RagJobRow:
|
|
||||||
index_job_id: str
|
|
||||||
rag_session_id: str
|
|
||||||
status: str
|
|
||||||
indexed_files: int
|
|
||||||
failed_files: int
|
|
||||||
error_code: str | None
|
|
||||||
error_desc: str | None
|
|
||||||
error_module: str | None
|
|
||||||
|
|
||||||
|
|
||||||
class RagRepository:
|
|
||||||
def ensure_tables(self) -> None:
|
|
||||||
engine = get_engine()
|
|
||||||
with engine.connect() as conn:
|
|
||||||
conn.execute(text("CREATE EXTENSION IF NOT EXISTS vector"))
|
|
||||||
conn.execute(
|
|
||||||
text(
|
|
||||||
"""
|
|
||||||
CREATE TABLE IF NOT EXISTS rag_sessions (
|
|
||||||
rag_session_id VARCHAR(64) PRIMARY KEY,
|
|
||||||
project_id VARCHAR(512) NOT NULL,
|
|
||||||
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
|
|
||||||
)
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
)
|
|
||||||
conn.execute(
|
|
||||||
text(
|
|
||||||
"""
|
|
||||||
CREATE TABLE IF NOT EXISTS rag_index_jobs (
|
|
||||||
index_job_id VARCHAR(64) PRIMARY KEY,
|
|
||||||
rag_session_id VARCHAR(64) NOT NULL,
|
|
||||||
status VARCHAR(16) NOT NULL,
|
|
||||||
indexed_files INTEGER NOT NULL DEFAULT 0,
|
|
||||||
failed_files INTEGER NOT NULL DEFAULT 0,
|
|
||||||
error_code VARCHAR(128) NULL,
|
|
||||||
error_desc TEXT NULL,
|
|
||||||
error_module VARCHAR(64) NULL,
|
|
||||||
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
|
|
||||||
)
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
)
|
|
||||||
conn.execute(
|
|
||||||
text(
|
|
||||||
"""
|
|
||||||
CREATE TABLE IF NOT EXISTS rag_chunks (
|
|
||||||
id BIGSERIAL PRIMARY KEY,
|
|
||||||
rag_session_id VARCHAR(64) NOT NULL,
|
|
||||||
path TEXT NOT NULL,
|
|
||||||
chunk_index INTEGER NOT NULL,
|
|
||||||
content TEXT NOT NULL,
|
|
||||||
embedding vector NULL,
|
|
||||||
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
|
|
||||||
)
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
)
|
|
||||||
conn.execute(
|
|
||||||
text(
|
|
||||||
"""
|
|
||||||
ALTER TABLE rag_chunks
|
|
||||||
ADD COLUMN IF NOT EXISTS created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
)
|
|
||||||
conn.execute(
|
|
||||||
text(
|
|
||||||
"""
|
|
||||||
ALTER TABLE rag_chunks
|
|
||||||
ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
)
|
|
||||||
conn.execute(text("CREATE INDEX IF NOT EXISTS idx_rag_chunks_session ON rag_chunks (rag_session_id)"))
|
|
||||||
conn.commit()
|
|
||||||
|
|
||||||
def upsert_session(self, rag_session_id: str, project_id: str) -> None:
|
|
||||||
with get_engine().connect() as conn:
|
|
||||||
conn.execute(
|
|
||||||
text(
|
|
||||||
"""
|
|
||||||
INSERT INTO rag_sessions (rag_session_id, project_id)
|
|
||||||
VALUES (:sid, :pid)
|
|
||||||
ON CONFLICT (rag_session_id) DO UPDATE SET project_id = EXCLUDED.project_id
|
|
||||||
"""
|
|
||||||
),
|
|
||||||
{"sid": rag_session_id, "pid": project_id},
|
|
||||||
)
|
|
||||||
conn.commit()
|
|
||||||
|
|
||||||
def session_exists(self, rag_session_id: str) -> bool:
|
|
||||||
with get_engine().connect() as conn:
|
|
||||||
row = conn.execute(
|
|
||||||
text("SELECT 1 FROM rag_sessions WHERE rag_session_id = :sid"),
|
|
||||||
{"sid": rag_session_id},
|
|
||||||
).fetchone()
|
|
||||||
return bool(row)
|
|
||||||
|
|
||||||
def get_session(self, rag_session_id: str) -> dict | None:
|
|
||||||
with get_engine().connect() as conn:
|
|
||||||
row = conn.execute(
|
|
||||||
text("SELECT rag_session_id, project_id FROM rag_sessions WHERE rag_session_id = :sid"),
|
|
||||||
{"sid": rag_session_id},
|
|
||||||
).mappings().fetchone()
|
|
||||||
return dict(row) if row else None
|
|
||||||
|
|
||||||
def create_job(self, index_job_id: str, rag_session_id: str, status: str) -> None:
|
|
||||||
with get_engine().connect() as conn:
|
|
||||||
conn.execute(
|
|
||||||
text(
|
|
||||||
"""
|
|
||||||
INSERT INTO rag_index_jobs (index_job_id, rag_session_id, status)
|
|
||||||
VALUES (:jid, :sid, :status)
|
|
||||||
"""
|
|
||||||
),
|
|
||||||
{"jid": index_job_id, "sid": rag_session_id, "status": status},
|
|
||||||
)
|
|
||||||
conn.commit()
|
|
||||||
|
|
||||||
def update_job(
|
|
||||||
self,
|
|
||||||
index_job_id: str,
|
|
||||||
*,
|
|
||||||
status: str,
|
|
||||||
indexed_files: int,
|
|
||||||
failed_files: int,
|
|
||||||
error_code: str | None = None,
|
|
||||||
error_desc: str | None = None,
|
|
||||||
error_module: str | None = None,
|
|
||||||
) -> None:
|
|
||||||
with get_engine().connect() as conn:
|
|
||||||
conn.execute(
|
|
||||||
text(
|
|
||||||
"""
|
|
||||||
UPDATE rag_index_jobs
|
|
||||||
SET status = :status,
|
|
||||||
indexed_files = :indexed,
|
|
||||||
failed_files = :failed,
|
|
||||||
error_code = :ecode,
|
|
||||||
error_desc = :edesc,
|
|
||||||
error_module = :emodule,
|
|
||||||
updated_at = CURRENT_TIMESTAMP
|
|
||||||
WHERE index_job_id = :jid
|
|
||||||
"""
|
|
||||||
),
|
|
||||||
{
|
|
||||||
"jid": index_job_id,
|
|
||||||
"status": status,
|
|
||||||
"indexed": indexed_files,
|
|
||||||
"failed": failed_files,
|
|
||||||
"ecode": error_code,
|
|
||||||
"edesc": error_desc,
|
|
||||||
"emodule": error_module,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
conn.commit()
|
|
||||||
|
|
||||||
def get_job(self, index_job_id: str) -> RagJobRow | None:
|
|
||||||
with get_engine().connect() as conn:
|
|
||||||
row = conn.execute(
|
|
||||||
text(
|
|
||||||
"""
|
|
||||||
SELECT index_job_id, rag_session_id, status, indexed_files, failed_files,
|
|
||||||
error_code, error_desc, error_module
|
|
||||||
FROM rag_index_jobs
|
|
||||||
WHERE index_job_id = :jid
|
|
||||||
"""
|
|
||||||
),
|
|
||||||
{"jid": index_job_id},
|
|
||||||
).mappings().fetchone()
|
|
||||||
if not row:
|
|
||||||
return None
|
|
||||||
return RagJobRow(**dict(row))
|
|
||||||
|
|
||||||
def replace_chunks(self, rag_session_id: str, items: list[dict]) -> None:
|
|
||||||
with get_engine().connect() as conn:
|
|
||||||
conn.execute(text("DELETE FROM rag_chunks WHERE rag_session_id = :sid"), {"sid": rag_session_id})
|
|
||||||
self._insert_chunks(conn, rag_session_id, items)
|
|
||||||
conn.commit()
|
|
||||||
|
|
||||||
def apply_changes(self, rag_session_id: str, delete_paths: list[str], upserts: list[dict]) -> None:
|
|
||||||
with get_engine().connect() as conn:
|
|
||||||
if delete_paths:
|
|
||||||
conn.execute(
|
|
||||||
text("DELETE FROM rag_chunks WHERE rag_session_id = :sid AND path = ANY(:paths)"),
|
|
||||||
{"sid": rag_session_id, "paths": delete_paths},
|
|
||||||
)
|
|
||||||
if upserts:
|
|
||||||
paths = sorted({str(x["path"]) for x in upserts})
|
|
||||||
conn.execute(
|
|
||||||
text("DELETE FROM rag_chunks WHERE rag_session_id = :sid AND path = ANY(:paths)"),
|
|
||||||
{"sid": rag_session_id, "paths": paths},
|
|
||||||
)
|
|
||||||
self._insert_chunks(conn, rag_session_id, upserts)
|
|
||||||
conn.commit()
|
|
||||||
|
|
||||||
def retrieve(self, rag_session_id: str, query_embedding: list[float], limit: int = 5) -> list[dict]:
|
|
||||||
emb = "[" + ",".join(str(x) for x in query_embedding) + "]"
|
|
||||||
with get_engine().connect() as conn:
|
|
||||||
rows = conn.execute(
|
|
||||||
text(
|
|
||||||
"""
|
|
||||||
SELECT path, content
|
|
||||||
FROM rag_chunks
|
|
||||||
WHERE rag_session_id = :sid
|
|
||||||
ORDER BY embedding <=> CAST(:emb AS vector)
|
|
||||||
LIMIT :lim
|
|
||||||
"""
|
|
||||||
),
|
|
||||||
{"sid": rag_session_id, "emb": emb, "lim": limit},
|
|
||||||
).mappings().fetchall()
|
|
||||||
return [dict(x) for x in rows]
|
|
||||||
|
|
||||||
def fallback_chunks(self, rag_session_id: str, limit: int = 5) -> list[dict]:
|
|
||||||
with get_engine().connect() as conn:
|
|
||||||
rows = conn.execute(
|
|
||||||
text(
|
|
||||||
"""
|
|
||||||
SELECT path, content
|
|
||||||
FROM rag_chunks
|
|
||||||
WHERE rag_session_id = :sid
|
|
||||||
ORDER BY id DESC
|
|
||||||
LIMIT :lim
|
|
||||||
"""
|
|
||||||
),
|
|
||||||
{"sid": rag_session_id, "lim": limit},
|
|
||||||
).mappings().fetchall()
|
|
||||||
return [dict(x) for x in rows]
|
|
||||||
|
|
||||||
def _insert_chunks(self, conn, rag_session_id: str, items: list[dict]) -> None:
|
|
||||||
for item in items:
|
|
||||||
emb = item.get("embedding") or []
|
|
||||||
emb_str = "[" + ",".join(str(x) for x in emb) + "]" if emb else None
|
|
||||||
conn.execute(
|
|
||||||
text(
|
|
||||||
"""
|
|
||||||
INSERT INTO rag_chunks (rag_session_id, path, chunk_index, content, embedding, created_at, updated_at)
|
|
||||||
VALUES (:sid, :path, :idx, :content, CAST(:emb AS vector), CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
|
||||||
"""
|
|
||||||
),
|
|
||||||
{
|
|
||||||
"sid": rag_session_id,
|
|
||||||
"path": item["path"],
|
|
||||||
"idx": int(item["chunk_index"]),
|
|
||||||
"content": item["content"],
|
|
||||||
"emb": emb_str,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,134 +0,0 @@
|
|||||||
import asyncio
|
|
||||||
import os
|
|
||||||
from collections.abc import Awaitable, Callable
|
|
||||||
from inspect import isawaitable
|
|
||||||
|
|
||||||
from app.modules.rag.embedding.gigachat_embedder import GigaChatEmbedder
|
|
||||||
from app.modules.rag.repository import RagRepository
|
|
||||||
from app.modules.rag.retrieval.chunker import TextChunker
|
|
||||||
|
|
||||||
|
|
||||||
class RagService:
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
embedder: GigaChatEmbedder,
|
|
||||||
repository: RagRepository,
|
|
||||||
chunker: TextChunker | None = None,
|
|
||||||
) -> None:
|
|
||||||
self._embedder = embedder
|
|
||||||
self._repo = repository
|
|
||||||
self._chunker = chunker or TextChunker()
|
|
||||||
|
|
||||||
async def index_snapshot(
|
|
||||||
self,
|
|
||||||
rag_session_id: str,
|
|
||||||
files: list[dict],
|
|
||||||
progress_cb: Callable[[int, int, str], Awaitable[None] | None] | None = None,
|
|
||||||
) -> tuple[int, int]:
|
|
||||||
total_files = len(files)
|
|
||||||
indexed_files = 0
|
|
||||||
failed_files = 0
|
|
||||||
all_chunks: list[dict] = []
|
|
||||||
for index, file in enumerate(files, start=1):
|
|
||||||
path = str(file.get("path", ""))
|
|
||||||
try:
|
|
||||||
chunks = self._build_chunks_for_file(file)
|
|
||||||
embedded_chunks = await asyncio.to_thread(self._embed_chunks, chunks)
|
|
||||||
all_chunks.extend(embedded_chunks)
|
|
||||||
indexed_files += 1
|
|
||||||
except Exception:
|
|
||||||
failed_files += 1
|
|
||||||
await self._notify_progress(progress_cb, index, total_files, path)
|
|
||||||
await asyncio.to_thread(self._repo.replace_chunks, rag_session_id, all_chunks)
|
|
||||||
return indexed_files, failed_files
|
|
||||||
|
|
||||||
async def index_changes(
|
|
||||||
self,
|
|
||||||
rag_session_id: str,
|
|
||||||
changed_files: list[dict],
|
|
||||||
progress_cb: Callable[[int, int, str], Awaitable[None] | None] | None = None,
|
|
||||||
) -> tuple[int, int]:
|
|
||||||
total_files = len(changed_files)
|
|
||||||
indexed_files = 0
|
|
||||||
failed_files = 0
|
|
||||||
delete_paths: list[str] = []
|
|
||||||
upsert_chunks: list[dict] = []
|
|
||||||
|
|
||||||
for index, file in enumerate(changed_files, start=1):
|
|
||||||
path = str(file.get("path", ""))
|
|
||||||
op = str(file.get("op", ""))
|
|
||||||
try:
|
|
||||||
if op == "delete":
|
|
||||||
delete_paths.append(path)
|
|
||||||
indexed_files += 1
|
|
||||||
await self._notify_progress(progress_cb, index, total_files, path)
|
|
||||||
continue
|
|
||||||
if op == "upsert" and file.get("content") is not None:
|
|
||||||
chunks = self._build_chunks_for_file(file)
|
|
||||||
embedded_chunks = await asyncio.to_thread(self._embed_chunks, chunks)
|
|
||||||
upsert_chunks.extend(embedded_chunks)
|
|
||||||
indexed_files += 1
|
|
||||||
await self._notify_progress(progress_cb, index, total_files, path)
|
|
||||||
continue
|
|
||||||
failed_files += 1
|
|
||||||
except Exception:
|
|
||||||
failed_files += 1
|
|
||||||
await self._notify_progress(progress_cb, index, total_files, path)
|
|
||||||
|
|
||||||
await asyncio.to_thread(
|
|
||||||
self._repo.apply_changes,
|
|
||||||
rag_session_id,
|
|
||||||
delete_paths,
|
|
||||||
upsert_chunks,
|
|
||||||
)
|
|
||||||
return indexed_files, failed_files
|
|
||||||
|
|
||||||
async def retrieve(self, rag_session_id: str, query: str) -> list[dict]:
|
|
||||||
try:
|
|
||||||
query_embedding = self._embedder.embed([query])[0]
|
|
||||||
rows = self._repo.retrieve(rag_session_id, query_embedding, limit=5)
|
|
||||||
except Exception:
|
|
||||||
rows = self._repo.fallback_chunks(rag_session_id, limit=5)
|
|
||||||
return [{"source": row["path"], "content": row["content"]} for row in rows]
|
|
||||||
|
|
||||||
def _build_chunks_for_file(self, file: dict) -> list[tuple[str, int, str]]:
|
|
||||||
path = str(file.get("path", ""))
|
|
||||||
content = str(file.get("content", ""))
|
|
||||||
output: list[tuple[str, int, str]] = []
|
|
||||||
for idx, chunk in enumerate(self._chunker.chunk(content)):
|
|
||||||
output.append((path, idx, chunk))
|
|
||||||
return output
|
|
||||||
|
|
||||||
def _embed_chunks(self, raw_chunks: list[tuple[str, int, str]]) -> list[dict]:
|
|
||||||
if not raw_chunks:
|
|
||||||
return []
|
|
||||||
batch_size = max(1, int(os.getenv("RAG_EMBED_BATCH_SIZE", "16")))
|
|
||||||
|
|
||||||
indexed: list[dict] = []
|
|
||||||
for i in range(0, len(raw_chunks), batch_size):
|
|
||||||
batch = raw_chunks[i : i + batch_size]
|
|
||||||
texts = [x[2] for x in batch]
|
|
||||||
vectors = self._embedder.embed(texts)
|
|
||||||
for (path, chunk_index, content), vector in zip(batch, vectors):
|
|
||||||
indexed.append(
|
|
||||||
{
|
|
||||||
"path": path,
|
|
||||||
"chunk_index": chunk_index,
|
|
||||||
"content": content,
|
|
||||||
"embedding": vector,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return indexed
|
|
||||||
|
|
||||||
async def _notify_progress(
|
|
||||||
self,
|
|
||||||
progress_cb: Callable[[int, int, str], Awaitable[None] | None] | None,
|
|
||||||
current_file_index: int,
|
|
||||||
total_files: int,
|
|
||||||
current_file_name: str,
|
|
||||||
) -> None:
|
|
||||||
if not progress_cb:
|
|
||||||
return
|
|
||||||
result = progress_cb(current_file_index, total_files, current_file_name)
|
|
||||||
if isawaitable(result):
|
|
||||||
await result
|
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,73 +0,0 @@
|
|||||||
import requests
|
|
||||||
|
|
||||||
from app.modules.shared.gigachat.errors import GigaChatError
|
|
||||||
from app.modules.shared.gigachat.settings import GigaChatSettings
|
|
||||||
from app.modules.shared.gigachat.token_provider import GigaChatTokenProvider
|
|
||||||
|
|
||||||
|
|
||||||
class GigaChatClient:
|
|
||||||
def __init__(self, settings: GigaChatSettings, token_provider: GigaChatTokenProvider) -> None:
|
|
||||||
self._settings = settings
|
|
||||||
self._tokens = token_provider
|
|
||||||
|
|
||||||
def complete(self, system_prompt: str, user_prompt: str) -> str:
|
|
||||||
token = self._tokens.get_access_token()
|
|
||||||
payload = {
|
|
||||||
"model": self._settings.model,
|
|
||||||
"messages": [
|
|
||||||
{"role": "system", "content": system_prompt},
|
|
||||||
{"role": "user", "content": user_prompt},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
try:
|
|
||||||
response = requests.post(
|
|
||||||
f"{self._settings.api_url.rstrip('/')}/chat/completions",
|
|
||||||
json=payload,
|
|
||||||
headers={
|
|
||||||
"Authorization": f"Bearer {token}",
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
timeout=90,
|
|
||||||
verify=self._settings.ssl_verify,
|
|
||||||
)
|
|
||||||
except requests.RequestException as exc:
|
|
||||||
raise GigaChatError(f"GigaChat completion request failed: {exc}") from exc
|
|
||||||
|
|
||||||
if response.status_code >= 400:
|
|
||||||
raise GigaChatError(f"GigaChat completion error {response.status_code}: {response.text}")
|
|
||||||
|
|
||||||
data = response.json()
|
|
||||||
choices = data.get("choices") or []
|
|
||||||
if not choices:
|
|
||||||
return ""
|
|
||||||
message = choices[0].get("message") or {}
|
|
||||||
return str(message.get("content") or "")
|
|
||||||
|
|
||||||
def embed(self, texts: list[str]) -> list[list[float]]:
|
|
||||||
token = self._tokens.get_access_token()
|
|
||||||
payload = {
|
|
||||||
"model": self._settings.embedding_model,
|
|
||||||
"input": texts,
|
|
||||||
}
|
|
||||||
try:
|
|
||||||
response = requests.post(
|
|
||||||
f"{self._settings.api_url.rstrip('/')}/embeddings",
|
|
||||||
json=payload,
|
|
||||||
headers={
|
|
||||||
"Authorization": f"Bearer {token}",
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
timeout=90,
|
|
||||||
verify=self._settings.ssl_verify,
|
|
||||||
)
|
|
||||||
except requests.RequestException as exc:
|
|
||||||
raise GigaChatError(f"GigaChat embeddings request failed: {exc}") from exc
|
|
||||||
|
|
||||||
if response.status_code >= 400:
|
|
||||||
raise GigaChatError(f"GigaChat embeddings error {response.status_code}: {response.text}")
|
|
||||||
|
|
||||||
data = response.json()
|
|
||||||
items = data.get("data")
|
|
||||||
if not isinstance(items, list):
|
|
||||||
raise GigaChatError("Unexpected GigaChat embeddings response")
|
|
||||||
return [list(map(float, x.get("embedding") or [])) for x in items]
|
|
||||||
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,34 +0,0 @@
|
|||||||
from enum import Enum
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from pydantic import BaseModel, Field, model_validator
|
|
||||||
|
|
||||||
|
|
||||||
class ChangeOp(str, Enum):
|
|
||||||
CREATE = "create"
|
|
||||||
UPDATE = "update"
|
|
||||||
DELETE = "delete"
|
|
||||||
|
|
||||||
|
|
||||||
class ChangeItem(BaseModel):
|
|
||||||
op: ChangeOp
|
|
||||||
path: str = Field(min_length=1)
|
|
||||||
base_hash: Optional[str] = None
|
|
||||||
proposed_content: Optional[str] = None
|
|
||||||
reason: str = Field(min_length=1, max_length=500)
|
|
||||||
|
|
||||||
@model_validator(mode="after")
|
|
||||||
def validate_op_fields(self) -> "ChangeItem":
|
|
||||||
if self.op in (ChangeOp.UPDATE, ChangeOp.DELETE) and not self.base_hash:
|
|
||||||
raise ValueError("base_hash is required for update/delete")
|
|
||||||
if self.op in (ChangeOp.CREATE, ChangeOp.UPDATE) and self.proposed_content is None:
|
|
||||||
raise ValueError("proposed_content is required for create/update")
|
|
||||||
if self.op == ChangeOp.DELETE and self.proposed_content is not None:
|
|
||||||
raise ValueError("proposed_content is forbidden for delete")
|
|
||||||
return self
|
|
||||||
|
|
||||||
|
|
||||||
class ChangeSetPayload(BaseModel):
|
|
||||||
schema_version: str
|
|
||||||
task_id: str
|
|
||||||
changeset: list[ChangeItem]
|
|
||||||
422
architecture_as_is.md
Normal file
422
architecture_as_is.md
Normal file
@@ -0,0 +1,422 @@
|
|||||||
|
# Архитектура агента As Is — отчёт
|
||||||
|
|
||||||
|
## 1. Executive Summary
|
||||||
|
|
||||||
|
- **Текущее устройство.** Запрос пользователя принимается в модуле `chat` (POST `/api/chat/messages`), ставится в очередь задач и обрабатывается асинхронно. Обработку выполняет `GraphAgentRuntime`: вызывается **router агента** (не IntentRouterV2), по его решению выбирается сценарий и план шагов оркестратора; шаги выполняются как вызовы функций или подграфов LangGraph. Итог — ответ или changeset в задаче и в событии `task_result`.
|
||||||
|
|
||||||
|
- **Два режима входа.** При `SIMPLE_CODE_EXPLAIN_ONLY=true` (по умолчанию) запросы идут в **CodeExplainChatService**: без роутера, сразу retrieval через CodeExplainRetrieverV2, evidence gate и один промпт. При `false` используется полный агент с роутером и оркестратором.
|
||||||
|
|
||||||
|
- **Router.** В продакшене используется **RouterService** из `app/modules/agent/engine/router/`: классификатор интентов (эвристики + LLM), регистр графов по `domain_id`/`process_id`, контекст диалога в AgentRepository. **IntentRouterV2** (модуль `rag/intent_router_v2`) в основном приложении **не вызывается** — только в тестах и пайплайне `tests/pipeline_intent_rag`.
|
||||||
|
|
||||||
|
- **Retrieval.** В основном флоу агента **RAG по запросу в рантайме не вызывается**: в `GraphAgentRuntime.run` переменная `rag_ctx` задаётся как пустой список и не заполняется; граф `project_qa/context_retrieval` получает этот пустой список и строит `source_bundle` только из `files_map` (вложения пользователя). Реальный retrieval по индексу выполняется только: (1) в режиме **direct code explain** (CodeExplainRetrieverV2 → LayeredRetrievalGateway → RagRepository); (2) внутри шага **build_code_explain_pack** при сценарии EXPLAIN_PART (тот же CodeExplainRetrieverV2). Контракт `RagRetriever` (async `retrieve(rag_session_id, query)`) в коде **нигде не реализован**: RagService умеет только индексировать, не извлекать.
|
||||||
|
|
||||||
|
- **Индексация.** При создании/обновлении RAG-сессии используется RagService с двумя пайплайнами: CodeIndexingPipeline (C0–C4: chunks, symbols, edges, entrypoints, traces, semantic roles) и DocsIndexingPipeline (D1–D4: module catalog, facts, sections, policy). Слои попадают в `rag_chunks`; retrieval по слоям делается только через RagRepository (LayeredRetrievalGateway), не через RagService.
|
||||||
|
|
||||||
|
- **Графы и пайплайны.** Есть общий оркестратор (ScenarioTemplateRegistry → PlanCompiler → ExecutionEngine). Сценарии: GENERAL_QA (в т.ч. project/qa как подплан project_qa), EXPLAIN_PART, ANALYTICS_REVIEW, DOCS_FROM_ANALYTICS, TARGETED_EDIT, GHERKIN_MODEL. Для project/qa выполняется цепочка подграфов: conversation_understanding → question_classification → context_retrieval (без вызова RAG) → опционально build_code_explain_pack → context_analysis → answer_composition. Отдельные графы зарегистрированы для default/general, project/qa, project/edits, docs/generation и для подшагов project_qa.
|
||||||
|
|
||||||
|
- **Сборка контекста для LLM.** В основном флоу в модель попадают: `rag_context` (из task_spec; по факту пустой), `confluence_context` (страницы из вложений), `files_map` (переданные пользователем файлы). Контекст по коду из индекса собирается только в direct code explain и в build_code_explain_pack (ExplainPack → PromptBudgeter).
|
||||||
|
|
||||||
|
- **Диагностика.** В приложении: логи (router decision, graph step result, code explain pack, orchestrator decision), события прогресса (task_progress с stage/message/meta), в `task_result` — meta (route, used_rag, orchestrator_steps, quality). Отдельная структура диагностики (router_plan, execution, retrieval, timings_ms, constraint_violations) реализована в тестовом пайплайне под IntentRouterV2 и в ответ не отдаётся.
|
||||||
|
|
||||||
|
- **Главные пробелы.** (1) Нет вызова RAG в основном агентском флоу — context_retrieval не использует индекс. (2) IntentRouterV2 и его retrieval/diagnostics не интегрированы в продакшен. (3) RagService не реализует контракт RagRetriever. (4) Целевые слои C5 (Test Mappings), C6 (Code Facts), D5 (Reference Graph), D6 (Doc-Code Links) в индексации и retrieval не представлены. (5) Evidence gate есть только у direct code explain; в оркестраторе quality gates проверяют артефакты шагов, но не «достаточность evidence» в целевом смысле.
|
||||||
|
|
||||||
|
- **Близость к целевой архитектуре.** Частично: индексация уже многослойная (CODE C0–C4, DOCS D1–D4), есть задел intent-based роутинга (IntentRouterV2 в тестах), оркестрация по сценариям и графам есть. Не хватает: подключения retrieval к основному флоу, объединения роутера с intent/retrieval-спеками, полного набора слоёв и явного evidence gate в оркестраторе.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. End-to-End Flow As Is
|
||||||
|
|
||||||
|
### 2.1. Request intake
|
||||||
|
|
||||||
|
**Что происходит.** Клиент шлёт POST `/api/chat/messages` (ChatMessageRequest: message, mode, attachments, files, dialog_session_id и т.д.). ChatModule при `SIMPLE_CODE_EXPLAIN_ONLY=true` отдаёт запрос в CodeExplainChatService.handle_message; иначе — в ChatOrchestrator.enqueue_message. В последнем случае создаётся задача (TaskStore), по idempotency может возвращаться существующая, затем в фоне запускается _process_task.
|
||||||
|
|
||||||
|
**Evidence.** `app/modules/chat/module.py` (send_message, условие по `_simple_code_explain_only`), `app/modules/chat/service.py` (enqueue_message, _process_task, _resolve_sessions).
|
||||||
|
|
||||||
|
**Комментарий.** Точка входа одна; разветвление по флагу окружения определяет упрощённый (direct) или полный (agent) путь.
|
||||||
|
|
||||||
|
### 2.2. Routing
|
||||||
|
|
||||||
|
**Что происходит.** В полном пути GraphAgentRuntime.run вызывает `self._router.resolve(message, dialog_session_id, mode)`. Это RouterService (agent): читает контекст из RouterContextStore (AgentRepository), при принудительном mode возвращает маршрут из маппинга (project_qa, project_edits, docs_generation и др.), иначе — IntentClassifier: короткие подтверждения → last_routing, детерминированные правила (редакт файла, запрос документации) → соответствующий route, в остальных случаях — LLM (prompt "router_intent") с разбором JSON. Результат — RouteResolution (domain_id, process_id, confidence, reason, fallback_used). При низкой уверенности или невалидной паре (domain_id, process_id) возвращается fallback default/general. Graph factory выбирается по (domain_id, process_id) из IntentRegistry.
|
||||||
|
|
||||||
|
**Evidence.** `app/modules/agent/service.py` (run, _resolve_graph), `app/modules/agent/engine/router/router_service.py` (resolve, _is_acceptable, _resolution, _fallback), `app/modules/agent/engine/router/intent_classifier.py` (classify_new_intent, from_mode, _deterministic_route, _classify_with_llm), `app/modules/agent/engine/router/__init__.py` (build_router_service, регистрация графов).
|
||||||
|
|
||||||
|
**Комментарий.** IntentRouterV2 в этом флоу не участвует. graph_id в продакшене — это пара (domain_id, process_id), а не строка из IntentRouterV2.
|
||||||
|
|
||||||
|
### 2.3. Retrieval
|
||||||
|
|
||||||
|
**Что происходит.** В GraphAgentRuntime.run перед вызовом оркестратора `rag_ctx` инициализируется как `[]` и не заполняется. TaskSpec получает rag_items=rag_ctx и rag_context=_format_rag(rag_ctx) — т.е. пустой контекст. В плане project_qa шаг context_retrieval (граф ProjectQaRetrievalGraphFactory) в _retrieve_context читает state (resolved_request, question_profile, files_map), объявляет `rag_items: list[dict] = []`, вызывает build_source_bundle(profile, rag_items, files_map) — т.е. только ранжирование переданных файлов, без вызова RAG. Реальный retrieval: (1) CodeExplainChatService — CodeExplainRetrieverV2.build_pack → LayeredRetrievalGateway (C3, C1, C2, C0, lexical fallback); (2) шаг build_code_explain_pack в оркестраторе — тот же build_pack с file_candidates из source_bundle.
|
||||||
|
|
||||||
|
**Evidence.** `app/modules/agent/service.py` (rag_ctx: list[dict] = [], task_spec), `app/modules/agent/engine/graphs/project_qa_step_graphs.py` (ProjectQaRetrievalGraphFactory._retrieve_context, rag_items=[], build_source_bundle), `app/modules/rag/explain/retriever_v2.py` (build_pack, _entrypoints, _seed_symbols, _trace_builder, _excerpt_fetcher, lexical fallback), `app/modules/rag/explain/layered_gateway.py` (retrieve_layer, retrieve_lexical_code → repository).
|
||||||
|
|
||||||
|
**Комментарий.** Намеренно не меняя код: в as is основной агентский пайплайн не выполняет retrieval по индексу; контракт RagRetriever не реализован (RagService не имеет метода retrieve).
|
||||||
|
|
||||||
|
### 2.4. Context assembly
|
||||||
|
|
||||||
|
**Что происходит.** Для основного агента контекст для шагов собирается в TaskSpecBuilder: metadata содержит rag_items, rag_context, confluence_context, files_map. rag_context формируется в agent как _format_rag(rag_ctx) — при пустом rag_ctx это пустая строка. Confluence — из вложений типа confluence_url. В шагах оркестратора (collect_state) в agent_state попадает этот metadata; графы получают state с rag_context, confluence_context, files_map. Для ответа LLM контекст собирается внутри графов: в project_qa — ProjectQaSupport (build_answer_brief, compose_answer) и при наличии explain_pack — PromptBudgeter.build_prompt_input + code_explain_answer_v2. В direct code explain контекст — только ExplainPack через PromptBudgeter.
|
||||||
|
|
||||||
|
**Evidence.** `app/modules/agent/service.py` (_format_rag, _format_confluence, task_spec), `app/modules/agent/engine/orchestrator/step_registry.py` (_collect_state, agent_state), `app/modules/agent/engine/graphs/project_qa_step_graphs.py` (ProjectQaAnswerGraphFactory._compose_answer, _compose_explain_answer), `app/modules/rag/explain` (PromptBudgeter).
|
||||||
|
|
||||||
|
**Комментарий.** Разделения «intent-driven» сборки контекста по слоям в основном флоу нет; слои используются только в CodeExplainRetrieverV2 и при формировании промпта code_explain_answer_v2.
|
||||||
|
|
||||||
|
### 2.5. LLM answer synthesis
|
||||||
|
|
||||||
|
**Что происходит.** Ответ генерируется внутри графов и шагов оркестратора. Для default/general — граф, собранный BaseGraphFactory(llm). Для project/qa — цепочка подграфов, финальный ответ в ProjectQaAnswerGraphFactory (_compose_answer: при наличии explain_pack — LLM code_explain_answer_v2, иначе ProjectQaSupport.compose_answer по brief). Для direct code explain — один вызов LLM code_explain_answer_v2 после evidence gate. AgentLlmService.generate вызывается с ключом промпта (router_intent, code_explain_answer_v2 и т.д.) и payload; промпты загружаются через PromptLoader.
|
||||||
|
|
||||||
|
**Evidence.** `app/modules/agent/engine/graphs/project_qa_step_graphs.py` (ProjectQaAnswerGraphFactory), `app/modules/agent/llm` (AgentLlmService), `app/modules/chat/direct_service.py` (CodeExplainChatService), `app/modules/agent/engine/graphs` (фабрики графов).
|
||||||
|
|
||||||
|
**Комментарий.** Итоговый ответ и/или changeset собираются ResultAssembler из артефактов шагов (final_answer, final_changeset).
|
||||||
|
|
||||||
|
### 2.6. Diagnostics
|
||||||
|
|
||||||
|
**Что происходит.** В рантайме: логи (router decision, graph step result, code explain pack, orchestrator decision); события task_progress (stage, message, meta); в конце — task_result с meta (route, used_rag, used_confluence, orchestrator_steps, quality при наличии). Quality метрики при наличии записываются MetricsPersister в agent_repository. Структурированная диагностика (router_plan, execution, retrieval, timings_ms, constraint_violations) строится в тестах pipeline_intent_rag (helpers/diagnostics) для результата IntentRouterV2 и в ответ API не входит.
|
||||||
|
|
||||||
|
**Evidence.** `app/modules/agent/service.py` (LOGGER.warning по route/orchestrator, meta в AgentResult, _persist_quality_metrics), `app/modules/chat/service.py` (_publish_progress, task_result), `tests/pipeline_intent_rag/helpers/diagnostics.py` (build_router_plan, init_diagnostics, apply_retrieval_report, validate_constraints).
|
||||||
|
|
||||||
|
**Комментарий.** used_rag в meta всегда False в текущем коде, т.к. rag_ctx не заполняется.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Router As Is
|
||||||
|
|
||||||
|
### 3.1. Main router entrypoints
|
||||||
|
|
||||||
|
Единственная точка входа роутера в основном приложении — `RouterService.resolve(user_message, conversation_key, mode)` в `app/modules/agent/engine/router/router_service.py`. Сборка: `build_router_service` в `app/modules/agent/engine/router/__init__.py` (IntentRegistry, IntentClassifier, RouterContextStore, IntentSwitchDetector, RouterService).
|
||||||
|
|
||||||
|
### 3.2. Input contract
|
||||||
|
|
||||||
|
- **Вход:** `user_message: str`, `conversation_key: str` (dialog_session_id), `mode: str` (например "auto", "project_qa", "project_edits", "docs_generation").
|
||||||
|
- **Контекст:** из RouterContextStore по conversation_key — RouterContext (last_routing, message_history, active_intent, dialog_started, turn_index). Контекст обновляется при persist_context после ответа агента.
|
||||||
|
|
||||||
|
**Evidence.** `app/modules/agent/engine/router/router_service.py` (resolve, context = self._ctx.get), `app/modules/agent/engine/router/schemas.py` (RouterContext).
|
||||||
|
|
||||||
|
### 3.3. Output contract
|
||||||
|
|
||||||
|
RouteResolution: domain_id, process_id, confidence, reason, fallback_used, decision_type, explicit_switch. Фабрика графа запрашивается отдельно: graph_factory(domain_id, process_id).
|
||||||
|
|
||||||
|
**Evidence.** `app/modules/agent/engine/router/schemas.py` (RouteResolution), `app/modules/agent/engine/router/router_service.py` (_resolution, _fallback, _continue_current, graph_factory).
|
||||||
|
|
||||||
|
### 3.4. Supported intents and sub-intents
|
||||||
|
|
||||||
|
**Намерения (domain_id / process_id):** default/general, project/qa, project/edits, docs/generation. Внутри оркестратора сценарий (Scenario) определяется TaskSpecBuilder по mode, route и тексту сообщения: GENERAL_QA, EXPLAIN_PART, ANALYTICS_REVIEW, DOCS_FROM_ANALYTICS, TARGETED_EDIT, GHERKIN_MODEL. Подграфы project_qa зарегистрированы как project_qa/conversation_understanding, question_classification, context_retrieval, context_analysis, answer_composition. Sub-intent в смысле IntentRouterV2 (EXPLAIN, OPEN_FILE и т.д.) в этом роутере не фигурируют.
|
||||||
|
|
||||||
|
**Evidence.** `app/modules/agent/engine/router/__init__.py` (registry.register), `app/modules/agent/engine/router/intent_classifier.py` (_route_mapping, from_mode), `app/modules/agent/engine/orchestrator/task_spec_builder.py` (_detect_scenario).
|
||||||
|
|
||||||
|
### 3.5. Graph selection logic
|
||||||
|
|
||||||
|
Граф выбирается по (domain_id, process_id). Для основного запроса оркестратор по сценарию решает, какой план выполнять; в плане шаги могут иметь graph_id="route" (тогда берётся domain_id/process_id из task.routing) или graph_id вида "project_qa/context_retrieval". Резолвер графа: _resolve_graph в GraphAgentRuntime — factory = self._router.graph_factory(domain_id, process_id), fallback на default/general.
|
||||||
|
|
||||||
|
**Evidence.** `app/modules/agent/service.py` (_resolve_graph), `app/modules/agent/engine/orchestrator/step_registry.py` (_execute_graph_step, graph_key), `app/modules/agent/engine/orchestrator/template_registry.py` (graph_id в шагах).
|
||||||
|
|
||||||
|
### 3.6. Heuristics / LLM / deterministic logic
|
||||||
|
|
||||||
|
- Детерминировано: короткие подтверждения → last_routing; целевой редакт файла (_is_targeted_file_edit_request); запрос документации (_is_broad_docs_request) → project/edits, docs/generation.
|
||||||
|
- LLM: один вызов при отсутствии детерминированного решения — prompt "router_intent", ожидается JSON с route, confidence, reason; парсинг с допуском code fence.
|
||||||
|
- Эвристики: min_confidence=0.7, проверка registry.is_valid(domain_id, process_id); при переключении интента — IntentSwitchDetector.should_switch, при отказе — _continue_current.
|
||||||
|
|
||||||
|
**Evidence.** `app/modules/agent/engine/router/intent_classifier.py`, `app/modules/agent/engine/router/router_service.py`.
|
||||||
|
|
||||||
|
### 3.7. Current limitations
|
||||||
|
|
||||||
|
- Нет keyword_hints, path_scope, layers в выходном контракте роутера; они не передаются в retrieval, т.к. retrieval в основном флоу не вызывается.
|
||||||
|
- Контекст диалога ограничен last_routing и message_history; нет структуры query plan / anchors как в IntentRouterV2.
|
||||||
|
- IntentRouterV2 с полным контрактом (intent, sub_intent, graph_id, retrieval_spec, retrieval_constraints, evidence_policy) в приложении не используется.
|
||||||
|
|
||||||
|
| Router capability | Current state | Status | Evidence | Notes |
|
||||||
|
|-------------------------|---------------|--------|---------------------------------------------------------------------------|------------------------------------------------------|
|
||||||
|
| Main entrypoint | RouterService.resolve | full | router_service.py | |
|
||||||
|
| Input: message, session, mode | Yes | full | router_service.py | |
|
||||||
|
| Output: domain_id, process_id, confidence, reason | Yes | full | RouteResolution, schemas.py | |
|
||||||
|
| intent / sub-intent | domain/process only | partial | intent_classifier _route_mapping; no sub_intent in agent router | Sub-intent только в IntentRouterV2 |
|
||||||
|
| graph_id | (domain_id, process_id) | full | registry.get_factory, _resolve_graph | |
|
||||||
|
| conversation_mode | decision_type (start/continue/switch) | partial | RouteResolution.decision_type | Не буквально CONTINUE/FOLLOWUP_LIKELY |
|
||||||
|
| keyword_hints | — | none | — | Есть в IntentRouterV2.query_plan |
|
||||||
|
| path_scope | — | none | — | Есть в IntentRouterV2.retrieval_spec.filters |
|
||||||
|
| layers | — | none | — | Есть в IntentRouterV2 |
|
||||||
|
| Heuristics + LLM | Yes | full | intent_classifier | |
|
||||||
|
| Fallback default/general| Yes | full | _fallback, _is_acceptable | |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Retrieval / RAG As Is
|
||||||
|
|
||||||
|
### 4.1. Code retrieval as is
|
||||||
|
|
||||||
|
- **Где выполняется:** только при direct code explain (CodeExplainChatService) и в шаге build_code_explain_pack (сценарий EXPLAIN_PART). Оба пути используют CodeExplainRetrieverV2 → LayeredRetrievalGateway → RagRepository.
|
||||||
|
- **Chunking:** при индексации — CodeTextChunker (code), при retrieval — C0_SOURCE_CHUNKS и lexical fallback по тексту запроса.
|
||||||
|
- **Symbol-based:** C1_SYMBOL_CATALOG через retrieve_layer; после entrypoints подтягиваются handler symbols через CodeGraphRepository.get_symbols_by_ids.
|
||||||
|
- **Path-aware:** path_prefixes передаются в retrieve_layer и retrieve_lexical_code (из intent/file_candidates).
|
||||||
|
- **Entrypoints:** C3_ENTRYPOINTS, фильтрация по entry_type из intent.
|
||||||
|
- **Test-specific:** exclude_tests в gateway; при малом числе excerpts делается _merge_test_fallback с include_tests.
|
||||||
|
- **Структурные связи:** C2 (edges), TraceBuilder строит пути по графу; SourceExcerptFetcher по путям достаёт фрагменты. Execution traces индексируются (entrypoints + execution_trace_document_builder).
|
||||||
|
|
||||||
|
В основном агентском флоу (project_qa/context_retrieval) вызова RAG нет: rag_items пустые, source_bundle строится только из files_map.
|
||||||
|
|
||||||
|
**Evidence.** `app/modules/rag/explain/retriever_v2.py`, `app/modules/rag/explain/layered_gateway.py`, `app/modules/rag/persistence/repository.py`, `app/modules/agent/engine/graphs/project_qa_step_graphs.py` (ProjectQaRetrievalGraphFactory).
|
||||||
|
|
||||||
|
### 4.2. Docs retrieval as is
|
||||||
|
|
||||||
|
- **Парсинг документов:** DocsIndexingPipeline при индексации: frontmatter, MarkdownDocChunker, DocsClassifier (doc_kind), build_module_catalog, build_section, build_policy, _extract_facts. Слои: D1_MODULE_CATALOG, D2_FACT_INDEX, D3_SECTION_INDEX, D4_POLICY_INDEX (enums).
|
||||||
|
- **Retrieval по документам:** в основном приложении отдельного «docs retrieval» по запросу пользователя не вызывается. LayeredRetrievalGateway и RetrievalStatementBuilder поддерживают слои D1–D4 в запросах (layer_rank_sql), но код, который бы по intent «DOCS_QA» или «GENERATE_DOCS_FROM_CODE» вызывал только docs-слои, в основном флоу не прослеживается — IntentRouterV2 с retrieval_profile "docs" используется только в тестах.
|
||||||
|
- **Doc-to-code linking:** явного слоя или индекса D6 (Doc-Code Links) нет. В индексации есть ссылки в документах (frontmatter links), но отдельного кросс-доменного индекса нет.
|
||||||
|
|
||||||
|
**Evidence.** `app/modules/rag/indexing/docs/pipeline.py`, `app/modules/rag/contracts/enums.py`, `app/modules/rag/persistence/retrieval_statement_builder.py` (D1–D4 в SQL).
|
||||||
|
|
||||||
|
### 4.3. Map current implementation to target layers
|
||||||
|
|
||||||
|
| Target element | Current implementation | Status | Evidence | Notes |
|
||||||
|
|----------------|------------------------|--------|----------|--------|
|
||||||
|
| CODE / C0 Source Chunks | CodeTextChunker + CodeTextDocumentBuilder, слой C0_SOURCE_CHUNKS; retrieval через retrieve_lexical_code и retrieve(..., layers=[C0]) | full | code_text/document_builder.py, pipeline.py, retrieval_statement_builder.py, layered_gateway | |
|
||||||
|
| CODE / C1 Symbol Catalog | SymbolExtractor + SymbolDocumentBuilder, C1_SYMBOL_CATALOG; retrieval в retriever_v2 _seed_symbols | full | indexing/code/symbols/, enums.py, retriever_v2.py | |
|
||||||
|
| CODE / C2 Symbol Relations | EdgeExtractor, EdgeDocumentBuilder, DataflowDocumentBuilder; слой C2_DEPENDENCY_GRAPH; TraceBuilder использует граф | full | indexing/code/edges/, graph_repository.py | Целевое имя «Symbol Relations»; в коде «dependency/dataflow» |
|
||||||
|
| CODE / C3 Entrypoints | EntrypointDetectorRegistry (FastAPI, Flask, Typer/Click), EntrypointDocumentBuilder, execution_trace; C3_ENTRYPOINTS | full | indexing/code/entrypoints/, retriever_v2 _entrypoints | |
|
||||||
|
| CODE / C4 Execution Paths | Execution traces индексируются в C2 (execution_trace); пути строятся в рантайме TraceBuilder по графу | partial | execution_trace_builder, trace_builder.py, retriever_v2 | Нет отдельного слоя «C4 Execution Paths»; логика есть |
|
||||||
|
| CODE / C5 Test Mappings | Нет отдельного слоя или индекса тест→код | none | — | test_filter только исключает/включает пути |
|
||||||
|
| CODE / C6 Code Facts | Нет слоя «code facts» | none | — | |
|
||||||
|
| DOCS / D0 Document Chunks | Секции документов как D3_SECTION_INDEX; общего «D0» как аналога C0 нет | partial | docs pipeline build_section | Chunks по сути есть как section docs |
|
||||||
|
| DOCS / D1 Document Catalog | D1_MODULE_CATALOG, build_module_catalog | full | enums.py, docs/document_builder.py | |
|
||||||
|
| DOCS / D2 Fact Index | D2_FACT_INDEX, _extract_facts из frontmatter links | full | docs pipeline | |
|
||||||
|
| DOCS / D3 Entity Catalog | D3_SECTION_INDEX в коде; целевое «Entity Catalog» может отличаться | partial | enums.py DOCS_SECTION_INDEX | Именование не 1:1 |
|
||||||
|
| DOCS / D4 Workflow Index | D4_POLICY_INDEX (policy-документы) | partial | docs pipeline build_policy | Ограничено type=policy |
|
||||||
|
| DOCS / D5 Reference Graph | Нет отдельного графа ссылок между документами | none | — | |
|
||||||
|
| DOCS / D6 Doc-Code Links | Нет | none | — | |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Graphs / Pipelines As Is
|
||||||
|
|
||||||
|
### 5.1. List of existing graphs / pipelines
|
||||||
|
|
||||||
|
- **По (domain_id, process_id):** default/general (BaseGraphFactory), project/qa (ProjectQaGraphFactory), project/edits (ProjectEditsGraphFactory), docs/generation (DocsGraphFactory).
|
||||||
|
- **Подграфы project_qa:** project_qa/conversation_understanding (ProjectQaConversationGraphFactory), project_qa/question_classification (ProjectQaClassificationGraphFactory), project_qa/context_retrieval (ProjectQaRetrievalGraphFactory), project_qa/context_analysis (ProjectQaAnalysisGraphFactory), project_qa/answer_composition (ProjectQaAnswerGraphFactory).
|
||||||
|
|
||||||
|
Планы (ExecutionPlan) задаются ScenarioTemplateRegistry по сценарию: general_qa_v1, project_qa_reasoning_v1, explain_part_v1, analytics_review_v1, docs_from_analytics_v1, targeted_edit_v1, gherkin_model_v1.
|
||||||
|
|
||||||
|
**Evidence.** `app/modules/agent/engine/router/__init__.py`, `app/modules/agent/engine/orchestrator/template_registry.py`, `app/modules/agent/engine/graphs/`.
|
||||||
|
|
||||||
|
### 5.2. Shared orchestration logic
|
||||||
|
|
||||||
|
OrchestratorService: build template → compile plan → validate → ExecutionEngine.run(ctx) → ResultAssembler.assemble. Шаги выполняются StepRegistry: либо функция (action_id), либо executor="graph" с graph_id. Состояние передаётся через ExecutionContext (task, plan, artifacts, evidences, graph_resolver, graph_invoker). Общая логика: один сценарий на запрос, линейные/параллельные зависимости шагов.
|
||||||
|
|
||||||
|
**Evidence.** `app/modules/agent/engine/orchestrator/service.py`, `app/modules/agent/engine/orchestrator/execution_engine.py`, `app/modules/agent/engine/orchestrator/step_registry.py`.
|
||||||
|
|
||||||
|
### 5.3. Specialized logic
|
||||||
|
|
||||||
|
- project_qa: последовательность подграфов + опционально build_code_explain_pack; context_retrieval не вызывает RAG; context_analysis использует explain_pack или analyzer по profile (code/docs).
|
||||||
|
- explain_part: при route project/qa тот же project_qa_reasoning_v1 с добавлением шага build_code_explain_pack; анализ и ответ с ExplainPack.
|
||||||
|
- Остальные сценарии (review, docs, edit, gherkin) — свои шаги (fetch_source_doc, normalize_document, …), без слоёвого retrieval.
|
||||||
|
|
||||||
|
**Evidence.** template_registry.py _project_qa, _explain, _review, _docs, _edit, _gherkin; project_qa_step_graphs.py.
|
||||||
|
|
||||||
|
### 5.4. Retrieval failure behavior
|
||||||
|
|
||||||
|
В основном флоу retrieval не вызывается, поэтому «failure» не возникает. В CodeExplainRetrieverV2 при пустых entrypoints/symbols/traces/excerpts заполняется pack.missing, делается lexical fallback; при недостатке excerpts — test fallback. CodeExplainEvidenceGate при числе excerpts < min_excerpts возвращает passed=False и шаблонный ответ с диагностикой (без вызова LLM).
|
||||||
|
|
||||||
|
**Evidence.** retriever_v2.py (_run_pass, _merge_test_fallback), evidence_gate.py.
|
||||||
|
|
||||||
|
### 5.5. Fallback behavior
|
||||||
|
|
||||||
|
Router: при низкой уверенности или невалидной паре — fallback default/general. При явном переключении интента, если новый интент не принят — _continue_current. В retrieval (CodeExplainRetrieverV2): lexical fallback, затем при необходимости test fallback. Отдельного «fallback graph» или единого fallback-пайплайна при слабом retrieval в оркестраторе нет.
|
||||||
|
|
||||||
|
**Evidence.** router_service.py _fallback, _continue_current; retriever_v2.py.
|
||||||
|
|
||||||
|
### 5.6. Evidence sufficiency checks
|
||||||
|
|
||||||
|
CodeExplainEvidenceGate (direct code explain): проверка количества code_excerpts >= min_excerpts; при неуспехе — ответ без вызова LLM. В оркестраторе QualityGateRunner проверяет evidence_required по ctx.evidences; шаги вроде explain/review могут добавлять evidence через add_evidence, но в project_qa flow заполнение evidences не прослеживается в коде (collect_state не вызывает retrieval). Т.е. «evidence gate» в смысле «достаточно ли retrieval для ответа» есть только в direct code explain.
|
||||||
|
|
||||||
|
**Evidence.** `app/modules/chat/evidence_gate.py`, `app/modules/agent/engine/orchestrator/quality_gates.py` (_evidence_required), `app/modules/agent/engine/orchestrator/actions/common.py` (add_evidence).
|
||||||
|
|
||||||
|
| Pipeline / graph capability | Current state | Status | Evidence | Notes |
|
||||||
|
|-----------------------------|---------------|--------|----------|--------|
|
||||||
|
| List of graphs by route | Yes | full | router __init__, template_registry | |
|
||||||
|
| Shared orchestration | Yes | full | OrchestratorService, ExecutionEngine, StepRegistry | |
|
||||||
|
| project_qa subgraphs | Yes | full | project_qa_step_graphs, template_registry | |
|
||||||
|
| context_retrieval uses RAG | No | none | project_qa_step_graphs rag_items=[] | |
|
||||||
|
| Fallback default/general | Yes (router) | full | router_service | |
|
||||||
|
| Fallback on weak retrieval | Only direct explain (evidence gate) | partial | evidence_gate, retriever_v2 | |
|
||||||
|
| Evidence sufficiency in orchestrator | Gates есть, evidences в project_qa не наполняются из RAG | partial | quality_gates, step_registry | |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. LLM Context Assembly As Is
|
||||||
|
|
||||||
|
### 6.1. Inputs to LLM
|
||||||
|
|
||||||
|
В основном флоу: message (user_message), rag_context (пустой), confluence_context (из вложений), files_map (из запроса). В state графов дополнительно: resolved_request, question_profile, source_bundle (rag_items + file_candidates; rag_items пустые), при EXPLAIN_PART — explain_pack (entrypoints, trace_paths, code_excerpts, missing). В direct code explain — только user message и ExplainPack (после build_pack и evidence gate).
|
||||||
|
|
||||||
|
**Evidence.** agent/service.py (task_spec, _format_rag), template_registry (metadata), project_qa_step_graphs (state), direct_service.py.
|
||||||
|
|
||||||
|
### 6.2. Context selection logic
|
||||||
|
|
||||||
|
Нет отдельного «context selection» по слоям в основном агенте. В CodeExplainRetrieverV2: порядок C3 → C1 → C2 (traces) → excerpts по путям; при нехватке — lexical C0, затем при необходимости тесты. Ограничение по объёму — в PromptBudgeter при сборке промпта.
|
||||||
|
|
||||||
|
**Evidence.** retriever_v2.py, explain/prompt_budgeter (если есть).
|
||||||
|
|
||||||
|
### 6.3. Prompt construction
|
||||||
|
|
||||||
|
AgentLlmService.generate(key, payload). Ключи: router_intent, code_explain_answer_v2 и др. Промпты загружаются через PromptLoader. Для code_explain_answer_v2 вход формирует PromptBudgeter.build_prompt_input(message, pack).
|
||||||
|
|
||||||
|
**Evidence.** app/modules/agent/llm, project_qa_step_graphs _compose_explain_answer, direct_service.
|
||||||
|
|
||||||
|
### 6.4. Noise control
|
||||||
|
|
||||||
|
Явного «noise control» в основном флоу нет. В CodeExplainRetrieverV2: ограничение числа entrypoints, seeds, trace depth, excerpts; PromptBudgeter ограничивает объём в промпте.
|
||||||
|
|
||||||
|
### 6.5. Handling tests
|
||||||
|
|
||||||
|
exclude_tests в LayeredRetrievalGateway и retrieval; при малом количестве excerpts — _merge_test_fallback с include_tests. В project_qa_support при build_source_bundle для file_candidates/rag_items применяется штраф за test path, если нет explicit_test в terms.
|
||||||
|
|
||||||
|
**Evidence.** layered_gateway (exclude_tests, _filter_args), retriever_v2 _merge_test_fallback, project_qa_support build_source_bundle.
|
||||||
|
|
||||||
|
### 6.6. Current weaknesses
|
||||||
|
|
||||||
|
- Пустой rag_context в основном флоу; контекст по коду только из вложений (files_map) и при сценарии explain — из explain_pack.
|
||||||
|
- Нет intent-driven выбора слоёв и объёмов контекста в оркестраторе.
|
||||||
|
- Prompt-level контракты (имена полей, лимиты) разбросаны по промптам и бюджетеру, не оформлены как единый контракт.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Diagnostics As Is
|
||||||
|
|
||||||
|
### 7.1. Existing diagnostic artifacts
|
||||||
|
|
||||||
|
- В приложении: объект meta в ответе задачи (route, used_rag, used_confluence, orchestrator_steps, changeset_filtered_out и т.д.); при наличии quality — сохраняются через MetricsPersister. События: task_status, task_progress (stage, message, meta), task_result, task_error.
|
||||||
|
- В тестовом пайплайне (IntentRouterV2): router_plan (intent, sub_intent, graph_id, layers, path_scope, keyword_hints, retrieval_constraints), execution (executed_layers, retrieval_mode_by_layer, top_k_by_layer, filters_by_layer, repo_scope), retrieval (requests, applied, fallback), timings_ms (router, retrieval_total, retrieval_by_layer, prompt_build, llm_call), constraint_violations, prompt (prompt_stats, evidence_summary).
|
||||||
|
|
||||||
|
**Evidence.** agent/service.py (AgentResult.meta, _persist_quality_metrics), chat/service.py (task_result, _publish_progress), tests/pipeline_intent_rag/helpers/diagnostics.py.
|
||||||
|
|
||||||
|
### 7.2. Where diagnostics are produced
|
||||||
|
|
||||||
|
- Router: логи (router decision с route, reason, confidence, fallback_used) в agent/service.py.
|
||||||
|
- Графы: логи (graph step result) в project_qa_step_graphs и др.
|
||||||
|
- Оркестратор: логи (orchestrator decision), meta в результате (orchestrator_steps, scenario).
|
||||||
|
- Quality: quality_meta из orchestrator_result.meta, сохраняется в БД при наличии.
|
||||||
|
- IntentRouterV2-диагностика: только в тестах (init_diagnostics, apply_retrieval_report, validate_constraints).
|
||||||
|
|
||||||
|
### 7.3. Human-readable value
|
||||||
|
|
||||||
|
Поле message в task_progress; ответ при evidence gate failure (paths, entrypoints, symbols, missing); meta.route (domain_id, process_id, reason); orchestrator_steps (step_id, status). Для аналитика полезны: сценарий, маршрут, факт использования RAG (сейчас всегда false), количество шагов и их статусы.
|
||||||
|
|
||||||
|
### 7.4. Technical-only value
|
||||||
|
|
||||||
|
timings_ms, executed_layers, retrieval_mode_by_layer, constraint_violations, детали retrieval_spec — в текущем приложении в API не отдаются, только в тестовой диагностике.
|
||||||
|
|
||||||
|
### 7.5. Gaps and overload
|
||||||
|
|
||||||
|
- Пробел: в продакшене нет слоёвой диагностики retrieval (какие слои запрашивались, сколько вернулось, fallback), нет constraint_violations. Слабо видно, почему ответ мог быть слабым (нет привязки к «мало evidence»).
|
||||||
|
- Перегрузка: при желании вывести полную диагностику IntentRouterV2 в API пришлось бы дублировать структуру; сейчас она только для тестов.
|
||||||
|
|
||||||
|
| Diagnostic field / artifact | Produced where | Useful for human | Useful for debug | Notes |
|
||||||
|
|-----------------------------|----------------|------------------|------------------|--------|
|
||||||
|
| task_progress (stage, message) | chat/service, agent run | Yes | Yes | |
|
||||||
|
| meta.route | agent/service | Yes | Yes | |
|
||||||
|
| meta.orchestrator_steps | agent/service | Yes | Yes | |
|
||||||
|
| meta.used_rag | agent/service | Yes | — | Всегда false as is |
|
||||||
|
| quality (persisted) | metrics_persister | Yes | Yes | |
|
||||||
|
| router_plan, execution, retrieval, timings_ms | tests pipeline_intent_rag | — | Yes | Не в API |
|
||||||
|
| constraint_violations | diagnostics.validate_constraints | — | Yes | Только тесты |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Docs Support As Is
|
||||||
|
|
||||||
|
### 8.1. Current docs ingestion / parsing
|
||||||
|
|
||||||
|
DocsIndexingPipeline: поддержка файлов по DocsFileFilter; парсинг frontmatter (YAML), MarkdownDocChunker по body; DocsClassifier по path (doc_kind); build_module_catalog (D1), build_section (D3), build_policy для type=policy (D4), _extract_facts из frontmatter links (D2). Результат пишется в те же rag_chunks с layer D1–D4.
|
||||||
|
|
||||||
|
**Evidence.** `app/modules/rag/indexing/docs/pipeline.py`, file_filter, chunkers, document_builder.
|
||||||
|
|
||||||
|
### 8.2. Current docs retrieval
|
||||||
|
|
||||||
|
Retrieval по слоям D1–D4 возможен через RagRepository.retrieve(..., layers=[...]) и используется в общем retrieval_statement_builder (layer_rank_sql). В основном агенте и в direct code explain путь «только docs» не вызывается; IntentRouterV2 в тестах может задавать retrieval_profile "docs", но в приложении этот путь не задействован.
|
||||||
|
|
||||||
|
### 8.3. Existing cross-domain hooks
|
||||||
|
|
||||||
|
Нет явного слоя или индекса Doc-Code Links. В документах есть frontmatter links; код и доки индексируются в одну БД (rag_chunks), общий запрос по слоям может смешивать CODE и DOCS слои, но выделенной логики «связать док с кодом» нет.
|
||||||
|
|
||||||
|
### 8.4. Readiness for docs generation from code
|
||||||
|
|
||||||
|
Сценарий docs_from_analytics и граф docs/generation есть: шаги fetch_source_doc, normalize_document, extract_change_intents, map_to_doc_tree, load_current_docs_context, generate_doc_updates и т.д. Это рассчитано на генерацию/обновление документации из аналитики/контекста, а не напрямую «из кода» в одну команду. База для генерации доков (текущие доки, структура) частично есть; генерация «из кода» опиралась бы на код-контекст, который в основном флоу сейчас не подтягивается через RAG.
|
||||||
|
|
||||||
|
### 8.5. Main limitations
|
||||||
|
|
||||||
|
- Нет отдельного docs-only retrieval path в рантайме основного приложения.
|
||||||
|
- Нет D0 как общего «document chunks» и нет D5 (Reference Graph), D6 (Doc-Code Links).
|
||||||
|
- Различие code vs docs на уровне retrieval_profile и слоёв реализовано в IntentRouterV2 и в БД, но не в цепочке chat → agent → retrieval.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Gap Analysis vs Target Architecture
|
||||||
|
|
||||||
|
| Target capability | Current state | Gap | Priority | Notes |
|
||||||
|
|------------------|---------------|-----|----------|--------|
|
||||||
|
| Router: intent + sub-intent + graph_id | domain_id/process_id, сценарий по эвристикам | Нет sub_intent, keyword_hints, path_scope, layers в контракте агента | high | IntentRouterV2 есть, не подключён |
|
||||||
|
| Retrieval в основном флоу | Не вызывается; context_retrieval с пустым rag_items | Подключить вызов RAG по запросу и передавать результат в source_bundle | high | RagService не реализует retrieve |
|
||||||
|
| RagRetriever contract | Не реализован | Либо адаптер над RagRepository/LayeredRetrievalGateway, либо вызов gateway из агента | high | |
|
||||||
|
| Intent-driven context assembly | Только в direct explain и build_code_explain_pack | Сборка контекста по intent/layers в оркестраторе | medium | |
|
||||||
|
| Evidence gate в оркестраторе | Только quality gates по артефактам; evidence_required без RAG | Решение: достаточность evidence после retrieval и при отказе — fallback/деградация | medium | |
|
||||||
|
| Слои C5, C6, D5, D6 | Отсутствуют | C5 Test Mappings, C6 Code Facts; D5 Reference Graph, D6 Doc-Code Links | medium | |
|
||||||
|
| Docs retrieval в рантайме | Слои есть в БД, вызова нет | Отдельный путь или единый retrieval с profile docs | medium | |
|
||||||
|
| Диагностика retrieval и constraints | Только в тестах | Вынести router_plan, execution, retrieval, timings, violations в API или лог-артефакт | low | |
|
||||||
|
| README vs реализация | README описывает rag_session как источник retrieval | В основном флоу rag_session по запросу не используется для ответа | high | Уточнить или менять реализацию |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Concrete Evidence Index
|
||||||
|
|
||||||
|
### 10.1. Router
|
||||||
|
|
||||||
|
- `app/modules/agent/engine/router/router_service.py` — RouterService.resolve, persist_context, graph_factory, _fallback, _continue_current, _is_acceptable.
|
||||||
|
- `app/modules/agent/engine/router/intent_classifier.py` — classify_new_intent, from_mode, _deterministic_route, _classify_with_llm, _route_mapping.
|
||||||
|
- `app/modules/agent/engine/router/__init__.py` — build_router_service, регистрация графов в IntentRegistry.
|
||||||
|
- `app/modules/agent/engine/router/schemas.py` — RouteDecision, RouteResolution, RouterContext.
|
||||||
|
- `app/modules/agent/engine/router/intents_registry.yaml` — список допустимых пар (domain_id, process_id) для IntentRegistry.is_valid.
|
||||||
|
|
||||||
|
### 10.2. Retrieval
|
||||||
|
|
||||||
|
- `app/modules/agent/service.py` — rag_ctx = [], task_spec с rag_items/rag_context.
|
||||||
|
- `app/modules/agent/engine/graphs/project_qa_step_graphs.py` — ProjectQaRetrievalGraphFactory._retrieve_context (rag_items=[]), build_source_bundle.
|
||||||
|
- `app/modules/rag/explain/retriever_v2.py` — build_pack, _entrypoints, _seed_symbols, _run_pass, _merge_test_fallback.
|
||||||
|
- `app/modules/rag/explain/layered_gateway.py` — retrieve_layer, retrieve_lexical_code, RagRepository.
|
||||||
|
- `app/modules/rag/persistence/repository.py` — retrieve, retrieve_lexical_code, retrieve_exact_files.
|
||||||
|
- `app/modules/rag/services/rag_service.py` — только index_snapshot/index_changes, нет retrieve.
|
||||||
|
- `app/modules/contracts.py` — RagRetriever (async retrieve).
|
||||||
|
|
||||||
|
### 10.3. Graph orchestration
|
||||||
|
|
||||||
|
- `app/modules/agent/engine/orchestrator/service.py` — OrchestratorService.run, template → compile → validate → engine.run → assemble.
|
||||||
|
- `app/modules/agent/engine/orchestrator/template_registry.py` — ScenarioTemplateRegistry.build, _general, _project_qa, _explain, _review, _docs, _edit, _gherkin.
|
||||||
|
- `app/modules/agent/engine/orchestrator/step_registry.py` — StepRegistry.execute, _execute_graph_step, graph_id, _build_graph_state.
|
||||||
|
- `app/modules/agent/engine/orchestrator/execution_engine.py` — выполнение шагов, quality gates.
|
||||||
|
- `app/modules/agent/engine/graphs/project_qa_step_graphs.py` — все ProjectQa*GraphFactory.
|
||||||
|
|
||||||
|
### 10.4. Diagnostics
|
||||||
|
|
||||||
|
- `app/modules/agent/service.py` — LOGGER.warning (route, orchestrator), AgentResult.meta, _persist_quality_metrics.
|
||||||
|
- `app/modules/chat/service.py` — _publish_progress, task_result event.
|
||||||
|
- `tests/pipeline_intent_rag/helpers/diagnostics.py` — build_router_plan, init_diagnostics, apply_retrieval_report, validate_constraints.
|
||||||
|
|
||||||
|
### 10.5. Docs support
|
||||||
|
|
||||||
|
- `app/modules/rag/indexing/docs/pipeline.py` — DocsIndexingPipeline.index_file, D1–D4.
|
||||||
|
- `app/modules/rag/contracts/enums.py` — RagLayer DOCS_*.
|
||||||
|
- `app/modules/rag/intent_router_v2/router.py` — _resolve_retrieval_profile("docs" для DOCS_QA).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Final Conclusion
|
||||||
|
|
||||||
|
### 11.1. Насколько текущая реализация уже соответствует целевой архитектуре?
|
||||||
|
|
||||||
|
Реализация частично соответствует: есть многослойная индексация (CODE C0–C4, DOCS D1–D4), оркестрация по сценариям и подграфам, роутер по домену/процессу с LLM и эвристиками, задел в виде IntentRouterV2 с полным контрактом (intent, retrieval_spec, evidence_policy) и тестовый пайплайн с диагностикой. При этом основной агентский флоу не вызывает RAG при ответе, контракт RagRetriever не реализован, а IntentRouterV2 и его retrieval не интегрированы в приложение. Целевые слои C5, C6, D5, D6 отсутствуют; evidence gate в смысле «достаточность retrieval» есть только в direct code explain.
|
||||||
|
|
||||||
|
### 11.2. Что логичнее делать следующим шагом?
|
||||||
|
|
||||||
|
Имеет смысл **сначала уточнить документацию (README и целевую модель)** под as is, чтобы зафиксировать два режима (direct code explain vs full agent), факт отсутствия вызова RAG в основном флоу и роль IntentRouterV2 как тестового/перспективного контура. Затем **параллельно** задать приоритеты: (1) подключение retrieval к основному флоу (адаптер или вызов LayeredRetrievalGateway по решению роутера) и (2) решение о том, переносить ли в продакшен контракт IntentRouterV2 (intent, retrieval_spec, layers) или расширять текущий RouterService. Делать только доработку реализации без обновления README рискованно — расхождение описания и кода сохранится.
|
||||||
|
|
||||||
|
### 11.3. Какие 3–5 самых важных расхождений между as is и target есть сейчас?
|
||||||
|
|
||||||
|
1. **Retrieval не вызывается в основном флоу** — context_retrieval не использует индекс; rag_context всегда пустой. Приоритет: high.
|
||||||
|
2. **RagRetriever не реализован** — RagService только индексирует; агент не может получить список документов по запросу через контракт. Приоритет: high.
|
||||||
|
3. **IntentRouterV2 не в продакшене** — intent, sub_intent, retrieval_spec, layers, evidence_policy есть только в тестах; в приложении другой роутер и нет слоёвого retrieval. Приоритет: high.
|
||||||
|
4. **README говорит про retrieval из rag_session для ответа** — по факту для ответа в основном режиме rag_session по запросу не используется. Приоритет: high (документация).
|
||||||
|
5. **Нет evidence gate в оркестраторе** после retrieval — проверка «достаточно ли evidence» и деградация при слабом retrieval только в direct code explain. Приоритет: medium.
|
||||||
380
docs/architecture/contracts_retrieval.json
Normal file
380
docs/architecture/contracts_retrieval.json
Normal file
@@ -0,0 +1,380 @@
|
|||||||
|
{
|
||||||
|
"layers": {
|
||||||
|
"C0_SOURCE_CHUNKS": {
|
||||||
|
"retriever": {
|
||||||
|
"class": "RagService",
|
||||||
|
"file": "app/modules/rag/services/rag_service.py",
|
||||||
|
"method": "retrieve"
|
||||||
|
},
|
||||||
|
"indexer": {
|
||||||
|
"class": "CodeTextDocumentBuilder",
|
||||||
|
"file": "app/modules/rag/indexing/code/code_text/document_builder.py",
|
||||||
|
"method": "build"
|
||||||
|
},
|
||||||
|
"input": {
|
||||||
|
"type": "observed shape",
|
||||||
|
"fields": {
|
||||||
|
"rag_session_id": {
|
||||||
|
"type": "string",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
"query": {
|
||||||
|
"type": "string",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
"layers": {
|
||||||
|
"type": "implicit list[string]",
|
||||||
|
"required": false,
|
||||||
|
"source": "RagQueryRouter.layers_for_mode('code')"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"output": {
|
||||||
|
"type": "list[dict]",
|
||||||
|
"fields": {
|
||||||
|
"source": "string",
|
||||||
|
"content": "string",
|
||||||
|
"layer": "\"C0_SOURCE_CHUNKS\"",
|
||||||
|
"title": "string",
|
||||||
|
"metadata": {
|
||||||
|
"chunk_index": "int",
|
||||||
|
"chunk_type": "\"symbol_block\" | \"window\"",
|
||||||
|
"module_or_unit": "string",
|
||||||
|
"artifact_type": "\"CODE\""
|
||||||
|
},
|
||||||
|
"score": "float | null"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"examples": {
|
||||||
|
"input": {
|
||||||
|
"rag_session_id": "rag-123",
|
||||||
|
"query": "where is implemented get_user"
|
||||||
|
},
|
||||||
|
"output": {
|
||||||
|
"source": "app/api/users.py",
|
||||||
|
"content": "async def get_user(user_id: str):\n service = UserService()\n return service.get_user(user_id)",
|
||||||
|
"layer": "C0_SOURCE_CHUNKS",
|
||||||
|
"title": "app/api/users.py:get_user",
|
||||||
|
"metadata": {
|
||||||
|
"chunk_index": 0,
|
||||||
|
"chunk_type": "symbol_block",
|
||||||
|
"module_or_unit": "app.api.users",
|
||||||
|
"artifact_type": "CODE"
|
||||||
|
},
|
||||||
|
"score": 0.07
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"defaults": {
|
||||||
|
"retrieve_limit": 8,
|
||||||
|
"embed_batch_size_env": "RAG_EMBED_BATCH_SIZE",
|
||||||
|
"embed_batch_size_default": 16,
|
||||||
|
"window_chunk_size_lines": 80,
|
||||||
|
"window_overlap_lines": 15
|
||||||
|
},
|
||||||
|
"limitations": [
|
||||||
|
"Line spans are stored but not returned in the public retrieval item shape.",
|
||||||
|
"No direct path or namespace filter is exposed through the retrieval endpoint."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"C1_SYMBOL_CATALOG": {
|
||||||
|
"retriever": {
|
||||||
|
"class": "RagService",
|
||||||
|
"file": "app/modules/rag/services/rag_service.py",
|
||||||
|
"method": "retrieve"
|
||||||
|
},
|
||||||
|
"indexer": {
|
||||||
|
"class": "SymbolDocumentBuilder",
|
||||||
|
"file": "app/modules/rag/indexing/code/symbols/document_builder.py",
|
||||||
|
"method": "build"
|
||||||
|
},
|
||||||
|
"input": {
|
||||||
|
"type": "observed shape",
|
||||||
|
"fields": {
|
||||||
|
"rag_session_id": {
|
||||||
|
"type": "string",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
"query": {
|
||||||
|
"type": "string",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
"query_term_expansion": {
|
||||||
|
"type": "list[string]",
|
||||||
|
"required": false,
|
||||||
|
"source": "extract_query_terms(query_text)",
|
||||||
|
"max_items": 6
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"output": {
|
||||||
|
"type": "list[dict]",
|
||||||
|
"fields": {
|
||||||
|
"source": "string",
|
||||||
|
"content": "string",
|
||||||
|
"layer": "\"C1_SYMBOL_CATALOG\"",
|
||||||
|
"title": "string",
|
||||||
|
"metadata": {
|
||||||
|
"symbol_id": "string",
|
||||||
|
"qname": "string",
|
||||||
|
"kind": "\"class\" | \"function\" | \"method\" | \"const\"",
|
||||||
|
"signature": "string",
|
||||||
|
"decorators_or_annotations": "list[string]",
|
||||||
|
"docstring_or_javadoc": "string | null",
|
||||||
|
"parent_symbol_id": "string | null",
|
||||||
|
"package_or_module": "string",
|
||||||
|
"is_entry_candidate": "bool",
|
||||||
|
"lang_payload": "object",
|
||||||
|
"artifact_type": "\"CODE\""
|
||||||
|
},
|
||||||
|
"score": "float | null"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"examples": {
|
||||||
|
"input": {
|
||||||
|
"rag_session_id": "rag-123",
|
||||||
|
"query": "where is implemented get_user"
|
||||||
|
},
|
||||||
|
"output": {
|
||||||
|
"source": "app/api/users.py",
|
||||||
|
"content": "function get_user\nget_user(user_id)",
|
||||||
|
"layer": "C1_SYMBOL_CATALOG",
|
||||||
|
"title": "get_user",
|
||||||
|
"metadata": {
|
||||||
|
"symbol_id": "sha256(...)",
|
||||||
|
"qname": "get_user",
|
||||||
|
"kind": "function",
|
||||||
|
"signature": "get_user(user_id)",
|
||||||
|
"decorators_or_annotations": [
|
||||||
|
"router.get"
|
||||||
|
],
|
||||||
|
"docstring_or_javadoc": null,
|
||||||
|
"parent_symbol_id": null,
|
||||||
|
"package_or_module": "app.api.users",
|
||||||
|
"is_entry_candidate": true,
|
||||||
|
"lang_payload": {
|
||||||
|
"async": true
|
||||||
|
},
|
||||||
|
"artifact_type": "CODE"
|
||||||
|
},
|
||||||
|
"score": 0.07
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"defaults": {
|
||||||
|
"retrieve_limit": 8,
|
||||||
|
"layer_rank": 1
|
||||||
|
},
|
||||||
|
"limitations": [
|
||||||
|
"Only Python AST symbols are indexed.",
|
||||||
|
"Cross-file resolution is not implemented.",
|
||||||
|
"parent_symbol_id is an observed qname-like value, not guaranteed to be a symbol hash."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"C2_DEPENDENCY_GRAPH": {
|
||||||
|
"retriever": {
|
||||||
|
"class": "RagService",
|
||||||
|
"file": "app/modules/rag/services/rag_service.py",
|
||||||
|
"method": "retrieve"
|
||||||
|
},
|
||||||
|
"indexer": {
|
||||||
|
"class": "EdgeDocumentBuilder",
|
||||||
|
"file": "app/modules/rag/indexing/code/edges/document_builder.py",
|
||||||
|
"method": "build"
|
||||||
|
},
|
||||||
|
"input": {
|
||||||
|
"type": "observed shape",
|
||||||
|
"fields": {
|
||||||
|
"rag_session_id": {
|
||||||
|
"type": "string",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
"query": {
|
||||||
|
"type": "string",
|
||||||
|
"required": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"output": {
|
||||||
|
"type": "list[dict]",
|
||||||
|
"fields": {
|
||||||
|
"source": "string",
|
||||||
|
"content": "string",
|
||||||
|
"layer": "\"C2_DEPENDENCY_GRAPH\"",
|
||||||
|
"title": "string",
|
||||||
|
"metadata": {
|
||||||
|
"edge_id": "string",
|
||||||
|
"edge_type": "\"calls\" | \"imports\" | \"inherits\"",
|
||||||
|
"src_symbol_id": "string",
|
||||||
|
"src_qname": "string",
|
||||||
|
"dst_symbol_id": "string | null",
|
||||||
|
"dst_ref": "string | null",
|
||||||
|
"resolution": "\"resolved\" | \"partial\"",
|
||||||
|
"lang_payload": "object",
|
||||||
|
"artifact_type": "\"CODE\""
|
||||||
|
},
|
||||||
|
"score": "float | null"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"examples": {
|
||||||
|
"input": {
|
||||||
|
"rag_session_id": "rag-123",
|
||||||
|
"query": "how get_user calls service"
|
||||||
|
},
|
||||||
|
"output": {
|
||||||
|
"source": "app/api/users.py",
|
||||||
|
"content": "get_user calls UserService",
|
||||||
|
"layer": "C2_DEPENDENCY_GRAPH",
|
||||||
|
"title": "get_user:calls",
|
||||||
|
"metadata": {
|
||||||
|
"edge_id": "sha256(...)",
|
||||||
|
"edge_type": "calls",
|
||||||
|
"src_symbol_id": "sha256(...)",
|
||||||
|
"src_qname": "get_user",
|
||||||
|
"dst_symbol_id": null,
|
||||||
|
"dst_ref": "UserService",
|
||||||
|
"resolution": "partial",
|
||||||
|
"lang_payload": {
|
||||||
|
"callsite_kind": "function_call"
|
||||||
|
},
|
||||||
|
"artifact_type": "CODE"
|
||||||
|
},
|
||||||
|
"score": 0.11
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"defaults": {
|
||||||
|
"retrieve_limit": 8,
|
||||||
|
"layer_rank": 2,
|
||||||
|
"graph_build_mode": "static_python_ast"
|
||||||
|
},
|
||||||
|
"limitations": [
|
||||||
|
"No traversal API exists.",
|
||||||
|
"Edges are stored as retrievable rows, not as a graph-native store.",
|
||||||
|
"Destination resolution is local to one indexed file."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"C3_ENTRYPOINTS": {
|
||||||
|
"retriever": {
|
||||||
|
"class": "RagService",
|
||||||
|
"file": "app/modules/rag/services/rag_service.py",
|
||||||
|
"method": "retrieve"
|
||||||
|
},
|
||||||
|
"indexer": {
|
||||||
|
"class": "EntrypointDocumentBuilder",
|
||||||
|
"file": "app/modules/rag/indexing/code/entrypoints/document_builder.py",
|
||||||
|
"method": "build"
|
||||||
|
},
|
||||||
|
"input": {
|
||||||
|
"type": "observed shape",
|
||||||
|
"fields": {
|
||||||
|
"rag_session_id": {
|
||||||
|
"type": "string",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
"query": {
|
||||||
|
"type": "string",
|
||||||
|
"required": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"output": {
|
||||||
|
"type": "list[dict]",
|
||||||
|
"fields": {
|
||||||
|
"source": "string",
|
||||||
|
"content": "string",
|
||||||
|
"layer": "\"C3_ENTRYPOINTS\"",
|
||||||
|
"title": "string",
|
||||||
|
"metadata": {
|
||||||
|
"entry_id": "string",
|
||||||
|
"entry_type": "\"http\" | \"cli\"",
|
||||||
|
"framework": "\"fastapi\" | \"flask\" | \"typer\" | \"click\"",
|
||||||
|
"route_or_command": "string",
|
||||||
|
"handler_symbol_id": "string",
|
||||||
|
"lang_payload": "object",
|
||||||
|
"artifact_type": "\"CODE\""
|
||||||
|
},
|
||||||
|
"score": "float | null"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"examples": {
|
||||||
|
"input": {
|
||||||
|
"rag_session_id": "rag-123",
|
||||||
|
"query": "which endpoint handles get user"
|
||||||
|
},
|
||||||
|
"output": {
|
||||||
|
"source": "app/api/users.py",
|
||||||
|
"content": "fastapi http \"/users/{user_id}\"",
|
||||||
|
"layer": "C3_ENTRYPOINTS",
|
||||||
|
"title": "\"/users/{user_id}\"",
|
||||||
|
"metadata": {
|
||||||
|
"entry_id": "sha256(...)",
|
||||||
|
"entry_type": "http",
|
||||||
|
"framework": "fastapi",
|
||||||
|
"route_or_command": "\"/users/{user_id}\"",
|
||||||
|
"handler_symbol_id": "sha256(...)",
|
||||||
|
"lang_payload": {
|
||||||
|
"methods": [
|
||||||
|
"GET"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"artifact_type": "CODE"
|
||||||
|
},
|
||||||
|
"score": 0.05
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"defaults": {
|
||||||
|
"retrieve_limit": 8,
|
||||||
|
"layer_rank": 0
|
||||||
|
},
|
||||||
|
"limitations": [
|
||||||
|
"Detection is decorator-string based.",
|
||||||
|
"No Django, Celery, RQ, or cron entrypoints were found.",
|
||||||
|
"Returned payload does not expose line spans."
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"retrieval_endpoint": {
|
||||||
|
"entrypoint": {
|
||||||
|
"file": "app/modules/rag_session/module.py",
|
||||||
|
"method": "internal_router.retrieve"
|
||||||
|
},
|
||||||
|
"request": {
|
||||||
|
"type": "dict",
|
||||||
|
"fields": {
|
||||||
|
"rag_session_id": "string | optional if project_id provided",
|
||||||
|
"project_id": "string | optional fallback for rag_session_id",
|
||||||
|
"query": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"type": "dict",
|
||||||
|
"fields": {
|
||||||
|
"items": "list[retrieval item]"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"defaults": {
|
||||||
|
"mode": "docs unless RagQueryRouter detects code hints",
|
||||||
|
"limit": 8,
|
||||||
|
"embedding_provider": "GigaChat embeddings",
|
||||||
|
"fallback_after_embedding_error": true,
|
||||||
|
"fallback_to_docs_when_code_empty": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ranking": {
|
||||||
|
"storage": "PostgreSQL rag_chunks + pgvector",
|
||||||
|
"query_repository": {
|
||||||
|
"class": "RagQueryRepository",
|
||||||
|
"file": "app/modules/rag/persistence/query_repository.py",
|
||||||
|
"method": "retrieve"
|
||||||
|
},
|
||||||
|
"order_by": [
|
||||||
|
"lexical_rank ASC",
|
||||||
|
"test_penalty ASC",
|
||||||
|
"layer_rank ASC",
|
||||||
|
"embedding <=> query_embedding ASC"
|
||||||
|
],
|
||||||
|
"notes": [
|
||||||
|
"lexical_rank is derived from qname/symbol_id/title/path/content matching extracted query terms",
|
||||||
|
"test_penalty is applied only when prefer_non_tests=true",
|
||||||
|
"layer priority is C3 > C1 > C2 > C0 for code retrieval"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
270
docs/architecture/llm_inventory.md
Normal file
270
docs/architecture/llm_inventory.md
Normal file
@@ -0,0 +1,270 @@
|
|||||||
|
# LLM Inventory
|
||||||
|
|
||||||
|
## Provider and SDK
|
||||||
|
|
||||||
|
- Provider in code: GigaChat / Sber
|
||||||
|
- Local SDK style: custom thin HTTP client over `requests`
|
||||||
|
- Core files:
|
||||||
|
- `app/modules/shared/gigachat/client.py`
|
||||||
|
- `app/modules/shared/gigachat/settings.py`
|
||||||
|
- `app/modules/shared/gigachat/token_provider.py`
|
||||||
|
- `app/modules/agent/llm/service.py`
|
||||||
|
|
||||||
|
There is no OpenAI SDK, Azure SDK, or local model runtime in the current implementation.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Model and endpoint configuration are read from environment in `GigaChatSettings.from_env()`:
|
||||||
|
|
||||||
|
- `GIGACHAT_AUTH_URL`
|
||||||
|
- default: `https://ngw.devices.sberbank.ru:9443/api/v2/oauth`
|
||||||
|
- `GIGACHAT_API_URL`
|
||||||
|
- default: `https://gigachat.devices.sberbank.ru/api/v1`
|
||||||
|
- `GIGACHAT_SCOPE`
|
||||||
|
- default: `GIGACHAT_API_PERS`
|
||||||
|
- `GIGACHAT_TOKEN`
|
||||||
|
- required for auth
|
||||||
|
- `GIGACHAT_SSL_VERIFY`
|
||||||
|
- default: `true`
|
||||||
|
- `GIGACHAT_MODEL`
|
||||||
|
- default: `GigaChat`
|
||||||
|
- `GIGACHAT_EMBEDDING_MODEL`
|
||||||
|
- default: `Embeddings`
|
||||||
|
- `AGENT_PROMPTS_DIR`
|
||||||
|
- optional prompt directory override
|
||||||
|
|
||||||
|
PostgreSQL config for retrieval storage is separate:
|
||||||
|
|
||||||
|
- `DATABASE_URL`
|
||||||
|
- default: `postgresql+psycopg://agent:agent@db:5432/agent`
|
||||||
|
|
||||||
|
## Default models
|
||||||
|
|
||||||
|
- Chat/completions model default: `GigaChat`
|
||||||
|
- Embedding model default: `Embeddings`
|
||||||
|
|
||||||
|
## Completion payload
|
||||||
|
|
||||||
|
Observed payload sent by `GigaChatClient.complete(...)`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"model": "GigaChat",
|
||||||
|
"messages": [
|
||||||
|
{"role": "system", "content": "<prompt template text>"},
|
||||||
|
{"role": "user", "content": "<runtime user input>"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Endpoint:
|
||||||
|
|
||||||
|
- `POST {GIGACHAT_API_URL}/chat/completions`
|
||||||
|
|
||||||
|
Observed response handling:
|
||||||
|
|
||||||
|
- reads `choices[0].message.content`
|
||||||
|
- if no choices: returns empty string
|
||||||
|
|
||||||
|
## Embeddings payload
|
||||||
|
|
||||||
|
Observed payload sent by `GigaChatClient.embed(...)`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"model": "Embeddings",
|
||||||
|
"input": [
|
||||||
|
"<text1>",
|
||||||
|
"<text2>"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Endpoint:
|
||||||
|
|
||||||
|
- `POST {GIGACHAT_API_URL}/embeddings`
|
||||||
|
|
||||||
|
Observed response handling:
|
||||||
|
|
||||||
|
- expects `data` list
|
||||||
|
- maps each `item.embedding` to `list[float]`
|
||||||
|
|
||||||
|
## Parameters
|
||||||
|
|
||||||
|
### Explicitly implemented
|
||||||
|
|
||||||
|
- `model`
|
||||||
|
- `messages`
|
||||||
|
- `input`
|
||||||
|
- HTTP timeout:
|
||||||
|
- completions: `90s`
|
||||||
|
- embeddings: `90s`
|
||||||
|
- auth: `30s`
|
||||||
|
- TLS verification flag:
|
||||||
|
- `verify=settings.ssl_verify`
|
||||||
|
|
||||||
|
### Not implemented in payload
|
||||||
|
|
||||||
|
- `temperature`
|
||||||
|
- `top_p`
|
||||||
|
- `max_tokens`
|
||||||
|
- `response_format`
|
||||||
|
- tools/function calling
|
||||||
|
- streaming
|
||||||
|
- seed
|
||||||
|
- stop sequences
|
||||||
|
|
||||||
|
`ASSUMPTION:` the service uses provider defaults for sampling and output length because these fields are not sent in the request payload.
|
||||||
|
|
||||||
|
## Context and budget limits
|
||||||
|
|
||||||
|
There is no centralized token budget manager in the current code.
|
||||||
|
|
||||||
|
Observed practical limits instead:
|
||||||
|
|
||||||
|
- prompt file text is loaded as-is from disk
|
||||||
|
- user input is passed as-is
|
||||||
|
- RAG context shaping happens outside the LLM client
|
||||||
|
- docs indexing summary truncation:
|
||||||
|
- docs module catalog summary: `4000` chars
|
||||||
|
- docs policy text: `4000` chars
|
||||||
|
- project QA source bundle caps:
|
||||||
|
- top `12` rag items
|
||||||
|
- top `10` file candidates
|
||||||
|
- logging truncation only:
|
||||||
|
- LLM input/output logs capped at `1500` chars for logs
|
||||||
|
|
||||||
|
`ASSUMPTION:` there is no explicit max-context enforcement before chat completion requests. The current system relies on upstream graph logic to keep inputs small enough.
|
||||||
|
|
||||||
|
## Retry, backoff, timeout
|
||||||
|
|
||||||
|
### Timeouts
|
||||||
|
|
||||||
|
- auth: `30s`
|
||||||
|
- chat completion: `90s`
|
||||||
|
- embeddings: `90s`
|
||||||
|
|
||||||
|
### Retry
|
||||||
|
|
||||||
|
- Generic async retry wrapper exists in `app/modules/shared/retry_executor.py`
|
||||||
|
- It retries only:
|
||||||
|
- `TimeoutError`
|
||||||
|
- `ConnectionError`
|
||||||
|
- `OSError`
|
||||||
|
- Retry constants:
|
||||||
|
- `MAX_RETRIES = 5`
|
||||||
|
- backoff: `0.1 * attempt` seconds
|
||||||
|
|
||||||
|
### Important current limitation
|
||||||
|
|
||||||
|
- `GigaChatClient` raises `GigaChatError` on HTTP and request failures.
|
||||||
|
- `RetryExecutor` does not catch `GigaChatError`.
|
||||||
|
- Result: LLM and embeddings calls are effectively not retried by this generic retry helper unless errors are converted upstream.
|
||||||
|
|
||||||
|
## Prompt formation
|
||||||
|
|
||||||
|
Prompt loading is handled by `PromptLoader`:
|
||||||
|
|
||||||
|
- base dir: `app/modules/agent/prompts`
|
||||||
|
- override: `AGENT_PROMPTS_DIR`
|
||||||
|
- file naming convention: `<prompt_name>.txt`
|
||||||
|
|
||||||
|
Prompt composition model today:
|
||||||
|
|
||||||
|
- system prompt:
|
||||||
|
- full contents of selected prompt file
|
||||||
|
- user prompt:
|
||||||
|
- raw runtime input string passed by the caller
|
||||||
|
- no separate developer prompt layer in the application payload
|
||||||
|
|
||||||
|
If a prompt file is missing:
|
||||||
|
|
||||||
|
- fallback system prompt: `You are a helpful assistant.`
|
||||||
|
|
||||||
|
## Prompt templates present
|
||||||
|
|
||||||
|
- `router_intent`
|
||||||
|
- `general_answer`
|
||||||
|
- `project_answer`
|
||||||
|
- `docs_detect`
|
||||||
|
- `docs_strategy`
|
||||||
|
- `docs_plan_sections`
|
||||||
|
- `docs_generation`
|
||||||
|
- `docs_self_check`
|
||||||
|
- `docs_execution_summary`
|
||||||
|
- `project_edits_plan`
|
||||||
|
- `project_edits_hunks`
|
||||||
|
- `project_edits_self_check`
|
||||||
|
|
||||||
|
## Key LLM call entrypoints
|
||||||
|
|
||||||
|
### Composition roots
|
||||||
|
|
||||||
|
- `app/modules/agent/module.py`
|
||||||
|
- builds `GigaChatSettings`
|
||||||
|
- builds `GigaChatTokenProvider`
|
||||||
|
- builds `GigaChatClient`
|
||||||
|
- builds `PromptLoader`
|
||||||
|
- builds `AgentLlmService`
|
||||||
|
- `app/modules/rag_session/module.py`
|
||||||
|
- builds the same provider stack for embeddings used by RAG
|
||||||
|
|
||||||
|
### Main abstraction
|
||||||
|
|
||||||
|
- `AgentLlmService.generate(prompt_name, user_input, log_context=None)`
|
||||||
|
|
||||||
|
### Current generate callsites
|
||||||
|
|
||||||
|
- `app/modules/agent/engine/router/intent_classifier.py`
|
||||||
|
- `router_intent`
|
||||||
|
- `app/modules/agent/engine/graphs/base_graph.py`
|
||||||
|
- `general_answer`
|
||||||
|
- `app/modules/agent/engine/graphs/project_qa_graph.py`
|
||||||
|
- `project_answer`
|
||||||
|
- `app/modules/agent/engine/graphs/docs_graph_logic.py`
|
||||||
|
- `docs_detect`
|
||||||
|
- `docs_strategy`
|
||||||
|
- `docs_plan_sections`
|
||||||
|
- `docs_generation`
|
||||||
|
- `docs_self_check`
|
||||||
|
- `docs_execution_summary`-like usage via summary step
|
||||||
|
- `app/modules/agent/engine/graphs/project_edits_logic.py`
|
||||||
|
- `project_edits_plan`
|
||||||
|
- `project_edits_self_check`
|
||||||
|
- `project_edits_hunks`
|
||||||
|
|
||||||
|
## Logging and observability
|
||||||
|
|
||||||
|
`AgentLlmService` logs:
|
||||||
|
|
||||||
|
- input:
|
||||||
|
- `graph llm input: context=... prompt=... user_input=...`
|
||||||
|
- output:
|
||||||
|
- `graph llm output: context=... prompt=... output=...`
|
||||||
|
|
||||||
|
Log truncation:
|
||||||
|
|
||||||
|
- 1500 chars
|
||||||
|
|
||||||
|
RAG retrieval logs separately in `RagService`, but without embedding vectors.
|
||||||
|
|
||||||
|
## Integration with retrieval
|
||||||
|
|
||||||
|
There are two distinct GigaChat usages:
|
||||||
|
|
||||||
|
1. Chat/completion path for agent reasoning and generation
|
||||||
|
2. Embedding path for RAG indexing and retrieval
|
||||||
|
|
||||||
|
The embedding adapter is `GigaChatEmbedder`, used by:
|
||||||
|
|
||||||
|
- `app/modules/rag/services/rag_service.py`
|
||||||
|
|
||||||
|
## Notable limitations
|
||||||
|
|
||||||
|
- Single provider coupling: chat and embeddings both depend on GigaChat-specific endpoints.
|
||||||
|
- No model routing by scenario.
|
||||||
|
- No tool/function calling.
|
||||||
|
- No centralized prompt token budgeting.
|
||||||
|
- No explicit retry for `GigaChatError`.
|
||||||
|
- No streaming completions.
|
||||||
|
- No structured response mode beyond prompt conventions and downstream parsing.
|
||||||
13
docs/architecture/rag_chunks_column_audit.md
Normal file
13
docs/architecture/rag_chunks_column_audit.md
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
| column | used_by | safe_to_drop | notes |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `layer` | `USED_BY_CODE_V2`, `USED_BY_DOCS_INDEXING` | no | Core selector for C0-C3 and D1-D4 queries. |
|
||||||
|
| `title` | `USED_BY_CODE_V2`, `USED_BY_DOCS_INDEXING` | no | Used in lexical ranking and prompt evidence labels. |
|
||||||
|
| `metadata_json` | `USED_BY_CODE_V2`, `USED_BY_DOCS_INDEXING` | no | C2/C0 graph lookups and docs metadata depend on it. |
|
||||||
|
| `span_start`, `span_end` | `USED_BY_CODE_V2` | no | Needed for symbol-to-chunk resolution and locations. |
|
||||||
|
| `symbol_id`, `qname`, `kind`, `lang` | `USED_BY_CODE_V2` | no | Used by code indexing, ranking, trace building, and diagnostics. |
|
||||||
|
| `repo_id`, `commit_sha` | `USED_BY_CODE_V2`, `USED_BY_DOCS_INDEXING` | no | Used by indexing/cache and retained for provenance. |
|
||||||
|
| `entrypoint_type`, `framework` | `USED_BY_CODE_V2` | no | Used by C3 filtering and entrypoint diagnostics. |
|
||||||
|
| `doc_kind`, `module_id`, `section_path` | `USED_BY_DOCS_INDEXING` | no | Still written by docs indexing and covered by docs tests. |
|
||||||
|
| `artifact_type`, `section`, `doc_version`, `owner`, `system_component`, `last_modified`, `staleness_score` | `USED_BY_DOCS_INDEXING` | no | File metadata still flows through indexing/cache; left intact for now. |
|
||||||
|
| `rag_doc_id` | `UNUSED` | yes | Written into `rag_chunks` only; no reads in runtime/indexing code. |
|
||||||
|
| `links_json` | `UNUSED` | yes | Stored in `rag_chunks` only; reads exist for `rag_chunk_cache`, not `rag_chunks`. |
|
||||||
31
docs/architecture/retrieval_callgraph.mmd
Normal file
31
docs/architecture/retrieval_callgraph.mmd
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
flowchart TD
|
||||||
|
A["HTTP: POST /internal/rag/retrieve"] --> B["RagModule.internal_router.retrieve(payload)"]
|
||||||
|
B --> C["RagService.retrieve(rag_session_id, query)"]
|
||||||
|
C --> D["RagQueryRouter.resolve_mode(query)"]
|
||||||
|
D --> E["RagQueryRouter.layers_for_mode(mode)"]
|
||||||
|
C --> F["GigaChatEmbedder.embed([query])"]
|
||||||
|
F --> G["GigaChatClient.embed(payload)"]
|
||||||
|
G --> H["POST /embeddings"]
|
||||||
|
C --> I["RagRepository.retrieve(...)"]
|
||||||
|
I --> J["RagQueryRepository.retrieve(...)"]
|
||||||
|
J --> K["PostgreSQL rag_chunks + pgvector"]
|
||||||
|
K --> L["ORDER BY lexical_rank, test_penalty, layer_rank, vector distance"]
|
||||||
|
L --> M["rows: path/content/layer/title/metadata/span/distance"]
|
||||||
|
M --> N["normalize to {source, content, layer, title, metadata, score}"]
|
||||||
|
N --> O["response: {items: [...]}"]
|
||||||
|
|
||||||
|
C --> P["embedding error?"]
|
||||||
|
P -->|yes| Q["RagRepository.fallback_chunks(...)"]
|
||||||
|
Q --> R["latest rows by id DESC"]
|
||||||
|
R --> N
|
||||||
|
|
||||||
|
C --> S["no rows and mode != docs?"]
|
||||||
|
S -->|yes| T["fallback to docs layers"]
|
||||||
|
T --> I
|
||||||
|
|
||||||
|
U["GraphAgentRuntime for project/qa"] --> V["ProjectQaRetrievalGraphFactory._retrieve_context"]
|
||||||
|
V --> C
|
||||||
|
V --> W["ProjectQaSupport.build_source_bundle(...)"]
|
||||||
|
W --> X["source_bundle"]
|
||||||
|
X --> Y["context_analysis"]
|
||||||
|
Y --> Z["answer_composition"]
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user