Compare commits

...

7 Commits

Author SHA1 Message Date
095d354112 Фикс состояния 2026-03-12 16:55:30 +03:00
6ba0a18ac9 Фикс состояния 2026-03-12 16:55:23 +03:00
417b8b6f72 Фиксация изменений 2026-03-05 11:03:17 +03:00
1ef0b4d68c Новый раг 2026-03-01 14:21:33 +03:00
2728c07ba9 Обновил readme.md 2026-02-28 09:37:33 +03:00
9f1c67a751 chore: remove .env from tracking 2026-02-27 21:30:22 +03:00
e8805ffe29 первый коммит 2026-02-27 21:28:09 +03:00
1268 changed files with 636269 additions and 1302 deletions

3
.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
.env
.venv
__pycache__

271
README.DB.STORY_PLAN.md Normal file
View 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.

1262
README.md

File diff suppressed because it is too large Load Diff

161
README_old.md Normal file
View 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.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,10 +0,0 @@
Ты вносишь правку в один файл по запросу пользователя.
На вход приходит JSON с request, path, reason, current_content, previous_validation_feedback, rag_context, confluence_context.
Верни только полное итоговое содержимое файла (без JSON).
Критичные правила:
- Измени только те части, которые нужны по запросу.
- Не переписывай файл целиком без необходимости.
- Сохрани структуру, стиль и все нерелевантные разделы без изменений.
- Если данных недостаточно, внеси минимально безопасную правку и явно отрази ограничение в тексте файла.

View File

@@ -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 указан конкретный файл, включи его в первую очередь.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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
View 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 (C0C4: chunks, symbols, edges, entrypoints, traces, semantic roles) и DocsIndexingPipeline (D1D4: 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 C0C4, DOCS D1D4), есть задел 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 поддерживают слои D1D4 в запросах (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` (D1D4 в 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 D1D4.
**Evidence.** `app/modules/rag/indexing/docs/pipeline.py`, file_filter, chunkers, document_builder.
### 8.2. Current docs retrieval
Retrieval по слоям D1D4 возможен через 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, D1D4.
- `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 C0C4, DOCS D1D4), оркестрация по сценариям и подграфам, роутер по домену/процессу с 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. Какие 35 самых важных расхождений между 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.

View 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"
]
}
}

View 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.

View 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`. |

View 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