This commit is contained in:
2026-03-27 15:51:10 +03:00
parent 15586f9a8c
commit 0bff171936
1245 changed files with 99621 additions and 543076 deletions
+14
View File
@@ -0,0 +1,14 @@
# Analysis Assets
Этот каталог содержит служебные артефакты для аналитической и генеративной работы агента.
## Структура
- `rules/` — правила построения документации, frontmatter и шаблоны документов.
## Назначение
Каталог `.analysis/` отделен от `docs/`, чтобы:
- хранить служебные policy- и template-материалы вне пользовательской документации;
- передавать правила в LLM как отдельный policy-context;
- не смешивать документацию проекта и внутренние артефакты анализа.
+32
View File
@@ -0,0 +1,32 @@
# Documentation Rules
## Назначение
Этот файл фиксирует общие правила формирования, обновления и поддержки технической документации проекта.
Документация проекта должна создаваться как система атомарных, связанных между собой документов, пригодных:
- для чтения человеком;
- для сопровождения командой;
- для индексирования в RAG;
- для автоматического обновления агентом на основе кода и существующих артефактов.
Этот файл задает:
- общие принципы документационной архитектуры;
- правила декомпозиции документации;
- правила размещения файлов;
- требования к связям между документами;
- требования к качеству markdown-документов;
- правила генерации и обновления документации агентом.
Детальные шаблоны документов и правила frontmatter описываются отдельно:
- `.analysis/rules/frontmatter-rules.md`
- `.analysis/rules/templates/*.md`
---
## Область действия
Правила из этого файла применяются ко всей проектной документации, размещаемой в:
```text
docs/documentation/
+60
View File
@@ -0,0 +1,60 @@
# Frontmatter Rules
## Назначение
Этот файл фиксирует правила YAML frontmatter для документов в `docs/documentation/`.
Frontmatter обязателен для каждого markdown-документа и нужен для:
- идентификации документа;
- определения типа документа;
- фиксации связей с кодом и другими документами;
- выделения сущностей, тегов и домена;
- поддержки индексирования в RAG.
Общие правила построения документации описаны в:
- `.analysis/rules/documentation-rules.md`
Шаблоны markdown body описаны в:
- `.analysis/rules/templates/*.md`
---
## Общие правила
1. Frontmatter размещается в начале файла.
2. Формат — YAML между двумя строками `---`.
3. Все документы в `docs/documentation/` должны содержать frontmatter.
4. Поля должны быть стабильными и заполняться единообразно.
5. Не использовать произвольные поля без необходимости.
6. Если значение неизвестно и его нельзя уверенно вывести из evidence, поле лучше не заполнять, кроме обязательных полей.
7. Списковые поля должны оформляться как YAML-массивы.
8. Идентификаторы и ссылки должны быть стабильными и пригодными для машинной обработки.
---
## Базовый frontmatter
Каждый документ должен начинаться с frontmatter вида:
```yaml
---
id: api-orders-create
title: Метод создания заказа
doc_type: api_method
domain: orders
status: draft
owner: system-analyst
source_of_truth: code
related_docs:
- ui-order-create-page
- logic-order-validation
related_code:
- src/orders/api/create_order.py
entities:
- Order
- CreateOrder
tags:
- api
- orders
- create
---
@@ -0,0 +1,29 @@
# Rule: Use Document Templates From Fixed Paths
Агент должен создавать и обновлять техническую документацию только с опорой на шаблоны документов, расположенные в `.analysis/rules`.
Если агент формирует новый документ, он обязан:
- определить тип документа;
- выбрать соответствующий шаблон по фиксированному пути;
- сохранить структуру секций и базовых метаданных из шаблона;
- заполнять только те секции, которые подтверждены кодом и артефактами;
- не придумывать новые произвольные форматы, если для типа уже существует шаблон.
Пути к базовым шаблонам:
- `.analysis/rules/legacy/template_ui_page.md`
- `.analysis/rules/legacy/template_api_method.md`
- `.analysis/rules/legacy/template_logic_block.md`
Правило выбора шаблона:
- для документа типа `ui_page` использовать `.analysis/rules/legacy/template_ui_page.md`
- для документа типа `api_method` использовать `.analysis/rules/legacy/template_api_method.md`
- для документа типа `logic_block` использовать `.analysis/rules/legacy/template_logic_block.md`
Если для нужного типа шаблон отсутствует, агент должен:
1. использовать ближайший подходящий существующий шаблон как временную основу;
2. явно сохранить тип документа в `YAML frontmatter`;
3. не смешивать в одном документе несколько независимых сущностей.
@@ -0,0 +1,89 @@
# Template: api_method
```md
---
id: api-<stable-id>
title: <Human-readable title>
doc_type: api_method
status: draft
source_of_truth: code
domain: <domain-name>
owner: system-analyst
endpoint: <METHOD /path>
auth: <auth-mode-or-unknown>
idempotent: <true-or-false>
related_docs:
- <doc-id>
related_code:
- <path/to/file>
entities:
- <EntityName>
tags:
- api
---
# <API Method Title>
## Purpose
Кратко опиши, какую системную задачу решает метод.
## Endpoint Summary
- Endpoint: `<METHOD /path>`
- Auth: `<auth-mode>`
- Idempotent: `<true/false>`
- Triggered by: `<ui/system/integration if known>`
## Technical Use Case
Опиши пошагово обработку запроса:
- вход в endpoint;
- ключевые проверки;
- вызовы логики;
- обращения к БД и внешним системам;
- формирование ответа.
## Functional Requirements
Вынеси сюда подтвержденные правила, которые дополняют основной сценарий:
- валидации;
- branching logic;
- побочные эффекты;
- ограничения по данным;
- условия ошибок.
## Request and Response Contract
Опиши контракт в кратком виде или дай ссылку на OpenAPI / контрактный файл.
## Related Logic Blocks
- [<Logic block title>](<path-or-doc-link>)
## Data Access and Integrations
- Reads DB: `<if known>`
- Writes DB: `<if known>`
- Integrates with: `<if known>`
## Non-Functional Requirements
Укажи только подтвержденные НФТ:
- timeout;
- audit;
- monitoring;
- security;
- idempotency rules.
## Related Code
- `<path/to/file>`
## Related Documents
- [<Related document>](<path-or-doc-link>)
```
@@ -0,0 +1,71 @@
# Template: logic_block
```md
---
id: logic-<stable-id>
title: <Human-readable title>
doc_type: logic_block
status: draft
source_of_truth: code
domain: <domain-name>
owner: system-analyst
related_docs:
- <doc-id>
related_code:
- <path/to/file>
entities:
- <EntityName>
tags:
- logic
---
# <Logic Block Title>
## Purpose
Кратко опиши, какую переиспользуемую или устойчивую логику реализует блок.
## Where Used
- Called from: `<ui/api/jobs/services if known>`
- Used by: `<list of known callers>`
## Technical Use Case
Опиши пошагово, как работает логический блок:
- входные данные;
- ключевые проверки;
- преобразования;
- обращения к данным;
- результат работы.
## Functional Requirements
Вынеси сюда устойчивые правила и ограничения:
- бизнес-правила;
- проверки;
- ветвления;
- ограничения на вход и выход;
- условия отказа.
## Dependencies
- Uses logic: `<other logic blocks if known>`
- Reads DB: `<if known>`
- Writes DB: `<if known>`
- Integrates with: `<if known>`
## Error Cases
Опиши значимые ошибки и условия их возникновения, если они подтверждены кодом.
## Related Code
- `<path/to/file>`
## Related Documents
- [<Related document>](<path-or-doc-link>)
```
@@ -0,0 +1,82 @@
# Template: ui_page
```md
---
id: ui-<stable-id>
title: <Human-readable title>
doc_type: ui_page
status: draft
source_of_truth: code
domain: <domain-name>
owner: system-analyst
related_docs:
- <doc-id>
related_code:
- <path/to/file>
entities:
- <EntityName>
tags:
- ui
---
# <Page Title>
## Purpose
Кратко опиши, какую пользовательскую задачу решает страница.
## Route and Entry Points
- Route: `<route-if-known>`
- Entry points: `<where user comes from>`
## Technical Use Case
Опиши пошаговый сценарий работы страницы как поток действий и системных реакций.
## UI Structure
Перечисли основные UI-элементы и для каждого укажи:
- назначение;
- источник данных;
- значение по умолчанию или placeholder;
- условия доступности или активации;
- поведение при взаимодействии;
- правила валидации.
## Functional Requirements
Вынеси сюда детальные правила, которые не стоит перегружать в use case:
- вызовы API;
- обработку ответов;
- локальные правила отображения;
- условия переходов;
- feature toggles.
## Related APIs
- [<API document title>](<path-or-doc-link>)
## Related Logic Blocks
- [<Logic block title>](<path-or-doc-link>)
## Non-Functional Requirements
Укажи НФТ, если они подтверждены:
- analytics events;
- observability;
- feature toggles;
- security constraints.
## Related Code
- `<path/to/file>`
## Related Documents
- [<Related document>](<path-or-doc-link>)
```
+115
View File
@@ -0,0 +1,115 @@
# {{title}}
## Summary
- Purpose:
- Actor:
- Trigger:
- Endpoint:
- Main entities:
- Main logic:
- Main errors:
- Source of truth:
## Назначение
## Контекст
## Технический use case
### Основной сценарий
1.
2.
3.
### Альтернативные ветки
-
-
## Функциональные требования
### Request validation
-
### Processing rules
-
### State changes
-
### Side effects
-
## Contract
### Endpoint
- Method:
- Path:
- Auth:
- Idempotent:
- Timeout:
- Retry:
### Request
| Field | Type | Required | Constraints | Description |
|------|------|----------|-------------|-------------|
| | | | | |
### Response
| Field | Type | Description |
|------|------|-------------|
| | | |
### External contract refs
- OpenAPI:
- Schema:
- DTO / serializer:
- Additional refs:
## Errors
| error_id | http_code | when | client_behavior | retry |
|----------|-----------|------|-----------------|-------|
| | | | | |
## Нефункциональные требования
### Security
-
### Observability
- Logs:
- Metrics:
- Traces:
- Audit:
### Reliability
-
-
### Performance
-
## Связанные блоки логики
-
## Связанные сущности
-
## Связанный код
### Files
-
### Symbols
-
## Связанные документы
-
## История изменений
| Date | Source | Changes |
|------|--------|---------|
| | | |
@@ -0,0 +1,105 @@
# {{title}}
## Summary
- Scope:
- Purpose:
- Main modules:
- Main domains:
- Main integrations:
- Key entrypoints:
- Key data flows:
- Source of truth:
## Назначение
## Контекст
## Границы системы
### In scope
-
### Out of scope
-
## Архитектурная схема
## Основные модули
| module | responsibility | depends_on | key_code_refs |
|--------|----------------|------------|---------------|
| | | | |
## Основные доменные области
-
-
## Основные интеграции
| integration | direction | purpose | protocol / transport | related_docs |
|-------------|-----------|---------|----------------------|--------------|
| | | | | |
## Основные потоки
### Flow 1
1.
2.
3.
### Flow 2
1.
2.
3.
## Архитектурные решения и ограничения
### Key decisions
-
### Constraints
-
### Risks
-
## Нефункциональные аспекты
### Security
-
### Reliability
-
### Observability
- Logs:
- Metrics:
- Traces:
- Audit:
### Performance
-
### Scalability
-
## Связанные сущности
-
## Связанный код
### Files
-
### Symbols
-
## Связанные документы
-
## История изменений
| Date | Source | Changes |
|------|--------|---------|
| | | |
@@ -0,0 +1,92 @@
# {{title}}
## Summary
- Domain:
- Purpose:
- Entity role:
- Main attributes:
- Lifecycle:
- Invariants:
- Related APIs:
- Related logic:
- Source of truth:
## Назначение
## Контекст
## Роль в доменной модели
## Атрибуты
| attribute | type | required | description | constraints |
|-----------|------|----------|-------------|-------------|
| | | | | |
## Состояния и жизненный цикл
### Основные состояния
-
### Переходы состояний
1.
2.
3.
## Инварианты и ограничения
-
-
## Связи с другими сущностями
| entity | relation | description |
|--------|----------|-------------|
| | | |
## Использование в системе
### Related API
-
### Related UI
-
### Related logic
-
### Related integrations
-
## Функциональные требования
-
-
## Нефункциональные требования
### Audit / history
-
### Security
-
### Observability
-
## Связанный код
### Files
-
### Symbols
-
## Связанные документы
-
## История изменений
| Date | Source | Changes |
|------|--------|---------|
| | | |
+93
View File
@@ -0,0 +1,93 @@
```md
# {{title}}
## Summary
- Purpose:
- Trigger:
- Inputs:
- Outputs:
- Main entities:
- Main dependencies:
- Side effects:
- Source of truth:
## Назначение
## Контекст
## Технический use case
### Основной сценарий
1.
2.
3.
### Альтернативные ветки
-
-
## Функциональные требования
### Preconditions
-
### Processing rules
-
### Validation rules
-
### Output / result rules
-
### Side effects
-
## Ограничения и условия вызова
-
-
## Нефункциональные требования
### Security
-
### Observability
- Logs:
- Metrics:
- Traces:
- Audit:
### Reliability
-
-
### Performance
-
## Связанные API / UI / integration points
-
## Связанные сущности
-
## Связанный код
### Files
-
### Symbols
-
## Связанные документы
-
## История изменений
| Date | Source | Changes |
|------|--------|---------|
| | | |
```
+97
View File
@@ -0,0 +1,97 @@
# {{title}}
## Summary
- Purpose:
- Actor:
- Trigger:
- Route:
- Main API:
- Main entities:
- Main logic:
- Main states:
- Source of truth:
## Назначение
## Контекст
## Технический use case
### Основной сценарий
1.
2.
3.
### Альтернативные ветки
-
-
## Описание UI
## UI Elements
| id | type | label | data_source | default / placeholder | validation | behavior |
|----|------|-------|-------------|------------------------|------------|----------|
| | | | | | | |
## Функциональные требования
### Input rules
-
### State rules
-
### Navigation rules
-
### Client-side validation
-
## Нефункциональные требования
### Security
-
### Observability
- Logs:
- Metrics:
- Traces:
- Analytics:
### Accessibility
-
### Performance
-
### Feature toggles
-
## Связанные API
-
## Связанные блоки логики
-
## Связанные сущности
-
## Связанный код
### Files
-
### Symbols
-
## Связанные документы
-
## История изменений
| Date | Source | Changes |
|------|--------|---------|
| | | |
+15
View File
@@ -0,0 +1,15 @@
---
alwaysApply: true
---
При задачах на создание или обновление документации всегда:
1. Читай .analysis/rules/documentation-rules.md, .analysis/rules/frontmatter-rules.md и нужный шаблон из .analysis/rules/templates/.
2. Создавай и обновляй документы только в docs/documentation/.
3. Не создавай дублей: сначала ищи существующий документ, потом обновляй его.
4. Соблюдай принцип: один документ = одна сущность / один устойчивый аспект.
5. Каждый документ должен иметь YAML frontmatter, обязательные разделы Summary и Details и структуру по шаблону.
6. Все связи фиксируй явно: related_docs, related_code, entities, tags и typed-поля.
7. Используй только подтвержденный evidence из кода, контрактов, конфигов и существующей документации.
8. Не дублируй содержание между документами — используй ссылки.
9. Явно указывай связанный код и связанные документы.
10. Не выдумывай факты, если evidence недостаточно.
+25
View File
@@ -0,0 +1,25 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Agent Backend: Uvicorn (Debug)",
"type": "python",
"request": "launch",
"module": "uvicorn",
"args": [
"app.main:app",
"--host",
"0.0.0.0",
"--port",
"15000"
],
"cwd": "${workspaceFolder}",
"envFile": "${workspaceFolder}/.env",
"env": {
"PYTHONPATH": "${workspaceFolder}/src"
},
"console": "integratedTerminal",
"justMyCode": false
}
]
}
+3 -2
View File
@@ -3,12 +3,13 @@ FROM python:3.12-slim
WORKDIR /app
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
PYTHONUNBUFFERED=1 \
PYTHONPATH=/app/src
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
COPY app ./app
COPY src ./src
EXPOSE 15000
+126
View File
@@ -0,0 +1,126 @@
# Процессы работы с документацией (AS IS / TO BE)
## Основные артефакты системной аналитики
Системные аналитики работают с 3 артефактами:
- бизнес-требованиями
- системной аналитикой
- технической документацией
### Бизнес требования
Описывает бизнес и пользовательские требования, пользователькие use case, макеты экранов.
Сейчас не всегда оформляется как отдельный документ, часто этот шаг пропускается и требования фиксируются сразу в документе системной аналитики.
### Системная аналитика
Документ описыватет изменения в автоматизированной системе. Пишется системными аналитиками для разработчиков и тестировщиков. Так же этот документ проходит согласование с экспертами по архитектуре, безопасности, сопровождению.
Может описывать как целиком процесс (в случае реализации с нуля), так и инкремент, который вносит небольшие изменения в существующие процессы.
В данном документе содкржится вся информация по сути вносимых изменений, но отсутствует контекст о текущей реализации системы.
Состоит из разделов:
- Цели - короткое описание какую проблему и для кого решаем.
- Процесс AS IS и TO BE - фокус на изменения с точки зрения бизнес функций, без технической детализации.
- Ограничения - ограничения и допущения в реализации.
- Архитектура - описывает схему уровня контейнеров, основной фокус на интеграции между контейнерами и интеграционные сценарии.
- Функциональные требования - описывают изменения в системе.
- Нефункциональные требования - требования к аудиту, мониторингу, фичетоглам, пользовтелькой аналитике.
### Техническая документация
Техническая документация описывает реализацию системы. Эта информация используется командой разработки при проектировании и реализации новых фичей, понимании как работает система. Артефакт живет чуть впереди кода
Представялет из себя иерархическую модель документов, сейчас реализованную в конфлюенсе.
Есть несколько типов страниц, каждая из которы описывает определенный тип функциональности
- UI страницы
- API методы
- БД
- Логические блоки
#### UI страницы
Описывают экран на UI.
**Декомпозиция**
Как правило на страницу с описанием выносится целый макет/страница фронтального приложения, с одной основной интеграцией и опционально вспомогательными интеграциями.
Например - форма создания сущности. Есть вспомогательгные методы для полученяи правочников, использующихся при заполнении полей на форме, и вызов оснвного метода создания сущности.
Таким образом приложение декомпозируется на отдельные экраны, коотры свызываются между собой последовательно, но сами по себе являются независимыми
**Состав описания**
Все разделы обязательны.
Страница с описанием содержит:
- Краткое описание
- Технический use case
- Описание макета с декомпозицией на компоненты + их поведение
- Функциональные требования - описание интеграций и логики, специфичной для этой формы UI
- Нефункциональные требования - фичетоглы и события пользовательской аналитики
#### API методы
**Декомпозиция**
На каждый метод API заводится отдельная страница.
**Состав описания**
Все разделы обязательны.
Страница с описанием содержит:
- Краткое описание
- Технический use case
- Функциональные требования - описание интеграций и логики, специфичной для этой формы UI
- Нефункциональные требования - фичетоглы и события пользовательской аналитики
- Контракт метода - описание запроса и ответа. Для ответа так же приводится описание как заполнять поля.
#### БД
**Декомпозиция**
Сейсас это только странциа с описанием таблица. На каждую таблицу заводится отдельная страница.
**Состав описания**
Все разделы обязательны.
Страница с описанием содержит:
- Краткое описание
- Таблица с офисанием физической модели данных
#### Логические блоки
**Декомпозиция**
На отдельную страницу может быть вынесен общий переиспользуемый блок логики. Это позволяет не дублировать его на страницах документации. Как правило соответствует реализации общего компонента в коде.
**Состав описания**
Часть разделов в описании может отсутствовать.
- Краткое описание
- Технический use case
- Функциональные требования - описание интеграций и логики, специфичной для этой формы UI
- Нефункциональные требования - фичетоглы и события пользовательской аналитики
#### Прочие особенности процесса
##### Описание технических use cases
Сценарий описывает основные шаги процесса в разрезе участников, все технические детали, если их нельзя описать одним предложением, выносятся в разделы функциональных требований, нефункциональных требований, или даются ссылки на другие страницы (как правило это страницы с логическими блоками).
В технических use cases приводятся ссылки на страницы с описнаием вызываемых методов API. Особенно это актуально для страниц фронта, т.к. он использует наши методы API, которые есть в документации. Для интеграций с другими АС как правило приводистя ссылка на описание конфлюенса.
## AS IS
Сейчас все артефакты ведутся в конфлюенс. Одна страница содержит описанием одного аретфакта (бизнес требования, системная аналитика, страница документации), страницы организованы иерархически, используюстя ссылки для обозначения связей.
Проблемы:
- документация со временем теряет актуальность
- отсутствие автоматизации
- ручное ведение
---
## TO BE
Целевое состояние:
- аналитик продолжает писать артефакты бизнес-требований и системной аналитики
- агент генерирует и обновляет документацию по странице системной аналитики
- документация становится инженерным артефактом, который ведется в GIT
### Форматы
- Markdown
- OpenAPI
- Mermaid / PlantUML
### Роль агента
- использование документации как базы знаний - как для ответов на вопросы, так и для проектирования изменений в системе.
- внесение изменений в документацию по артефактам системной аналитики
- генерация из документации спецификаций OPENAPI и JSON-schema
+235
View File
@@ -0,0 +1,235 @@
Ниже обновленная версия с учетом гибридной модели интент роутера.
---
## 1. Концепция агента
Агент проектируется как intent-driven система для работы с кодом и документацией, где пользовательский запрос сначала нормализуется и интерпретируется, затем по нему извлекается релевантный контекст из многослойного RAG, после чего специализированный task workflow выполняет целевую задачу. Агент не является единым “умным чатом”: логика разделена на маршрутизацию, retrieval и специализированные execution workflows. Проверка evidence, вызовы LLM и правила сборки ответа находятся внутри task workflows и зависят от типа задачи.
---
## 2. Компонентная модель
```mermaid
flowchart LR
IDE[IDE Plugin / Client] --> API[API Layer]
API --> IR[IntentRouter V3]
IR --> RAG[Retrieval RAG]
RAG --> TW1[Task Workflow: Documentation Explain]
RAG --> TW2[Task Workflow: OpenAPI Generation]
RAG --> TW3[Task Workflow: Documentation Generation]
RAG --> TWN[Other Specialized Task Workflows]
TW1 --> OUT[Response / Artifact]
TW2 --> OUT
TW3 --> OUT
TWN --> OUT
```
---
## 3. Основной flow процесса
### Основной процесс
1. Пользователь отправляет запрос через IDE plugin или другой клиент.
2. `API Layer` принимает запрос и передает его в агент.
3. `IntentRouter V3`:
* нормализует запрос;
* детерминированно извлекает ключевые артефакты;
* с помощью LLM определяет тип задачи и параметры обработки;
* формирует параметры retrieval.
4. Выполняется извлечение данных из `Retrieval RAG`.
5. Извлеченный контекст передается в соответствующий `Task Workflow`.
6. Внутри workflow выполняется:
* подготовка контекста;
* evidence-проверки;
* вызовы LLM;
* формирование результата.
7. Результат возвращается пользователю.
### Sequence diagram
```mermaid
sequenceDiagram
participant User as User / IDE Plugin
participant API as API Layer
participant Router as IntentRouter V3
participant RAG as Retrieval RAG
participant WF as Task Workflow
User->>API: request
API->>Router: agent call
Router->>Router: normalize + extract artifacts
Router->>Router: LLM routing (task / intent)
Router->>RAG: retrieval request
RAG-->>Router: retrieved context
Router->>WF: route result + context
WF->>WF: evidence logic + LLM calls
WF-->>API: final result
API-->>User: response
```
---
## 4. Описание компонентов
### 4.1. IDE Plugin / Client
**Задача**
Точка входа пользователя в агент.
**Как устроен**
Любой внешний клиент (IDE plugin, web UI и др.), который отправляет запрос и получает результат.
**Почему так**
Агент изначально проектируется как backend-система, независимая от интерфейса.
---
### 4.2. API Layer
**Задача**
Обеспечивает внешний интерфейс взаимодействия с агентом.
**Как устроен**
Принимает запрос, валидирует его и передает во внутренний pipeline, затем возвращает результат.
**Почему так**
Позволяет изолировать транспортный слой от логики агента.
---
### 4.3. IntentRouter V3
**Задача**
Определяет, как должен обрабатываться пользовательский запрос и какой сценарий выполнения применить.
**Как устроен**
Гибридная модель из двух частей:
#### 1. Детерминированный слой
Выполняет:
* нормализацию запроса;
* извлечение ключевых артефактов:
* домены;
* типы сущностей (API, entity, component и т.д.);
* явные ссылки (endpoint, путь, имя);
* выделение базовых сигналов (например: explain / list / generate).
Этот слой задает **жесткие рамки интерпретации запроса**.
#### 2. LLM-роутинг
Использует:
* нормализованный запрос;
* извлеченные артефакты;
* описание доступных типов задач;
и определяет:
* тип задачи;
* общий сценарий обработки;
* параметры retrieval;
* ожидаемую форму ответа.
#### Итог
Router формирует:
* параметры retrieval;
* тип task workflow;
* контекст для дальнейшего выполнения.
**Почему решение такое**
Ранее использовался более детерминированный подход с фиксированными сценариями, который хорошо работал в узком наборе задач, но плохо масштабируется. Полностью LLM-based роутинг, наоборот, дает гибкость, но теряет предсказуемость и управляемость.
Поэтому выбран гибридный подход:
* детерминированный слой фиксирует ключевые артефакты и ограничения;
* LLM выполняет гибкую интерпретацию задачи.
Это позволяет:
* сохранить управляемость и стабильность;
* избежать взрывного роста количества сценариев;
* поддерживать сложные и нетиповые запросы.
---
### 4.4. Retrieval RAG
**Задача**
Извлечь релевантный контекст для выполнения задачи.
**Как устроен**
Многослойная система хранения знаний (код, документация, факты, связи), из которой извлекается структурированный контекст в зависимости от параметров, заданных роутером.
**Почему так**
Разные задачи требуют разных типов данных, поэтому используется слойная модель вместо плоского поиска.
---
### 4.5. Task Workflows
**Задача**
Реализуют прикладную логику выполнения конкретного типа задачи.
**Как устроены**
Набор специализированных workflows, например:
* объяснение по документации;
* генерация OpenAPI;
* генерация документации;
* другие сценарии.
Внутри workflow находятся:
* обработка контекста;
* evidence-проверки;
* вызовы LLM;
* сборка результата.
**Почему так**
Логика проверки данных и генерации сильно зависит от задачи, поэтому она инкапсулируется в отдельных workflows, а не в одном универсальном слое.
---
### 4.6. Output / Artifact
**Задача**
Вернуть результат пользователю.
**Как устроен**
Может быть:
* текстовый ответ;
* структурированный список;
* OpenAPI спецификация;
* документация;
* иной артефакт.
**Почему так**
Агент должен поддерживать не только ответы, но и генерацию инженерных артефактов.
---
## Итог
Обновленная архитектура строится на следующем принципе:
* **детерминированное извлечение ключевых артефактов** задает рамки;
* **LLM выполняет гибкий роутинг внутри этих рамок**;
* **retrieval обеспечивает данные**;
* **task workflows реализуют прикладную логику и контроль качества**.
Это позволяет одновременно сохранить управляемость системы и обеспечить масштабируемость под новые типы задач.
View File
+790
View File
@@ -0,0 +1,790 @@
## 1. Формат ведения технической документации агентом
## 1.1. Общие принципы
Техническая документация, формируемая агентом, должна строиться как **система атомарных, не пересекающихся по смыслу документов**, связанных между собой явными ссылками.
Ключевые принципы:
- один документ описывает одну сущность или один устойчивый технический аспект;
- документ не должен дублировать соседние документы;
- общая система знаний должна собираться через ссылки, а не через копипасту;
- структура документации должна быть пригодна как для чтения человеком, так и для индексирования в RAG.
## 1.2. Базовые типы документных единиц
На первом этапе логично сохранить текущую семантику типов документов, но перенести ее в файловую модель.
Базовые типы:
- `ui_page`
- `api_method`
- `logic_block`
Позже могут добавиться:
- `architecture_overview`
- `integration_doc`
- `domain_entity`
- `glossary_item`
- `index_page`
## 1.3. Принцип декомпозиции страниц / файлов
### Один устойчивый объект — один документ
Если объект можно переиспользовать или на него могут ссылаться другие документы, его надо выносить в отдельный файл.
Примеры:
- отдельная UI-страница;
- отдельный API endpoint;
- отдельный блок логики;
- отдельный интеграционный сценарий.
### Документы не должны пересекаться по смыслу
Если описание повторяется в нескольких местах, нужно выделять общий документ и ссылаться на него.
Примеры:
- фронтальная страница не должна заново описывать логику API;
- документ по API не должен заново раскрывать общую логику переиспользуемого блока;
- вместо дублирования должен быть переход по ссылке.
### Use case и детальные правила живут раздельно
Сценарий описывает поток работы, а детали выносятся в функциональные требования, отдельные блоки логики или контрактные описания.
Это важно и для RAG-индексации:
- сценарии индексируются как workflows;
- отдельные правила — как facts;
- сущности и блоки — как entities.
## 1.4. Иерархическая организация документации
Документация должна быть организована как иерархическое дерево каталогов и файлов, а не как набор неструктурированных страниц.
Пример верхнего уровня:
```text
docs/
ui/
api/
logic/
domains/
integrations/
architecture/
glossary/
errors/
```
Пример организации:
```text
docs/
ui/
order-create-page.md
order-edit-page.md
api/
orders-create.md
orders-get.md
logic/
order-validation.md
order-enrichment.md
architecture/
system-overview.md
integration-landscape.md
errors/
catalog.yaml
```
## 1.5. Учет связей между документами
Связи должны быть **явными и поддерживаемыми агентом**.
Примеры:
- UI-страница ссылается на API, который она вызывает;
- API-документ ссылается на переиспользуемые логические блоки;
- логический блок ссылается на связанные интеграции;
- архитектурный обзор ссылается на набор конкретных модулей и документов;
- документ по коду может ссылаться на системную аналитику, которая инициировала изменение.
Именно эта сеть ссылок затем индексируется в слоях:
- `D1_DOCUMENT_CATALOG`
- `D3_ENTITY_CATALOG`
- `D4_WORKFLOW_INDEX`
- `D5_REFERENCE_GRAPH`
- `D6_DOC_CODE_LINKS`
## 1.6. Формат markdown-документов
Основной формат технической документации — `Markdown`.
Каждый документ состоит из двух частей:
1. **YAML frontmatter** — структурные метаданные;
2. **Markdown body** — основное содержимое по шаблону.
## 3.7. YAML frontmatter
Frontmatter нужен для:
- определения типа документа;
- идентификации документа;
- определения его места в иерархии;
- фиксации связей с кодом и другими документами;
- выделения сущностей и тегов;
- упрощения построения слоев `D1`, `D3`, `D5`, `D6`.
### Базовый frontmatter
```yaml
---
id: ui-order-create-page
title: Страница создания заказа
doc_type: ui_page
domain: orders
status: draft
related_docs:
- api-orders-create
- logic-order-validation
entities:
- Order
- CreateOrder
tags:
- ui
- orders
- creation
#owner: system-analyst
#source_of_truth: code
#related_code:
# - src/orders/ui/create_page.tsx
# - src/orders/api/orders_controller.py
---
```
### Обязательные поля
- `id` — стабильный уникальный идентификатор документа;
- `title` — человекочитаемый заголовок;
- `doc_type` — тип документа;
- `related_docs` — ссылки на связанные документы;
- `status` — статус документа;
- `domain` - домен фичи (Карточка клиента, Задачи, Сделки, Предложения)
- `sub_domain` - поддомен внутри основной сущности (Счета, ЗДА, ECM)
### Рекомендуемые поля
- `owner`
- `entities`
- `tags`
- `parent`
- `children`
- `feature`
- `system_analytics_refs`
- `business_refs`
- `updated_from`
- `reviewers`
- `source_of_truth`
- `related_code`
### Допустимые значения `doc_type`
- `ui_page`
- `api_method`
- `logic_block`
- `architecture_overview`
- `integration_doc`
- `domain_entity`
- `glossary_item`
- `index_page`
### Допустимые значения `status`
- `draft`
- `in_review`
- `approved`
- `outdated`
- `generated`
- `active`
### Допустимые значения `source_of_truth`
- `code`
- `doc`
- `system_analysis`
- `business_requirements`
- `mixed`
## 1.8. Typed frontmatter для разных типов документов
У каждого типа документа есть:
- **общие поля**;
- **тип-специфичные поля**.
### Пример для `api_method`
```yaml
---
id: api.create_invoice
doc_type: api_method
domain: billing
title: Создание инвойса
endpoint: POST /api/v1/invoices
auth: USER
idempotent: false
timeout_ms: 3000
links:
called_by:
- ui.invoice_form
uses_logic:
- logic.invoice_validation
writes_db:
- db.invoices
- db.invoice_items
integrates_with:
- int.crm_sync
related_docs:
- ui.invoice_form
- logic.invoice_validation
related_code:
- services/billing/api/create_invoice.py
entities:
- Invoice
- CreateInvoice
tags:
- invoice
- create
- billing
status: active
version: 1.3
source_of_truth: code
---
```
### Для `api_method` полезно поддерживать
- `endpoint`
- `sup_parameters`
- `role_model_actions`
- `monitoring_actions`
- `audit_actions`
- `idempotent`
- `timeout_ms`
- `links.called_by`
- `links.uses_logic`
- `links.writes_db`
- `links.integrates_with`
### Для `ui_page` позже полезно поддерживать
- `calls_api`
- `user_analitycs_actions`
- `sup_parameters`
- `role_model_actions`
- `entry_points`
- `uses_logic`
### Для `logic_block` полезно поддерживать
- `called_from`
- `uses_logic`
- `reads_db`
- `writes_db`
- `integrates_with`
## 1.9. Двухслойная структура документа: `Summary` + `Details`
LLM не должна каждый раз тонуть в полном документе. Поэтому каждый документ должен содержать два уровня представления.
### `Summary`
Короткая, строго структурированная спецификация. Это слой **быстрого контекста**.
Рекомендуемый объем:
- примерно 30–60 строк;
- без длинных пояснений;
- только ключевые факты.
Пример:
```md
## Summary
- Purpose: создание инвойса из формы
- Actor: пользователь
- Trigger: Submit
- Main API: POST /api/v1/invoices (api.create_invoice)
- Validation: required fields, amount > 0, date <= today
- Errors: 400(field errors), 409(duplicate), 503(retryable)
- Analytics: event invoice_submit, invoice_error
```
### `Details`
Полное раскрытие объекта:
- use case;
- функциональные требования;
- UI;
- API;
- ошибки;
- НФТ;
- связи;
- кодовые привязки.
## 1.10. Общие требования к markdown body
1. В документе должен быть один `H1`, совпадающий с `title`.
2. Основные разделы используют `H2`.
3. Подразделы внутри разделов используют `H3`.
4. Не должно быть хаотической вложенности заголовков.
5. Один раздел должен описывать одну смысловую часть.
6. Текст не должен дублировать соседние документы.
7. Вместо дублирования должны использоваться явные ссылки на связанные документы.
8. Сценарии, правила, ограничения и ссылки на код должны быть отделены друг от друга.
## 1.11. Базовый каркас markdown-документа
```md
---
id: api-orders-create
title: Метод создания заказа
doc_type: api_method
domain: orders
status: draft
source_of_truth: code
related_docs:
- logic-order-validation
- ui-order-create-page
related_code:
- src/orders/api/create_order.py
entities:
- Order
- CreateOrder
tags:
- api
- orders
---
# Метод создания заказа
## Summary
- Purpose: создание заказа
- Actor: пользователь
- Trigger: submit формы
- Main API: POST /orders
## Details
### Описание
### Технический use case
### Функциональные требования
### Нефункциональные требования
### Контракт
## 3.13. Специализированные шаблоны документов
### UI Page
```md
# <Название страницы>
## Summary
## Назначение
## Контекст
## Технический use case
## Описание UI
## UI Elements
## Функциональные требования
## Нефункциональные требования
## Связанные API
## Связанные блоки логики
## Связанный код
## Связанные документы
## История изменений
```
#### Требования к разделу `Описание UI`
Для каждого элемента желательно описывать:
- тип элемента;
- назначение;
- источник данных;
- default / placeholder;
- правила активации;
- поведение при взаимодействии;
- валидацию.
#### Требования к разделу `UI Elements`
UI-элементы должны храниться в **табличном** или **полуструктурированном** виде.
Пример:
```md
## UI Elements
| id | type | label | data_source | validation | behavior |
|--------|--------|---------|------------|------------|----------|
| amount | input | Amount | local | >0 | enables submit |
| submit | button | Create | - | - | calls api.create_invoice |
```
Если модель UI сложная, допустим sidecar-файл `ui_elements.yaml` или `ui_elements.json` рядом с основным документом.
### API Method
```md
# <Название API метода>
## Summary
## Назначение
## Контекст
## Технический use case
## Функциональные требования
## Contract
## Errors
## Нефункциональные требования
## Связанные блоки логики
## Связанный код
## Связанные документы
## История изменений
```
#### Требования к разделу `Contract`
Контракт может:
- быть кратко описан прямо в документе;
- ссылаться на OpenAPI;
- ссылаться на отдельный контрактный файл.
Для REST API целевым источником истины должен становиться `OpenAPI`.
### Reusable Logic Block
```md
# <Название блока логики>
## Summary
## Назначение
## Контекст
## Технический use case
## Функциональные требования
## Ограничения и условия вызова
## Нефункциональные требования
## Связанные API / UI / integration points
## Связанный код
## Связанные документы
## История изменений
```
## 3.14. Машинно-читаемые API-контракты
Для API контрактов **источником истины** должен становиться:
- `OpenAPI` — предпочтительно;
- либо временно строгий markdown/yaml-контракт, если OpenAPI еще нет.
Минимальный набор для API-контракта:
- `endpoint`
- `method`
- `request fields`
- `required / optional`
- `constraints`
- `response`
- `errors`
- `idempotency`
- `retry`
- `timeout`
- `auth`
## 3.15. Каталог ошибок
Ошибки, HTTP-коды, retry-правила и клиентское поведение не должны размазываться по разным документам.
Нужен единый каталог ошибок, например `docs/errors/catalog.yaml`.
Пример:
```yaml
errors:
- error_id: invoice_validation_failed
http_code: 400
internal_code: BILLING_400_01
when: invalid request fields
client_behavior: show field errors
retry: false
owner: billing
- error_id: invoice_duplicate
http_code: 409
internal_code: BILLING_409_01
when: duplicate invoice detected
client_behavior: show duplicate warning
retry: false
owner: billing
- error_id: crm_sync_unavailable
http_code: 503
internal_code: BILLING_503_02
when: downstream CRM unavailable
client_behavior: retry later
retry: true
owner: billing
```
В API- и logic-документах лучше ссылаться на `error_id`, а не заново подробно описывать каждую ошибку.
## 3.16. Требования к качеству документа для RAG
1. **Явные заголовки** — не использовать безымянные блоки текста.
2. **Атомарные утверждения** — не смешивать несколько правил в одном пункте, если их можно разделить.
3. **Явные сущности** — использовать стабильные названия компонентов, API, модулей, страниц.
4. **Явные ссылки** — не писать «этот метод», если можно указать конкретную ссылку или идентификатор.
5. **Минимум дублирования** — повторяющийся контент должен заменяться ссылками.
6. **Привязка к коду** — если документ описывает кодовый объект, это должно быть явно указано.
7. **Разделение сценариев и правил** — workflow и fact-like требования должны быть отделены.
## 3.17. Как структура markdown помогает RAG
- `frontmatter` + заголовки → `D1_DOCUMENT_CATALOG`
- `entities`, `tags`, устойчивые термины → `D3_ENTITY_CATALOG`
- атомарные функциональные и нефункциональные требования → `D2_FACT_INDEX`
- `Технический use case``D4_WORKFLOW_INDEX`
- `related_docs`, явные ссылки → `D5_REFERENCE_GRAPH`
- `related_code`, упоминания symbols и файлов → `D6_DOC_CODE_LINKS`
- `Summary` → быстрый retrieval и short-form context для LLM
## 3.18. Принципы генерации документации агентом
Когда документ пишет агент, он должен:
- сначала извлечь evidence из кода, системной аналитики и существующих документов;
- определить тип документа;
- заполнить frontmatter;
- построить markdown body по шаблону;
- явно указать связи с кодом и другими документами;
- не дублировать уже существующее описание, если можно сослаться на него.
---
## 4.4. Layered RAG
RAG строится как система специализированных слоев для двух основных доменов:
- `CODE RAG`
- `DOCS RAG`
Каждый graph извлекает контекст не из одного общего индекса, а из нужного набора слоев в зависимости от intent.
## 4.5. Evidence gate
Перед синтезом ответа или документа агент должен проверять, хватает ли опоры.
Примеры:
- найден ли symbol;
- найдено ли достаточное количество code chunks;
- есть ли supporting relations;
- есть ли document evidence;
- есть ли docs ↔ code mapping.
Если опоры недостаточно, агент должен:
- деградировать в упрощенный режим;
- честно фиксировать неполноту ответа;
- при необходимости уходить в fallback.
## 4.6. Synthesis layer
На этом этапе LLM:
- агрегирует найденные артефакты;
- формирует объяснение;
- пишет документ;
- структурирует результат под нужный шаблон.
LLM не должна быть основным источником фактов. Фактическая основа должна приходить из RAG и диагностируемого pipeline.
## 4.7. Diagnostics
Система должна сохранять диагностический след:
- какой graph был выбран;
- какие слои использовались;
- что было найдено;
- где retrieval был слабым;
- почему был выбран fallback;
- какие evidence стали основой ответа.
## 4.8. Сценарии: Target Architecture vs MVP-now
### 4.8.1. Target Architecture
#### CODE
- `OPEN_FILE` — открыть конкретный файл;
- `OPEN_SYMBOL` — открыть класс / функцию / метод;
- `EXPLAIN` — объяснить, как работает сущность или фрагмент;
- `FIND_TESTS` — найти релевантные тесты;
- `FIND_ENTRYPOINTS` — найти основные точки входа;
- `RELATED_CODE` — найти связанные сущности и ближайший контекст.
#### DOCS
- `DOC_SEARCH` — найти релевантный фрагмент документации;
- `DOC_EXPLAIN` — кратко объяснить, что сказано в документации по теме;
- `DOC_ENTITY_LOOKUP` — найти разделы, связанные с сущностью или компонентом;
- `GENERATE_DOCS_FROM_CODE` — сформировать документацию по коду с нуля для модуля, класса, функции, компонента или сценария.
#### CROSS-DOMAIN
- `FIND_IMPLEMENTATION_BY_DOC` — найти реализацию по описанию;
- `FIND_DOC_BY_CODE` — найти документацию по коду;
- `COMPARE_DOCS_AND_CODE` — базовое сопоставление документации и реализации.
#### GENERAL / FALLBACK
- `GENERAL_QA` — общий сценарий ответа на вопрос, если домен или интент не удалось определить уверенно.
### 4.8.2. MVP-now
В текущем цикле фокус на сценариях:
- `OPEN_FILE`
- `EXPLAIN`
- `FIND_TESTS`
- `FIND_ENTRYPOINTS`
- `GENERAL_QA`
DOCS и CROSS_DOMAIN остаются частью target architecture; в текущем цикле они не являются обязательной частью test-first MVP.
---
## 5. Структура слоев RAG
## 5.1. CODE RAG
### C0 — Source Chunks
**Назначение:** базовые фрагменты исходного кода.
**Единица:** chunk кода.
**Как формируется:** исходные файлы обходятся и режутся на chunk’и с учетом структурных границ.
**Статус в MVP:** да.
### C1 — Symbol Catalog
**Назначение:** каталог модулей, классов, функций, методов и других значимых сущностей.
**Единица:** symbol.
**Как формируется:** из AST и синтаксического разбора кода.
**Статус в MVP:** да.
### C2 — Symbol Relations
**Назначение:** связи между symbols.
**Единица:** relation.
**Как формируется:** вторым проходом по AST и структурным зависимостям.
**Статус в MVP:** да, в ограниченном виде.
### C3 — Entrypoints
**Назначение:** каталог точек входа системы.
**Единица:** entrypoint.
**Как формируется:** специализированными детекторами entrypoint-паттернов.
**Статус в MVP:** да, минимально.
### C4 — Execution Paths
**Назначение:** типовые пути исполнения.
**Единица:** path.
**Как формируется:** поверх `C2` и `C3` через производную трассировку.
**Статус в MVP:** нет.
### C5 — Test Mappings
**Назначение:** связи production code ↔ tests.
**Единица:** mapping.
**Как формируется:** по путям, именам, импортам и конвенциям проекта.
**Статус в MVP:** да, минимально.
### C6 — Code Facts
**Назначение:** нормализованные факты из кода.
**Единица:** fact.
**Как формируется:** поверх `C1C3` как производный слой.
**Статус в MVP:** нет.
## 5.2. DOCS RAG
### D0 — Document Chunks
**Назначение:** базовые фрагменты документации.
**Единица:** document chunk.
**Как формируется:** документы нормализуются и режутся на chunk’и с сохранением `section path`.
**Статус в MVP:** да.
### D1 — Document Catalog
**Назначение:** каталог документов и разделов.
**Единица:** `document node / section node`.
**Как формируется:** из структуры документов и их заголовков.
**Статус в MVP:** да.
### D2 — Fact Index
**Назначение:** атомарные факты из документации.
**Единица:** fact.
**Как формируется:** из `D0/D1` через правила, шаблоны и при необходимости LLM extraction с валидацией.
**Статус в MVP:** частично.
### D3 — Entity Catalog
**Назначение:** каталог сущностей и понятий документации.
**Единица:** entity / concept.
**Как формируется:** из устойчивых терминов, заголовков, словарей и нормализации повторяющихся сущностей.
**Статус в MVP:** да, минимально.
### D4 — Workflow Index
**Назначение:** процедуры, сценарии, последовательности шагов.
**Единица:** workflow.
**Как формируется:** из use case, процессных разделов и последовательных описаний шагов.
**Статус в MVP:** нет.
### D5 — Reference Graph
**Назначение:** граф ссылок между документами, секциями, сущностями и фактами.
**Единица:** reference link.
**Как формируется:** из явных и неявных cross-links между документами.
**Статус в MVP:** нет.
### D6 — Doc-Code Links
**Назначение:** мост между документацией и кодом.
**Единица:** `doc artifact ↔ code artifact link`.
**Как формируется:** из имен, aliases, путей, устойчивых терминов и других надежных соответствий.
**Статус в MVP:** да, минимально.
## 5.3. Layer scope: Target Architecture vs MVP-now
### 5.3.1. Target Architecture
Полная карта слоёв:
- **CODE:** C0C6 (Source Chunks, Symbol Catalog, Symbol Relations, Entrypoints, Execution Paths, Test Mappings, Code Facts)
- **DOCS:** D0D6 (Document Chunks, Document Catalog, Fact Index, Entity Catalog, Workflow Index, Reference Graph, Doc-Code Links)
### 5.3.2. MVP-now
**Обязательные сейчас:**
- `C0_SOURCE_CHUNKS`
- `C1_SYMBOL_CATALOG`
- `C2_SYMBOL_RELATIONS`
- `C3_ENTRYPOINTS`
**В облегчённом виде:**
- `C5_TEST_MAPPINGS` или `C5-lite`
**Не блокируют текущий этап:**
- `C4_EXECUTION_PATHS`
- `C6_CODE_FACTS`
- весь docs runtime (слои D0D6 в исполнении runtime)
Слои документации остаются частью target architecture; docs retrieval пока не обязателен для текущего code-first milestone.
---
## 6. Итоговая рамка MVP-now
Сейчас система должна стабильно работать в **test-first** режиме.
**Фокус:**
- CODE_QA;
- через тесты настраиваются:
- intent routing (IntentRouterV2);
- layered retrieval;
- evidence sufficiency;
- answer quality;
- diagnostics.
**Не входят в текущий milestone:**
- UI-интеграция;
- docs runtime;
- полная интеграция orchestration переносится на следующий этап после стабилизации test pipeline.
В целевой архитектуре по-прежнему заложены:
- уверенная работа с кодом, symbols, entrypoints, тестами;
- ответ по документации и мост docs ↔ code;
- генерация документации по коду;
- fallback при неуверенном роутинге.
В MVP-now сознательно **не включаются** самые дорогие части:
- полноценные execution paths для всей системы;
- богатые fact-индексы по всем доменам;
- полный reference graph документации;
- глубокая автоматизация подготовки системной аналитики.
+1 -1
View File
@@ -27,7 +27,7 @@ services:
env_file:
- .env
environment:
DATABASE_URL: ${DATABASE_URL}
DATABASE_URL: postgresql+psycopg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
GIGACHAT_AUTH_URL: ${GIGACHAT_AUTH_URL}
GIGACHAT_API_URL: ${GIGACHAT_API_URL}
GIGACHAT_SCOPE: ${GIGACHAT_SCOPE}
-380
View File
@@ -1,380 +0,0 @@
{
"layers": {
"C0_SOURCE_CHUNKS": {
"retriever": {
"class": "RagService",
"file": "app/modules/rag/services/rag_service.py",
"method": "retrieve"
},
"indexer": {
"class": "CodeTextDocumentBuilder",
"file": "app/modules/rag/indexing/code/code_text/document_builder.py",
"method": "build"
},
"input": {
"type": "observed shape",
"fields": {
"rag_session_id": {
"type": "string",
"required": true
},
"query": {
"type": "string",
"required": true
},
"layers": {
"type": "implicit list[string]",
"required": false,
"source": "RagQueryRouter.layers_for_mode('code')"
}
}
},
"output": {
"type": "list[dict]",
"fields": {
"source": "string",
"content": "string",
"layer": "\"C0_SOURCE_CHUNKS\"",
"title": "string",
"metadata": {
"chunk_index": "int",
"chunk_type": "\"symbol_block\" | \"window\"",
"module_or_unit": "string",
"artifact_type": "\"CODE\""
},
"score": "float | null"
}
},
"examples": {
"input": {
"rag_session_id": "rag-123",
"query": "where is implemented get_user"
},
"output": {
"source": "app/api/users.py",
"content": "async def get_user(user_id: str):\n service = UserService()\n return service.get_user(user_id)",
"layer": "C0_SOURCE_CHUNKS",
"title": "app/api/users.py:get_user",
"metadata": {
"chunk_index": 0,
"chunk_type": "symbol_block",
"module_or_unit": "app.api.users",
"artifact_type": "CODE"
},
"score": 0.07
}
},
"defaults": {
"retrieve_limit": 8,
"embed_batch_size_env": "RAG_EMBED_BATCH_SIZE",
"embed_batch_size_default": 16,
"window_chunk_size_lines": 80,
"window_overlap_lines": 15
},
"limitations": [
"Line spans are stored but not returned in the public retrieval item shape.",
"No direct path or namespace filter is exposed through the retrieval endpoint."
]
},
"C1_SYMBOL_CATALOG": {
"retriever": {
"class": "RagService",
"file": "app/modules/rag/services/rag_service.py",
"method": "retrieve"
},
"indexer": {
"class": "SymbolDocumentBuilder",
"file": "app/modules/rag/indexing/code/symbols/document_builder.py",
"method": "build"
},
"input": {
"type": "observed shape",
"fields": {
"rag_session_id": {
"type": "string",
"required": true
},
"query": {
"type": "string",
"required": true
},
"query_term_expansion": {
"type": "list[string]",
"required": false,
"source": "extract_query_terms(query_text)",
"max_items": 6
}
}
},
"output": {
"type": "list[dict]",
"fields": {
"source": "string",
"content": "string",
"layer": "\"C1_SYMBOL_CATALOG\"",
"title": "string",
"metadata": {
"symbol_id": "string",
"qname": "string",
"kind": "\"class\" | \"function\" | \"method\" | \"const\"",
"signature": "string",
"decorators_or_annotations": "list[string]",
"docstring_or_javadoc": "string | null",
"parent_symbol_id": "string | null",
"package_or_module": "string",
"is_entry_candidate": "bool",
"lang_payload": "object",
"artifact_type": "\"CODE\""
},
"score": "float | null"
}
},
"examples": {
"input": {
"rag_session_id": "rag-123",
"query": "where is implemented get_user"
},
"output": {
"source": "app/api/users.py",
"content": "function get_user\nget_user(user_id)",
"layer": "C1_SYMBOL_CATALOG",
"title": "get_user",
"metadata": {
"symbol_id": "sha256(...)",
"qname": "get_user",
"kind": "function",
"signature": "get_user(user_id)",
"decorators_or_annotations": [
"router.get"
],
"docstring_or_javadoc": null,
"parent_symbol_id": null,
"package_or_module": "app.api.users",
"is_entry_candidate": true,
"lang_payload": {
"async": true
},
"artifact_type": "CODE"
},
"score": 0.07
}
},
"defaults": {
"retrieve_limit": 8,
"layer_rank": 1
},
"limitations": [
"Only Python AST symbols are indexed.",
"Cross-file resolution is not implemented.",
"parent_symbol_id is an observed qname-like value, not guaranteed to be a symbol hash."
]
},
"C2_DEPENDENCY_GRAPH": {
"retriever": {
"class": "RagService",
"file": "app/modules/rag/services/rag_service.py",
"method": "retrieve"
},
"indexer": {
"class": "EdgeDocumentBuilder",
"file": "app/modules/rag/indexing/code/edges/document_builder.py",
"method": "build"
},
"input": {
"type": "observed shape",
"fields": {
"rag_session_id": {
"type": "string",
"required": true
},
"query": {
"type": "string",
"required": true
}
}
},
"output": {
"type": "list[dict]",
"fields": {
"source": "string",
"content": "string",
"layer": "\"C2_DEPENDENCY_GRAPH\"",
"title": "string",
"metadata": {
"edge_id": "string",
"edge_type": "\"calls\" | \"imports\" | \"inherits\"",
"src_symbol_id": "string",
"src_qname": "string",
"dst_symbol_id": "string | null",
"dst_ref": "string | null",
"resolution": "\"resolved\" | \"partial\"",
"lang_payload": "object",
"artifact_type": "\"CODE\""
},
"score": "float | null"
}
},
"examples": {
"input": {
"rag_session_id": "rag-123",
"query": "how get_user calls service"
},
"output": {
"source": "app/api/users.py",
"content": "get_user calls UserService",
"layer": "C2_DEPENDENCY_GRAPH",
"title": "get_user:calls",
"metadata": {
"edge_id": "sha256(...)",
"edge_type": "calls",
"src_symbol_id": "sha256(...)",
"src_qname": "get_user",
"dst_symbol_id": null,
"dst_ref": "UserService",
"resolution": "partial",
"lang_payload": {
"callsite_kind": "function_call"
},
"artifact_type": "CODE"
},
"score": 0.11
}
},
"defaults": {
"retrieve_limit": 8,
"layer_rank": 2,
"graph_build_mode": "static_python_ast"
},
"limitations": [
"No traversal API exists.",
"Edges are stored as retrievable rows, not as a graph-native store.",
"Destination resolution is local to one indexed file."
]
},
"C3_ENTRYPOINTS": {
"retriever": {
"class": "RagService",
"file": "app/modules/rag/services/rag_service.py",
"method": "retrieve"
},
"indexer": {
"class": "EntrypointDocumentBuilder",
"file": "app/modules/rag/indexing/code/entrypoints/document_builder.py",
"method": "build"
},
"input": {
"type": "observed shape",
"fields": {
"rag_session_id": {
"type": "string",
"required": true
},
"query": {
"type": "string",
"required": true
}
}
},
"output": {
"type": "list[dict]",
"fields": {
"source": "string",
"content": "string",
"layer": "\"C3_ENTRYPOINTS\"",
"title": "string",
"metadata": {
"entry_id": "string",
"entry_type": "\"http\" | \"cli\"",
"framework": "\"fastapi\" | \"flask\" | \"typer\" | \"click\"",
"route_or_command": "string",
"handler_symbol_id": "string",
"lang_payload": "object",
"artifact_type": "\"CODE\""
},
"score": "float | null"
}
},
"examples": {
"input": {
"rag_session_id": "rag-123",
"query": "which endpoint handles get user"
},
"output": {
"source": "app/api/users.py",
"content": "fastapi http \"/users/{user_id}\"",
"layer": "C3_ENTRYPOINTS",
"title": "\"/users/{user_id}\"",
"metadata": {
"entry_id": "sha256(...)",
"entry_type": "http",
"framework": "fastapi",
"route_or_command": "\"/users/{user_id}\"",
"handler_symbol_id": "sha256(...)",
"lang_payload": {
"methods": [
"GET"
]
},
"artifact_type": "CODE"
},
"score": 0.05
}
},
"defaults": {
"retrieve_limit": 8,
"layer_rank": 0
},
"limitations": [
"Detection is decorator-string based.",
"No Django, Celery, RQ, or cron entrypoints were found.",
"Returned payload does not expose line spans."
]
}
},
"retrieval_endpoint": {
"entrypoint": {
"file": "app/modules/rag_session/module.py",
"method": "internal_router.retrieve"
},
"request": {
"type": "dict",
"fields": {
"rag_session_id": "string | optional if project_id provided",
"project_id": "string | optional fallback for rag_session_id",
"query": "string"
}
},
"response": {
"type": "dict",
"fields": {
"items": "list[retrieval item]"
}
},
"defaults": {
"mode": "docs unless RagQueryRouter detects code hints",
"limit": 8,
"embedding_provider": "GigaChat embeddings",
"fallback_after_embedding_error": true,
"fallback_to_docs_when_code_empty": true
}
},
"ranking": {
"storage": "PostgreSQL rag_chunks + pgvector",
"query_repository": {
"class": "RagQueryRepository",
"file": "app/modules/rag/persistence/query_repository.py",
"method": "retrieve"
},
"order_by": [
"lexical_rank ASC",
"test_penalty ASC",
"layer_rank ASC",
"embedding <=> query_embedding ASC"
],
"notes": [
"lexical_rank is derived from qname/symbol_id/title/path/content matching extracted query terms",
"test_penalty is applied only when prefer_non_tests=true",
"layer priority is C3 > C1 > C2 > C0 for code retrieval"
]
}
}
-270
View File
@@ -1,270 +0,0 @@
# 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.
@@ -1,13 +0,0 @@
| column | used_by | safe_to_drop | notes |
| --- | --- | --- | --- |
| `layer` | `USED_BY_CODE_V2`, `USED_BY_DOCS_INDEXING` | no | Core selector for C0-C3 and D1-D4 queries. |
| `title` | `USED_BY_CODE_V2`, `USED_BY_DOCS_INDEXING` | no | Used in lexical ranking and prompt evidence labels. |
| `metadata_json` | `USED_BY_CODE_V2`, `USED_BY_DOCS_INDEXING` | no | C2/C0 graph lookups and docs metadata depend on it. |
| `span_start`, `span_end` | `USED_BY_CODE_V2` | no | Needed for symbol-to-chunk resolution and locations. |
| `symbol_id`, `qname`, `kind`, `lang` | `USED_BY_CODE_V2` | no | Used by code indexing, ranking, trace building, and diagnostics. |
| `repo_id`, `commit_sha` | `USED_BY_CODE_V2`, `USED_BY_DOCS_INDEXING` | no | Used by indexing/cache and retained for provenance. |
| `entrypoint_type`, `framework` | `USED_BY_CODE_V2` | no | Used by C3 filtering and entrypoint diagnostics. |
| `doc_kind`, `module_id`, `section_path` | `USED_BY_DOCS_INDEXING` | no | Still written by docs indexing and covered by docs tests. |
| `artifact_type`, `section`, `doc_version`, `owner`, `system_component`, `last_modified`, `staleness_score` | `USED_BY_DOCS_INDEXING` | no | File metadata still flows through indexing/cache; left intact for now. |
| `rag_doc_id` | `UNUSED` | yes | Written into `rag_chunks` only; no reads in runtime/indexing code. |
| `links_json` | `UNUSED` | yes | Stored in `rag_chunks` only; reads exist for `rag_chunk_cache`, not `rag_chunks`. |
-31
View File
@@ -1,31 +0,0 @@
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"]
-457
View File
@@ -1,457 +0,0 @@
# Retrieval Inventory
## Scope and method
This document describes the retrieval and indexing pipeline as implemented in code today. The inventory is based primarily on:
- `app/modules/rag/services/rag_service.py`
- `app/modules/rag/persistence/*.py`
- `app/modules/rag/indexing/code/**/*.py`
- `app/modules/rag/indexing/docs/**/*.py`
- `app/modules/rag_session/module.py`
- `app/modules/agent/engine/graphs/project_qa_step_graphs.py`
- `app/modules/agent/engine/orchestrator/*.py`
`ASSUMPTION:` the intended layer semantics are the ones implied by code and tests, not by future architecture plans. This matters because only `C0` through `C3` are materially implemented today; `C4+` exist only as enum constants.
## Current retrieval pipeline
1. Retrieval entrypoint is `POST /internal/rag/retrieve` in `app/modules/rag_session/module.py`.
2. The endpoint calls `RagService.retrieve(rag_session_id, query)`.
3. `RagQueryRouter` chooses `docs` or `code` mode from the raw query text.
4. `RagService` computes a single embedding for the full query via `GigaChatEmbedder`.
5. `RagQueryRepository.retrieve(...)` runs one SQL query against `rag_chunks` in PostgreSQL with `pgvector`.
6. Ranking order is:
- lexical rank
- test-file penalty
- layer rank
- vector distance `embedding <=> query_embedding`
7. Response items are normalized to `{source, content, layer, title, metadata, score}`.
8. If embeddings fail, retrieval falls back to latest chunks from the same layers.
9. If code retrieval returns nothing, service falls back to docs layers.
## Storage and indices
- Primary store: PostgreSQL from `DATABASE_URL`, configured in `app/modules/shared/db.py`.
- Vector extension: `CREATE EXTENSION IF NOT EXISTS vector` in `app/modules/rag/persistence/schema_repository.py`.
- Primary table: `rag_chunks`.
- Cache tables:
- `rag_blob_cache`
- `rag_chunk_cache`
- `rag_session_chunk_map`
- SQL indexes currently created:
- `(rag_session_id)`
- `(rag_session_id, layer)`
- `(rag_session_id, layer, path)`
- `(qname)`
- `(symbol_id)`
- `(module_id)`
- `(doc_kind)`
- `(entrypoint_type, framework)`
`ASSUMPTION:` there is no explicit ANN index for the vector column in schema code. The code creates general SQL indexes, but no `ivfflat`/`hnsw` index is defined here.
## Layer: C0_SOURCE_CHUNKS
### Implementation
- Produced by `CodeIndexingPipeline.index_file(...)` in `app/modules/rag/indexing/code/pipeline.py`.
- Chunking logic: `CodeTextChunker.chunk(...)` in `app/modules/rag/indexing/code/code_text/chunker.py`.
- Document builder: `CodeTextDocumentBuilder.build(...)` in `app/modules/rag/indexing/code/code_text/document_builder.py`.
- Persisted via `RagDocumentRepository.insert_documents(...)` into `rag_chunks`.
### Input contract
This is an indexing layer, not a direct public retriever. The observed upstream indexing input is a file dict with at least:
- required:
- `path: str`
- `content: str`
- optional:
- `commit_sha: str | None`
- `content_hash: str`
- metadata fields copied through by `RagService._document_metadata(...)`
For retrieval, the layer is queried only indirectly through:
- `rag_session_id: str`
- `query: str`
- inferred mode/layers from `RagQueryRouter`
- fixed `limit=8`
### Output contract
Stored document shape:
- top-level:
- `layer = "C0_SOURCE_CHUNKS"`
- `lang = "python"`
- `source.repo_id`
- `source.commit_sha`
- `source.path`
- `title`
- `text`
- `span.start_line`
- `span.end_line`
- `embedding`
- metadata:
- `chunk_index`
- `chunk_type`: `symbol_block` or `window`
- `module_or_unit`
- `artifact_type = "CODE"`
- plus file-level metadata injected by `RagService`
Returned retrieval item shape:
- `source`
- `content`
- `layer`
- `title`
- `metadata`
- `score`
No `line_start` / `line_end` are returned to the caller directly; they remain in DB columns `span_start` / `span_end` and are only used in logs.
### Defaults & limits
- AST chunking prefers one chunk per top-level class/function/async function.
- Fallback window chunking:
- `size = 80` lines
- `overlap = 15` lines
- Global retrieval limit from `RagService.retrieve(...)`: `8`
- Embedding batch size from env:
- `RAG_EMBED_BATCH_SIZE`
- default `16`
### Known issues
- Nested methods/functions are not emitted as C0 chunks unless represented inside a selected top-level block.
- Returned API payload omits line spans even though storage has them.
- No direct filter by path, namespace, symbol, or `top_k` is exposed through the current endpoint.
## Layer: C1_SYMBOL_CATALOG
### Implementation
- Symbol extraction: `SymbolExtractor.extract(...)` in `app/modules/rag/indexing/code/symbols/extractor.py`.
- AST parsing: `PythonAstParser.parse_module(...)`.
- Document builder: `SymbolDocumentBuilder.build(...)`.
- Retrieval reads rows from `rag_chunks`; there is no dedicated symbol table.
### Input contract
Indexing input is the same per-file payload as C0.
Observed symbol extraction source:
- Python AST only
- supported symbol kinds:
- `class`
- `function`
- `method`
- `const` for top-level imports/import aliases
Retrieval input is still the generic text query endpoint. Query terms are enriched by `extract_query_terms(...)`:
- extracts identifier-like tokens from query text
- normalizes camelCase/PascalCase to snake_case
- adds special intent terms for management/control-related queries
- max observed query terms: `6`
### Output contract
Stored document shape:
- top-level:
- `layer = "C1_SYMBOL_CATALOG"`
- `title = qname`
- `text = "<kind> <qname>\n<signature>\n<docstring?>"`
- `span.start_line`
- `span.end_line`
- metadata:
- `symbol_id`
- `qname`
- `kind`
- `signature`
- `decorators_or_annotations`
- `docstring_or_javadoc`
- `parent_symbol_id`
- `package_or_module`
- `is_entry_candidate`
- `lang_payload`
- `artifact_type = "CODE"`
Observed `lang_payload` variants:
- class:
- `bases`
- function/method:
- `async`
- import alias:
- `imported_from`
- `import_alias`
### Defaults & limits
- Only Python source files are indexed into C-layers.
- Import and import-from declarations are materialized as `const` symbols only at module top level.
- Retrieval ranking gives C1 priority rank `1`, after C3 and before C2/C0.
### Known issues
- No explicit visibility/public-private model.
- `parent_symbol_id` currently stores the parent qname string from the stack, not the parent symbol hash. This is an observed implementation detail.
- Cross-file symbol resolution is not implemented; `dst_symbol_id` in edges resolves only against symbols extracted from the same file.
## Layer: C2_DEPENDENCY_GRAPH
### Implementation
- Edge extraction: `EdgeExtractor.extract(...)` in `app/modules/rag/indexing/code/edges/extractor.py`.
- Document builder: `EdgeDocumentBuilder.build(...)`.
- Built during `CodeIndexingPipeline.index_file(...)`.
### Input contract
Indexing input is the same per-file source payload as C0/C1.
Graph construction method:
- static analysis only
- Python AST walk only
- no runtime tracing
- no tree-sitter
Observed edge types:
- `calls`
- `imports`
- `inherits`
### Output contract
Stored document shape:
- top-level:
- `layer = "C2_DEPENDENCY_GRAPH"`
- `title = "<src_qname>:<edge_type>"`
- `text = "<src_qname> <edge_type> <dst>"`
- `span.start_line`
- `span.end_line`
- `links` contains one evidence link of type `EDGE`
- metadata:
- `edge_id`
- `edge_type`
- `src_symbol_id`
- `src_qname`
- `dst_symbol_id`
- `dst_ref`
- `resolution`: `resolved` or `partial`
- `lang_payload`
- `artifact_type = "CODE"`
Observed `lang_payload` usage:
- for calls: may include `callsite_kind = "function_call"`
### Defaults & limits
- Edge extraction is per-file only.
- `imports` edges are emitted only while visiting a class/function scope; top-level imports do not become C2 edges.
- Layer rank in retrieval SQL: `2`
### Known issues
- There is no traversal API, graph repository, or query language over C2. Retrieval only treats edges as text/vector rows in `rag_chunks`.
- Destination resolution is local to the file-level qname map.
- Top-level module import relationships are incompletely represented because `visit_Import` / `visit_ImportFrom` skip when there is no current scope.
## Layer: C3_ENTRYPOINTS
### Implementation
- Detection registry: `EntrypointDetectorRegistry.detect_all(...)`.
- Detectors:
- `FastApiEntrypointDetector`
- `FlaskEntrypointDetector`
- `TyperClickEntrypointDetector`
- Document builder: `EntrypointDocumentBuilder.build(...)`.
### Input contract
Indexing input is the same per-file source payload as other C-layers.
Detected entrypoint families today:
- HTTP:
- FastAPI decorators such as `.get`, `.post`, `.put`, `.patch`, `.delete`, `.route`
- Flask `.route`
- CLI:
- Typer/Click `.command`
- Typer/Click `.callback`
Not detected:
- Django routes
- Celery tasks
- RQ jobs
- cron jobs / scheduler entries
### Output contract
Stored document shape:
- top-level:
- `layer = "C3_ENTRYPOINTS"`
- `title = route_or_command`
- `text = "<framework> <entry_type> <route_or_command>"`
- `span.start_line`
- `span.end_line`
- `links` contains one evidence link of type `CODE_SPAN`
- metadata:
- `entry_id`
- `entry_type`: observed `http` or `cli`
- `framework`: observed `fastapi`, `flask`, `typer`, `click`
- `route_or_command`
- `handler_symbol_id`
- `lang_payload`
- `artifact_type = "CODE"`
FastAPI-specific observed payload:
- `lang_payload.methods = [HTTP_METHOD]` for `.get/.post/...`
### Defaults & limits
- Retrieval layer rank: `0` highest among code layers.
- Entrypoint mapping is handler-symbol centric:
- decorator match -> symbol -> `handler_symbol_id`
- physical location comes from symbol span
### Known issues
- Route parsing is string-based from decorator text, not semantic AST argument parsing.
- No dedicated entrypoint tags beyond `entry_type`, `framework`, and raw decorator-derived payload.
- Background jobs and non-decorator entrypoints are not indexed.
## Dependency graph / trace current state
### Exists or stub?
- C2 exists and is populated.
- It is not a stub.
- It is also not a full-project dependency graph service; it is a set of per-edge documents stored in `rag_chunks`.
### How the graph is built
- static Python AST analysis
- no runtime instrumentation
- no import graph resolver across modules
- no tree-sitter
### Edge types in data
- `calls`
- `imports`
- `inherits`
### Traversal API
- No traversal API was found in `app/modules/rag/*` or `app/modules/agent/*`.
- No method accepts graph traversal parameters such as depth, start node, edge filters, or BFS/DFS strategy.
- Current access path is only retrieval over indexed edge documents.
## Entrypoints current state
### Implemented extraction
- HTTP routes:
- FastAPI
- Flask
- CLI:
- Typer
- Click
### Mapping model
- `entrypoint -> handler_symbol_id -> symbol span/path`
- The entrypoint record itself stores:
- framework
- entry type
- raw route/command string
- handler symbol id
### Tags/types
- `entry_type` is the main normalized tag.
- Observed values: `http`, `cli`.
- `framework` is the second discriminator.
- There are no richer endpoint taxonomies such as `job`, `worker`, `webhook`, `scheduler`.
## Defaults and operational limits
- Query mode default: `docs`
- Code mode is enabled by keyword heuristics in `RagQueryRouter`
- Retrieval hard limit: `8`
- Fallback limit: `8`
- Query term extraction limit: `6`
- Ranked source bundle for project QA:
- top `12` RAG items
- top `10` file candidates
- No exposed `namespace`, `path_prefixes`, `top_k`, `max_chars`, `max_chunks`, `max_depth` in the public/internal retrieval endpoint
`ASSUMPTION:` the absence of these controls in endpoint and service signatures means they are not part of the current supported contract, even though `RagQueryRepository.retrieve(...)` has an internal `path_prefixes` parameter.
## Known cross-cutting issues
- Retrieval contract is effectively text-only at API level; structured retrieval exists only as internal SQL parameters.
- Response payload drops explicit line spans even though spans are stored.
- Vector retrieval is coupled to a single provider-specific embedder.
- Docs mode is the default, so code retrieval depends on heuristic query phrasing unless the project/qa graph prepends `по коду`.
- There is no separate retrieval contract per layer exposed over API; all layer selection is implicit.
## Where to plug ExplainPack pipeline
### Option 1: replace or extend `project_qa/context_analysis`
- Code location:
- `app/modules/agent/engine/graphs/project_qa_step_graphs.py`
- Why:
- retrieval is already complete at this step
- input bundle already contains ranked `rag_items` and `file_candidates`
- output is already a structured `analysis_brief`
- Risk:
- low
- minimal invasion if ExplainPack consumes `source_bundle` and emits the same `analysis_brief` shape
### Option 2: insert a new orchestrator step between `context_retrieval` and `context_analysis`
- Code location:
- `app/modules/agent/engine/orchestrator/template_registry.py`
- `app/modules/agent/engine/orchestrator/step_registry.py`
- Why:
- preserves current retrieval behavior
- makes ExplainPack an explicit pipeline stage with its own artifact
- cleanest for observability and future A/B migration
- Risk:
- low to medium
- requires one new artifact contract and one extra orchestration step, but no change to retrieval storage
### Option 3: introduce ExplainPack inside `ExplainActions.extract_logic`
- Code location:
- `app/modules/agent/engine/orchestrator/actions/explain_actions.py`
- Why:
- useful if ExplainPack is meant only for explain-style scenarios
- keeps general project QA untouched
- Risk:
- medium
- narrower integration point; may create duplicate reasoning logic separate from project QA analysis path
## Bottom line
- C0-C3 are implemented and persisted in one physical store: `rag_chunks`.
- Retrieval is a hybrid SQL ranking over lexical heuristics plus pgvector distance.
- C2 exists, but only as retrievable edge documents, not as a traversable graph subsystem.
- C3 covers FastAPI/Flask/Typer/Click only.
- The least invasive ExplainPack integration point is after retrieval and before answer composition, preferably as a new explicit orchestrator artifact or as a replacement for `context_analysis`.
@@ -0,0 +1,168 @@
---
id: api-rag-session-changes
title: Применение изменений к RAG-сессии
doc_type: api_method
domain: rag
status: draft
owner: system-analyst
source_of_truth: code
related_docs:
- arch-rag-package
- logic-rag-indexing
- entity-rag-session
- entity-rag-index-job
related_code:
- src/app/modules/rag/module.py
- src/app/schemas/rag_sessions.py
- src/app/schemas/indexing.py
entities:
- RagSession
- IndexJob
tags:
- rag
- api
- changes
- incremental-indexing
---
# Применение изменений к RAG-сессии
## Summary
- Purpose: поставить incremental indexing для уже существующей `RagSession`.
- Actor: внешний клиент модуля RAG.
- Trigger: частичное обновление индекса после изменения файлов.
- Endpoint: `POST /api/rag/sessions/{rag_session_id}/changes`
- Main entities: `RagSession`, `IndexJob`.
- Main logic: проверка существования сессии, создание change job, асинхронная обработка `upsert`/`delete`.
- Main errors: `not_found` для отсутствующей сессии, `422` для некорректного payload.
- Source of truth: `src/app/modules/rag/module.py`, `src/app/schemas/rag_sessions.py`.
## Назначение
Метод позволяет обновить индекс без полной переиндексации проекта. Он принимает только изменённые файлы и операции удаления.
## Контекст
Endpoint относится к новому API с явной работой через `rag_session_id`. В отличие от legacy `/api/index/changes`, он не создаёт сессию молча и требует, чтобы она уже существовала.
## Технический use case
### Основной сценарий
1. Клиент передаёт `rag_session_id` в path и список `changed_files` в body.
2. Endpoint проверяет наличие сессии через `RagSessionStore.get`.
3. При успехе `IndexingOrchestrator.enqueue_changes` создаёт новую job и запускает фоновое применение изменений.
4. API возвращает `index_job_id` и стартовый статус.
### Альтернативные ветки
- Если `rag_session_id` не найдена, endpoint бросает `AppError("not_found", ...)`.
- Для `op=delete` в последующей логике происходит удаление документов по пути без повторной генерации embeddings.
## Функциональные требования
### Request validation
- Path parameter `rag_session_id` обязателен.
- `changed_files` обязателен и состоит из элементов `ChangedFile`.
- Для каждого элемента обязательны `op` и `path`.
- `op` допускает только `upsert` или `delete`.
### Processing rules
- Сессия должна существовать до постановки change job.
- Каждый вызов создаёт новый `IndexJob`.
- Фактическое применение изменений выполняется асинхронно.
### State changes
- В `rag_index_jobs` появляется новая задача.
- Сам индекс меняется позже, внутри `RagService.index_changes`.
### Side effects
- Публикация job events.
- Удаление документов по `delete_paths` и upsert новых документов в фоне.
## Contract
### Endpoint
- Method: `POST`
- Path: `/api/rag/sessions/{rag_session_id}/changes`
- Auth: определяется внешним слоем приложения.
- Idempotent: нет, повторный вызов создаёт новую job.
- Timeout: короткий, endpoint не дожидается завершения индексации.
- Retry: только если клиент готов к созданию дополнительной job.
### Request
| Field | Type | Required | Constraints | Description |
|------|------|----------|-------------|-------------|
| `rag_session_id` | `string` | yes | path param, non-empty | идентификатор существующей RAG-сессии |
| `changed_files` | `array<ChangedFile>` | yes | схема каждого элемента обязательна | изменения файлов |
| `changed_files[].op` | `enum` | yes | `upsert` or `delete` | тип операции |
| `changed_files[].path` | `string` | yes | `min_length=1` | путь файла |
| `changed_files[].content` | `string \| null` | no | нужен для `upsert` | содержимое файла |
| `changed_files[].content_hash` | `string \| null` | no | повышает cache reuse | hash содержимого |
### Response
| Field | Type | Description |
|------|------|-------------|
| `index_job_id` | `string` | идентификатор фоновой задачи |
| `status` | `string` | стартовый статус задачи |
### External contract refs
- OpenAPI: формируется FastAPI по `response_model=IndexJobQueuedResponse`.
- Schema: `RagSessionChangesRequest`, `ChangedFile`, `IndexJobQueuedResponse`.
- DTO / serializer: `src/app/schemas/rag_sessions.py`, `src/app/schemas/indexing.py`.
- Additional refs: `logic-rag-indexing`.
## Errors
| error_id | http_code | when | client_behavior | retry |
|----------|-----------|------|-----------------|-------|
| `not_found` | `404` | `rag_session_id` отсутствует | создать новую сессию или исправить id | no |
| `validation_error` | `422` | нарушена схема request | исправить payload | no |
## Нефункциональные требования
### Security
- Метод доверяет внешнему слою авторизации.
### Observability
- Logs: прямое логирование endpoint отсутствует.
- Metrics: нет отдельной метрики на уровне метода.
- Traces: отсутствуют.
- Audit: каждая операция материализуется в `IndexJob`.
### Reliability
- Проверка существования сессии защищает от случайной записи в неинициализированный scope.
- Ошибки индексации доступны через job status и SSE events.
### Performance
- Быстрый ответ за счёт фонового выполнения.
## Связанные блоки логики
- `logic-rag-indexing`
## Связанные сущности
- `RagSession`
- `IndexJob`
## Связанный код
### Files
- `src/app/modules/rag/module.py`
- `src/app/schemas/rag_sessions.py`
- `src/app/schemas/indexing.py`
### Symbols
- `RagModule.public_router.rag_session_changes`
- `RagSessionStore.get`
- `IndexingOrchestrator.enqueue_changes`
## Связанные документы
- `arch-rag-package`
- `logic-rag-indexing`
- `entity-rag-session`
- `entity-rag-index-job`
## История изменений
| Date | Source | Changes |
|------|--------|---------|
| 2026-03-13 | code | Задокументирован публичный endpoint incremental indexing для существующей сессии. |
@@ -0,0 +1,166 @@
---
id: api-rag-session-create
title: Создание RAG-сессии и запуск snapshot-индексации
doc_type: api_method
domain: rag
status: draft
owner: system-analyst
source_of_truth: code
related_docs:
- arch-rag-package
- logic-rag-indexing
- entity-rag-session
- entity-rag-index-job
related_code:
- src/app/modules/rag/module.py
- src/app/schemas/rag_sessions.py
- src/app/schemas/indexing.py
entities:
- RagSession
- IndexJob
tags:
- rag
- api
- session
- snapshot
---
# Создание RAG-сессии и запуск snapshot-индексации
## Summary
- Purpose: создать новую `RagSession` и асинхронно поставить полную индексацию snapshot-файлов.
- Actor: внешний клиент модуля RAG.
- Trigger: первичная загрузка файлов проекта в индекс.
- Endpoint: `POST /api/rag/sessions`
- Main entities: `RagSession`, `IndexJob`.
- Main logic: создание UUID-сессии, постановка snapshot job, возврат идентификаторов сессии и job.
- Main errors: в коде endpoint нет собственной бизнес-валидации сверх pydantic; ошибки индексации проявляются позже в job status.
- Source of truth: `src/app/modules/rag/module.py`, `src/app/schemas/rag_sessions.py`.
## Назначение
Метод открывает новую RAG-сессию и запускает первичную индексацию файлов. Он используется как основной публичный вход для нового API пакета `rag`.
## Контекст
В отличие от legacy `/api/index/snapshot`, этот endpoint всегда создаёт новый `rag_session_id`, что позволяет независимо хранить несколько снимков одного проекта.
## Технический use case
### Основной сценарий
1. Клиент передаёт `project_id` и массив `files`.
2. `RagSessionStore.create` создаёт новую запись в `rag_sessions`.
3. `IndexingOrchestrator.enqueue_snapshot` создаёт `IndexJob` и запускает фоновую обработку.
4. API сразу возвращает `rag_session_id`, `index_job_id` и стартовый статус.
### Альтернативные ветки
- Если часть файлов не подлежит индексации, они будут отфильтрованы уже внутри indexing pipeline, а не на этапе ответа API.
- Ошибки индексации не меняют синхронный ответ create endpoint, а отражаются в последующем статусе job.
## Функциональные требования
### Request validation
- `project_id` обязателен и не может быть пустым.
- `files` передаются списком объектов `FileSnapshot`.
- Для каждого файла обязательны `path`, `content`, `content_hash`.
### Processing rules
- На каждый вызов создаётся новая `RagSession`.
- Snapshot job создаётся сразу после сохранения сессии.
- Ответ не ждёт завершения индексации.
### State changes
- В `rag_sessions` появляется новая запись.
- В `rag_index_jobs` появляется новая запись в статусе `queued`.
### Side effects
- Запуск фоновой `asyncio` task.
- Последующая публикация progress events в EventBus.
## Contract
### Endpoint
- Method: `POST`
- Path: `/api/rag/sessions`
- Auth: определяется внешним слоем приложения, внутри endpoint не задана.
- Idempotent: нет.
- Timeout: короткий, так как endpoint не ждёт индексацию.
- Retry: допустим только на стороне клиента с пониманием, что будет создана новая сессия.
### Request
| Field | Type | Required | Constraints | Description |
|------|------|----------|-------------|-------------|
| `project_id` | `string` | yes | `min_length=1` | идентификатор проекта |
| `files` | `array<FileSnapshot>` | yes | может быть пустым, но схема обязана соблюдаться | snapshot файлов для первичной индексации |
| `files[].path` | `string` | yes | `min_length=1` | путь файла |
| `files[].content` | `string` | yes | без дополнительных ограничений | содержимое файла |
| `files[].content_hash` | `string` | yes | `min_length=1` | hash содержимого для cache reuse |
### Response
| Field | Type | Description |
|------|------|-------------|
| `rag_session_id` | `string` | идентификатор созданной сессии |
| `index_job_id` | `string` | идентификатор фоновой задачи индексации |
| `status` | `IndexJobStatus` | стартовый статус задачи, обычно `queued` |
### External contract refs
- OpenAPI: формируется FastAPI по `response_model=RagSessionCreateResponse`.
- Schema: `RagSessionCreateRequest`, `RagSessionCreateResponse`.
- DTO / serializer: `src/app/schemas/rag_sessions.py`, `src/app/schemas/indexing.py`.
- Additional refs: `logic-rag-indexing`.
## Errors
| error_id | http_code | when | client_behavior | retry |
|----------|-----------|------|-----------------|-------|
| `validation_error` | `422` | нарушена pydantic-схема request | исправить payload | no |
## Нефункциональные требования
### Security
- Авторизация и аутентификация находятся вне этого метода.
### Observability
- Logs: прямое логирование в endpoint отсутствует.
- Metrics: отдельные API-метрики не выделены.
- Traces: отсутствуют.
- Audit: факт вызова материализуется через `RagSession` и `IndexJob`.
### Reliability
- Даже при дальнейшей ошибке индексации клиент может получить статус через job endpoint.
- Фоновая задача создаётся немедленно после ответа.
### Performance
- Время ответа не зависит от размера snapshot, кроме времени сериализации request.
## Связанные блоки логики
- `logic-rag-indexing`
## Связанные сущности
- `RagSession`
- `IndexJob`
## Связанный код
### Files
- `src/app/modules/rag/module.py`
- `src/app/schemas/rag_sessions.py`
- `src/app/schemas/indexing.py`
### Symbols
- `RagModule.public_router.create_rag_session`
- `RagSessionStore.create`
- `IndexingOrchestrator.enqueue_snapshot`
## Связанные документы
- `arch-rag-package`
- `logic-rag-indexing`
- `entity-rag-session`
- `entity-rag-index-job`
## История изменений
| Date | Source | Changes |
|------|--------|---------|
| 2026-03-13 | code | Задокументирован публичный endpoint создания RAG-сессии. |
@@ -0,0 +1,166 @@
---
id: api-rag-session-job
title: Получение статуса и событий задачи индексации
doc_type: api_method
domain: rag
status: draft
owner: system-analyst
source_of_truth: code
related_docs:
- arch-rag-package
- entity-rag-session
- entity-rag-index-job
related_code:
- src/app/modules/rag/module.py
- src/app/modules/rag/job_store.py
- src/app/schemas/rag_sessions.py
entities:
- RagSession
- IndexJob
tags:
- rag
- api
- job-status
- sse
---
# Получение статуса и событий задачи индексации
## Summary
- Purpose: отдать текущее состояние job и поток событий её выполнения в рамках конкретной `RagSession`.
- Actor: внешний клиент модуля RAG.
- Trigger: polling или live-monitoring после запуска snapshot/change indexing.
- Endpoint: `GET /api/rag/sessions/{rag_session_id}/jobs/{index_job_id}` и `GET /api/rag/sessions/{rag_session_id}/jobs/{index_job_id}/events`
- Main entities: `RagSession`, `IndexJob`.
- Main logic: чтение job по id, проверка принадлежности сессии, возврат status payload или SSE stream.
- Main errors: `not_found` при отсутствии job или несовпадении `rag_session_id`.
- Source of truth: `src/app/modules/rag/module.py`, `src/app/modules/rag/job_store.py`.
## Назначение
Документ описывает два связанных метода наблюдения: синхронный status endpoint и потоковый SSE endpoint. Оба работают поверх одной сущности `IndexJob`.
## Контекст
Create и changes endpoints возвращают только стартовый статус задачи, поэтому клиенту нужны отдельные методы для отслеживания выполнения. SSE-поток даёт live progress, а status endpoint нужен для простого polling.
## Технический use case
### Основной сценарий
1. Клиент вызывает status endpoint или открывает SSE stream по `index_job_id`.
2. Endpoint читает job из `IndexJobStore`.
3. Если job отсутствует или принадлежит другой `rag_session_id`, возвращается `not_found`.
4. Status endpoint отдаёт снимок counters и error payload.
5. SSE endpoint подписывается на `EventBus` c `replay=True` и транслирует `index_status`, `index_progress`, `terminal`.
### Альтернативные ветки
- При отсутствии новых событий SSE endpoint каждые 10 секунд отправляет `: keepalive`.
- После события `terminal` поток завершается и отписывается от EventBus.
## Функциональные требования
### Request validation
- `rag_session_id` и `index_job_id` обязательны как path parameters.
- Job должна существовать и принадлежать переданной сессии.
### Processing rules
- Status endpoint не подписывается на события и читает только текущее состояние job.
- SSE endpoint использует `replay=True`, чтобы клиент получил уже опубликованные события.
- Оба метода защищают от доступа к job другой сессии.
### State changes
- Методы не меняют состояние job.
### Side effects
- SSE endpoint создаёт временную подписку на EventBus.
- При завершении или разрыве соединения выполняется `unsubscribe`.
## Contract
### Endpoint
- Method: `GET`
- Path: `/api/rag/sessions/{rag_session_id}/jobs/{index_job_id}` и `/api/rag/sessions/{rag_session_id}/jobs/{index_job_id}/events`
- Auth: определяется внешним слоем приложения.
- Idempotent: да.
- Timeout: status endpoint короткий; SSE stream долгоживущий.
- Retry: polling можно повторять безопасно; SSE можно переподключать.
### Request
| Field | Type | Required | Constraints | Description |
|------|------|----------|-------------|-------------|
| `rag_session_id` | `string` | yes | path param | идентификатор сессии |
| `index_job_id` | `string` | yes | path param | идентификатор задачи |
### Response
| Field | Type | Description |
|------|------|-------------|
| `rag_session_id` | `string` | идентификатор сессии, только для status endpoint |
| `index_job_id` | `string` | идентификатор задачи |
| `status` | `IndexJobStatus` | текущее состояние job |
| `indexed_files` | `integer` | число успешно обработанных файлов |
| `failed_files` | `integer` | число файлов с ошибками |
| `cache_hit_files` | `integer` | число cache hit |
| `cache_miss_files` | `integer` | число cache miss |
| `error` | `object \| null` | ошибка, если job завершилась с `error` |
### External contract refs
- OpenAPI: status endpoint использует `response_model=RagSessionJobResponse`; SSE endpoint отдаёт `text/event-stream`.
- Schema: `RagSessionJobResponse`.
- DTO / serializer: `src/app/schemas/rag_sessions.py`.
- Additional refs: `entity-rag-index-job`.
## Errors
| error_id | http_code | when | client_behavior | retry |
|----------|-----------|------|-----------------|-------|
| `not_found` | `404` | job отсутствует или не принадлежит переданной сессии | проверить id или создать новую задачу | no |
## Нефункциональные требования
### Security
- Проверка `job.rag_session_id == rag_session_id` обязательна для обоих методов.
### Observability
- Logs: отдельные логи чтения статуса не реализованы.
- Metrics: отсутствуют.
- Traces: отсутствуют.
- Audit: история job хранится в `rag_index_jobs`, поток событий в памяти EventBus.
### Reliability
- SSE heartbeat удерживает соединение активным.
- `finally` блок гарантирует `unsubscribe`.
### Performance
- Status endpoint работает как лёгкий запрос к БД.
- SSE stream масштабируется числом активных подписчиков и объёмом событий.
## Связанные блоки логики
- `logic-rag-indexing`
## Связанные сущности
- `RagSession`
- `IndexJob`
## Связанный код
### Files
- `src/app/modules/rag/module.py`
- `src/app/modules/rag/job_store.py`
- `src/app/schemas/rag_sessions.py`
### Symbols
- `RagModule.public_router.rag_session_job`
- `RagModule.public_router.rag_session_job_events`
- `IndexJobStore.get`
## Связанные документы
- `arch-rag-package`
- `entity-rag-session`
- `entity-rag-index-job`
## История изменений
| Date | Source | Changes |
|------|--------|---------|
| 2026-03-13 | code | Задокументированы status и SSE endpoints для наблюдения за indexing job. |
@@ -0,0 +1,222 @@
---
## id: arch-rag-package
title: Пакет RAG
doc_type: architecture_overview
domain: rag
status: draft
owner: system-analyst
source_of_truth: code
related_docs:
- logic-rag-indexing
- logic-rag-retrieval
- entity-rag-session
- entity-rag-index-job
- api-rag-session-create
- api-rag-session-changes
- api-rag-session-job
related_code:
- src/app/modules/rag/module.py
- src/app/modules/rag/services/rag_service.py
- src/app/modules/rag/indexing_service.py
- src/app/modules/rag/persistence/repository.py
- src/app/modules/rag/persistence/schema_repository.py
entities:
- RagSession
- IndexJob
- RagDocument
tags:
- rag
- indexing
- retrieval
- architecture
# Пакет RAG
## Summary
- Scope: модуль индексации проектных файлов, хранения RAG-слоёв и выдачи retrieval-контекста.
- Purpose: построить индекс по документации и Python-коду и дать runtime доступ к релевантным фрагментам.
- Main modules: `RagModule`, `RagService`, `IndexingOrchestrator`, `RagRepository`, `RepoWebhookService`.
- Main domains: RAG-сессии, задачи индексации, документы индекса, blob-cache, retrieval.
- Main integrations: PostgreSQL/pgvector, GigaChat embeddings, FastAPI, EventBus, story context.
- Key entrypoints: `/api/rag/sessions`, `/api/rag/sessions/{rag_session_id}/changes`, `/api/rag/sessions/{rag_session_id}/jobs/{index_job_id}`, `/internal/rag-repo/webhook`.
- Key data flows: snapshot indexing, incremental reindex, retrieval из `rag_chunks`, webhook-нормализация коммитов.
- Source of truth: код `src/app/modules/rag/*`.
## Назначение
Пакет `rag` отвечает за полный цикл подготовки retrieval-контекста для проекта: принимает снапшоты и изменения файлов, преобразует их в набор атомарных `RagDocument`, векторизует, сохраняет в БД и предоставляет доступ к индексированным данным другим частям системы.
## Контекст
Модуль используется как инфраструктурный слой для agent/runtime и смежных интеграций. На вход он принимает либо список файлов проекта, либо webhook репозитория. На выходе формирует устойчивый индекс, ассоциированный с `rag_session_id`, и статус задач индексации, пригодный для опроса и SSE-подписки.
## Границы системы
### In scope
- Создание и хранение `RagSession`.
- Постановка и выполнение задач snapshot/change indexing.
- Индексация markdown-документации в слои `D1-D4`.
- Индексация Python-кода в слои `C0-C4`.
- Кэширование по `repo_id + blob_sha`.
- Сохранение retrieval-документов в `rag_chunks`.
- Выдача статуса задач и событий прогресса.
- Нормализация webhook Gitea/Bitbucket и связывание коммитов со story.
### Out of scope
- Финальная генерация ответа пользователю.
- Оркестрация LLM-диалога.
- Управление git-репозиторием и загрузка файлов из внешних источников.
- Политики маршрутизации intent/runtime вне собственного persistence/retrieval API.
## Архитектурная схема
`RagModule` собирает зависимости модуля и публикует HTTP endpoints. Для индексации он использует `RagSessionStore`, `IndexJobStore`, `IndexingOrchestrator` и `RagService`. `RagService` выбирает docs/code pipeline, обогащает документы метаданными файла, запрашивает embeddings и записывает результат через `RagRepository`. `RagRepository` агрегирует schema/session/job/document/cache/query репозитории. Отдельно `RagRepoModule` принимает repository webhooks и прокидывает нормализованный commit context в story storage и cache writer.
## Основные модули
| module | responsibility | depends_on | key_code_refs |
| ---------------------- | -------------------------------------------------------- | ------------------------------------------------------------------------ | ----------------------------------------------- |
| `RagModule` | сборка зависимостей, публичный и internal API | `RagService`, `IndexingOrchestrator`, `RagSessionStore`, `IndexJobStore` | `src/app/modules/rag/module.py` |
| `RagService` | синхронная бизнес-логика индексации файлов и cache reuse | docs/code pipeline, embedder, `RagRepository` | `src/app/modules/rag/services/rag_service.py` |
| `IndexingOrchestrator` | асинхронный job lifecycle, retry, project lock, EventBus | `IndexJobStore`, `RagIndexer`, `EventBus`, `RetryExecutor` | `src/app/modules/rag/indexing_service.py` |
| `DocsIndexingPipeline` | построение слоёв документации `D1-D4` | classifier, chunker, document builder | `src/app/modules/rag/indexing/docs/pipeline.py` |
| `CodeIndexingPipeline` | построение слоёв кода `C0-C4` | AST parser, symbol/edge/entrypoint/role builders | `src/app/modules/rag/indexing/code/pipeline.py` |
| `RagRepository` | единая точка persistence и retrieval | schema/session/job/document/cache/query repositories | `src/app/modules/rag/persistence/repository.py` |
| `RepoWebhookService` | нормализация webhook payload и выделение story id | story writer, cache writer | `src/app/modules/rag/webhook_service.py` |
## Основные доменные области
- RAG session как граница индекса конкретного проекта или его временного снапшота.
- Index job как жизненный цикл асинхронной индексации и канал наблюдения за прогрессом.
- RagDocument как атом индекса, который попадает в retrieval-хранилище и в cache.
- Repo webhook context как источник commit metadata для story и cache.
## Основные интеграции
| integration | direction | purpose | protocol / transport | related_docs |
| ------------------------ | --------- | --------------------------------------------------- | ---------------------------------- | -------------------------------------------------------------------------- |
| PostgreSQL + pgvector | outbound | хранение документов, jobs, sessions и vector search | SQLAlchemy / SQL / pgvector | `logic-rag-retrieval` |
| GigaChat embeddings | outbound | получение embedding для batch документов | HTTP client через `GigaChatClient` | `logic-rag-indexing` |
| FastAPI | inbound | публичный и internal API модуля | HTTP | `api-rag-session-create`, `api-rag-session-changes`, `api-rag-session-job` |
| EventBus | outbound | публикация прогресса индексации и terminal events | in-process async events / SSE | `api-rag-session-job` |
| Story context repository | outbound | запись webhook-коммитов для story | Python interface | `logic-rag-indexing` |
## Основные потоки
### Flow 1
1. Клиент вызывает `POST /api/rag/sessions` с `project_id` и snapshot файлов.
2. `RagSessionStore` создаёт `rag_session_id`, а `IndexingOrchestrator` создаёт `IndexJob`.
3. `RagService` фильтрует файлы, переиспользует cache по `blob_sha` или строит docs/code документы заново.
4. Документы векторизуются, записываются в `rag_chunks`, а job получает финальный статус `done` или `error`.
### Flow 2
1. Клиент вызывает `POST /api/rag/sessions/{rag_session_id}/changes`.
2. `IndexingOrchestrator` сериализует обработку по `rag_session_id`.
3. `RagService` удаляет документы по `delete_paths`, пересобирает upsert-файлы и применяет изменения к индексу.
4. Клиент читает статус и события задачи через job endpoints.
## Архитектурные решения и ограничения
### Key decisions
- Snapshot и incremental indexing используют один и тот же `RagService`, различаясь только стратегией записи.
- Кэш документов привязан к `repo_id + blob_sha`, а не к `rag_session_id`, что позволяет переиспользовать embeddings между сессиями одного проекта.
- Документация и код индексируются разными pipeline, но сохраняются в общую таблицу `rag_chunks`.
- Асинхронность вынесена в `IndexingOrchestrator`, чтобы `RagService` оставался application-service без управления job lifecycle.
### Constraints
- Code indexing поддерживает только Python-файлы.
- Docs indexing ориентирован на markdown и frontmatter YAML.
- Deprecated endpoint `/internal/rag/retrieve` не используется для рабочего retrieval.
- Реальное retrieval API доступно через repository/runtime adapters, а не через публичный HTTP endpoint модуля.
### Risks
- Ошибки embeddings или временные сетевые сбои переводят job в `error` только после исчерпания retry.
- Полное `replace_documents` для snapshot удаляет все документы сессии перед вставкой новых.
- Retrieval ranking завязан на SQL-эвристики по layer, lexical match и metadata, поэтому качество зависит от корректности metadata builders.
## Нефункциональные аспекты
### Security
- Публичные endpoints не содержат собственной бизнес-авторизации внутри модуля и полагаются на внешний слой приложения.
- Webhook provider определяется по headers/payload без явной проверки подписи в самом `RepoWebhookService`.
### Reliability
- Проектный `asyncio.Lock` предотвращает параллельную индексацию одной `rag_session`.
- `RetryExecutor` повторяет временные сбои индексации.
### Observability
- Logs: `RagService` пишет предупреждения по cache hit/miss и skipped files.
- Metrics: явные метрики не выделены.
- Traces: явная трассировка не реализована.
- Audit: job status и webhook commit binding сохраняются в БД.
### Performance
- Embeddings отправляются батчами с размером из `RAG_EMBED_BATCH_SIZE`.
- Cache reuse исключает повторную векторизацию неизменённых blob.
### Scalability
- Индекс хранится на уровне SQL-таблиц с векторными полями и индексами по session/layer/path.
- При росте объёма данных узким местом остаются полнотабличные delete/insert по snapshot и SQL sorting retrieval.
## Связанные сущности
- `RagSession`
- `IndexJob`
- `RagDocument`
## Связанный код
### Files
- `src/app/modules/rag/module.py`
- `src/app/modules/rag/services/rag_service.py`
- `src/app/modules/rag/indexing_service.py`
- `src/app/modules/rag/persistence/repository.py`
- `src/app/modules/rag/persistence/schema_repository.py`
- `src/app/modules/rag/webhook_service.py`
### Symbols
- `RagModule`
- `RagRepoModule`
- `RagService`
- `IndexingOrchestrator`
- `RagRepository`
- `RepoWebhookService`
## Связанные документы
- `logic-rag-indexing`
- `logic-rag-retrieval`
- `entity-rag-session`
- `entity-rag-index-job`
- `api-rag-session-create`
- `api-rag-session-changes`
- `api-rag-session-job`
## История изменений
| Date | Source | Changes |
| ---------- | ------ | ------------------------------------------------------------------- |
| 2026-03-13 | code | Создан обзор архитектуры пакета `rag` на основе текущей реализации. |
+154
View File
@@ -0,0 +1,154 @@
---
id: entity-rag-index-job
title: Сущность IndexJob
doc_type: domain_entity
domain: rag
status: draft
owner: system-analyst
source_of_truth: code
related_docs:
- arch-rag-package
- logic-rag-indexing
- entity-rag-session
- api-rag-session-job
related_code:
- src/app/modules/rag/job_store.py
- src/app/modules/rag/indexing_service.py
- src/app/modules/rag/persistence/job_repository.py
- src/app/modules/rag/persistence/schema_repository.py
entities:
- IndexJob
- RagSession
tags:
- rag
- indexing
- job
- domain-entity
---
# Сущность IndexJob
## Summary
- Domain: rag
- Purpose: представить асинхронную задачу индексации и её наблюдаемый статус.
- Entity role: operational entity для выполнения snapshot/change indexing.
- Main attributes: `index_job_id`, `rag_session_id`, `status`, `indexed_files`, `failed_files`, `cache_hit_files`, `cache_miss_files`, `error`.
- Lifecycle: `queued -> running -> done|error`.
- Invariants: job всегда принадлежит одной `RagSession`, статус хранится как enum `IndexJobStatus`.
- Related APIs: создание job косвенно через session endpoints, чтение через job status endpoint и SSE endpoint.
- Related logic: `IndexingOrchestrator`, retry, EventBus publishing.
- Source of truth: `src/app/modules/rag/job_store.py`, `src/app/modules/rag/indexing_service.py`.
## Назначение
`IndexJob` хранит технический прогресс и итог выполнения индексации. Он нужен, чтобы API модуля мог вернуть результат не синхронно, а через опрос статуса и подписку на события.
## Контекст
Job создаётся на каждую snapshot- или changes-операцию. Сервис индексации обновляет его counters и публикует события прогресса в EventBus под ключом `index_job_id`.
## Роль в доменной модели
Это операционная сущность, которая связывает пользовательский запрос на индексацию с фактическим процессом обработки файлов. Она не хранит сам индекс, но управляет прозрачностью выполнения и ошибками.
## Атрибуты
| attribute | type | required | description | constraints |
|-----------|------|----------|-------------|-------------|
| `index_job_id` | `str` | yes | уникальный идентификатор задачи | primary key, non-empty |
| `rag_session_id` | `str` | yes | ссылка на целевую RAG-сессию | non-empty |
| `status` | `IndexJobStatus` | yes | текущее состояние задачи | `queued`, `running`, `done`, `error` |
| `indexed_files` | `int` | yes | число успешно обработанных файлов | `>= 0` |
| `failed_files` | `int` | yes | число файлов с ошибками | `>= 0` |
| `cache_hit_files` | `int` | yes | число файлов, обслуженных из cache | `>= 0` |
| `cache_miss_files` | `int` | yes | число файлов, потребовавших embeddings | `>= 0` |
| `error` | `ErrorPayload \| None` | no | информация о необработанной временной ошибке после retry | optional |
## Состояния и жизненный цикл
### Основные состояния
- `queued`
- `running`
- `done`
- `error`
### Переходы состояний
1. `IndexJobStore.create` создаёт job в состоянии `queued`.
2. `IndexingOrchestrator._run_with_project_lock` переводит job в `running`.
3. Успешная индексация переводит job в `done` и заполняет counters.
4. Ошибка после исчерпания retry переводит job в `error` и заполняет `ErrorPayload`.
## Инварианты и ограничения
- Job не мигрирует между `rag_session_id`.
- Финальные counters сохраняются в БД перед публикацией terminal event.
- Ошибки уровня `TimeoutError`, `ConnectionError`, `OSError` считаются временными и оборачиваются в `index_retry_exhausted` только после retry exhaustion.
## Связи с другими сущностями
| entity | relation | description |
|--------|----------|-------------|
| `RagSession` | many-to-one | каждая задача относится к одной сессии |
| `RagDocument` | indirect | job обновляет набор документов сессии, но не владеет ими напрямую |
## Использование в системе
### Related API
- `POST /api/rag/sessions`
- `POST /api/rag/sessions/{rag_session_id}/changes`
- `GET /api/rag/sessions/{rag_session_id}/jobs/{index_job_id}`
- `GET /api/rag/sessions/{rag_session_id}/jobs/{index_job_id}/events`
### Related UI
- Прямого UI в репозитории не обнаружено.
### Related logic
- `logic-rag-indexing`
### Related integrations
- EventBus SSE stream
- PostgreSQL таблица `rag_index_jobs`
## Функциональные требования
- Job должна создаваться до запуска фоновой задачи.
- Публичный API обязан проверять принадлежность job указанной `rag_session_id`.
- Progress events должны публиковаться в формате, достаточном для фронта или внешнего клиента.
## Нефункциональные требования
### Audit / history
- `created_at` и `updated_at` сохраняются в таблице `rag_index_jobs`.
### Security
- Доступ к job опирается на проверку связи `job.rag_session_id == requested rag_session_id`.
### Observability
- SSE stream отдаёт `index_status`, `index_progress`, `terminal`.
## Связанный код
### Files
- `src/app/modules/rag/job_store.py`
- `src/app/modules/rag/indexing_service.py`
- `src/app/modules/rag/persistence/job_repository.py`
- `src/app/modules/rag/persistence/schema_repository.py`
### Symbols
- `IndexJob`
- `IndexJobStore.create`
- `IndexJobStore.get`
- `IndexJobStore.save`
- `IndexingOrchestrator._run_with_project_lock`
## Связанные документы
- `arch-rag-package`
- `logic-rag-indexing`
- `entity-rag-session`
- `api-rag-session-job`
## История изменений
| Date | Source | Changes |
|------|--------|---------|
| 2026-03-13 | code | Добавлено описание lifecycle и контракта сущности `IndexJob`. |
@@ -0,0 +1,143 @@
---
id: entity-rag-session
title: Сущность RagSession
doc_type: domain_entity
domain: rag
status: draft
owner: system-analyst
source_of_truth: code
related_docs:
- arch-rag-package
- logic-rag-indexing
- api-rag-session-create
- api-rag-session-changes
- api-rag-session-job
related_code:
- src/app/modules/rag/session_store.py
- src/app/modules/rag/persistence/session_repository.py
- src/app/modules/rag/persistence/schema_repository.py
entities:
- RagSession
tags:
- rag
- session
- domain-entity
---
# Сущность RagSession
## Summary
- Domain: rag
- Purpose: связать индекс и связанные job с конкретным проектом или его рабочим снимком.
- Entity role: корневая сущность области RAG indexing/retrieval.
- Main attributes: `rag_session_id`, `project_id`, `created_at`.
- Lifecycle: создаётся до первой индексации и используется как scope всех retrieval-запросов.
- Invariants: `rag_session_id` уникален, `project_id` обязателен.
- Related APIs: `POST /api/rag/sessions`, `POST /api/rag/sessions/{rag_session_id}/changes`, `GET /api/rag/sessions/{rag_session_id}/jobs/{index_job_id}`.
- Related logic: snapshot indexing, change indexing, retrieval filtering.
- Source of truth: `src/app/modules/rag/session_store.py`, таблица `rag_sessions`.
## Назначение
`RagSession` определяет границу индекса для проекта. Все документы, задачи и retrieval-запросы внутри `rag` привязаны к этой сущности.
## Контекст
Сессия используется и в новом API с UUID, и в legacy/internal режиме, где `project_id` может совпадать с `rag_session_id`. Через неё сервис восстанавливает `repo_id`, который затем участвует в кэшировании документов.
## Роль в доменной модели
`RagSession` является владельцем набора индексированных документов и асинхронных `IndexJob`. Без неё нельзя безопасно выполнять reindex или retrieval, потому что именно она задаёт scope таблицы `rag_chunks`.
## Атрибуты
| attribute | type | required | description | constraints |
|-----------|------|----------|-------------|-------------|
| `rag_session_id` | `str` | yes | уникальный идентификатор сессии | primary key, non-empty |
| `project_id` | `str` | yes | идентификатор проекта или workspace | non-empty |
| `created_at` | `timestamp with time zone` | yes | время создания записи в БД | default `CURRENT_TIMESTAMP` |
## Состояния и жизненный цикл
### Основные состояния
- created
- active
- reused via legacy/internal API
### Переходы состояний
1. `RagSessionStore.create(project_id)` создаёт новую сессию с UUID.
2. `RagSessionStore.put(rag_session_id, project_id)` создаёт или обновляет сессию с заданным ключом.
3. После создания сессия используется для indexing и retrieval до тех пор, пока не будет заменена новым идентификатором на уровне вызывающего сервиса.
## Инварианты и ограничения
- `project_id` не должен быть пустым.
- Для retrieval и indexing используется только один `rag_session_id` за операцию.
- `RagService._resolve_repo_id` использует `project_id` этой сущности как `repo_id` для cache scope.
## Связи с другими сущностями
| entity | relation | description |
|--------|----------|-------------|
| `IndexJob` | one-to-many | одна сессия может иметь много задач индексации |
| `RagDocument` | one-to-many | все записи в `rag_chunks` привязаны к одной сессии |
## Использование в системе
### Related API
- `POST /api/rag/sessions`
- `POST /api/rag/sessions/{rag_session_id}/changes`
- `GET /api/rag/sessions/{rag_session_id}/jobs/{index_job_id}`
### Related UI
- Прямого UI в репозитории не обнаружено.
### Related logic
- `logic-rag-indexing`
- `logic-rag-retrieval`
### Related integrations
- PostgreSQL таблица `rag_sessions`
## Функциональные требования
- Сессия должна создаваться до постановки snapshot job.
- При change indexing запрос должен ссылаться на существующую сессию в новом публичном API.
- Legacy/internal API может создавать запись с предсказуемым `rag_session_id`, равным `project_id`.
## Нефункциональные требования
### Audit / history
- Время создания фиксируется в таблице `rag_sessions`.
### Security
- Отдельных прав доступа на уровне сущности внутри модуля нет.
### Observability
- Основная наблюдаемость сессии идёт через связанные `IndexJob`.
## Связанный код
### Files
- `src/app/modules/rag/session_store.py`
- `src/app/modules/rag/persistence/session_repository.py`
- `src/app/modules/rag/persistence/schema_repository.py`
### Symbols
- `RagSession`
- `RagSessionStore.create`
- `RagSessionStore.put`
- `RagSessionStore.get`
## Связанные документы
- `arch-rag-package`
- `logic-rag-indexing`
- `api-rag-session-create`
- `api-rag-session-changes`
- `api-rag-session-job`
## История изменений
| Date | Source | Changes |
|------|--------|---------|
| 2026-03-13 | code | Добавлено описание сущности `RagSession` и её роли в границах индекса. |
@@ -0,0 +1,166 @@
---
id: logic-rag-indexing
title: Индексация файлов в RAG
doc_type: logic_block
domain: rag
status: draft
owner: system-analyst
source_of_truth: code
related_docs:
- arch-rag-package
- entity-rag-session
- entity-rag-index-job
- api-rag-session-create
- api-rag-session-changes
related_code:
- src/app/modules/rag/indexing_service.py
- src/app/modules/rag/services/rag_service.py
- src/app/modules/rag/indexing/docs/pipeline.py
- src/app/modules/rag/indexing/code/pipeline.py
- src/app/modules/rag/persistence/document_repository.py
entities:
- RagSession
- IndexJob
- RagDocument
tags:
- rag
- indexing
- snapshot
- changes
---
# Индексация файлов в RAG
## Summary
- Purpose: превратить входной список файлов в набор индексируемых `RagDocument` и сохранить их в persistence.
- Trigger: создание RAG-сессии, запрос изменений, internal snapshot/changes endpoints.
- Inputs: `rag_session_id`, файлы snapshot или changed_files, progress callback.
- Outputs: обновлённые записи в `rag_chunks`, `rag_session_chunk_map`, статистика indexed/failed/cache hit/cache miss.
- Main entities: `RagSession`, `IndexJob`, `RagDocument`.
- Main dependencies: `IndexingOrchestrator`, `RagService`, `DocsIndexingPipeline`, `CodeIndexingPipeline`, `GigaChatEmbedder`, `RagRepository`.
- Side effects: SQL delete/insert, cache write, SSE events, job status update.
- Source of truth: `src/app/modules/rag/indexing_service.py`, `src/app/modules/rag/services/rag_service.py`.
## Назначение
Блок обеспечивает управляемую индексацию файлов проекта в многослойный RAG-индекс. Он должен одинаково поддерживать полный snapshot и инкрементальные изменения, не допуская гонок внутри одной `rag_session_id`.
## Контекст
Индексация запускается через HTTP API модуля. `IndexingOrchestrator` отвечает за job lifecycle и progress events, а `RagService` за фактическую переработку файлов в документы. Для уменьшения стоимости embeddings используется cache по содержимому blob.
## Технический use case
### Основной сценарий
1. API создаёт `IndexJob` и передаёт управление в `IndexingOrchestrator`.
2. `IndexingOrchestrator` фильтрует входной набор, ставит статус `running`, публикует стартовое событие и захватывает lock по `rag_session_id`.
3. `RagService` определяет `repo_id`, фильтрует индексируемые файлы, проверяет cache по `blob_sha` и либо переиспользует документы, либо заново строит docs/code слои.
4. Для новых документов `RagService` добавляет file metadata, запрашивает embeddings батчами и сохраняет документы через `RagRepository`.
5. `IndexingOrchestrator` обновляет job counters, публикует финальный `index_status` и `terminal`.
### Альтернативные ветки
- Для `changes` операции с `op=delete` только удаляют документы по `path`, без повторной сборки.
- Если файл не поддержан ни docs-, ни code-pipeline, сервис делает fallback в docs pipeline.
- При временном сбое индексации `RetryExecutor` повторяет операцию; после исчерпания попыток job получает `error`.
## Функциональные требования
### Preconditions
- `rag_session_id` уже существует либо создаётся до запуска indexing job.
- Файлы передаются в виде словарей, совместимых со схемами `FileSnapshot` или `ChangedFile`.
- Для cache reuse у файла должен быть `content_hash` или доступный `content`.
### Processing rules
- Snapshot перед записью выполняет полную замену документов сессии через `replace_documents`.
- Incremental changes отделяет `delete_paths` от upsert-файлов и применяет изменения через `apply_document_changes`.
- `repo_id` выводится из `project_id` у сессии, а при отсутствии сессии fallback равен `rag_session_id`.
- Для поддерживаемого markdown строятся docs-слои `D1-D4`.
- Для поддерживаемого Python-кода строятся code-слои `C0-C4`.
- Каждый документ получает metadata файла: `blob_sha`, `repo_id`, `artifact_type`, `section`, `doc_id`, `owner`, `system_component`, `last_modified`, `staleness_score`.
### Validation rules
- До индексации snapshot/changes проходят фильтрацию через `filter_snapshot_files` и `filter_changes_for_indexing`.
- Пустые или неподходящие файлы исключаются из обрабатываемого набора.
- Вектор сохраняется только если размерность embedding совпадает с размерностью поля `vector` при retrieval.
### Output / result rules
- Результат операции всегда выражается четырьмя счётчиками: `indexed_files`, `failed_files`, `cache_hit_files`, `cache_miss_files`.
- Для snapshot весь набор документов сессии после операции должен соответствовать текущему переданному snapshot.
- Для changes в индексе должны остаться только документы по актуальному состоянию изменённых путей.
### Side effects
- Удаление и вставка строк в `rag_chunks`.
- Запись `rag_session_chunk_map` для документов, имеющих `repo_id` и `blob_sha`.
- Сохранение cache в `rag_blob_cache` и `rag_chunk_cache`.
- Публикация SSE-событий прогресса и завершения задачи.
## Ограничения и условия вызова
- Одновременно может выполняться только одна indexing operation на `rag_session_id`.
- Code indexing работает только для Python файлов, распознаваемых `PythonFileFilter`.
- Docs indexing рассчитывает на markdown с возможным YAML frontmatter.
- Метод `replace_documents` делает жёсткую замену индекса сессии и не подходит для конкурентного merge разных snapshot-источников.
## Нефункциональные требования
### Security
- Модуль не валидирует источник файлов и не выполняет контентную санацию сверх собственных парсеров.
### Observability
- Logs: фиксируются skipped files и режим обработки `cache` / `embed`.
- Metrics: отдельные счётчики не выделены, но статистика сохраняется в job.
- Traces: не реализованы.
- Audit: `rag_index_jobs` и `rag_session_chunk_map` образуют журнал выполнения и происхождения chunk.
### Reliability
- `asyncio.Lock` сериализует операции в рамках одной сессии.
- `RetryExecutor` покрывает временные ошибки `TimeoutError`, `ConnectionError`, `OSError`.
### Performance
- Embeddings обрабатываются батчами.
- Cache hit исключает повторный парсинг и повторный вызов embedder.
## Связанные API / UI / integration points
- `POST /api/rag/sessions`
- `POST /api/rag/sessions/{rag_session_id}/changes`
- `POST /internal/rag/index/snapshot`
- `POST /internal/rag/index/changes`
## Связанные сущности
- `RagSession`
- `IndexJob`
- `RagDocument`
## Связанный код
### Files
- `src/app/modules/rag/indexing_service.py`
- `src/app/modules/rag/services/rag_service.py`
- `src/app/modules/rag/indexing/docs/pipeline.py`
- `src/app/modules/rag/indexing/code/pipeline.py`
- `src/app/modules/rag/persistence/document_repository.py`
### Symbols
- `IndexingOrchestrator.enqueue_snapshot`
- `IndexingOrchestrator.enqueue_changes`
- `IndexingOrchestrator._run_with_project_lock`
- `RagService.index_snapshot`
- `RagService.index_changes`
- `RagService._index_files`
## Связанные документы
- `arch-rag-package`
- `entity-rag-session`
- `entity-rag-index-job`
- `api-rag-session-create`
- `api-rag-session-changes`
## История изменений
| Date | Source | Changes |
|------|--------|---------|
| 2026-03-13 | code | Описана фактическая логика snapshot и incremental indexing пакета `rag`. |
@@ -0,0 +1,150 @@
---
id: logic-rag-retrieval
title: Retrieval и ранжирование RAG-документов
doc_type: logic_block
domain: rag
status: draft
owner: system-analyst
source_of_truth: code
related_docs:
- arch-rag-package
- entity-rag-session
related_code:
- src/app/modules/rag/persistence/repository.py
- src/app/modules/rag/persistence/query_repository.py
- src/app/modules/rag/persistence/retrieval_statement_builder.py
- src/app/modules/rag/contracts/enums.py
entities:
- RagSession
- RagDocument
tags:
- rag
- retrieval
- ranking
- pgvector
---
# Retrieval и ранжирование RAG-документов
## Summary
- Purpose: вернуть релевантные RAG-документы из `rag_chunks` для заданной сессии и набора фильтров.
- Trigger: вызовы runtime adapters и внутренних retrieval-компонентов.
- Inputs: `rag_session_id`, `query_embedding`, `query_text`, `layers`, path filters, preference filters, limit.
- Outputs: список rows с `path`, `content`, `layer`, `title`, `metadata`, `span_start`, `span_end`, ranking fields.
- Main entities: `RagSession`, `RagDocument`.
- Main dependencies: `RagRepository`, `RagQueryRepository`, `RetrievalStatementBuilder`, PostgreSQL/pgvector.
- Side effects: отсутствуют, retrieval только читает БД.
- Source of truth: `src/app/modules/rag/persistence/query_repository.py`, `src/app/modules/rag/persistence/retrieval_statement_builder.py`.
## Назначение
Блок retrieval выбирает из индекса наиболее полезные документы по конкретному `rag_session_id`. Он объединяет в одном SQL векторную близость, lexical match, слой документа и структурные сигналы из metadata.
## Контекст
Публичный HTTP endpoint retrieval внутри `rag` помечен как deprecated, поэтому рабочий доступ к retrieval идёт через repository/runtime adapters. Это означает, что контракт фактически определяется SQL-builder и форматом rows, возвращаемых `RagQueryRepository`.
## Технический use case
### Основной сценарий
1. Клиент runtime вызывает `RagRepository.retrieve(...)`.
2. `RagQueryRepository` строит SQL через `RetrievalStatementBuilder.build_retrieve`.
3. SQL ограничивает поиск текущей `rag_session_id`, при необходимости слоями и path-фильтрами.
4. База сортирует документы по `prefer_bonus`, `test_penalty`, `layer_rank`, `lexical_rank`, `structural_rank`, `distance`.
5. Репозиторий возвращает rows с распарсенным `metadata_json`.
### Альтернативные ветки
- Для lexical fallback по коду используется `retrieve_lexical_code`, который работает только по слою `C0_SOURCE_CHUNKS`.
- Для точного добора файлов используется `retrieve_exact_files`, который читает заданные `path` без векторного ранжирования.
- Если `query_text` не даёт terms, lexical retrieval возвращает пустой результат без выполнения SQL.
## Функциональные требования
### Preconditions
- В `rag_chunks` уже должны существовать документы нужной `rag_session_id`.
- Для vector retrieval embedding документа должен быть ненулевым и совпадать по размерности с query embedding.
### Processing rules
- Базовый фильтр retrieval всегда включает `rag_session_id = :sid`.
- При наличии `layers` запрос ограничивается указанными слоями.
- `path_prefixes` задают include-фильтр по `LIKE prefix%`.
- `exclude_path_prefixes` и `exclude_like_patterns` исключают части дерева путей до сортировки.
- `prefer_path_prefixes` и `prefer_like_patterns` формируют `prefer_bonus`, поднимая приоритет совпавших путей.
- `prefer_non_tests` создаёт `test_penalty`, если путь попадает под test-паттерны.
### Validation rules
- Path filters экранируются для корректной работы `LIKE`.
- `retrieve_exact_files` нормализует и отбрасывает пустые пути до построения SQL.
- `retrieve_lexical_code` не выполняет SQL, если query terms отсутствуют.
### Output / result rules
- Каждый row содержит контент документа и технические поля ранжирования.
- `metadata_json` всегда декодируется в словарь `metadata`.
- Limit применяется на уровне SQL и ограничивает итоговый набор строк.
### Side effects
- Побочных эффектов нет, кроме чтения из БД.
## Ограничения и условия вызова
- Retrieval работает только внутри одной `rag_session_id` и не агрегирует несколько сессий.
- Layer ranking зашит в код SQL-builder и требует явного обновления при появлении новых слоёв.
- Полноценный HTTP retrieval endpoint в модуле отсутствует: `/internal/rag/retrieve` возвращает `410 deprecated`.
## Нефункциональные требования
### Security
- Retrieval не выполняет маскирование содержимого документов.
### Observability
- Logs: отдельное логирование запросов retrieval не реализовано.
- Metrics: метрики по latency и quality не выделены.
- Traces: отсутствуют.
- Audit: результат зависит только от состояния `rag_chunks` и входных фильтров.
### Reliability
- Пустой или некорректный lexical search безопасно возвращает пустой набор.
- `retrieve_exact_files` работает без embeddings и может использоваться как fallback.
### Performance
- Основной ranking выполняется в одном SQL-запросе.
- Для vector retrieval используются поля `embedding` и индексы по session/layer/path.
## Связанные API / UI / integration points
- Runtime retrieval adapters в `src/app/modules/agent/runtime/steps/retrieval/adapter.py`
- Explain retrieval gateway в `src/app/modules/agent/runtime/steps/explain/layered_gateway.py`
- Deprecated endpoint `POST /internal/rag/retrieve`
## Связанные сущности
- `RagSession`
- `RagDocument`
## Связанный код
### Files
- `src/app/modules/rag/persistence/repository.py`
- `src/app/modules/rag/persistence/query_repository.py`
- `src/app/modules/rag/persistence/retrieval_statement_builder.py`
- `src/app/modules/rag/contracts/enums.py`
### Symbols
- `RagRepository.retrieve`
- `RagRepository.retrieve_lexical_code`
- `RagRepository.retrieve_exact_files`
- `RagQueryRepository.retrieve`
- `RetrievalStatementBuilder.build_retrieve`
- `RetrievalStatementBuilder.build_lexical_code`
## Связанные документы
- `arch-rag-package`
- `entity-rag-session`
## История изменений
| Date | Source | Changes |
|------|--------|---------|
| 2026-03-13 | code | Описан фактический retrieval contract и ranking SQL для пакета `rag`. |
View File
+190
View File
@@ -0,0 +1,190 @@
# Снимок runtime-контура CODE_QA (answer layer)
Документ фиксирует текущее состояние runtime-контура `CODE_QA` после рефакторинга для планирования доработок answer layer. Без предложений по новому дизайну и без implementation brief.
---
## 1. Entry point
- **HTTP:** `POST /api/chat/messages``ChatModule.public_router()``send_message()`.
Файл: `src/app/modules/chat/module.py`, строки 7481.
- **Условие:** при `SIMPLE_CODE_EXPLAIN_ONLY=true` запрос идёт в `CodeExplainChatService.handle_message()` (прямой explain без полного CODE_QA pipeline). При `false` — в оркестратор.
- **Оркестратор:** `ChatOrchestrator.enqueue_message()` создаёт задачу и запускает `_process_task()` → в нём вызывается `self._runtime.run(...)`.
Файл: `src/app/modules/chat/service.py`, строки 4769, 71132.
- **Runtime-адаптер:** `CodeQaRunnerAdapter` реализует `AgentRunner`; в `run()` вызывает `self._executor.execute(user_query=..., rag_session_id=..., files_map=...)` в thread pool.
Файл: `src/app/modules/agent/runtime/code_qa_runner_adapter.py`, строки 2141.
- **Фактическая точка входа CODE_QA:** `AgentRuntimeExecutor.execute()`.
Файл: `src/app/modules/agent/runtime/executor.py`, строка 53.
Создание executor: `application.py`, строка 48 — `_executor = AgentRuntimeExecutor(llm=..., retrieval=...)`.
---
## 2. Runtime pipeline
Цепочка внутри `AgentRuntimeExecutor.execute()` (файл `executor.py`):
| Шаг | Файл | Класс/функция | Роль |
|-----|------|----------------|------|
| 1. Роутинг | `executor.py` | `self._router.route(user_query, ...)` | Intent + sub-intent, query_plan, retrieval_spec, symbol_resolution (pending). |
| 2. Сборка запроса retrieval | `retrieval_request_builder.py` | `build_retrieval_request(router_result, rag_session_id)` | Из `RouterResult` собирается `RetrievalRequest`: query, sub_intent, path_scope, requested_layers, retrieval_spec, constraints, query_plan. |
| 3. Retrieval | `executor.py` | `self._retrieve(state)``RuntimeRetrievalAdapter.retrieve_with_plan()` или `retrieve_exact_files()` для OPEN_FILE | По плану или по точным путям; возвращает `raw_rows` (list[dict]). |
| 4. Догидрация (только FIND_ENTRYPOINTS) | `executor.py` | `_hydrate_entrypoint_sources()` | Дозапрос C0 по путям из C3 entrypoints. |
| 5. Разрешение символа | `executor.py` | `_resolve_symbol(initial, raw_rows)` | По C1_SYMBOL_CATALOG: resolved / ambiguous / not_found; обновляет `state.router_result.symbol_resolution`. |
| 6. Retrieval result | `retrieval_result_builder.py` | `build_retrieval_result(raw_rows, report, symbol_resolution)` | Нормализованный `RetrievalResult`: code_chunks, relations, entrypoints, test_candidates, layer_outcomes и т.д. Для EXPLAIN при not_found/ambiguous — пересборка с пустыми rows (строки 9091 executor). |
| 7. Evidence bundle | `evidence_bundle_builder.py` | `build_evidence_bundle(retrieval_result, router_result)` | `EvidenceBundle`: resolved_sub_intent, resolved_target, code_chunks, relations, entrypoints, test_evidence, retrieval_summary. sufficient/failure_reasons не выставляются здесь. |
| 8. Pre evidence gate | `evidence_gate.py` | `evaluate_evidence(state.evidence_pack)` | По sub_intent проверяет достаточность (target, evidence_count, слои, entrypoints, tests). Выставляет `bundle.sufficient`, возвращает `EvidenceGateDecision`; от этого — `state.answer_mode` (normal/degraded). |
| 9. Answer policy | `policy.py` | `self._answer_policy.decide(router_result, gate_decision)` | Решение: вызывать LLM или короткий ответ (OPEN_FILE not_found, EXPLAIN not_found/ambiguous, gate не прошёл). При `should_call_llm=False` сразу идём в `assemble_final_result` с `decision.answer`. |
| 10. Synthesis input | `answer_synthesis.py` | `build_answer_synthesis_input(user_query, state.evidence_pack)` | Строит `AnswerSynthesisInput`: fast_context, deep_context, evidence_summary, semantic_hints, curated_facts (из answer_fact_curator). |
| 11. Выбор промпта | `prompt_selector.py` | `self._prompt_selector.select(sub_intent=..., answer_mode=...)` | Имя системного промпта по sub_intent (и degraded). |
| 12. Payload | `prompt_payload_builder.py` | `self._payload_builder.build(user_query, synthesis_input, evidence_pack, answer_mode)` | JSON payload для LLM: user_query, resolved_scenario, fast/deep_context, evidence_summary, curated must_mention_*, layer_guide, entrypoints, scenario-specific поля. |
| 13. Генерация черновика | `generator.py` | `self._generator.generate(prompt_name, prompt_payload)` | Вызов `AgentLlmService.generate(prompt_name, payload)` → черновик ответа. |
| 14. Post evidence gate | `post_gate.py` | `self._post_gate.validate(answer, answer_mode, ..., sub_intent, user_query, evidence_pack)` | Проверка черновика по sub_intent (EXPLAIN/ARCHITECTURE/TRACE_FLOW/…), возврат `RuntimeValidationResult(passed, action, reasons)`. |
| 15. Repair (если не passed) | `repair.py` | `self._repair.repair(draft_answer, validation, prompt_payload)` | Один вызов LLM с промптом `code_qa_repair_answer`; повторная валидация; при повторном fail — fallback answer. |
| 16. Финальный результат | `result_assembler.py` | `assemble_final_result(state, draft=..., final_answer=..., ...)` | Сборка `RuntimeFinalResult` и диагностики. |
Sub-intent для CODE_QA задаётся в роутере: `QueryPlanBuilder` использует `SubIntentDetector.detect()` и `_resolve_sub_intent()`; итог в `query_plan.sub_intent`. Ретривал-слои по sub_intent задаются в `RetrievalSpecFactory._with_sub_intent_layers()` (`retrieval_spec_factory.py`).
---
## 3. Answer path
- **Выбор промпта:** `RuntimePromptSelector.select(sub_intent, answer_mode)``src/app/modules/agent/runtime/steps/generation/prompt_selector.py`, строки 1821. При answer_mode in `{"degraded","not_found","insufficient"}` возвращается `code_qa_degraded_answer`, иначе — по `sub_intent` из словаря (fallback `code_qa_explain_answer`).
- **Сборка payload:** `RuntimePromptPayloadBuilder.build()``prompt_payload_builder.py`, строки 21–44. В payload попадают: `user_query`, `resolved_scenario`, `resolved_target`, `answer_mode`, `fast_context`, `deep_context`, `evidence_summary`, `semantic_hints`, `diagnostic_hints`, `retrieval_summary`, `confirmed_entrypoints`, `required_entrypoints`, `layer_guide`, плюс сценарий-специфичные поля из `_scenario_payload(synthesis_input)` (must_mention_*, fact_gaps и т.д.).
- **Draft answer:** создаётся в `executor.py`, строки 242246: `RuntimeDraftAnswer(prompt_name=..., prompt_payload=..., answer=self._generator.generate(...))`.
- **Post-processing:** отдельного шага нет; после генерации сразу идёт post-validation.
- **Repair:** `RuntimeAnswerRepairService.repair()``repair.py`, строки 16–37. Формирует JSON с draft_answer, validation_reasons, repair_focus, prompt_payload и один раз вызывает LLM с `code_qa_repair_answer`.
- **Final text:** в executor: при passed — `final_answer = draft.answer` (или результат repair); при не passed после repair — `_fallback_answer(state)`. Итоговая строка попадает в `RuntimeFinalResult.final_answer` в `assemble_final_result()`.
---
## 4. Prompt selection
- **Где:** `src/app/modules/agent/runtime/steps/generation/prompt_selector.py`, класс `RuntimePromptSelector`, метод `select(sub_intent, answer_mode)`.
- **Правила:**
- answer_mode in `{"degraded","not_found","insufficient"}``code_qa_degraded_answer`.
- Иначе по `sub_intent.upper()` из `_PROMPTS`; при отсутствии ключа — `code_qa_explain_answer`.
- **Используемые имена промптов для целевых sub_intent:**
| sub_intent | prompt name |
|-------------|--------------------------------|
| EXPLAIN | `code_qa_explain_answer` |
| EXPLAIN_LOCAL| `code_qa_explain_local_answer` |
| ARCHITECTURE| `code_qa_architecture_answer` |
| TRACE_FLOW | `code_qa_trace_flow_answer` |
- **Шаблоны:** загружаются по имени из YAML в `AgentLlmService.generate()``PromptLoader.load(name)`; конфиг — `src/app/modules/agent/llm/prompts.yml`. Ключи в YAML совпадают с именами выше (в т.ч. `code_qa_explain_answer`, `code_qa_architecture_answer`, `code_qa_trace_flow_answer`); repair — `code_qa_repair_answer`.
- **Выбор по sub_intent:** да, только через `RuntimePromptSelector.select(sub_intent=state.retrieval_request.sub_intent, ...)` в executor, строка 231.
---
## 5. Evidence-to-answer boundary
- **В answer layer evidence приходит как:**
- `EvidenceBundle` (в state.evidence_pack) и
- `AnswerSynthesisInput` (state.synthesis_input), собранный из bundle в `build_answer_synthesis_input()`.
- **Модели/DTO:**
- `EvidenceBundle`: `contracts.py`, 90106 — resolved_intent, resolved_sub_intent, resolved_target, target_type, code_chunks, relations, entrypoints, test_evidence, evidence_count, retrieval_summary.
- `AnswerSynthesisInput`: `contracts.py`, 109121 — user_question, resolved_scenario, resolved_target, fast_context, deep_context, evidence_summary, semantic_hints, **curated_facts**, evidence_sufficient, diagnostic_hints.
- Curated facts строит `answer_fact_curator.build_curated_answer_facts(bundle)` — словарь с ключами `explain`, `architecture`, `trace_flow` и общими полями (scenario, semantic_hints, relation_count и т.д.).
- **Что реально уходит в payload (prompt_payload_builder):**
- Общее: user_query, resolved_scenario, resolved_target, answer_mode, fast_context, deep_context, evidence_summary, semantic_hints, diagnostic_hints, retrieval_summary, confirmed_entrypoints, required_entrypoints, layer_guide.
- EXPLAIN: must_mention_methods/fields/calls/dependencies/constructor_args/files, must_not_infer_missing_details, fact_gaps (из curated_facts["explain"]).
- ARCHITECTURE: must_mention_components/relations, must_use_relation_verbs, must_avoid_semantic_labels_as_primary_claims, must_not_use_retrieval_labels, fact_gaps (из curated_facts["architecture"]).
- TRACE_FLOW: must_mention_flow_steps/calls/sequence_edges, must_avoid_overclaiming_full_flow, fact_gaps (из curated_facts["trace_flow"]).
- **Curated-поля (answer_fact_curator):**
- explain: required_methods, required_calls, required_fields, required_dependencies, required_constructor_args, required_files, fact_gaps (и др.).
- architecture: required_components, required_relations (source/verb/target/edge_type), required_relation_verbs, required_*_edges, forbidden_labels, fact_gaps.
- trace_flow: required_flow_steps (step, source, verb, target, path, line_span), required_calls, required_sequence_edges, fact_gaps.
То есть в LLM попадает не сырой retrieval, а нормализованный контекст (fast/deep_context, evidence_summary) плюс явные списки «must_mention_*» и fact_gaps по сценарию; для methods/dependencies/relations/flow steps уже есть выделенные curated-поля.
---
## 6. Post-validation / answer quality control
- **Post-evidence gate (runtime):** есть. `RuntimePostEvidenceGate.validate()``src/app/modules/agent/runtime/steps/gates/post/post_gate.py`, строки 39–65. Вызывается после генерации черновика (и после repair — повторно).
- **Answer validator:** это тот же post_gate: проверяет пустой ответ, соответствие answer_mode (degraded/not_found/ambiguous) требуемым формулировкам, длину при degraded, затем для normal — `_normal_answer_reasons()` по sub_intent.
- **Repair loop:** один раунд. При `not validation.passed` и наличии `self._repair` вызывается `repair()`; затем повторный `validate()`; если снова не passed — подставляется `_fallback_answer()` и смена answer_mode (`executor.py`, 281298).
- **Правила по sub_intent (post_gate):**
- **EXPLAIN** (93124): target focus; vagueness (_VAGUE_PHRASES); наличие required_methods/calls/dependencies (хотя бы одна группа); «too_vague_for_explain» при нуле совпадений; semantic_leakage (роли из semantic_hints без опоры на код).
- **ARCHITECTURE** (126150): target focus; vagueness; required_components, required_relations, relation_verbs; forbidden_labels (retrieval artifacts); methods_as_primary_components; «too_vague_for_architecture»; semantic_leakage.
- **TRACE_FLOW** (152171): target focus; vagueness; required_flow_steps и required_calls; _mentions_steps (сначала/затем или нумерация); overclaims (_OPTIMISTIC_TRACE_CLAIMS); «too_vague_for_trace_flow».
- **Technical precision для EXPLAIN:** проверяется косвенно: упоминание методов/вызовов/зависимостей из curated; явной проверки «только факты из кода» по токенам нет.
- **Concrete relations для ARCHITECTURE:** да — `_mentions_relations(answer, relations)` и упоминание verbs.
- **Concrete steps и overclaim для TRACE_FLOW:** да — `_mentions_steps`, `_mentions_relations` по steps, и проверка фраз из _OPTIMISTIC_TRACE_CLAIMS.
---
## 7. Problem sources (что может давать слабые ответы)
- **Payload shaping:** `prompt_payload_builder.py` — если curated_facts пустые или скудные (мало methods/calls/relations/steps), must_mention_* не направляют модель; deep_context обрезается до 30 чанков по 800 символов — возможна потеря важных деталей.
- **Prompts:** `prompts.yml` — длинные общие инструкции; для EXPLAIN/ARCHITECTURE/TRACE_FLOW нет жёсткой привязки к структуре payload (например, «обязательно используй must_mention_flow_steps по порядку»); модель может игнорировать fact_gaps.
- **Evidence normalization:** `answer_fact_curator` — методы/вызовы/relations извлекаются эвристически (regex, C1/C2); при слабом C1/C2 или нестандартных именах curated-списки пустеют → валидатор не к чему привязываться, ответ считается «vague».
- **Weak validation:** `post_gate` — проверки по вхождению подстрок (alias) и по небольшому набору фраз; нет проверки полноты (все ли must_mention_* упомянуты), нет проверки порядка шагов для TRACE_FLOW; semantic_leakage выключается при has_concrete_support, что может пропускать смешанные ответы.
- **Repair policy:** один вызов repair с общим промптом `code_qa_repair_answer` и repair_focus по reasons; при множественных reasons фокус может размываться; после repair при повторном fail сразу fallback — без второго раунда repair.
---
## 8. Minimal intervention points
1. **`src/app/modules/agent/runtime/steps/generation/prompt_payload_builder.py`**
Класс `RuntimePromptPayloadBuilder`, метод `build()` и `_scenario_payload()`.
Контролирует: какие поля и списки (must_mention_*, fact_gaps, layer_guide) попадают в JSON для LLM.
Удобно: один вход в «что видит модель»; можно усилить структуру под EXPLAIN/ARCHITECTURE/TRACE_FLOW без трогания оркестрации.
2. **`src/app/modules/agent/runtime/steps/context/answer_fact_curator.py`**
Функции `_explain_facts()`, `_architecture_facts()`, `_trace_flow_facts()`.
Контролируют: состав и качество curated_facts (required_*, fact_gaps).
Удобно: улучшение извлечения методов/relations/steps напрямую улучшает и payload, и валидацию.
3. **`src/app/modules/agent/runtime/steps/gates/post/post_gate.py`**
Класс `RuntimePostEvidenceGate`, методы `_validate_explain()`, `_validate_architecture()`, `_validate_trace_flow()` и хелперы (`_mentions_fact_group`, `_mentions_relations`, `_mentions_steps`).
Контролирует: критерии прохождения и набор reasons для repair.
Удобно: уже разбито по сценариям; можно ужесточить правила и добавить новые reasons без смены архитектуры.
4. **`src/app/modules/agent/llm/prompts.yml`**
Блоки `code_qa_explain_answer`, `code_qa_architecture_answer`, `code_qa_trace_flow_answer`, `code_qa_repair_answer`.
Контролируют: инструкции для черновика и починки.
Удобно: точечные правки формулировок и явные отсылки к полям payload (must_mention_*, fact_gaps).
5. **`src/app/modules/agent/runtime/steps/generation/prompt_selector.py`**
Класс `RuntimePromptSelector`, словарь `_PROMPTS` и метод `select()`.
Контролирует: какой системный промпт выбирается по sub_intent/answer_mode.
Удобно: введение отдельных промптов для подвидов (например, TRACE_FLOW по типу запроса) без изменения executor.
6. **`src/app/modules/agent/runtime/steps/context/answer_synthesis.py`**
Функция `build_answer_synthesis_input()`, формирование `fast_context` и `deep_context` (в т.ч. фильтр по C4 для EXPLAIN/ARCHITECTURE).
Контролирует: объём и приоритет контекста, передаваемого в synthesis_input.
Удобно: можно менять лимиты, порядок чанков или фильтры слоёв локально.
7. **`src/app/modules/agent/runtime/steps/finalization/repair.py`**
Класс `RuntimeAnswerRepairService`, метод `repair()` и `_repair_focus()`.
Контролирует: как validation.reasons мапятся в repair_focus и что уходит в промпт починки.
Удобно: можно сузить фокус repair под конкретные reasons или добавить приоритизацию без изменения цикла в executor.
---
*Документ описывает только текущую реализацию по коду после рефакторинга.*
+33
View File
@@ -0,0 +1,33 @@
# Agent Rules v1
## 1. Evidence-first
Агент должен формировать документацию только на основе подтвержденных источников: кода, существующей документации, системной аналитики и других явно доступных артефактов. Нельзя додумывать поведение системы, зависимости или бизнес-логику, если они не подтверждаются исходными материалами.
## 2. One Stable Object = One Document
Каждый документ должен описывать только одну устойчивую техническую сущность или один устойчивый аспект системы. Если по сущности могут ссылаться другие документы или она может переиспользоваться, ее нужно выносить в отдельный документ.
## 3. No Semantic Duplication
Документы не должны пересекаться по смыслу и повторять одно и то же содержание. Если одна и та же логика, правило или описание нужны в нескольких местах, агент должен вынести их в отдельный документ и использовать ссылки вместо дублирования текста.
## 4. Explicit Document Type
Для каждого документа агент должен явно определять его тип. На базовом уровне нужно использовать типы `ui_page`, `api_method`, `logic_block` и применять для каждого типа свой шаблон содержания и набор метаданных.
## 5. Hierarchical File Structure
Агент должен строить документацию как иерархию каталогов и файлов, а не как плоский набор страниц. Документы нужно раскладывать по смысловым разделам, например `docs/ui`, `docs/api`, `docs/logic`, `docs/architecture`, чтобы структура отражала архитектуру и упрощала навигацию.
## 6. Required YAML Frontmatter
Каждый документ должен начинаться с единообразного `YAML frontmatter`. В нем обязательно должны быть базовые метаданные документа: стабильный `id`, `title`, `doc_type`, `status`, `source_of_truth`, а также ссылки на связанные документы и кодовые артефакты.
## 7. Correct Internal Decomposition
Содержимое документа должно следовать шаблону своего типа и быть правильно декомпозировано внутри самого документа. Сценарии работы нужно описывать отдельно от детальных правил, контрактов, ограничений и дополнительных требований, чтобы документ оставался читаемым, атомарным и пригодным для индексирования.
## 8. Explicit Links Between Code and Docs
Каждый документ должен быть явно связан как минимум с соответствующим кодом и с соседними документами, если такие связи существуют. Агент должен фиксировать эти связи в метаданных и в тексте документа, чтобы документация образовывала связанную систему знаний, а не набор изолированных файлов.
+323
View File
@@ -0,0 +1,323 @@
# Концепция документации (Strong MVP, без связи с кодом)
## 1. Область применения
Документ описывает систему работы с документацией как самостоятельный слой.
Включает:
- текущее состояние (as-is)
- целевые сценарии использования
- целевую модель документации
- расширенный frontmatter
- базовую структуру документа `frontmatter + Summary + Details`
- RAG-слои (без связи с кодом)
---
# 2. Текущее состояние (As-Is)
## 2.1 Источник истины
- Документация хранится в Confluence
- Основная модель — иерархия страниц
- Навигация через дерево страниц
## 2.2 Структура и связи
- Документы не атомарны
- Одна страница содержит несколько тем
- Используются перекрестные ссылки между страницами
## 2.3 Ограничения
- Нет типизации документов
- Нет структурированного metadata-слоя
- Связи не формализованы
---
# 3. Целевые сценарии использования
## 3.1 Поиск документации
Пользователь формулирует запрос, например: "как работает создание заказа".
Система должна:
- найти релевантные документы
- отобрать наиболее точные фрагменты
- учитывать тип документа, модуль и сущности
Результат:
- короткий список релевантных документов
- возможность перейти в детали
## 3.2 Объяснение (Explain)
Пользователь хочет понять:
- как работает компонент
- что делает API
- как устроен процесс
Система должна:
- взять summary документа
- дополнить его деталями из Details
- дать краткое и точное объяснение без лишнего текста
## 3.3 Поиск по сущности (Entity Lookup)
Пользователь ищет:
- где используется сущность
- какие документы с ней связаны
Система должна:
- найти все документы, где упоминается сущность
- показать связи между ними
## 3.4 Навигация по документации
Пользователь начинает с одного документа и хочет:
- понять контекст
- перейти к связанным частям
Система должна:
- использовать parent/children
- использовать links
- строить осмысленный путь по документации
## 3.5 Генерация документации
Система создает новый документ:
- по шаблону
- с корректным frontmatter
- с заполненными разделами `Summary` и `Details`
Результат:
- документ сразу пригоден для использования
- соответствует структуре системы
## 3.6 Актуализация документации
При изменениях документа:
- система должна обновить только нужные части
- сохранить структуру
- обновить frontmatter и Details
Результат:
- документ остается консистентным
- не происходит деградации структуры
## 3.7 Связывание документации
При создании или обновлении:
- система добавляет связи между документами
- формирует граф знаний
Результат:
- документы не изолированы
- появляется навигация и reasoning
---
# 4. Frontmatter (расширенный контракт)
```yaml
id: api.create_order
type: api_method
name: create_order
title: Create order API
module: orders
layer: application
status: draft
updated_at: 2026-03-20
tags:
- orders
- api
entities:
- Order
- Cart
parent: orders_api
children: []
links:
- type: related_api
target: api.get_order
```
## Назначение ключевых полей
- `id` — стабильный идентификатор документа
- `type` — тип документа
- `title` — человекочитаемое имя
- `module` — модуль или bounded context
- `layer` — слой системы
- `status` — состояние документа
- `updated_at` — дата последнего обновления
- `tags` — фильтрация и поиск
- `entities` — база для entity lookup
- `parent` / `children` — иерархия
- `links` — граф связей
Все сведения о связях, сущностях и навигации должны храниться во frontmatter.
В теле документа отдельные разделы для связей, сущностей и навигации не создаются.
---
# 5. Структура документа (целевая)
Каждый документ состоит из двух смысловых слоев:
- frontmatter
- контент
Контент документа всегда состоит из двух обязательных разделов:
- `# Summary`
- `# Details`
## 5.1 Общие правила структуры
- `Summary` и `Details` всегда имеют заголовок уровня `#`
- других заголовков первого уровня в документе быть не должно
- `Summary` остается кратким и пригодным для explain
- `Details` заменяет прежние разделы `Context` и `Основное описание`
- внутренняя структура `Details` зависит от типа документа
## 5.2 Summary
Краткое описание на 3-6 строк.
Используется для explain, краткого ответа и быстрого понимания сути документа.
## 5.3 Details
Раздел содержит полное содержательное описание документа.
Состав подразделов определяется типом документа.
---
# 6. Типовая структура Details для API
Для документов типа `api_method` раздел `# Details` должен содержать следующие подразделы:
- `## Описание`
- `## Сценарий`
- `## Функциональные требования`
- `## Нефункциональные требования`
- `## Контракт`
## 6.1 Описание
Короткое описание сути метода:
- какую работу он выполняет
- для чего предназначен
## 6.2 Сценарий
Сценарий описывается в формате технического use case и включает:
- название
- предусловия
- триггер
- основной сценарий
- альтернативный сценарий
- обработку ошибок
- постусловие
Правила для сценария:
- сценарий должен быть лаконичным
- сценарий должен быть читаемым человеком
- в сценарии фиксируется суть шага, а не полная техническая реализация
- если условие можно выразить одним предложением, его допустимо оставить в сценарии
- если шаг требует детального описания формирования запроса, обработки ответа или внутренней логики, детали выносятся в функциональные требования
## 6.3 Функциональные требования
Функциональные требования описываются как последовательность требований внутри одного документа.
Формат:
- `FR-1`
- `FR-2`
- `FR-3`
Правила:
- идентификаторы локальны для документа
- на них нельзя ссылаться извне как на сквозные идентификаторы
- каждое требование описывает отдельный обязательный аспект реализации
## 6.4 Нефункциональные требования
Нефункциональные требования описываются аналогично функциональным.
Формат:
- `NFR-1`
- `NFR-2`
- `NFR-3`
Детальный формат записи может быть уточнен правилами конкретного проекта.
## 6.5 Контракт
Контракт должен быть пригоден для последующей сборки OpenAPI-спецификации.
Контракт описывает:
- входные параметры метода API
- выходные параметры
- структуру сообщения, как правило JSON
- обязательность полей
- типы полей
- ограничения на размер и формат
- назначение полей
- правила заполнения полей
- примеры данных
---
# 7. Базовая структура Details для остальных типов документов
Для всех типов документов, кроме `api_method`, на текущем этапе обязателен минимум:
- `# Summary`
- `# Details`
Дополнительные рекомендации по внутренней структуре `Details` для `logic_block`, `architecture_overview`, `domain_entity` и других типов будут заданы отдельно.
---
# 8. RAG слои (только документация)
## D0 — Чанки документов
Полный текст + разбиение.
## D1 — Каталог документов
- `id`
- `type`
- `title`
- `module`
- `tags`
## D2 — Индекс фактов
Факты, извлеченные из документов.
## D3 — Каталог сущностей
- сущности
- документы, где они используются
## D4 — Индекс сценариев (Workflow)
- последовательности действий
- пользовательские сценарии
## D5 — Граф связей
- связи между документами
---
# 9. Итог
Система документации:
- атомарные документы
- строгий frontmatter
- единая структура `Summary + Details`
- типовые правила для API-документов
- разделенные RAG-слои
Это позволяет:
- точно искать
- объяснять
- строить навигацию
- генерировать и обновлять документацию
@@ -0,0 +1,546 @@
# Текущая архитектура тестового пайплайна `pipeline_setup_v3`
Документ предназначен как краткое, но точное описание текущего устройства `pipeline_setup_v3` для внешней модели вроде ChatGPT.
Важно: текущий `pipeline_setup_v3` уже использует реальные runtime-компоненты агента, но по сути остается в первую очередь `code-first` пайплайном. Это особенно заметно в `evidence gate` и в наборе prompt'ов для LLM.
## 1. Общая схема пайплайна
`pipeline_setup_v3` запускает один из трех режимов:
- `router_only`
- `router_rag`
- `full_chain`
Во всех режимах используется `AgentRuntimeAdapter`, который является тестовым адаптером поверх реальных компонентов рантайма.
Общий поток для `full_chain`:
1. Пользовательский запрос
2. `IntentRouterV2`
3. Построение `RetrievalRequest`
4. `RuntimeRetrievalAdapter`
5. Построение нормализованного `RetrievalResult`
6. Сборка `EvidenceBundle`
7. `pre-evidence gate`
8. `RuntimeAnswerPolicy`
9. Вызов LLM через `AgentLlmService`
10. `post-evidence gate`
11. При необходимости `repair`
12. Сборка итогового результата, диагностики и артефактов теста
Ключевая идея: `pipeline_setup_v3` не эмулирует локальный тестовый сценарий вручную, а прогоняет реальные компоненты: роутер, retrieval, runtime executor и LLM.
## 2. Из каких компонентов состоит `pipeline_setup_v3`
### 2.1. Harness уровня тестов
Основные части:
- `tests/pipeline_setup_v3/run.py` — CLI-вход для запуска набора кейсов
- `tests/pipeline_setup_v3/core/runner.py` — оркестратор прогона кейсов
- `tests/pipeline_setup_v3/core/case_loader.py` — загрузка YAML-кейсов
- `tests/pipeline_setup_v3/core/validators.py` — проверка ожиданий
- `tests/pipeline_setup_v3/core/artifacts.py` — запись JSON/Markdown-результатов
- `tests/pipeline_setup_v3/runtime/agent_runtime_adapter.py` — мост к runtime-компонентам приложения
### 2.2. Runtime-компоненты, которые реально вызываются
- `IntentRouterV2`
- `RuntimeRepoContextFactory`
- `RuntimeRetrievalAdapter`
- `AgentRuntimeExecutor`
- `AgentLlmService`
- `PromptLoader`
### 2.3. Два варианта исполнения
#### `router_only`
Проверяет только результат роутера:
- `intent`
- `sub_intent`
- `graph_id`
- `conversation_mode`
RAG и LLM не вызываются.
#### `router_rag`
Проверяет:
- роутер
- retrieval plan
- реальный retrieval
- нормализованный `RetrievalResult`
LLM не вызывается.
#### `full_chain`
Проверяет полный runtime-контур:
- роутер
- retrieval
- evidence bundle
- pre-gate
- answer policy
- LLM
- post-gate
- repair
- итоговый answer/diagnostics
## 3. Компонент: Intent Router
### 3.1. Что это такое
`IntentRouterV2` классифицирует запрос и возвращает структурированный план для retrieval и дальнейшего рантайма.
Он не просто выбирает `intent`, а сразу строит:
- `conversation_mode`
- `query_plan`
- `retrieval_spec`
- `retrieval_constraints`
- `symbol_resolution`
- `evidence_policy`
- `graph_id`
### 3.2. Какие интенты есть сейчас
Сейчас в коде поддерживаются:
- `CODE_QA`
- `DOCUMENTATION_EXPLAIN`
- `GENERATE_DOCS_FROM_CODE`
- `FALLBACK`
### 3.3. Какие саб-интенты есть сейчас
#### Для `CODE_QA`
- `OPEN_FILE`
- `EXPLAIN`
- `EXPLAIN_LOCAL`
- `FIND_TESTS`
- `FIND_ENTRYPOINTS`
- `TRACE_FLOW`
- `ARCHITECTURE`
- `NEXT_STEPS` может появляться как follow-up режим на уровне query planning
#### Для `DOCUMENTATION_EXPLAIN`
- `SYSTEM_FLOW_EXPLAIN`
- `COMPONENT_EXPLAIN`
- `API_METHOD_EXPLAIN`
- `ENTITY_EXPLAIN`
#### Для `FALLBACK`
- `GENERIC_FALLBACK`
### 3.4. Что роутер возвращает на выходе
Результат роутера — это `IntentRouterResult`.
Ключевые поля:
- `intent`
- `retrieval_profile`
- `graph_id`
- `conversation_mode`
- `query_plan`
- `retrieval_spec`
- `retrieval_constraints`
- `symbol_resolution`
- `evidence_policy`
### 3.5. Что входит в `query_plan`
`query_plan` содержит:
- `raw`
- `normalized`
- `sub_intent`
- `negations`
- `expansions`
- `keyword_hints`
- `path_hints`
- `doc_scope_hints`
- `symbol_candidates`
- `symbol_kind_hint`
- `anchors`
Это основной bridge между классификацией запроса и retrieval.
### 3.6. Что входит в `retrieval_spec`
`retrieval_spec` содержит:
- `domains`
- `layer_queries`
- `filters`
- `rerank_profile`
Именно этот объект задает, какие слои RAG должны быть запрошены.
### 3.7. Что важно про текущее состояние
Текущий роутер уже умеет выделять docs-интенты и docs-сабинтенты, но downstream runtime ниже по пайплайну все еще во многом оптимизирован под `CODE_QA`.
Это означает:
- docs routing уже есть
- docs layer selection уже есть
- но `pre/post evidence gate` и prompt selection пока ориентированы в первую очередь на code sub-intents
## 4. Структура RAG
### 4.1. Общая идея
RAG устроен как многослойный индекс. Retrieval работает не по одному единственному типу чанков, а по наборам специализированных слоев.
### 4.2. Code-слои
- `C0_SOURCE_CHUNKS` — сырой код / чанки исходников
- `C1_SYMBOL_CATALOG` — каталог символов
- `C2_DEPENDENCY_GRAPH` — зависимости и связи
- `C3_ENTRYPOINTS` — точки входа, маршруты, handler'ы
- `C4_SEMANTIC_ROLES` — семантические роли и behavioral hints
### 4.3. Docs-слои
- `D0_DOC_CHUNKS` — чанки документов
- `D1_DOCUMENT_CATALOG` — каталог документов
- `D2_FACT_INDEX` — атомарные факты
- `D3_ENTITY_CATALOG` — сущности
- `D4_WORKFLOW_INDEX` — сценарии и workflow
- `D5_RELATION_GRAPH` — связи между документами
### 4.4. Как retrieval связывается с роутером
Роутер возвращает:
- `domains`
- `layer_queries`
- `filters`
- `retrieval_constraints`
Дальше `build_retrieval_request(...)` превращает это в `RetrievalRequest`, который содержит:
- `rag_session_id`
- `query`
- `sub_intent`
- `path_scope`
- `keyword_hints`
- `symbol_candidates`
- `requested_layers`
- `retrieval_spec`
- `retrieval_constraints`
- `query_plan`
### 4.5. Что возвращает retrieval
Сырые строки RAG затем нормализуются в `RetrievalResult`, который содержит:
- `target_symbol_candidates`
- `resolved_symbol`
- `symbol_resolution_status`
- `file_candidates`
- `code_chunks`
- `relations`
- `semantic_hints`
- `entrypoints`
- `test_candidates`
- `layer_outcomes`
- `missing_layers`
- `raw_rows`
- `retrieval_report`
### 4.6. Как из retrieval строится evidence
`build_evidence_bundle(...)` собирает `EvidenceBundle`.
Ключевые поля:
- `resolved_intent`
- `resolved_sub_intent`
- `resolved_target`
- `target_type`
- `target_symbol_candidates`
- `file_candidates`
- `code_chunks`
- `relations`
- `entrypoints`
- `test_evidence`
- `evidence_count`
- `sufficient`
- `failure_reasons`
- `retrieval_summary`
Важно: `evidence_count` сейчас считается по количеству `code_chunks`. Это еще одно подтверждение, что runtime сегодня code-first.
## 5. Evidence Gate
В пайплайне есть два gate'а.
## 5.1. Pre-evidence gate
Расположение:
- `src/app/modules/agent/runtime/steps/gates/pre/evidence_gate.py`
Когда срабатывает:
- после retrieval
- после сборки `EvidenceBundle`
- до вызова LLM
Задача:
- понять, достаточно ли evidence для уверенного ответа
- выдать `passed/failure_reasons/degraded_message`
Как работает сейчас:
- для `OPEN_FILE` требует найденный path/file и хотя бы один `C0` chunk
- для `EXPLAIN` требует target symbol и минимум 2 evidence chunk'а
- для `FIND_TESTS` требует target и хотя бы один test candidate
- для `FIND_ENTRYPOINTS` требует хотя бы один entrypoint
- для остальных сценариев требует минимум 1 evidence
Что возвращает:
- `passed`
- `failure_reasons`
- `degraded_message`
Ограничение:
- логика pre-gate пока написана в терминах code sub-intents
- docs-сценарии там явно не моделированы
## 5.2. Post-evidence gate
Расположение:
- `src/app/modules/agent/runtime/steps/gates/post/post_gate.py`
Когда срабатывает:
- после генерации draft answer
- до возврата финального ответа
Задача:
- проверить groundedness черновика
- убедиться, что ответ действительно опирается на evidence
- решить, нужен ли `repair`
Что проверяет:
- что ответ не пустой
- что degraded/not_found/ambiguous-ответы содержат обязательные guardrail-фразы
- что normal answer не слишком общий
- что упоминаются обязательные факты из curated evidence
- что нет явной semantic leakage или contradictions
Отдельные проверки есть для:
- `FIND_ENTRYPOINTS`
- `EXPLAIN`
- `ARCHITECTURE`
- `TRACE_FLOW`
Если gate не проходит, он возвращает:
- `passed=False`
- `action="repair"`
- список `reasons`
После этого runtime может сделать дополнительный шаг `repair` через LLM.
Ограничение:
- post-gate тоже пока ориентирован на code-oriented sub-intents
- docs-сабинтенты для него еще не описаны отдельными правилами
## 6. Обращение к LLM
### 6.1. Где вызывается LLM
В `pipeline_setup_v3` есть два места использования LLM:
1. В классификации интента внутри `IntentClassifierV2`
2. В финальной генерации ответа внутри `AgentRuntimeExecutor`
### 6.2. Prompt для классификации интента
Используется prompt:
- `rag_intent_router_v2`
Назначение:
- если deterministic rules не дали результата, LLM выбирает интент
Текущий prompt исторически описывает старые имена интентов, поэтому его еще нужно синхронизировать с новым docs/fallback контрактом.
### 6.3. Prompt'ы для генерации ответа
Prompt selector сейчас выбирает:
- `code_qa_architecture_answer`
- `code_qa_explain_answer`
- `code_qa_explain_local_answer`
- `code_qa_find_entrypoints_answer`
- `code_qa_find_tests_answer`
- `code_qa_general_answer`
- `code_qa_open_file_answer`
- `code_qa_trace_flow_answer`
- `code_qa_degraded_answer`
Дополнительно для repair используется:
- `code_qa_repair_answer`
### 6.4. Как строится payload для LLM
Перед вызовом LLM runtime собирает:
- `AnswerSynthesisInput`
- `EvidenceBundle`
- `prompt_payload`
В payload передаются:
- `user_question`
- `resolved_target`
- `answer_mode`
- `evidence_summary`
- `retrieval_summary`
- curated facts
- обязательные для упоминания сущности, методы, связи, шаги и т.д.
То есть LLM не получает просто "вопрос и куски текста", а получает уже структурированный grounded payload.
### 6.5. Что важно про текущее состояние prompt'ов
Сейчас runtime prompt selection и prompt contracts явно заточены под code QA.
Это значит:
- для `CODE_QA` full chain оформлен хорошо
- для `DOCUMENTATION_EXPLAIN` routing и retrieval есть, но отдельного docs answer-prompt слоя пока нет
- для docs full-chain пока не хватает собственных prompt names, prompt payload contract и post-gate правил
## 7. Что именно сейчас проверяет `pipeline_setup_v3`
YAML-кейс может проверять четыре группы ожиданий:
- `router`
- `retrieval`
- `llm`
- `pipeline`
Примеры ожиданий:
- ожидаемый `intent`
- ожидаемый `sub_intent`
- нужные слои в `layers_include`
- что retrieval не пустой
- что в answer есть нужные фразы
- какой `answer_mode` получился
## 8. Ключевые выводы о текущей архитектуре
### 8.1. Что уже сделано хорошо
- `pipeline_setup_v3` работает поверх реальных runtime-компонентов
- есть явный контракт между router → retrieval → evidence → answer
- есть два evidence gate
- есть structured diagnostics
- есть нормализованные типы `RetrievalRequest`, `RetrievalResult`, `EvidenceBundle`
### 8.2. Что остается code-first
- pre-evidence gate
- post-evidence gate
- prompt selector
- набор answer prompts
- часть логики нормализации evidence
### 8.3. Что это значит для docs use case
Сейчас docs use case уже частично внедрен:
- есть docs intent
- есть docs sub-intents
- есть docs layer mapping
- есть docs retrieval profile
Но для полноценного `full_chain` по документации еще не хватает:
- docs-oriented pre-gate правил
- docs-oriented post-gate правил
- docs-specific answer prompts
- docs-specific synthesis contract
- отдельных full-chain test cases для `DOCUMENTATION_EXPLAIN` и `FALLBACK`
## 9. Краткое резюме по компонентам
### Intent Router
Назначение:
- классифицировать запрос
- построить retrieval plan
- задать evidence policy
Выход:
- `IntentRouterResult`
### RAG
Назначение:
- вернуть evidence из многослойного индекса
Выход:
- `RetrievalResult`
- затем `EvidenceBundle`
### Pre-evidence gate
Назначение:
- решить, можно ли вообще уверенно отвечать
Выход:
- `passed/failure_reasons/degraded_message`
### Post-evidence gate
Назначение:
- проверить, grounded ли уже сгенерированный ответ
Выход:
- `return` или `repair`
### LLM
Назначение:
- классификация сложных интентов
- генерация финального ответа
- repair ответа при провале post-gate
Текущий фокус:
- в первую очередь `CODE_QA`
+1
View File
@@ -4,3 +4,4 @@ markers =
router_rag: intent-router -> rag integration pipeline tests
full_chain: intent-router -> rag -> llm integration pipeline tests
code_qa_eval: CODE_QA golden evaluation harness (fixture + real-adapter; needs DB for full run)
docs_qa_eval: DOCS_QA golden evaluation harness
@@ -0,0 +1,130 @@
# Request Trace: req_02f645dac43746099e9ffb6663899b9e
- session_id: as_54f9e993483f499489530ad2d074ad95
- active_rag_session_id: 333b97ca-1715-47bc-9944-fbacbb5c8108
- process_version: v1
- created_at: 2026-03-26T19:28:59.371830+00:00
## User Message
что можешт сделать?
## Step bootstrap
```json
{
"status": "started",
"details": {}
}
```
## Event status
```json
{
"source": "orchestrator",
"text": "Запрос принят и поставлен в обработку.",
"payload": {},
"created_at": "2026-03-26T19:28:59.372678+00:00"
}
```
## Event status
```json
{
"source": "orchestrator",
"text": "Запускаю процесс обработки v1.",
"payload": {
"process_version": "v1"
},
"created_at": "2026-03-26T19:28:59.372768+00:00"
}
```
## Step bootstrap
```json
{
"status": "completed",
"details": {}
}
```
## Step run_llm
```json
{
"status": "started",
"details": {}
}
```
## Event status
```json
{
"source": "llm_process",
"text": "Отправляю запрос пользователя в LLM.",
"payload": {},
"created_at": "2026-03-26T19:28:59.373014+00:00"
}
```
## Event status
```json
{
"source": "llm_process",
"text": "Ответ от LLM получен.",
"payload": {},
"created_at": "2026-03-26T19:29:06.352287+00:00"
}
```
## Step run_llm
```json
{
"status": "completed",
"details": {
"answer_length": 1101
}
}
```
## Step finalize
```json
{
"status": "started",
"details": {}
}
```
## Event user
```json
{
"source": "agent",
"text": "Я могу помочь тебе с различными задачами и вопросами, например:\n\n1. **Обучение и образование:** объясню сложные темы простым языком, помогу разобраться в математике, физике, химии, истории и других предметах.\n2. **Программирование:** подскажу по вопросам написания кода на разных языках программирования (Python, JavaScript, C++, Java и другие), объясню алгоритмы и структуры данных.\n3. **Написание текстов:** помогу составить письмо, эссе, статью или доклад; а также проверю грамматику и стилистику готового текста.\n4. **Помощь с переводами:** переведу текст на русский язык или с русского на другой язык.\n5. **Ответы на вопросы:** отвечу на любые вопросы общего характера: от научных до бытовых.\n6. **Генерация контента:** создам идеи для креативных проектов, рассказов, сценариев, стихов и песен.\n7. **Советы и рекомендации:** дам советы по саморазвитию, личностному росту, планированию времени и улучшению продуктивности.\n8. **Развлечения:** сыграем в интеллектуальные игры, загадки или просто поболтаем о чем-нибудь интересном.\n\nЕсли у тебя есть конкретная задача — расскажи, я постараюсь помочь!",
"payload": {},
"created_at": "2026-03-26T19:29:06.353995+00:00"
}
```
## Event status
```json
{
"source": "orchestrator",
"text": "Обработка запроса завершена.",
"payload": {},
"created_at": "2026-03-26T19:29:06.354352+00:00"
}
```
## Step finalize
```json
{
"status": "completed",
"details": {}
}
```
## Result
```json
{
"status": "done",
"answer": "Я могу помочь тебе с различными задачами и вопросами, например:\n\n1. **Обучение и образование:** объясню сложные темы простым языком, помогу разобраться в математике, физике, химии, истории и других предметах.\n2. **Программирование:** подскажу по вопросам написания кода на разных языках программирования (Python, JavaScript, C++, Java и другие), объясню алгоритмы и структуры данных.\n3. **Написание текстов:** помогу составить письмо, эссе, статью или доклад; а также проверю грамматику и стилистику готового текста.\n4. **Помощь с переводами:** переведу текст на русский язык или с русского на другой язык.\n5. **Ответы на вопросы:** отвечу на любые вопросы общего характера: от научных до бытовых.\n6. **Генерация контента:** создам идеи для креативных проектов, рассказов, сценариев, стихов и песен.\n7. **Советы и рекомендации:** дам советы по саморазвитию, личностному росту, планированию времени и улучшению продуктивности.\n8. **Развлечения:** сыграем в интеллектуальные игры, загадки или просто поболтаем о чем-нибудь интересном.\n\nЕсли у тебя есть конкретная задача — расскажи, я постараюсь помочь!",
"completed_at": "2026-03-26T19:29:06.354735+00:00"
}
```
@@ -0,0 +1,130 @@
# Request Trace: req_18f4de6d677946739b66c533a2f95b95
- session_id: as_c1a11b6a3e3e4138acbfd7848e784ef2
- active_rag_session_id: 1d011692-a2a1-4a7d-af17-dc35ac7e158b
- process_version: v1
- created_at: 2026-03-26T19:23:38.293838+00:00
## User Message
Ты здесь?
## Step bootstrap
```json
{
"status": "started",
"details": {}
}
```
## Event status
```json
{
"source": "orchestrator",
"text": "Запрос принят и поставлен в обработку.",
"payload": {},
"created_at": "2026-03-26T19:23:38.297116+00:00"
}
```
## Event status
```json
{
"source": "orchestrator",
"text": "Запускаю процесс обработки v1.",
"payload": {
"process_version": "v1"
},
"created_at": "2026-03-26T19:23:38.297199+00:00"
}
```
## Step bootstrap
```json
{
"status": "completed",
"details": {}
}
```
## Step run_llm
```json
{
"status": "started",
"details": {}
}
```
## Event status
```json
{
"source": "llm_process",
"text": "Отправляю запрос пользователя в LLM.",
"payload": {},
"created_at": "2026-03-26T19:23:38.297331+00:00"
}
```
## Event status
```json
{
"source": "llm_process",
"text": "Ответ от LLM получен.",
"payload": {},
"created_at": "2026-03-26T19:23:39.473901+00:00"
}
```
## Step run_llm
```json
{
"status": "completed",
"details": {
"answer_length": 26
}
}
```
## Step finalize
```json
{
"status": "started",
"details": {}
}
```
## Event user
```json
{
"source": "agent",
"text": "Да, я здесь! Чем займёмся?",
"payload": {},
"created_at": "2026-03-26T19:23:39.475294+00:00"
}
```
## Event status
```json
{
"source": "orchestrator",
"text": "Обработка запроса завершена.",
"payload": {},
"created_at": "2026-03-26T19:23:39.475469+00:00"
}
```
## Step finalize
```json
{
"status": "completed",
"details": {}
}
```
## Result
```json
{
"status": "done",
"answer": "Да, я здесь! Чем займёмся?",
"completed_at": "2026-03-26T19:23:39.475706+00:00"
}
```
@@ -0,0 +1,245 @@
# Request Trace: req_4a5e5dc2bdec4045971575d2434bfa03
- session_id: as_9d330fb7437341f18539f819dcace95f
- active_rag_session_id: 34007a57-cf69-45f1-a0cd-c6a39bd81d42
- process_version: v2
- created_at: 2026-03-26T20:16:30.377725+00:00
## User Message
Как работает метод health/
## Step bootstrap
```json
{
"status": "started",
"details": {}
}
```
## Event status
```json
{
"source": "orchestrator",
"text": "Запрос принят и поставлен в обработку.",
"payload": {},
"created_at": "2026-03-26T20:16:30.378388+00:00"
}
```
## Event status
```json
{
"source": "orchestrator",
"text": "Запускаю процесс обработки v1.",
"payload": {
"process_version": "v2"
},
"created_at": "2026-03-26T20:16:30.378460+00:00"
}
```
## Step bootstrap
```json
{
"status": "completed",
"details": {}
}
```
## Step intent_router
```json
{
"status": "started",
"details": {}
}
```
## Event status
```json
{
"source": "intent_router",
"text": "Маршрутизирую запрос и определяю целевой workflow.",
"payload": {},
"created_at": "2026-03-26T20:16:30.378707+00:00"
}
```
## Event status
```json
{
"source": "intent_router",
"text": "Маршрут выбран: CODE_QA / EXPLAIN.",
"payload": {
"intent": "CODE_QA",
"sub_intent": "EXPLAIN",
"matched_intent_source": "deterministic"
},
"created_at": "2026-03-26T20:16:30.380022+00:00"
}
```
## Step intent_router
```json
{
"status": "completed",
"details": {
"intent": "CODE_QA",
"sub_intent": "EXPLAIN",
"matched_intent_source": "deterministic"
}
}
```
## Step workflow_fallback
```json
{
"status": "started",
"details": {}
}
```
## Event status
```json
{
"source": "task_workflow",
"text": "Запускаю workflow fallback.",
"payload": {
"intent": "CODE_QA",
"sub_intent": "EXPLAIN"
},
"created_at": "2026-03-26T20:16:30.380251+00:00"
}
```
## Event status
```json
{
"source": "status.rag_retrieval",
"text": "RAG Retrieval",
"payload": {
"status_block": {
"id": "rag_retrieval",
"title": "RAG Retrieval",
"lines": [
"not used in fallback workflow"
],
"append": false
},
"kind": "task_progress"
},
"created_at": "2026-03-26T20:16:30.380631+00:00"
}
```
## Event status
```json
{
"source": "status.workflow",
"text": "Task Workflow",
"payload": {
"status_block": {
"id": "workflow",
"title": "Task Workflow",
"lines": [
"workflow_id: fallback"
],
"append": false
},
"kind": "task_progress"
},
"created_at": "2026-03-26T20:16:32.313137+00:00"
}
```
## Event status
```json
{
"source": "status.evidence_gate",
"text": "Evidence Gate",
"payload": {
"status_block": {
"id": "evidence_gate",
"title": "Evidence Gate",
"lines": [
"not applied in fallback workflow"
],
"append": false
},
"kind": "task_progress"
},
"created_at": "2026-03-26T20:16:32.314125+00:00"
}
```
## Event status
```json
{
"source": "workflow_result",
"text": "Workflow fallback завершен.",
"payload": {
"workflow_id": "fallback",
"result_type": "answer",
"answer_length": 190
},
"created_at": "2026-03-26T20:16:32.314639+00:00"
}
```
## Step workflow_fallback
```json
{
"status": "completed",
"details": {
"workflow_id": "fallback",
"meta": {
"workflow_id": "fallback",
"intent": "CODE_QA"
}
}
}
```
## Step finalize
```json
{
"status": "started",
"details": {}
}
```
## Event user
```json
{
"source": "agent",
"text": "```yaml\npaths:\n /health/:\n get:\n summary: Проверка состояния здоровья сервиса\n responses:\n '200':\n description: Успешная проверка, сервис работает корректно\n```",
"payload": {},
"created_at": "2026-03-26T20:16:32.315272+00:00"
}
```
## Event status
```json
{
"source": "orchestrator",
"text": "Обработка запроса завершена.",
"payload": {},
"created_at": "2026-03-26T20:16:32.317145+00:00"
}
```
## Step finalize
```json
{
"status": "completed",
"details": {}
}
```
## Result
```json
{
"status": "done",
"answer": "```yaml\npaths:\n /health/:\n get:\n summary: Проверка состояния здоровья сервиса\n responses:\n '200':\n description: Успешная проверка, сервис работает корректно\n```",
"completed_at": "2026-03-26T20:16:32.317835+00:00"
}
```
@@ -0,0 +1,137 @@
# Request Trace: req_6556fd999c2548e58071a1d1518e35cb
- session_id: as_a4d91e7eca7547e0a37dc4dc6492cf9b
- active_rag_session_id: aa7d907e-2a76-4bf7-99dc-5a400a417f8c
- process_version: v2
- created_at: 2026-03-26T20:30:43.129032+00:00
## User Message
Какие методы есть в API?
## Step bootstrap
```json
{
"status": "started",
"details": {}
}
```
## Event status
```json
{
"source": "orchestrator",
"text": "Запрос принят и поставлен в обработку.",
"payload": {},
"created_at": "2026-03-26T20:30:43.130630+00:00"
}
```
## Event status
```json
{
"source": "orchestrator",
"text": "Запускаю процесс обработки v1.",
"payload": {
"process_version": "v2"
},
"created_at": "2026-03-26T20:30:43.130808+00:00"
}
```
## Step bootstrap
```json
{
"status": "completed",
"details": {}
}
```
## Step intent_router
```json
{
"status": "started",
"details": {}
}
```
## Event status
```json
{
"source": "intent_router",
"text": "Маршрутизирую запрос и определяю целевой workflow.",
"payload": {},
"created_at": "2026-03-26T20:30:43.131176+00:00"
}
```
## Event status
```json
{
"source": "intent_router",
"text": "Маршрут выбран: DOCUMENTATION_EXPLAIN / API_METHOD_EXPLAIN.",
"payload": {
"intent": "DOCUMENTATION_EXPLAIN",
"sub_intent": "API_METHOD_EXPLAIN",
"matched_intent_source": "deterministic"
},
"created_at": "2026-03-26T20:30:43.134624+00:00"
}
```
## Step intent_router
```json
{
"status": "completed",
"details": {
"intent": "DOCUMENTATION_EXPLAIN",
"sub_intent": "API_METHOD_EXPLAIN",
"matched_intent_source": "deterministic"
}
}
```
## Step workflow_documentation_explain
```json
{
"status": "started",
"details": {}
}
```
## Event status
```json
{
"source": "task_workflow",
"text": "Запускаю workflow docs_qa.",
"payload": {
"intent": "DOCUMENTATION_EXPLAIN",
"sub_intent": "API_METHOD_EXPLAIN"
},
"created_at": "2026-03-26T20:30:43.134934+00:00"
}
```
## Error
```json
{
"status": "error",
"error": {
"code": "agent_api_runtime_error",
"desc": "Agent request failed unexpectedly.",
"module": "agent"
},
"completed_at": "2026-03-26T20:30:50.387415+00:00"
}
```
## Event status
```json
{
"source": "orchestrator",
"text": "Во время обработки запроса произошла ошибка.",
"payload": {
"code": "agent_api_runtime_error"
},
"created_at": "2026-03-26T20:30:50.388003+00:00"
}
```
File diff suppressed because one or more lines are too long
@@ -0,0 +1,507 @@
# Request Trace: req_7e47a8b3458741568e2b974079a74cb5
- session_id: as_9d330fb7437341f18539f819dcace95f
- active_rag_session_id: 34007a57-cf69-45f1-a0cd-c6a39bd81d42
- process_version: v2
- created_at: 2026-03-26T20:17:03.006084+00:00
## User Message
Опиши что делает /health
## Step bootstrap
```json
{
"status": "started",
"details": {}
}
```
## Event status
```json
{
"source": "orchestrator",
"text": "Запрос принят и поставлен в обработку.",
"payload": {},
"created_at": "2026-03-26T20:17:03.012795+00:00"
}
```
## Event status
```json
{
"source": "orchestrator",
"text": "Запускаю процесс обработки v1.",
"payload": {
"process_version": "v2"
},
"created_at": "2026-03-26T20:17:03.013195+00:00"
}
```
## Step bootstrap
```json
{
"status": "completed",
"details": {}
}
```
## Step intent_router
```json
{
"status": "started",
"details": {}
}
```
## Event status
```json
{
"source": "intent_router",
"text": "Маршрутизирую запрос и определяю целевой workflow.",
"payload": {},
"created_at": "2026-03-26T20:17:03.015461+00:00"
}
```
## Event status
```json
{
"source": "intent_router",
"text": "Маршрут выбран: DOCUMENTATION_EXPLAIN / API_METHOD_EXPLAIN.",
"payload": {
"intent": "DOCUMENTATION_EXPLAIN",
"sub_intent": "API_METHOD_EXPLAIN",
"matched_intent_source": "deterministic"
},
"created_at": "2026-03-26T20:17:03.021546+00:00"
}
```
## Step intent_router
```json
{
"status": "completed",
"details": {
"intent": "DOCUMENTATION_EXPLAIN",
"sub_intent": "API_METHOD_EXPLAIN",
"matched_intent_source": "deterministic"
}
}
```
## Step workflow_documentation_explain
```json
{
"status": "started",
"details": {}
}
```
## Event status
```json
{
"source": "task_workflow",
"text": "Запускаю workflow docs_qa.",
"payload": {
"intent": "DOCUMENTATION_EXPLAIN",
"sub_intent": "API_METHOD_EXPLAIN"
},
"created_at": "2026-03-26T20:17:03.021922+00:00"
}
```
## Event status
```json
{
"source": "status.rag_retrieval",
"text": "RAG Retrieval",
"payload": {
"status_block": {
"id": "rag_retrieval",
"title": "RAG Retrieval",
"lines": [
"planned_layers: D2_FACT_INDEX, D4_WORKFLOW_INDEX, D1_DOCUMENT_CATALOG, D0_DOC_CHUNKS",
"executed_layers: D2_FACT_INDEX, D4_WORKFLOW_INDEX, D1_DOCUMENT_CATALOG, D0_DOC_CHUNKS",
"D2_FACT_INDEX: 8 hits",
"D4_WORKFLOW_INDEX: 2 hits",
"D1_DOCUMENT_CATALOG: 4 hits",
"D0_DOC_CHUNKS: 4 hits"
],
"append": false
},
"kind": "task_progress"
},
"created_at": "2026-03-26T20:17:05.913522+00:00"
}
```
## Event status
```json
{
"source": "status.workflow",
"text": "Task Workflow",
"payload": {
"status_block": {
"id": "workflow",
"title": "Task Workflow",
"lines": [
"workflow_id: docs_qa",
"prompt: docs_explain_answer",
"answer_mode: degraded"
],
"append": false
},
"kind": "task_progress"
},
"created_at": "2026-03-26T20:17:05.914387+00:00"
}
```
## Event status
```json
{
"source": "status.evidence_gate",
"text": "Evidence Gate",
"payload": {
"status_block": {
"id": "evidence_gate",
"title": "Evidence Gate",
"lines": [
"decision: reject",
"reason: not_found_exact_anchor",
"missing: retrieval_non_empty, exact_anchor_match"
],
"append": false
},
"kind": "task_progress"
},
"created_at": "2026-03-26T20:17:05.914679+00:00"
}
```
## Event status
```json
{
"source": "rag_retrieval",
"text": "RAG retrieval завершен.",
"payload": {
"planned_layers": [
"D2_FACT_INDEX",
"D4_WORKFLOW_INDEX",
"D1_DOCUMENT_CATALOG",
"D0_DOC_CHUNKS"
],
"executed_layers": [
"D2_FACT_INDEX",
"D4_WORKFLOW_INDEX",
"D1_DOCUMENT_CATALOG",
"D0_DOC_CHUNKS"
],
"non_empty_layers": [
"D2_FACT_INDEX",
"D4_WORKFLOW_INDEX",
"D1_DOCUMENT_CATALOG",
"D0_DOC_CHUNKS"
]
},
"created_at": "2026-03-26T20:17:05.914992+00:00"
}
```
## Event status
```json
{
"source": "evidence_gate",
"text": "Evidence gate оценен.",
"payload": {
"decision": "reject",
"reason": "not_found_exact_anchor",
"missing": [
"retrieval_non_empty",
"exact_anchor_match"
],
"satisfied": []
},
"created_at": "2026-03-26T20:17:05.915395+00:00"
}
```
## Event status
```json
{
"source": "workflow_result",
"text": "Workflow docs_qa завершен.",
"payload": {
"workflow_id": "docs_qa",
"result_type": "answer",
"answer_length": 57
},
"created_at": "2026-03-26T20:17:05.916924+00:00"
}
```
## Step workflow_documentation_explain
```json
{
"status": "completed",
"details": {
"workflow_id": "docs_qa",
"meta": {
"workflow_id": "docs_qa",
"intent": "DOCUMENTATION_EXPLAIN",
"diagnostics": {
"intent": "DOCUMENTATION_EXPLAIN",
"sub_intent": "API_METHOD_EXPLAIN",
"layers_used": [
"D2_FACT_INDEX",
"D4_WORKFLOW_INDEX",
"D1_DOCUMENT_CATALOG",
"D0_DOC_CHUNKS"
],
"documents_found": 0,
"facts_found": 0,
"relations_found": 0,
"openapi_fields_extracted": 0,
"missing_required_fields": [],
"openapi_status": {
"has_path": false,
"has_method": false,
"has_request": false,
"has_response": false
},
"prompt_used": "docs_explain_answer",
"llm_mode": "prose",
"output_valid": true,
"matched_intent_source": "deterministic",
"matched_anchor_type": "endpoint",
"matched_anchor_value": "/health",
"exact_anchor_match": false,
"docs_layers_requested": [
"D2_FACT_INDEX",
"D4_WORKFLOW_INDEX",
"D1_DOCUMENT_CATALOG",
"D0_DOC_CHUNKS"
],
"docs_layers_with_hits": [
"D2_FACT_INDEX",
"D4_WORKFLOW_INDEX",
"D1_DOCUMENT_CATALOG",
"D0_DOC_CHUNKS"
],
"planned_layers": [
"D2_FACT_INDEX",
"D4_WORKFLOW_INDEX",
"D1_DOCUMENT_CATALOG",
"D0_DOC_CHUNKS"
],
"executed_layers": [
"D2_FACT_INDEX",
"D4_WORKFLOW_INDEX",
"D1_DOCUMENT_CATALOG",
"D0_DOC_CHUNKS"
],
"non_empty_layers": [
"D2_FACT_INDEX",
"D4_WORKFLOW_INDEX",
"D1_DOCUMENT_CATALOG",
"D0_DOC_CHUNKS"
],
"layer_diagnostics": {
"D2_FACT_INDEX": {
"hits": 8,
"top_ids": [
"8879bb1d923dff0d783ef202f98fdfe5b774870912b2cd261ae127003daffacb",
"5bc72ce58bd31c654a380034beb59f47224e9b03bd306503a7f0b8634008409d",
"5a471b2380ec55b5866b99bb337b92cb78b91051cf616593937f1603d1011fa6",
"a31bbca2eb31ffb6655bcec7ff07b8cf2b6c7416610cf58dfc1e0c737df12fe1",
"e64a6aebed07076a1cccb4d6537b5f54489e25eda776e0230b3c2ff1df2ae648"
],
"top_sections": [
"domain.runtime_health:mentions_entity",
"docs/README.md:doc_list_item"
]
},
"D4_WORKFLOW_INDEX": {
"hits": 2,
"top_ids": [
"api.send_message_endpoint",
"api.control_actions_endpoint"
],
"top_sections": [
"Scenario"
]
},
"D1_DOCUMENT_CATALOG": {
"hits": 4,
"top_ids": [
"domain.runtime_health",
"docs/README.md",
"api.send_message_endpoint",
"api.control_actions_endpoint"
],
"top_sections": [
"Сущность runtime health",
"Readme",
"HTTP API /send",
"HTTP API /actions/{action}"
]
},
"D0_DOC_CHUNKS": {
"hits": 4,
"top_ids": [
"domain.runtime_health",
"docs/README.md",
"architecture.telegram_notify_app"
],
"top_sections": [
"domain.runtime_health:Details",
"domain.runtime_health:Summary",
"docs/README.md:Навигация",
"architecture.telegram_notify_app:Details"
]
}
},
"query_entity_candidates": [],
"resolved_entity_candidates": [],
"query_anchor_candidates": [
"/health"
],
"resolved_anchor_candidates": [],
"anchor_candidates": [],
"selected_anchor": null,
"anchor_selection_reason": "",
"anchor_match_type": "",
"doc_ids": [],
"doc_paths": [],
"doc_titles": [],
"relation_hits_count": 0,
"relation_targets": [],
"selected_doc_ids": [],
"selected_fact_ids": [],
"selected_relation_ids": [],
"selected_chunk_ids": [],
"selected_entity_ids": [],
"selected_workflow_ids": [],
"fallback_doc_hits_count": 0,
"fallback_used": false,
"fact_hits": 0,
"entity_hits": 0,
"evidence_summary": {
"documents": 0,
"facts": 0,
"entities": 0,
"workflows": 0,
"relations": 0,
"chunks": 0,
"selected_doc_ids": [],
"selected_fact_ids": [],
"selected_relation_ids": [],
"selected_chunk_ids": [],
"entity_hits": 0,
"openapi_signals": {
"path_found": false,
"method_found": false,
"operation_semantics_found": false,
"request_payload_found": false,
"request_schema": false,
"request_fields_found": false,
"response_payload_found": false,
"response_schema": false,
"response_fields_found": false,
"status_codes": false,
"content_type_found": false,
"examples_found": false,
"payload_description": false
}
},
"gate_decision": "reject",
"gate_decision_reason": "not_found_exact_anchor",
"gate_missing_requirements": [
"retrieval_non_empty",
"exact_anchor_match"
],
"gate_satisfied_requirements": [],
"openapi_evidence": {
"path_found": false,
"method_found": false,
"operation_semantics_found": false,
"request_payload_found": false,
"request_schema": false,
"request_fields_found": false,
"response_payload_found": false,
"response_schema": false,
"response_fields_found": false,
"status_codes": false,
"content_type_found": false,
"examples_found": false,
"payload_description": false
},
"requested_fragment_type": null,
"fragment_evidence_found": [],
"fragment_missing_requirements": [],
"prompt": {
"prompt_name": "docs_explain_answer",
"system_prompt": "Ты объясняешь документацию системы.\n\nНа вход приходит JSON с полями:\n- question\n- intent\n- sub_intent\n- documents\n- facts\n- relations\n\nПравила:\n- Используй только предоставленные факты\n- Не додумывай\n- Если данных недостаточно, скажи это явно\n- Объясняй структурировано\n\nФормат ответа:\n1. Краткое описание\n2. Основные элементы\n3. Как это работает\n4. Связи с другими частями системы (если есть)",
"user_prompt": "{\n \"question\": \"Опиши что делает /health\",\n \"intent\": \"DOCUMENTATION_EXPLAIN\",\n \"sub_intent\": \"API_METHOD_EXPLAIN\",\n \"documents\": [],\n \"facts\": [],\n \"entities\": [],\n \"workflows\": [],\n \"relations\": [],\n \"chunks\": []\n}",
"log_context": "graph.project_qa.docs.answer",
"prompt_stats": {
"system_chars": 393,
"user_chars": 225,
"tokens_in_estimate": 155
}
},
"answer_mode": "degraded",
"degrade_reason": "not_found_exact_anchor",
"degraded_reason": "not_found_exact_anchor",
"code_intents_stubbed": false
}
}
}
}
```
## Step finalize
```json
{
"status": "started",
"details": {}
}
```
## Event user
```json
{
"source": "agent",
"text": "Не найдено точное совпадение по запрошенному docs anchor.",
"payload": {},
"created_at": "2026-03-26T20:17:05.919336+00:00"
}
```
## Event status
```json
{
"source": "orchestrator",
"text": "Обработка запроса завершена.",
"payload": {},
"created_at": "2026-03-26T20:17:05.919876+00:00"
}
```
## Step finalize
```json
{
"status": "completed",
"details": {}
}
```
## Result
```json
{
"status": "done",
"answer": "Не найдено точное совпадение по запрошенному docs anchor.",
"completed_at": "2026-03-26T20:17:05.920288+00:00"
}
```
@@ -0,0 +1,507 @@
# Request Trace: req_90367a184bfa4638bd9c473e89d2b5b7
- session_id: as_a4d91e7eca7547e0a37dc4dc6492cf9b
- active_rag_session_id: aa7d907e-2a76-4bf7-99dc-5a400a417f8c
- process_version: v2
- created_at: 2026-03-26T20:30:20.880909+00:00
## User Message
как работает метод /health
## Step bootstrap
```json
{
"status": "started",
"details": {}
}
```
## Event status
```json
{
"source": "orchestrator",
"text": "Запрос принят и поставлен в обработку.",
"payload": {},
"created_at": "2026-03-26T20:30:20.882319+00:00"
}
```
## Event status
```json
{
"source": "orchestrator",
"text": "Запускаю процесс обработки v1.",
"payload": {
"process_version": "v2"
},
"created_at": "2026-03-26T20:30:20.882447+00:00"
}
```
## Step bootstrap
```json
{
"status": "completed",
"details": {}
}
```
## Step intent_router
```json
{
"status": "started",
"details": {}
}
```
## Event status
```json
{
"source": "intent_router",
"text": "Маршрутизирую запрос и определяю целевой workflow.",
"payload": {},
"created_at": "2026-03-26T20:30:20.882855+00:00"
}
```
## Event status
```json
{
"source": "intent_router",
"text": "Маршрут выбран: DOCUMENTATION_EXPLAIN / API_METHOD_EXPLAIN.",
"payload": {
"intent": "DOCUMENTATION_EXPLAIN",
"sub_intent": "API_METHOD_EXPLAIN",
"matched_intent_source": "deterministic"
},
"created_at": "2026-03-26T20:30:20.886041+00:00"
}
```
## Step intent_router
```json
{
"status": "completed",
"details": {
"intent": "DOCUMENTATION_EXPLAIN",
"sub_intent": "API_METHOD_EXPLAIN",
"matched_intent_source": "deterministic"
}
}
```
## Step workflow_documentation_explain
```json
{
"status": "started",
"details": {}
}
```
## Event status
```json
{
"source": "task_workflow",
"text": "Запускаю workflow docs_qa.",
"payload": {
"intent": "DOCUMENTATION_EXPLAIN",
"sub_intent": "API_METHOD_EXPLAIN"
},
"created_at": "2026-03-26T20:30:20.886364+00:00"
}
```
## Event status
```json
{
"source": "status.rag_retrieval",
"text": "RAG Retrieval",
"payload": {
"status_block": {
"id": "rag_retrieval",
"title": "RAG Retrieval",
"lines": [
"planned_layers: D2_FACT_INDEX, D4_WORKFLOW_INDEX, D1_DOCUMENT_CATALOG, D0_DOC_CHUNKS",
"executed_layers: D2_FACT_INDEX, D4_WORKFLOW_INDEX, D1_DOCUMENT_CATALOG, D0_DOC_CHUNKS",
"D2_FACT_INDEX: 8 hits",
"D4_WORKFLOW_INDEX: 2 hits",
"D1_DOCUMENT_CATALOG: 4 hits",
"D0_DOC_CHUNKS: 4 hits"
],
"append": false
},
"kind": "task_progress"
},
"created_at": "2026-03-26T20:30:24.170029+00:00"
}
```
## Event status
```json
{
"source": "status.workflow",
"text": "Task Workflow",
"payload": {
"status_block": {
"id": "workflow",
"title": "Task Workflow",
"lines": [
"workflow_id: docs_qa",
"prompt: docs_explain_answer",
"answer_mode: degraded"
],
"append": false
},
"kind": "task_progress"
},
"created_at": "2026-03-26T20:30:24.170817+00:00"
}
```
## Event status
```json
{
"source": "status.evidence_gate",
"text": "Evidence Gate",
"payload": {
"status_block": {
"id": "evidence_gate",
"title": "Evidence Gate",
"lines": [
"decision: reject",
"reason: not_found_exact_anchor",
"missing: retrieval_non_empty, exact_anchor_match"
],
"append": false
},
"kind": "task_progress"
},
"created_at": "2026-03-26T20:30:24.172958+00:00"
}
```
## Event status
```json
{
"source": "rag_retrieval",
"text": "RAG retrieval завершен.",
"payload": {
"planned_layers": [
"D2_FACT_INDEX",
"D4_WORKFLOW_INDEX",
"D1_DOCUMENT_CATALOG",
"D0_DOC_CHUNKS"
],
"executed_layers": [
"D2_FACT_INDEX",
"D4_WORKFLOW_INDEX",
"D1_DOCUMENT_CATALOG",
"D0_DOC_CHUNKS"
],
"non_empty_layers": [
"D2_FACT_INDEX",
"D4_WORKFLOW_INDEX",
"D1_DOCUMENT_CATALOG",
"D0_DOC_CHUNKS"
]
},
"created_at": "2026-03-26T20:30:24.173401+00:00"
}
```
## Event status
```json
{
"source": "evidence_gate",
"text": "Evidence gate оценен.",
"payload": {
"decision": "reject",
"reason": "not_found_exact_anchor",
"missing": [
"retrieval_non_empty",
"exact_anchor_match"
],
"satisfied": []
},
"created_at": "2026-03-26T20:30:24.173890+00:00"
}
```
## Event status
```json
{
"source": "workflow_result",
"text": "Workflow docs_qa завершен.",
"payload": {
"workflow_id": "docs_qa",
"result_type": "answer",
"answer_length": 57
},
"created_at": "2026-03-26T20:30:24.174256+00:00"
}
```
## Step workflow_documentation_explain
```json
{
"status": "completed",
"details": {
"workflow_id": "docs_qa",
"meta": {
"workflow_id": "docs_qa",
"intent": "DOCUMENTATION_EXPLAIN",
"diagnostics": {
"intent": "DOCUMENTATION_EXPLAIN",
"sub_intent": "API_METHOD_EXPLAIN",
"layers_used": [
"D2_FACT_INDEX",
"D4_WORKFLOW_INDEX",
"D1_DOCUMENT_CATALOG",
"D0_DOC_CHUNKS"
],
"documents_found": 0,
"facts_found": 0,
"relations_found": 0,
"openapi_fields_extracted": 0,
"missing_required_fields": [],
"openapi_status": {
"has_path": false,
"has_method": false,
"has_request": false,
"has_response": false
},
"prompt_used": "docs_explain_answer",
"llm_mode": "prose",
"output_valid": true,
"matched_intent_source": "deterministic",
"matched_anchor_type": "endpoint",
"matched_anchor_value": "/health",
"exact_anchor_match": false,
"docs_layers_requested": [
"D2_FACT_INDEX",
"D4_WORKFLOW_INDEX",
"D1_DOCUMENT_CATALOG",
"D0_DOC_CHUNKS"
],
"docs_layers_with_hits": [
"D2_FACT_INDEX",
"D4_WORKFLOW_INDEX",
"D1_DOCUMENT_CATALOG",
"D0_DOC_CHUNKS"
],
"planned_layers": [
"D2_FACT_INDEX",
"D4_WORKFLOW_INDEX",
"D1_DOCUMENT_CATALOG",
"D0_DOC_CHUNKS"
],
"executed_layers": [
"D2_FACT_INDEX",
"D4_WORKFLOW_INDEX",
"D1_DOCUMENT_CATALOG",
"D0_DOC_CHUNKS"
],
"non_empty_layers": [
"D2_FACT_INDEX",
"D4_WORKFLOW_INDEX",
"D1_DOCUMENT_CATALOG",
"D0_DOC_CHUNKS"
],
"layer_diagnostics": {
"D2_FACT_INDEX": {
"hits": 8,
"top_ids": [
"8879bb1d923dff0d783ef202f98fdfe5b774870912b2cd261ae127003daffacb",
"5bc72ce58bd31c654a380034beb59f47224e9b03bd306503a7f0b8634008409d",
"5a471b2380ec55b5866b99bb337b92cb78b91051cf616593937f1603d1011fa6",
"a31bbca2eb31ffb6655bcec7ff07b8cf2b6c7416610cf58dfc1e0c737df12fe1",
"e64a6aebed07076a1cccb4d6537b5f54489e25eda776e0230b3c2ff1df2ae648"
],
"top_sections": [
"domain.runtime_health:mentions_entity",
"docs/README.md:doc_list_item"
]
},
"D4_WORKFLOW_INDEX": {
"hits": 2,
"top_ids": [
"api.control_actions_endpoint",
"api.send_message_endpoint"
],
"top_sections": [
"Scenario"
]
},
"D1_DOCUMENT_CATALOG": {
"hits": 4,
"top_ids": [
"domain.runtime_health",
"docs/README.md",
"api.send_message_endpoint",
"api.control_actions_endpoint"
],
"top_sections": [
"Сущность runtime health",
"Readme",
"HTTP API /send",
"HTTP API /actions/{action}"
]
},
"D0_DOC_CHUNKS": {
"hits": 4,
"top_ids": [
"domain.runtime_health",
"docs/README.md",
"architecture.telegram_notify_app"
],
"top_sections": [
"domain.runtime_health:Details",
"domain.runtime_health:Summary",
"docs/README.md:Навигация",
"architecture.telegram_notify_app:Details"
]
}
},
"query_entity_candidates": [],
"resolved_entity_candidates": [],
"query_anchor_candidates": [
"/health"
],
"resolved_anchor_candidates": [],
"anchor_candidates": [],
"selected_anchor": null,
"anchor_selection_reason": "",
"anchor_match_type": "",
"doc_ids": [],
"doc_paths": [],
"doc_titles": [],
"relation_hits_count": 0,
"relation_targets": [],
"selected_doc_ids": [],
"selected_fact_ids": [],
"selected_relation_ids": [],
"selected_chunk_ids": [],
"selected_entity_ids": [],
"selected_workflow_ids": [],
"fallback_doc_hits_count": 0,
"fallback_used": false,
"fact_hits": 0,
"entity_hits": 0,
"evidence_summary": {
"documents": 0,
"facts": 0,
"entities": 0,
"workflows": 0,
"relations": 0,
"chunks": 0,
"selected_doc_ids": [],
"selected_fact_ids": [],
"selected_relation_ids": [],
"selected_chunk_ids": [],
"entity_hits": 0,
"openapi_signals": {
"path_found": false,
"method_found": false,
"operation_semantics_found": false,
"request_payload_found": false,
"request_schema": false,
"request_fields_found": false,
"response_payload_found": false,
"response_schema": false,
"response_fields_found": false,
"status_codes": false,
"content_type_found": false,
"examples_found": false,
"payload_description": false
}
},
"gate_decision": "reject",
"gate_decision_reason": "not_found_exact_anchor",
"gate_missing_requirements": [
"retrieval_non_empty",
"exact_anchor_match"
],
"gate_satisfied_requirements": [],
"openapi_evidence": {
"path_found": false,
"method_found": false,
"operation_semantics_found": false,
"request_payload_found": false,
"request_schema": false,
"request_fields_found": false,
"response_payload_found": false,
"response_schema": false,
"response_fields_found": false,
"status_codes": false,
"content_type_found": false,
"examples_found": false,
"payload_description": false
},
"requested_fragment_type": null,
"fragment_evidence_found": [],
"fragment_missing_requirements": [],
"prompt": {
"prompt_name": "docs_explain_answer",
"system_prompt": "Ты объясняешь документацию системы.\n\nНа вход приходит JSON с полями:\n- question\n- intent\n- sub_intent\n- documents\n- facts\n- relations\n\nПравила:\n- Используй только предоставленные факты\n- Не додумывай\n- Если данных недостаточно, скажи это явно\n- Объясняй структурировано\n\nФормат ответа:\n1. Краткое описание\n2. Основные элементы\n3. Как это работает\n4. Связи с другими частями системы (если есть)",
"user_prompt": "{\n \"question\": \"как работает метод /health\",\n \"intent\": \"DOCUMENTATION_EXPLAIN\",\n \"sub_intent\": \"API_METHOD_EXPLAIN\",\n \"documents\": [],\n \"facts\": [],\n \"entities\": [],\n \"workflows\": [],\n \"relations\": [],\n \"chunks\": []\n}",
"log_context": "graph.project_qa.docs.answer",
"prompt_stats": {
"system_chars": 393,
"user_chars": 227,
"tokens_in_estimate": 155
}
},
"answer_mode": "degraded",
"degrade_reason": "not_found_exact_anchor",
"degraded_reason": "not_found_exact_anchor",
"code_intents_stubbed": false
}
}
}
}
```
## Step finalize
```json
{
"status": "started",
"details": {}
}
```
## Event user
```json
{
"source": "agent",
"text": "Не найдено точное совпадение по запрошенному docs anchor.",
"payload": {},
"created_at": "2026-03-26T20:30:24.177128+00:00"
}
```
## Event status
```json
{
"source": "orchestrator",
"text": "Обработка запроса завершена.",
"payload": {},
"created_at": "2026-03-26T20:30:24.177918+00:00"
}
```
## Step finalize
```json
{
"status": "completed",
"details": {}
}
```
## Result
```json
{
"status": "done",
"answer": "Не найдено точное совпадение по запрошенному docs anchor.",
"completed_at": "2026-03-26T20:30:24.178650+00:00"
}
```
@@ -0,0 +1,130 @@
# Request Trace: req_9827472a716044cf983328dc959c4042
- session_id: as_1d66a74b9e594dcca5648c964164d540
- active_rag_session_id: 69c34e19-c302-4510-b0fb-9ec64cf381aa
- process_version: v1
- created_at: 2026-03-26T19:26:38.681374+00:00
## User Message
проверка связи
## Step bootstrap
```json
{
"status": "started",
"details": {}
}
```
## Event status
```json
{
"source": "orchestrator",
"text": "Запрос принят и поставлен в обработку.",
"payload": {},
"created_at": "2026-03-26T19:26:38.682328+00:00"
}
```
## Event status
```json
{
"source": "orchestrator",
"text": "Запускаю процесс обработки v1.",
"payload": {
"process_version": "v1"
},
"created_at": "2026-03-26T19:26:38.682413+00:00"
}
```
## Step bootstrap
```json
{
"status": "completed",
"details": {}
}
```
## Step run_llm
```json
{
"status": "started",
"details": {}
}
```
## Event status
```json
{
"source": "llm_process",
"text": "Отправляю запрос пользователя в LLM.",
"payload": {},
"created_at": "2026-03-26T19:26:38.682589+00:00"
}
```
## Event status
```json
{
"source": "llm_process",
"text": "Ответ от LLM получен.",
"payload": {},
"created_at": "2026-03-26T19:26:39.482668+00:00"
}
```
## Step run_llm
```json
{
"status": "completed",
"details": {
"answer_length": 33
}
}
```
## Step finalize
```json
{
"status": "started",
"details": {}
}
```
## Event user
```json
{
"source": "agent",
"text": "Связь в норме, вас хорошо слышно!",
"payload": {},
"created_at": "2026-03-26T19:26:39.485479+00:00"
}
```
## Event status
```json
{
"source": "orchestrator",
"text": "Обработка запроса завершена.",
"payload": {},
"created_at": "2026-03-26T19:26:39.486541+00:00"
}
```
## Step finalize
```json
{
"status": "completed",
"details": {}
}
```
## Result
```json
{
"status": "done",
"answer": "Связь в норме, вас хорошо слышно!",
"completed_at": "2026-03-26T19:26:39.487945+00:00"
}
```
@@ -0,0 +1,507 @@
# Request Trace: req_bc0ca07c5a344978bdbfa3311283f4c8
- session_id: as_d77b5786695d4968a0d7faac4292f7bb
- active_rag_session_id: 47aeddca-0011-45e4-b99c-70f1a242f2e4
- process_version: v2
- created_at: 2026-03-26T20:23:49.781134+00:00
## User Message
Как работает метод /health
## Step bootstrap
```json
{
"status": "started",
"details": {}
}
```
## Event status
```json
{
"source": "orchestrator",
"text": "Запрос принят и поставлен в обработку.",
"payload": {},
"created_at": "2026-03-26T20:23:49.782470+00:00"
}
```
## Event status
```json
{
"source": "orchestrator",
"text": "Запускаю процесс обработки v1.",
"payload": {
"process_version": "v2"
},
"created_at": "2026-03-26T20:23:49.782560+00:00"
}
```
## Step bootstrap
```json
{
"status": "completed",
"details": {}
}
```
## Step intent_router
```json
{
"status": "started",
"details": {}
}
```
## Event status
```json
{
"source": "intent_router",
"text": "Маршрутизирую запрос и определяю целевой workflow.",
"payload": {},
"created_at": "2026-03-26T20:23:49.782839+00:00"
}
```
## Event status
```json
{
"source": "intent_router",
"text": "Маршрут выбран: DOCUMENTATION_EXPLAIN / API_METHOD_EXPLAIN.",
"payload": {
"intent": "DOCUMENTATION_EXPLAIN",
"sub_intent": "API_METHOD_EXPLAIN",
"matched_intent_source": "deterministic"
},
"created_at": "2026-03-26T20:23:49.785619+00:00"
}
```
## Step intent_router
```json
{
"status": "completed",
"details": {
"intent": "DOCUMENTATION_EXPLAIN",
"sub_intent": "API_METHOD_EXPLAIN",
"matched_intent_source": "deterministic"
}
}
```
## Step workflow_documentation_explain
```json
{
"status": "started",
"details": {}
}
```
## Event status
```json
{
"source": "task_workflow",
"text": "Запускаю workflow docs_qa.",
"payload": {
"intent": "DOCUMENTATION_EXPLAIN",
"sub_intent": "API_METHOD_EXPLAIN"
},
"created_at": "2026-03-26T20:23:49.786090+00:00"
}
```
## Event status
```json
{
"source": "status.rag_retrieval",
"text": "RAG Retrieval",
"payload": {
"status_block": {
"id": "rag_retrieval",
"title": "RAG Retrieval",
"lines": [
"planned_layers: D2_FACT_INDEX, D4_WORKFLOW_INDEX, D1_DOCUMENT_CATALOG, D0_DOC_CHUNKS",
"executed_layers: D2_FACT_INDEX, D4_WORKFLOW_INDEX, D1_DOCUMENT_CATALOG, D0_DOC_CHUNKS",
"D2_FACT_INDEX: 8 hits",
"D4_WORKFLOW_INDEX: 2 hits",
"D1_DOCUMENT_CATALOG: 4 hits",
"D0_DOC_CHUNKS: 4 hits"
],
"append": false
},
"kind": "task_progress"
},
"created_at": "2026-03-26T20:23:52.332219+00:00"
}
```
## Event status
```json
{
"source": "status.workflow",
"text": "Task Workflow",
"payload": {
"status_block": {
"id": "workflow",
"title": "Task Workflow",
"lines": [
"workflow_id: docs_qa",
"prompt: docs_explain_answer",
"answer_mode: degraded"
],
"append": false
},
"kind": "task_progress"
},
"created_at": "2026-03-26T20:23:52.332932+00:00"
}
```
## Event status
```json
{
"source": "status.evidence_gate",
"text": "Evidence Gate",
"payload": {
"status_block": {
"id": "evidence_gate",
"title": "Evidence Gate",
"lines": [
"decision: reject",
"reason: not_found_exact_anchor",
"missing: retrieval_non_empty, exact_anchor_match"
],
"append": false
},
"kind": "task_progress"
},
"created_at": "2026-03-26T20:23:52.334102+00:00"
}
```
## Event status
```json
{
"source": "rag_retrieval",
"text": "RAG retrieval завершен.",
"payload": {
"planned_layers": [
"D2_FACT_INDEX",
"D4_WORKFLOW_INDEX",
"D1_DOCUMENT_CATALOG",
"D0_DOC_CHUNKS"
],
"executed_layers": [
"D2_FACT_INDEX",
"D4_WORKFLOW_INDEX",
"D1_DOCUMENT_CATALOG",
"D0_DOC_CHUNKS"
],
"non_empty_layers": [
"D2_FACT_INDEX",
"D4_WORKFLOW_INDEX",
"D1_DOCUMENT_CATALOG",
"D0_DOC_CHUNKS"
]
},
"created_at": "2026-03-26T20:23:52.334498+00:00"
}
```
## Event status
```json
{
"source": "evidence_gate",
"text": "Evidence gate оценен.",
"payload": {
"decision": "reject",
"reason": "not_found_exact_anchor",
"missing": [
"retrieval_non_empty",
"exact_anchor_match"
],
"satisfied": []
},
"created_at": "2026-03-26T20:23:52.334791+00:00"
}
```
## Event status
```json
{
"source": "workflow_result",
"text": "Workflow docs_qa завершен.",
"payload": {
"workflow_id": "docs_qa",
"result_type": "answer",
"answer_length": 57
},
"created_at": "2026-03-26T20:23:52.335076+00:00"
}
```
## Step workflow_documentation_explain
```json
{
"status": "completed",
"details": {
"workflow_id": "docs_qa",
"meta": {
"workflow_id": "docs_qa",
"intent": "DOCUMENTATION_EXPLAIN",
"diagnostics": {
"intent": "DOCUMENTATION_EXPLAIN",
"sub_intent": "API_METHOD_EXPLAIN",
"layers_used": [
"D2_FACT_INDEX",
"D4_WORKFLOW_INDEX",
"D1_DOCUMENT_CATALOG",
"D0_DOC_CHUNKS"
],
"documents_found": 0,
"facts_found": 0,
"relations_found": 0,
"openapi_fields_extracted": 0,
"missing_required_fields": [],
"openapi_status": {
"has_path": false,
"has_method": false,
"has_request": false,
"has_response": false
},
"prompt_used": "docs_explain_answer",
"llm_mode": "prose",
"output_valid": true,
"matched_intent_source": "deterministic",
"matched_anchor_type": "endpoint",
"matched_anchor_value": "/health",
"exact_anchor_match": false,
"docs_layers_requested": [
"D2_FACT_INDEX",
"D4_WORKFLOW_INDEX",
"D1_DOCUMENT_CATALOG",
"D0_DOC_CHUNKS"
],
"docs_layers_with_hits": [
"D2_FACT_INDEX",
"D4_WORKFLOW_INDEX",
"D1_DOCUMENT_CATALOG",
"D0_DOC_CHUNKS"
],
"planned_layers": [
"D2_FACT_INDEX",
"D4_WORKFLOW_INDEX",
"D1_DOCUMENT_CATALOG",
"D0_DOC_CHUNKS"
],
"executed_layers": [
"D2_FACT_INDEX",
"D4_WORKFLOW_INDEX",
"D1_DOCUMENT_CATALOG",
"D0_DOC_CHUNKS"
],
"non_empty_layers": [
"D2_FACT_INDEX",
"D4_WORKFLOW_INDEX",
"D1_DOCUMENT_CATALOG",
"D0_DOC_CHUNKS"
],
"layer_diagnostics": {
"D2_FACT_INDEX": {
"hits": 8,
"top_ids": [
"8879bb1d923dff0d783ef202f98fdfe5b774870912b2cd261ae127003daffacb",
"5bc72ce58bd31c654a380034beb59f47224e9b03bd306503a7f0b8634008409d",
"5a471b2380ec55b5866b99bb337b92cb78b91051cf616593937f1603d1011fa6",
"a31bbca2eb31ffb6655bcec7ff07b8cf2b6c7416610cf58dfc1e0c737df12fe1",
"e64a6aebed07076a1cccb4d6537b5f54489e25eda776e0230b3c2ff1df2ae648"
],
"top_sections": [
"domain.runtime_health:mentions_entity",
"docs/README.md:doc_list_item"
]
},
"D4_WORKFLOW_INDEX": {
"hits": 2,
"top_ids": [
"api.send_message_endpoint",
"api.control_actions_endpoint"
],
"top_sections": [
"Scenario"
]
},
"D1_DOCUMENT_CATALOG": {
"hits": 4,
"top_ids": [
"domain.runtime_health",
"docs/README.md",
"logic.telegram_notification_loop",
"architecture.telegram_notify_app"
],
"top_sections": [
"Сущность runtime health",
"Readme",
"Цикл отправки уведомлений в Telegram",
"Архитектура Telegram Notify App"
]
},
"D0_DOC_CHUNKS": {
"hits": 4,
"top_ids": [
"domain.runtime_health",
"docs/README.md",
"logic.telegram_notification_loop"
],
"top_sections": [
"domain.runtime_health:Details",
"domain.runtime_health:Summary",
"docs/README.md:Навигация",
"logic.telegram_notification_loop:Details"
]
}
},
"query_entity_candidates": [],
"resolved_entity_candidates": [],
"query_anchor_candidates": [
"/health"
],
"resolved_anchor_candidates": [],
"anchor_candidates": [],
"selected_anchor": null,
"anchor_selection_reason": "",
"anchor_match_type": "",
"doc_ids": [],
"doc_paths": [],
"doc_titles": [],
"relation_hits_count": 0,
"relation_targets": [],
"selected_doc_ids": [],
"selected_fact_ids": [],
"selected_relation_ids": [],
"selected_chunk_ids": [],
"selected_entity_ids": [],
"selected_workflow_ids": [],
"fallback_doc_hits_count": 0,
"fallback_used": false,
"fact_hits": 0,
"entity_hits": 0,
"evidence_summary": {
"documents": 0,
"facts": 0,
"entities": 0,
"workflows": 0,
"relations": 0,
"chunks": 0,
"selected_doc_ids": [],
"selected_fact_ids": [],
"selected_relation_ids": [],
"selected_chunk_ids": [],
"entity_hits": 0,
"openapi_signals": {
"path_found": false,
"method_found": false,
"operation_semantics_found": false,
"request_payload_found": false,
"request_schema": false,
"request_fields_found": false,
"response_payload_found": false,
"response_schema": false,
"response_fields_found": false,
"status_codes": false,
"content_type_found": false,
"examples_found": false,
"payload_description": false
}
},
"gate_decision": "reject",
"gate_decision_reason": "not_found_exact_anchor",
"gate_missing_requirements": [
"retrieval_non_empty",
"exact_anchor_match"
],
"gate_satisfied_requirements": [],
"openapi_evidence": {
"path_found": false,
"method_found": false,
"operation_semantics_found": false,
"request_payload_found": false,
"request_schema": false,
"request_fields_found": false,
"response_payload_found": false,
"response_schema": false,
"response_fields_found": false,
"status_codes": false,
"content_type_found": false,
"examples_found": false,
"payload_description": false
},
"requested_fragment_type": null,
"fragment_evidence_found": [],
"fragment_missing_requirements": [],
"prompt": {
"prompt_name": "docs_explain_answer",
"system_prompt": "Ты объясняешь документацию системы.\n\nНа вход приходит JSON с полями:\n- question\n- intent\n- sub_intent\n- documents\n- facts\n- relations\n\nПравила:\n- Используй только предоставленные факты\n- Не додумывай\n- Если данных недостаточно, скажи это явно\n- Объясняй структурировано\n\nФормат ответа:\n1. Краткое описание\n2. Основные элементы\n3. Как это работает\n4. Связи с другими частями системы (если есть)",
"user_prompt": "{\n \"question\": \"Как работает метод /health\",\n \"intent\": \"DOCUMENTATION_EXPLAIN\",\n \"sub_intent\": \"API_METHOD_EXPLAIN\",\n \"documents\": [],\n \"facts\": [],\n \"entities\": [],\n \"workflows\": [],\n \"relations\": [],\n \"chunks\": []\n}",
"log_context": "graph.project_qa.docs.answer",
"prompt_stats": {
"system_chars": 393,
"user_chars": 227,
"tokens_in_estimate": 155
}
},
"answer_mode": "degraded",
"degrade_reason": "not_found_exact_anchor",
"degraded_reason": "not_found_exact_anchor",
"code_intents_stubbed": false
}
}
}
}
```
## Step finalize
```json
{
"status": "started",
"details": {}
}
```
## Event user
```json
{
"source": "agent",
"text": "Не найдено точное совпадение по запрошенному docs anchor.",
"payload": {},
"created_at": "2026-03-26T20:23:52.338517+00:00"
}
```
## Event status
```json
{
"source": "orchestrator",
"text": "Обработка запроса завершена.",
"payload": {},
"created_at": "2026-03-26T20:23:52.339122+00:00"
}
```
## Step finalize
```json
{
"status": "completed",
"details": {}
}
```
## Result
```json
{
"status": "done",
"answer": "Не найдено точное совпадение по запрошенному docs anchor.",
"completed_at": "2026-03-26T20:23:52.342384+00:00"
}
```
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -27,7 +27,7 @@ def create_app() -> FastAPI:
allow_headers=["*"],
)
app.include_router(modules.chat.public_router())
app.include_router(modules.agent_api.public_router())
app.include_router(modules.rag.public_router())
app.include_router(modules.rag.internal_router())
app.include_router(modules.rag_repo.internal_router())
@@ -0,0 +1,64 @@
from __future__ import annotations
from app.modules.agent.intent_router_v2.analysis.docs_query_signals import DocsQuerySignals
from app.modules.agent.intent_router_v2.analysis.docs_sub_intent_detector import DocsSubIntentDetector
class DocsAmbiguityDetector:
_ALLOWED = {
"SYSTEM_FLOW_EXPLAIN",
"COMPONENT_EXPLAIN",
"API_METHOD_EXPLAIN",
"ENTITY_EXPLAIN",
"RELATED_DOCS_EXPLAIN",
"GENERIC_QA",
}
def __init__(
self,
docs_signals: DocsQuerySignals | None = None,
detector: DocsSubIntentDetector | None = None,
) -> None:
self._docs_signals = docs_signals or DocsQuerySignals()
self._detector = detector or DocsSubIntentDetector()
self._natural_entity_markers = ("runtime health", "статус воркера", "состояние runtime", "состояние воркера")
self._natural_flow_markers = ("health check runtime", "проверка состояния", "как работает health check", "как происходит проверка")
def detect(self, query: str, *, intent: str, sub_intent: str) -> dict[str, object]:
if intent not in {"DOCUMENTATION_EXPLAIN", "GENERAL_QA"}:
return self._result(False, "", sub_intent, [])
candidates = [name for name, _ in self._detector.rank_candidates(query, intent="DOCUMENTATION_EXPLAIN") if name in self._ALLOWED]
if not candidates:
return self._result(False, "", sub_intent, [])
top_scores = self._detector.rank_candidates(query, intent="DOCUMENTATION_EXPLAIN")
score_map = {name: score for name, score in top_scores}
selected_score = int(score_map.get(sub_intent, 0))
second_score = int(next((score for name, score in top_scores if name != sub_intent), 0))
has_strong_anchor = bool(self._docs_signals.query_anchor_candidates(query)) or self._docs_signals.has_component_like_token(query)
if sub_intent == "GENERIC_QA" and has_strong_anchor:
return self._result(True, "general_vs_anchor_conflict", sub_intent, candidates[:4])
if sub_intent != "GENERIC_QA" and self._looks_overview_like(query) and not has_strong_anchor:
return self._result(True, "overview_vs_explain_conflict", sub_intent, candidates[:4])
if not has_strong_anchor and self._looks_natural_boundary(query):
return self._result(True, "natural_language_boundary", sub_intent, candidates[:4])
if selected_score and second_score and selected_score - second_score <= 1 and second_score >= 2:
return self._result(True, "small_score_gap", sub_intent, candidates[:4])
if not has_strong_anchor and selected_score <= 3 and second_score >= 1:
return self._result(True, "weak_deterministic_signal", sub_intent, candidates[:4])
return self._result(False, "", sub_intent, candidates[:4])
def _looks_overview_like(self, query: str) -> bool:
text = " ".join((query or "").lower().split())
return any(marker in text for marker in ("что есть в документации", "какая структура документации", "что описано", "обзор документации", "с чего начать"))
def _looks_natural_boundary(self, query: str) -> bool:
text = " ".join((query or "").lower().split())
return any(marker in text for marker in (*self._natural_entity_markers, *self._natural_flow_markers))
def _result(self, is_ambiguous: bool, reason: str, selected: str, candidates: list[str]) -> dict[str, object]:
return {
"is_ambiguous": is_ambiguous,
"ambiguity_reason": reason,
"deterministic_candidates": list(candidates),
"deterministic_primary_candidate": selected,
}
@@ -0,0 +1,75 @@
from __future__ import annotations
import re
class DocsQuerySignals:
_ENDPOINT_RE = re.compile(r"(?P<value>/(?:[a-z0-9_-]+|{[a-z0-9_]+})(?:/(?:[a-z0-9_-]+|{[a-z0-9_]+}))*)", re.IGNORECASE)
_ENTITY_RE = re.compile(r"(?:entity|сущност[ьи]|модель|объект)\s+(?P<value>[A-Za-z][A-Za-z0-9_-]+(?:\s+[A-Za-z][A-Za-z0-9_-]+)?)", re.IGNORECASE)
_COMPONENT_RE = re.compile(r"(?:component|компонент|service|module|модул[ья])\s+(?P<value>[A-Za-z][A-Za-z0-9_-]+)", re.IGNORECASE)
_WORKFLOW_RE = re.compile(r"(?:workflow|сценарий|процесс|overview)\s+(?P<value>[A-Za-zА-Яа-я0-9_-]+)", re.IGNORECASE)
_DOC_RE = re.compile(r"(?:document|документ|documentation|документац[ияи])\s+(?P<value>[A-Za-zА-Яа-я0-9_.-]+)", re.IGNORECASE)
_CAMEL_RE = re.compile(r"\b(?P<value>[A-Z][A-Za-z0-9]+(?:Manager|Worker|Channel|Service|Module|Status|Health)|[A-Z][a-z0-9]+(?:[A-Z][A-Za-z0-9]+)+)\b")
_QUOTED_RE = re.compile(r"[\"'«“](?P<value>[^\"'»”]{2,})[\"'»”]")
_MULTIWORD_RE = re.compile(r"\b(runtime health|worker status|control plane)\b", re.IGNORECASE)
def detect_anchor(self, raw: str) -> tuple[str, str | None]:
text = raw or ""
anchor_patterns = [
("entity", self._ENTITY_RE),
("component", self._COMPONENT_RE),
("workflow", self._WORKFLOW_RE),
("document", self._DOC_RE),
]
if not any(ext in text.lower() for ext in (".py", ".ts", ".js", ".java", ".go", ".rb")):
anchor_patterns.insert(0, ("endpoint", self._ENDPOINT_RE))
for anchor_type, pattern in anchor_patterns:
match = pattern.search(text)
if match:
return anchor_type, str(match.group("value")).strip()
if any(marker in text.lower() for marker in ("api", "endpoint", "topic", "тема")):
return "topic", None
return "none", None
def has_docs_anchor(self, raw: str) -> bool:
anchor_type, _ = self.detect_anchor(raw)
return anchor_type != "none"
def query_entity_candidates(self, raw: str) -> list[str]:
text = raw or ""
values: list[str] = []
for match in self._ENTITY_RE.finditer(text):
values.append(match.group("value").strip())
for match in self._CAMEL_RE.finditer(text):
values.append(match.group("value").strip())
for match in self._MULTIWORD_RE.finditer(text):
values.append(match.group(1).strip())
return self._dedupe(values)
def query_anchor_candidates(self, raw: str) -> list[str]:
text = raw or ""
values: list[str] = []
for match in self._ENDPOINT_RE.finditer(text):
values.append(match.group("value").strip())
for match in self._CAMEL_RE.finditer(text):
values.append(match.group("value").strip())
for match in self._QUOTED_RE.finditer(text):
values.append(match.group("value").strip())
return self._dedupe(values)
def has_component_like_token(self, raw: str) -> bool:
return bool(self._CAMEL_RE.search(raw or ""))
def _dedupe(self, values: list[str]) -> list[str]:
result: list[str] = []
seen: set[str] = set()
for value in values:
normalized = value.strip()
if not normalized:
continue
key = normalized.lower()
if key in seen:
continue
seen.add(key)
result.append(normalized)
return result
@@ -0,0 +1,161 @@
from __future__ import annotations
import re
class DocsSubIntentDetector:
_ENTITY_LIKE_TOKEN_RE = re.compile(r"\b[A-Z][A-Za-z0-9]+(?:Health|Status|State)\b")
_CAMEL_ENTITY_CONTEXT_RE = re.compile(r"\b[A-Z][a-z0-9]+(?:[A-Z][A-Za-z0-9]+)+\b")
_RELATED_MARKERS = (
"найди документацию",
"документация по",
"где в документации",
"что связано",
"связанные документы",
"что дальше читать",
"по каким документам идти",
"родительский документ",
"дочерние документы",
"какие документы",
"что еще посмотреть",
"где еще описано",
"где еще используется",
"с чем связан",
"какие страницы связаны",
)
_FLOW_MARKERS = (
"цикл",
"как работает",
"lifecycle",
"как происходит",
"как устроен",
"как устроена",
"как проходит",
"последовательность",
"шаги",
"флоу",
"flow",
"workflow",
"сценар",
"процесс",
"по шагам",
)
_API_MARKERS = (
"api",
"endpoint",
"method",
"route",
"handler",
"контракт",
"request",
"response",
"webhook",
"метод",
"эндпоинт",
"роут",
)
_ENTITY_MARKERS = (
"сущност",
"entity",
"модел",
"бизнес-объект",
"что такое",
"что за",
"объект",
"как используется",
)
_COMPONENT_MARKERS = (
"компонент",
"модул",
"сервис",
"класс",
"блок",
"подсистем",
"часть системы",
"роль",
"какую роль",
"manager",
"worker",
"channel",
"service",
"module",
)
_GENERAL_MARKERS = (
"что вообще",
"в целом",
"с чего начать",
"обзор",
"какая структура документации",
"какая документация есть",
"что описано",
"что есть в документации",
"обзор документации",
)
_NATURAL_ENTITY_MARKERS = ("runtime health", "health state", "статус воркера", "состояние воркера", "состояние runtime")
_NATURAL_FLOW_MARKERS = ("health check runtime", "проверка состояния", "как работает health check", "как происходит проверка")
def detect(self, raw: str, *, intent: str = "DOCUMENTATION_EXPLAIN") -> str:
candidates = self.rank_candidates(raw, intent=intent)
return candidates[0][0] if candidates else "COMPONENT_EXPLAIN"
def rank_candidates(self, raw: str, *, intent: str = "DOCUMENTATION_EXPLAIN") -> list[tuple[str, int]]:
source = raw or ""
text = " ".join(source.lower().split())
if intent == "GENERAL_QA":
return [("GENERIC_QA", 100)]
if intent == "OPENAPI_GENERATION":
return [(self._detect_openapi(text), 100)]
if not text:
return [("COMPONENT_EXPLAIN", 1)]
scores = {
"RELATED_DOCS_EXPLAIN": 0,
"API_METHOD_EXPLAIN": 0,
"ENTITY_EXPLAIN": 0,
"COMPONENT_EXPLAIN": 0,
"SYSTEM_FLOW_EXPLAIN": 0,
"GENERIC_QA": 0,
}
if any(marker in text for marker in self._RELATED_MARKERS):
scores["RELATED_DOCS_EXPLAIN"] += 8
if self._has_http_path(text) or any(marker in text for marker in self._API_MARKERS):
scores["API_METHOD_EXPLAIN"] += 6
if scores["RELATED_DOCS_EXPLAIN"] > 0 and scores["API_METHOD_EXPLAIN"] > 0:
scores["API_METHOD_EXPLAIN"] -= 2
if any(marker in text for marker in self._ENTITY_MARKERS):
scores["ENTITY_EXPLAIN"] += 5
if self._has_entity_like_camel_token(source):
scores["ENTITY_EXPLAIN"] += 3
if self._looks_like_entity_question(text, source):
scores["ENTITY_EXPLAIN"] += 2
if any(marker in text for marker in self._NATURAL_ENTITY_MARKERS):
scores["ENTITY_EXPLAIN"] += 3
if any(marker in text for marker in self._COMPONENT_MARKERS):
scores["COMPONENT_EXPLAIN"] += 5
if any(marker in text for marker in self._FLOW_MARKERS):
scores["SYSTEM_FLOW_EXPLAIN"] += 5
if any(marker in text for marker in self._NATURAL_FLOW_MARKERS):
scores["SYSTEM_FLOW_EXPLAIN"] += 3
if any(marker in text for marker in self._GENERAL_MARKERS):
scores["GENERIC_QA"] += 6
if not any(scores.values()):
scores["COMPONENT_EXPLAIN"] = 1
scores["GENERIC_QA"] = 1
ranked = sorted(scores.items(), key=lambda item: (-item[1], item[0]))
return [item for item in ranked if item[1] > 0]
def _detect_openapi(self, text: str) -> str:
markers = ("request", "response", "schema", "parameters", "fragment")
if any(marker in text for marker in markers):
return "OPENAPI_FRAGMENT_GENERATE"
return "OPENAPI_METHOD_GENERATE"
def _has_http_path(self, text: str) -> bool:
return any(token.startswith("/") and len(token) > 1 for token in text.split())
def _has_entity_like_camel_token(self, raw: str) -> bool:
return bool(self._ENTITY_LIKE_TOKEN_RE.search(raw or ""))
def _looks_like_entity_question(self, text: str, raw: str) -> bool:
if not any(marker in text for marker in ("что такое", "что за", "как используется")):
return False
return bool(self._CAMEL_ENTITY_CONTEXT_RE.search(raw or ""))
@@ -3,6 +3,7 @@ from __future__ import annotations
from app.modules.agent.intent_router_v2.analysis.anchor_extractor import AnchorExtractor
from app.modules.agent.intent_router_v2.analysis.anchor_span_validator import AnchorSpanValidator
from app.modules.agent.intent_router_v2.analysis.conversation_anchor_builder import ConversationAnchorBuilder
from app.modules.agent.intent_router_v2.analysis.docs_sub_intent_detector import DocsSubIntentDetector
from app.modules.agent.intent_router_v2.analysis.keyword_hint_builder import KeywordHintBuilder
from app.modules.agent.intent_router_v2.analysis.keyword_hint_sanitizer import KeywordHintSanitizer
from app.modules.agent.intent_router_v2.models import ConversationState, QueryAnchor, QueryPlan
@@ -14,6 +15,11 @@ from app.modules.agent.intent_router_v2.analysis.term_mapping import RuEnTermMap
class QueryPlanBuilder:
_DOCS_INTENTS = {
"DOCUMENTATION_EXPLAIN",
"OPENAPI_GENERATION",
"GENERAL_QA",
}
_WHY_MARKERS = ("почему", "зачем", "откуда", "из-за чего")
_NEXT_STEP_MARKERS = ("что дальше", "дальше что", "и что теперь", "продолжай")
_DOCS_TOPIC_HINTS = {
@@ -35,6 +41,7 @@ class QueryPlanBuilder:
carryover: ConversationAnchorBuilder | None = None,
span_validator: AnchorSpanValidator | None = None,
sub_intent_detector: SubIntentDetector | None = None,
docs_sub_intent_detector: DocsSubIntentDetector | None = None,
negation_detector: NegationDetector | None = None,
) -> None:
self._normalizer = normalizer or QueryNormalizer()
@@ -45,6 +52,7 @@ class QueryPlanBuilder:
self._carryover = carryover or ConversationAnchorBuilder()
self._span_validator = span_validator or AnchorSpanValidator()
self._sub_intent_detector = sub_intent_detector or SubIntentDetector()
self._docs_sub_intent_detector = docs_sub_intent_detector or DocsSubIntentDetector()
self._negation_detector = negation_detector or NegationDetector()
def build(
@@ -54,7 +62,7 @@ class QueryPlanBuilder:
continue_mode: bool,
*,
conversation_mode: str = "START",
intent: str = "PROJECT_MISC",
intent: str = "FALLBACK",
) -> QueryPlan:
raw = user_query or ""
normalized = self._normalizer.normalize(raw)
@@ -75,8 +83,10 @@ class QueryPlanBuilder:
skip_tests = "tests" in negations or is_negative_test_request(raw)
cleaned_anchors = self._remove_negated_test_terms(skip_tests, merged_anchors)
sub_intent = self._resolve_sub_intent(sub_intent, raw, cleaned_anchors, intent=intent, negations=negations)
if intent == "DOCS_QA":
sub_intent = "EXPLAIN"
if intent in self._DOCS_INTENTS:
sub_intent = self._docs_sub_intent_detector.detect(raw, intent=intent)
elif intent == "FALLBACK":
sub_intent = "GENERIC_QA"
expansions = self._expansions(normalized, cleaned_anchors, skip_tests=skip_tests)
keyword_hints = self._keyword_hints(
raw,
@@ -95,7 +105,7 @@ class QueryPlanBuilder:
symbol_candidates = []
symbol_kind_hint = "unknown"
doc_scope_hints = []
if intent == "DOCS_QA":
if intent in self._DOCS_INTENTS:
symbol_candidates = []
symbol_kind_hint = "unknown"
keyword_hints = self._docs_keyword_hints(raw, keyword_hints)
@@ -134,7 +144,7 @@ class QueryPlanBuilder:
)
if (
conversation_mode == "SWITCH"
and intent == "DOCS_QA"
and intent in self._DOCS_INTENTS
and not has_user_file
and not has_user_symbol
and state.active_symbol
@@ -186,7 +196,7 @@ class QueryPlanBuilder:
if skip_tests:
values = [value for value in values if not is_test_related_token(value)]
sanitized = self._keyword_hint_sanitizer.sanitize(raw, anchors, values)
if intent == "DOCS_QA" and not sanitized:
if intent in self._DOCS_INTENTS and not sanitized:
fallback = list(dict.fromkeys([*self._expansions(normalized, anchors, skip_tests=skip_tests)]))
sanitized = fallback[:3]
if state.active_symbol and state.active_symbol not in sanitized:
@@ -295,7 +305,7 @@ class QueryPlanBuilder:
return result[:12]
def _doc_scope_hints(self, intent: str, raw: str, keyword_hints: list[str], path_hints: list[str]) -> list[str]:
if intent != "DOCS_QA":
if intent not in self._DOCS_INTENTS:
return []
values = list(path_hints)
for candidate in ("README*", "docs/**", "**/*.md"):
@@ -3,6 +3,7 @@ from __future__ import annotations
from app.modules.agent.llm import AgentLlmService
from app.modules.agent.llm.prompt_loader import PromptLoader
from app.modules.agent.intent_router_v2.intent.classifier import IntentClassifierV2
from app.modules.agent.intent_router_v2.intent.llm_disambiguator import DocsLlmDisambiguator
from app.modules.agent.intent_router_v2.router import IntentRouterV2
from app.modules.shared.env_loader import load_workspace_env
from app.modules.shared.gigachat.client import GigaChatClient
@@ -19,4 +20,8 @@ class GigaChatIntentRouterFactory:
prompt_loader = PromptLoader()
llm = AgentLlmService(client=client, prompts=prompt_loader)
classifier = IntentClassifierV2(llm=llm)
return IntentRouterV2(classifier=classifier)
return IntentRouterV2(
classifier=classifier,
llm_disambiguator=DocsLlmDisambiguator(llm),
enable_llm_disambiguation=True,
)
@@ -3,6 +3,7 @@ from __future__ import annotations
import json
import re
from app.modules.agent.intent_router_v2.analysis.docs_query_signals import DocsQuerySignals
from app.modules.agent.intent_router_v2.models import ConversationState, IntentDecision
from app.modules.agent.intent_router_v2.protocols import TextGenerator
from app.modules.agent.intent_router_v2.analysis.test_signals import has_test_focus
@@ -23,6 +24,73 @@ class IntentClassifierV2:
"write documentation",
)
_DOCS_MARKERS = ("документац", "readme", "docs/", ".md", "spec", "runbook", "markdown")
_OPENAPI_MARKERS = ("openapi", "swagger", "yaml", "spec", "schema")
_OPENAPI_EXTENDED_MARKERS = ("request body", "response schema", "request schema", "response body")
_DOCS_RELATED_MARKERS = (
"что связано",
"связанные документы",
"что дальше читать",
"по каким документам идти",
"родительский документ",
"дочерние документы",
"какие документы",
"что еще посмотреть",
"где еще описано",
"где еще используется",
"с чем связан",
"какие страницы связаны",
"документация по",
"найди",
"где описан",
"где в документации",
"покажи документы",
"после этого",
)
_DOCS_EXPLAIN_MARKERS = ("объясни", "как работает", "что делает", "что такое")
_DOCS_FLOW_MARKERS = (
"цикл",
"сценар",
"процесс",
"происходит",
"последовательность",
"шаги",
"как работает процесс",
"как происходит",
"как устроен процесс",
"workflow",
"flow",
"lifecycle",
"жизненный цикл",
)
_DOCS_COMPONENT_MARKERS = (
"компонент",
"модул",
"подсистем",
"часть системы",
"блок",
"роль",
"какую роль",
"что делает",
"как работает компонент",
"как устроен",
)
_DOCS_ENTITY_MARKERS = ("сущност", "entity", "бизнес-объект", "объект", "как используется", "что такое")
_DOCS_API_MARKERS = ("api", "endpoint", "эндпоинт", "ручк", "request", "response")
_GENERAL_QA_MARKERS = (
"помоги понять",
"с чего начать",
"как начать",
"куда смотреть",
"что тут важно",
"что вообще",
"в целом",
"обзор",
"какая структура документации",
"структура документации",
"какая документация есть",
"что описано в документации",
"что есть по сервису",
)
_CODE_MARKERS = (
"по коду",
"код",
@@ -45,6 +113,7 @@ class IntentClassifierV2:
def __init__(self, llm: TextGenerator | None = None) -> None:
self._llm = llm
self._docs_signals = DocsQuerySignals()
def classify(self, user_query: str, conversation_state: ConversationState) -> IntentDecision:
deterministic = self._deterministic(user_query)
@@ -53,17 +122,27 @@ class IntentClassifierV2:
llm_decision = self._classify_with_llm(user_query, conversation_state)
if llm_decision:
return llm_decision
return IntentDecision(intent="PROJECT_MISC", confidence=0.55, reason="fallback_project_misc")
return IntentDecision(intent="FALLBACK", confidence=0.55, reason="fallback")
def _deterministic(self, user_query: str) -> IntentDecision | None:
text = " ".join((user_query or "").lower().split())
if any(marker in text for marker in self._GENERATE_DOCS_MARKERS):
return IntentDecision(intent="GENERATE_DOCS_FROM_CODE", confidence=0.97, reason="deterministic_generate_docs")
if any(marker in text for marker in (*self._OPENAPI_MARKERS, *self._OPENAPI_EXTENDED_MARKERS)):
return IntentDecision(intent="OPENAPI_GENERATION", confidence=0.98, reason="deterministic_openapi")
if self._is_general_docs_qa(text):
return IntentDecision(intent="GENERAL_QA", confidence=0.76, reason="deterministic_general_docs")
if self._is_docs_explain(text, user_query):
return IntentDecision(intent="DOCUMENTATION_EXPLAIN", confidence=0.91, reason="deterministic_docs_explain")
if self._is_related_docs(text):
return IntentDecision(intent="DOCUMENTATION_EXPLAIN", confidence=0.9, reason="deterministic_docs_related")
if not has_test_focus(text) and self._docs_signals.has_component_like_token(user_query) and not self._is_general_docs_qa(text):
return IntentDecision(intent="DOCUMENTATION_EXPLAIN", confidence=0.82, reason="deterministic_docs_component_anchor")
if self._looks_like_docs_question(text):
return IntentDecision(intent="DOCS_QA", confidence=0.9, reason="deterministic_docs")
return IntentDecision(intent="DOCUMENTATION_EXPLAIN", confidence=0.9, reason="deterministic_docs")
if self._looks_like_code_question(user_query, text):
return IntentDecision(intent="CODE_QA", confidence=0.9, reason="deterministic_code")
return None
return IntentDecision(intent="GENERAL_QA", confidence=0.62, reason="deterministic_general")
def _classify_with_llm(self, user_query: str, conversation_state: ConversationState) -> IntentDecision | None:
if self._llm is None:
@@ -73,7 +152,13 @@ class IntentClassifierV2:
"message": user_query,
"active_intent": conversation_state.active_intent,
"last_query": conversation_state.last_query,
"allowed_intents": ["CODE_QA", "DOCS_QA", "GENERATE_DOCS_FROM_CODE", "PROJECT_MISC"],
"allowed_intents": [
"CODE_QA",
"DOCUMENTATION_EXPLAIN",
"OPENAPI_GENERATION",
"GENERAL_QA",
"GENERATE_DOCS_FROM_CODE",
],
},
ensure_ascii=False,
)
@@ -93,10 +178,19 @@ class IntentClassifierV2:
except json.JSONDecodeError:
return None
intent = str(payload.get("intent") or "").strip().upper()
if intent not in {"CODE_QA", "DOCS_QA", "GENERATE_DOCS_FROM_CODE", "PROJECT_MISC"}:
if intent not in {
"CODE_QA",
"DOCUMENTATION_EXPLAIN",
"OPENAPI_GENERATION",
"GENERAL_QA",
"GENERATE_DOCS_FROM_CODE",
"FALLBACK",
}:
return None
sub_intent = str(payload.get("sub_intent") or "").strip() or None
return IntentDecision(
intent=intent,
sub_intent=sub_intent,
confidence=float(payload.get("confidence") or 0.7),
reason=str(payload.get("reason") or "llm").strip() or "llm",
)
@@ -112,8 +206,33 @@ class IntentClassifierV2:
def _looks_like_docs_question(self, text: str) -> bool:
if self._has_code_file_path(text):
return False
if self._is_general_docs_qa(text):
return False
return self._has_docs_context(text) or self._has_docs_subject(text)
def _has_docs_context(self, text: str) -> bool:
return any(marker in text for marker in self._DOCS_MARKERS)
def _is_docs_discovery(self, text: str) -> bool:
return self._is_related_docs(text)
def _is_related_docs(self, text: str) -> bool:
return any(marker in text for marker in self._DOCS_RELATED_MARKERS)
def _is_docs_explain(self, text: str, user_query: str) -> bool:
if not any(marker in text for marker in self._DOCS_EXPLAIN_MARKERS):
return False
return self._docs_signals.has_docs_anchor(user_query) or self._has_docs_subject(text)
def _has_docs_subject(self, text: str) -> bool:
return any(
marker in text
for marker in (*self._DOCS_FLOW_MARKERS, *self._DOCS_COMPONENT_MARKERS, *self._DOCS_ENTITY_MARKERS, *self._DOCS_API_MARKERS)
)
def _is_general_docs_qa(self, text: str) -> bool:
return any(marker in text for marker in self._GENERAL_QA_MARKERS)
def _looks_like_code_question(self, raw_text: str, lowered: str) -> bool:
if self._has_code_file_path(raw_text):
return True
@@ -123,8 +242,6 @@ class IntentClassifierV2:
return False
if any(marker in lowered for marker in self._CODE_MARKERS):
return True
if re.search(r"\b[A-Z][A-Za-z0-9_]{2,}(?:\.[A-Za-z_][A-Za-z0-9_]*)*\b", raw_text or ""):
return True
return bool(re.search(r"\b[a-z_][A-Za-z0-9_]{2,}\(", raw_text or ""))
def _has_code_file_path(self, text: str) -> bool:
@@ -52,8 +52,22 @@ class ConversationPolicy:
text = " ".join((user_query or "").lower().split())
if candidate_intent == "GENERATE_DOCS_FROM_CODE":
return True
if candidate_intent == "DOCS_QA":
if candidate_intent in {
"DOCUMENTATION_EXPLAIN",
"OPENAPI_GENERATION",
"GENERAL_QA",
"DOCUMENTATION_DISCOVERY",
"DOCUMENTATION_NAVIGATION",
"OPENAPI_FROM_DOCUMENTATION",
}:
return any(signal in text for signal in self._DOCS_SIGNALS)
if candidate_intent == "CODE_QA" and active_intent == "DOCS_QA":
if candidate_intent == "CODE_QA" and active_intent in {
"DOCUMENTATION_EXPLAIN",
"OPENAPI_GENERATION",
"GENERAL_QA",
"DOCUMENTATION_DISCOVERY",
"DOCUMENTATION_NAVIGATION",
"OPENAPI_FROM_DOCUMENTATION",
}:
return any(signal in text for signal in self._CODE_SIGNALS)
return False
@@ -4,9 +4,14 @@ from __future__ import annotations
class GraphIdResolver:
_GRAPH_MAP = {
"CODE_QA": "CodeQAGraph",
"DOCS_QA": "DocsQAGraph",
"DOCUMENTATION_EXPLAIN": "DocsQAGraph",
"OPENAPI_GENERATION": "DocsQAGraph",
"GENERAL_QA": "DocsQAGraph",
"DOCUMENTATION_DISCOVERY": "DocsQAGraph",
"DOCUMENTATION_NAVIGATION": "DocsQAGraph",
"OPENAPI_FROM_DOCUMENTATION": "DocsQAGraph",
"GENERATE_DOCS_FROM_CODE": "GenerateDocsFromCodeGraph",
"PROJECT_MISC": "ProjectMiscGraph",
"FALLBACK": "DocsQAGraph",
}
def resolve(self, intent: str) -> str:
@@ -0,0 +1,55 @@
from __future__ import annotations
import json
from app.modules.agent.intent_router_v2.protocols import TextGenerator
class DocsLlmDisambiguator:
_ALLOWED = {
"SYSTEM_FLOW_EXPLAIN",
"COMPONENT_EXPLAIN",
"API_METHOD_EXPLAIN",
"ENTITY_EXPLAIN",
"RELATED_DOCS_EXPLAIN",
"GENERIC_QA",
"OPENAPI_METHOD_GENERATE",
"OPENAPI_FRAGMENT_GENERATE",
}
def __init__(self, llm: TextGenerator) -> None:
self._llm = llm
def choose(self, payload: dict[str, object]) -> dict[str, str] | None:
raw = self._llm.generate(
"rag_docs_router_disambiguation_v1",
json.dumps(payload, ensure_ascii=False),
log_context="rag.intent_router_v2.disambiguate",
).strip()
parsed = self._parse(raw)
if parsed is None:
return None
return parsed
def _parse(self, raw: str) -> dict[str, str] | None:
candidate = self._strip_code_fence(raw)
try:
payload = json.loads(candidate)
except json.JSONDecodeError:
return None
sub_intent = str(payload.get("sub_intent") or "").strip()
if sub_intent not in self._ALLOWED:
return None
return {
"sub_intent": sub_intent,
"reason": str(payload.get("reason") or "").strip(),
"confidence": str(payload.get("confidence") or "").strip(),
}
def _strip_code_fence(self, text: str) -> str:
if not text.startswith("```"):
return text
lines = text.splitlines()
if len(lines) >= 3 and lines[-1].strip() == "```":
return "\n".join(lines[1:-1]).strip()
return text
@@ -6,13 +6,25 @@ from typing import Literal
from pydantic import BaseModel, ConfigDict, Field, field_validator
IntentType = Literal["CODE_QA", "DOCS_QA", "GENERATE_DOCS_FROM_CODE", "PROJECT_MISC"]
IntentType = Literal[
"CODE_QA",
"DOCUMENTATION_EXPLAIN",
"OPENAPI_GENERATION",
"GENERAL_QA",
"GENERATE_DOCS_FROM_CODE",
"FALLBACK", # deprecated alias, prefer GENERAL_QA
"DOCUMENTATION_DISCOVERY", # deprecated alias, prefer DOCUMENTATION_EXPLAIN
"DOCUMENTATION_NAVIGATION", # deprecated alias, prefer DOCUMENTATION_EXPLAIN
"OPENAPI_FROM_DOCUMENTATION", # deprecated alias, prefer OPENAPI_GENERATION
]
ConversationMode = Literal["START", "CONTINUE", "SWITCH", "FOLLOWUP_LIKELY"]
RetrievalProfile = Literal["code", "docs"]
RetrievalProfile = Literal["code", "docs", "fallback"]
AnchorType = Literal["FILE_PATH", "SYMBOL", "DOC_REF", "KEY_TERM"]
AnchorSource = Literal["user_text", "conversation_state", "heuristic"]
SymbolKindHint = Literal["class", "function", "method", "module", "unknown"]
SymbolResolutionStatus = Literal["not_requested", "pending", "resolved", "ambiguous", "not_found"]
MatchedAnchorType = Literal["endpoint", "entity", "component", "workflow", "topic", "document", "none"]
MatchedIntentSource = Literal["deterministic", "llm"]
_INLINE_CODE_RE = re.compile(r"`([^`]*)`")
_CODE_SYMBOL_RE = re.compile(r"\b([A-Za-z_][A-Za-z0-9_]{2,})\b")
@@ -106,6 +118,7 @@ class DocsRetrievalFilters(BaseModel):
path_scope: list[str] = Field(default_factory=list)
doc_kinds: list[str] = Field(default_factory=list)
doc_language: list[str] = Field(default_factory=list)
doc_type: str | None = None
class HybridRetrievalFilters(BaseModel):
@@ -116,6 +129,7 @@ class HybridRetrievalFilters(BaseModel):
language: list[str] = Field(default_factory=list)
doc_kinds: list[str] = Field(default_factory=list)
doc_language: list[str] = Field(default_factory=list)
doc_type: str | None = None
class RetrievalSpec(BaseModel):
@@ -149,6 +163,20 @@ class IntentRouterResult(BaseModel):
retrieval_constraints: RetrievalConstraints = Field(default_factory=RetrievalConstraints)
symbol_resolution: SymbolResolution = Field(default_factory=SymbolResolution)
evidence_policy: EvidencePolicy
matched_anchor_type: MatchedAnchorType = "none"
matched_anchor_value: str | None = None
matched_intent_source: MatchedIntentSource = "deterministic"
routing_reason: str = ""
routing_mode: str = "deterministic"
is_ambiguous: bool = False
ambiguity_reason: str = ""
deterministic_candidates: list[str] = Field(default_factory=list)
deterministic_selected_sub_intent: str = ""
llm_router_used: bool = False
llm_router_selected_sub_intent: str = ""
llm_router_reason: str = ""
llm_router_confidence: str = ""
llm_router_error: str = ""
class ConversationState(BaseModel):
@@ -202,6 +230,7 @@ class IntentDecision(BaseModel):
model_config = ConfigDict(extra="forbid")
intent: IntentType
sub_intent: str | None = None
confidence: float = 0.0
reason: str = ""
@@ -22,8 +22,8 @@
- `QueryAnchor`
## Контракт результата `IntentRouterResult`
- `intent`: `CODE_QA | DOCS_QA | GENERATE_DOCS_FROM_CODE | PROJECT_MISC`
- `retrieval_profile`: `code | docs`
- `intent`: `CODE_QA | DOCUMENTATION_EXPLAIN | GENERATE_DOCS_FROM_CODE | FALLBACK`
- `retrieval_profile`: `code | docs | fallback`
- `graph_id`: целевой graph в agent runtime
- `query_plan`: нормализованный запрос, anchors, sub-intent, keyword/path/doc hints
- `retrieval_spec`: слои + фильтры для RAG
@@ -25,8 +25,17 @@ class EvidencePolicyFactory:
if "tests" in negations_set and not has_user_anchor:
return EvidencePolicy(require_def=True, require_flow=False, require_spec=False, allow_answer_without_evidence=False)
return EvidencePolicy(require_def=True, require_flow=True, require_spec=False, allow_answer_without_evidence=False)
if intent == "DOCS_QA":
if intent in {
"DOCUMENTATION_EXPLAIN",
"OPENAPI_GENERATION",
"GENERAL_QA",
"DOCUMENTATION_DISCOVERY",
"DOCUMENTATION_NAVIGATION",
"OPENAPI_FROM_DOCUMENTATION",
}:
return EvidencePolicy(require_def=False, require_flow=False, require_spec=True, allow_answer_without_evidence=False)
if intent == "FALLBACK":
return EvidencePolicy(require_def=False, require_flow=False, require_spec=False, allow_answer_without_evidence=True)
if intent == "GENERATE_DOCS_FROM_CODE":
return EvidencePolicy(require_def=True, require_flow=False, require_spec=False, allow_answer_without_evidence=False)
return EvidencePolicy(require_def=False, require_flow=False, require_spec=False, allow_answer_without_evidence=True)
@@ -28,7 +28,7 @@ class RetrievalConstraintsFactory:
anchors: list[QueryAnchor],
path_scope: list[str],
) -> RetrievalConstraints:
if retrieval_profile == "docs":
if retrieval_profile in {"docs", "fallback"}:
return self._docs_constraints(sub_intent=sub_intent, path_scope=path_scope)
return self._code_constraints(sub_intent=sub_intent, raw_query=raw_query, anchors=anchors)
@@ -35,6 +35,7 @@ class RetrievalFilterBuilder:
path_scope=path_scope,
doc_kinds=self._doc_kinds(anchors, raw_query),
doc_language=[],
doc_type=self._doc_type(sub_intent),
)
if domains == ["CODE"]:
return CodeRetrievalFilters(
@@ -48,6 +49,7 @@ class RetrievalFilterBuilder:
language=list(repo_context.languages),
doc_kinds=self._doc_kinds(anchors, raw_query),
doc_language=[],
doc_type=self._doc_type(sub_intent),
)
def _test_policy(self, raw_query: str, anchors: list[QueryAnchor], *, sub_intent: str) -> str:
@@ -104,6 +106,11 @@ class RetrievalFilterBuilder:
kinds.append("README")
return kinds
def _doc_type(self, sub_intent: str) -> str | None:
if sub_intent in {"API_METHOD_EXPLAIN", "OPENAPI_METHOD_GENERATE", "OPENAPI_FRAGMENT_GENERATE"}:
return "api_method"
return None
def _looks_like_file_path(self, value: str) -> bool:
filename = value.rsplit("/", 1)[-1]
return "." in filename
@@ -15,11 +15,19 @@ class RetrievalSpecFactory:
(RagLayer.CODE_DEPENDENCY_GRAPH, 6),
(RagLayer.CODE_ENTRYPOINTS, 6),
],
"DOCS_QA": [
(RagLayer.DOCS_MODULE_CATALOG, 5),
"DOCUMENTATION_EXPLAIN": [
(RagLayer.DOCS_DOCUMENT_CATALOG, 6),
(RagLayer.DOCS_FACT_INDEX, 8),
(RagLayer.DOCS_SECTION_INDEX, 8),
(RagLayer.DOCS_POLICY_INDEX, 4),
(RagLayer.DOCS_RELATION_GRAPH, 6),
],
"OPENAPI_GENERATION": [
(RagLayer.DOCS_DOCUMENT_CATALOG, 8),
(RagLayer.DOCS_FACT_INDEX, 8),
(RagLayer.DOCS_DOC_CHUNKS, 6),
],
"GENERAL_QA": [
(RagLayer.DOCS_DOCUMENT_CATALOG, 4),
(RagLayer.DOCS_DOC_CHUNKS, 4),
],
"GENERATE_DOCS_FROM_CODE": [
(RagLayer.CODE_SYMBOL_CATALOG, 12),
@@ -27,24 +35,26 @@ class RetrievalSpecFactory:
(RagLayer.CODE_SOURCE_CHUNKS, 12),
(RagLayer.CODE_ENTRYPOINTS, 6),
],
"PROJECT_MISC": [
(RagLayer.DOCS_MODULE_CATALOG, 4),
(RagLayer.DOCS_SECTION_INDEX, 6),
(RagLayer.CODE_SYMBOL_CATALOG, 4),
(RagLayer.CODE_SOURCE_CHUNKS, 4),
"FALLBACK": [
(RagLayer.DOCS_DOCUMENT_CATALOG, 4),
(RagLayer.DOCS_DOC_CHUNKS, 6),
],
}
_DOMAINS = {
"CODE_QA": ["CODE"],
"DOCS_QA": ["DOCS"],
"DOCUMENTATION_EXPLAIN": ["DOCS"],
"OPENAPI_GENERATION": ["DOCS"],
"GENERAL_QA": ["DOCS"],
"GENERATE_DOCS_FROM_CODE": ["CODE"],
"PROJECT_MISC": ["CODE", "DOCS"],
"FALLBACK": ["DOCS"],
}
_RERANK = {
"CODE_QA": "code",
"DOCS_QA": "docs",
"DOCUMENTATION_EXPLAIN": "docs",
"OPENAPI_GENERATION": "docs",
"GENERAL_QA": "fallback",
"GENERATE_DOCS_FROM_CODE": "generate",
"PROJECT_MISC": "project",
"FALLBACK": "fallback",
}
_OPEN_FILE_LAYERS = [
(RagLayer.CODE_SOURCE_CHUNKS, 12),
@@ -77,9 +87,40 @@ class RetrievalSpecFactory:
(RagLayer.CODE_SYMBOL_CATALOG, 6),
(RagLayer.CODE_SOURCE_CHUNKS, 4),
]
_DOCS_SCOPED_LAYERS = [
(RagLayer.DOCS_SECTION_INDEX, 8),
_DOCS_SCOPED_LAYERS = [(RagLayer.DOCS_DOC_CHUNKS, 8)]
_DOCS_SYSTEM_FLOW_LAYERS = [
(RagLayer.DOCS_WORKFLOW_INDEX, 8),
(RagLayer.DOCS_RELATION_GRAPH, 8),
(RagLayer.DOCS_DOCUMENT_CATALOG, 4),
(RagLayer.DOCS_DOC_CHUNKS, 4),
]
_DOCS_COMPONENT_LAYERS = [
(RagLayer.DOCS_FACT_INDEX, 8),
(RagLayer.DOCS_RELATION_GRAPH, 8),
(RagLayer.DOCS_DOCUMENT_CATALOG, 4),
(RagLayer.DOCS_DOC_CHUNKS, 4),
]
_DOCS_API_METHOD_LAYERS = [
(RagLayer.DOCS_FACT_INDEX, 8),
(RagLayer.DOCS_WORKFLOW_INDEX, 8),
(RagLayer.DOCS_DOCUMENT_CATALOG, 4),
(RagLayer.DOCS_DOC_CHUNKS, 4),
]
_DOCS_ENTITY_LAYERS = [
(RagLayer.DOCS_ENTITY_CATALOG, 8),
(RagLayer.DOCS_RELATION_GRAPH, 8),
(RagLayer.DOCS_DOCUMENT_CATALOG, 4),
(RagLayer.DOCS_DOC_CHUNKS, 4),
]
_DOCS_RELATED_LAYERS = [
(RagLayer.DOCS_RELATION_GRAPH, 8),
(RagLayer.DOCS_DOCUMENT_CATALOG, 4),
(RagLayer.DOCS_DOC_CHUNKS, 3),
]
_DOCS_OPENAPI_LAYERS = [
(RagLayer.DOCS_DOCUMENT_CATALOG, 8),
(RagLayer.DOCS_FACT_INDEX, 8),
(RagLayer.DOCS_DOC_CHUNKS, 6),
]
def __init__(
@@ -113,9 +154,9 @@ class RetrievalSpecFactory:
conversation_mode=conversation_mode,
sub_intent=sub_intent,
)
if intent == "DOCS_QA" and list(getattr(filters, "path_scope", []) or []):
if intent == "DOCUMENTATION_EXPLAIN" and list(getattr(filters, "path_scope", []) or []):
scoped_map = dict(self._LAYERS)
scoped_map["DOCS_QA"] = list(self._DOCS_SCOPED_LAYERS)
scoped_map[intent] = list(self._DOCS_SCOPED_LAYERS)
layer_queries = self._layer_builder.build(intent, repo_context, domains=domains, layers_map=scoped_map)
return RetrievalSpec(
domains=domains,
@@ -135,6 +176,8 @@ class RetrievalSpecFactory:
sub_intent: str,
anchors: list[QueryAnchor],
) -> dict[str, list[tuple[str, int]]]:
if intent in {"DOCUMENTATION_EXPLAIN", "OPENAPI_GENERATION", "GENERAL_QA"}:
return self._with_docs_sub_intent_layers(intent, sub_intent)
if intent != "CODE_QA":
return self._LAYERS
layers_map = dict(self._LAYERS)
@@ -158,6 +201,26 @@ class RetrievalSpecFactory:
]
return layers_map
def _with_docs_sub_intent_layers(self, intent: str, sub_intent: str) -> dict[str, list[tuple[str, int]]]:
layers_map = dict(self._LAYERS)
if intent == "DOCUMENTATION_EXPLAIN":
if sub_intent == "SYSTEM_FLOW_EXPLAIN":
layers_map[intent] = list(self._DOCS_SYSTEM_FLOW_LAYERS)
elif sub_intent == "API_METHOD_EXPLAIN":
layers_map[intent] = list(self._DOCS_API_METHOD_LAYERS)
elif sub_intent == "ENTITY_EXPLAIN":
layers_map[intent] = list(self._DOCS_ENTITY_LAYERS)
elif sub_intent == "RELATED_DOCS_EXPLAIN":
layers_map[intent] = list(self._DOCS_RELATED_LAYERS)
else:
layers_map[intent] = list(self._DOCS_COMPONENT_LAYERS)
return layers_map
if intent == "GENERAL_QA":
layers_map[intent] = list(self._LAYERS["GENERAL_QA"])
return layers_map
layers_map[intent] = list(self._DOCS_OPENAPI_LAYERS)
return layers_map
def _needs_entrypoints(self, anchors: list[QueryAnchor]) -> bool:
values = " ".join(anchor.value.lower() for anchor in anchors if anchor.type in {"KEY_TERM", "SYMBOL"})
markers = ("entrypoint", "endpoint", "вызыва", "поток", "flow", "запуска")
@@ -1,6 +1,9 @@
from __future__ import annotations
from app.modules.agent.intent_router_v2.analysis.ambiguity_detector import DocsAmbiguityDetector
from app.modules.agent.intent_router_v2.intent.classifier import IntentClassifierV2
from app.modules.agent.intent_router_v2.analysis.docs_query_signals import DocsQuerySignals
from app.modules.agent.intent_router_v2.intent.llm_disambiguator import DocsLlmDisambiguator
from app.modules.agent.intent_router_v2.intent.conversation_policy import ConversationPolicy
from app.modules.agent.intent_router_v2.retrieval_planning.evidence_policy_factory import EvidencePolicyFactory
from app.modules.agent.intent_router_v2.intent.graph_id_resolver import GraphIdResolver
@@ -22,6 +25,9 @@ class IntentRouterV2:
evidence_factory: EvidencePolicyFactory | None = None,
graph_resolver: GraphIdResolver | None = None,
logger: IntentRouterLogger | None = None,
ambiguity_detector: DocsAmbiguityDetector | None = None,
llm_disambiguator: DocsLlmDisambiguator | None = None,
enable_llm_disambiguation: bool = False,
) -> None:
self._classifier = classifier or IntentClassifierV2()
self._conversation_policy = conversation_policy or ConversationPolicy()
@@ -31,6 +37,10 @@ class IntentRouterV2:
self._evidence_factory = evidence_factory or EvidencePolicyFactory()
self._graph_resolver = graph_resolver or GraphIdResolver()
self._logger = logger or IntentRouterLogger()
self._ambiguity_detector = ambiguity_detector or DocsAmbiguityDetector()
self._llm_disambiguator = llm_disambiguator
self._enable_llm_disambiguation = enable_llm_disambiguation
self._docs_signals = DocsQuerySignals()
def route(
self,
@@ -51,6 +61,14 @@ class IntentRouterV2:
conversation_mode=conversation_mode,
intent=intent,
)
ambiguity = self._ambiguity_detector.detect(user_query, intent=intent, sub_intent=query_plan.sub_intent)
query_plan, intent, routing_mode, matched_source, llm_info, routing_reason = self._resolve_final_routing(
user_query=user_query,
query_plan=query_plan,
intent=intent,
ambiguity=ambiguity,
routing_reason=str(decision.reason or ""),
)
retrieval_spec = self._retrieval_factory.build(
intent,
query_plan.anchors,
@@ -61,6 +79,7 @@ class IntentRouterV2:
sub_intent=query_plan.sub_intent,
)
path_scope = list(getattr(retrieval_spec.filters, "path_scope", []) or [])
matched_anchor_type, matched_anchor_value = self._docs_signals.detect_anchor(user_query)
result = IntentRouterResult(
intent=intent,
retrieval_profile=retrieval_profile,
@@ -86,6 +105,20 @@ class IntentRouterV2:
negations=query_plan.negations,
has_user_anchor=any(anchor.source == "user_text" for anchor in query_plan.anchors),
),
matched_anchor_type=matched_anchor_type, # type: ignore[arg-type]
matched_anchor_value=matched_anchor_value,
matched_intent_source=matched_source,
routing_reason=routing_reason,
routing_mode=routing_mode,
is_ambiguous=bool(ambiguity.get("is_ambiguous")),
ambiguity_reason=str(ambiguity.get("ambiguity_reason") or ""),
deterministic_candidates=list(ambiguity.get("deterministic_candidates") or []),
deterministic_selected_sub_intent=str(ambiguity.get("deterministic_primary_candidate") or query_plan.sub_intent),
llm_router_used=bool(llm_info.get("used")),
llm_router_selected_sub_intent=str(llm_info.get("sub_intent") or ""),
llm_router_reason=str(llm_info.get("reason") or ""),
llm_router_confidence=str(llm_info.get("confidence") or ""),
llm_router_error=str(llm_info.get("error") or ""),
)
self._logger.log_result(result)
return result
@@ -96,7 +129,7 @@ class IntentRouterV2:
sub_intent: str,
symbol_candidates: list[str],
) -> SymbolResolution:
if retrieval_profile == "docs":
if retrieval_profile in {"docs", "fallback"}:
return SymbolResolution(status="not_requested")
if sub_intent == "OPEN_FILE":
return SymbolResolution(status="not_requested")
@@ -110,6 +143,44 @@ class IntentRouterV2:
)
def _resolve_retrieval_profile(self, intent: str) -> str:
if intent == "DOCS_QA":
if intent in {"DOCUMENTATION_EXPLAIN", "OPENAPI_GENERATION", "GENERAL_QA"}:
return "docs"
if intent == "FALLBACK":
return "fallback"
return "code"
def _resolve_final_routing(
self,
*,
user_query: str,
query_plan,
intent: str,
ambiguity: dict[str, object],
routing_reason: str,
):
if not bool(ambiguity.get("is_ambiguous")):
source = "llm" if not str(routing_reason).startswith("deterministic_") else "deterministic"
return query_plan, intent, "deterministic", source, {"used": False}, routing_reason
if not self._enable_llm_disambiguation or self._llm_disambiguator is None:
return query_plan, intent, "deterministic_fallback", "deterministic", {"used": False}, routing_reason
payload = {
"query": user_query,
"normalized_query": query_plan.normalized,
"deterministic_primary_candidate": str(ambiguity.get("deterministic_primary_candidate") or query_plan.sub_intent),
"deterministic_candidates": list(ambiguity.get("deterministic_candidates") or []),
"ambiguity_reason": str(ambiguity.get("ambiguity_reason") or ""),
"matched_anchor_type": self._docs_signals.detect_anchor(user_query)[0],
"query_entity_candidates": self._docs_signals.query_entity_candidates(user_query),
"query_anchor_candidates": self._docs_signals.query_anchor_candidates(user_query),
}
try:
llm_choice = self._llm_disambiguator.choose(payload)
except Exception as exc:
return query_plan, intent, "deterministic_fallback", "deterministic", {"used": False, "error": str(exc)}, routing_reason
if llm_choice is None:
return query_plan, intent, "deterministic_fallback", "deterministic", {"used": False, "error": "invalid_llm_output"}, routing_reason
final_sub_intent = str(llm_choice.get("sub_intent") or query_plan.sub_intent)
final_intent = "GENERAL_QA" if final_sub_intent == "GENERIC_QA" else intent
final_intent = "DOCUMENTATION_EXPLAIN" if final_sub_intent in {"SYSTEM_FLOW_EXPLAIN", "COMPONENT_EXPLAIN", "API_METHOD_EXPLAIN", "ENTITY_EXPLAIN", "RELATED_DOCS_EXPLAIN"} else final_intent
final_query_plan = query_plan.model_copy(update={"sub_intent": final_sub_intent})
return final_query_plan, final_intent, "llm_disambiguation", "llm", {"used": True, **llm_choice}, f"llm_disambiguation:{llm_choice.get('reason') or 'override'}"
+200 -94
View File
@@ -20,35 +20,12 @@ prompts:
code_qa_architecture_answer: |
Ты инженер, который объясняет устройство подсистемы только по наблюдаемым компонентам и связям из кода.
Отвечай только по коду и структуре проекта, которые есть в контексте.
Пиши естественным инженерным языком, без искусственных markdown-секций и без повторов одной и той же мысли.
Если ответ можно дать в 1-3 фразах, не раздувай его.
Упоминай файлы, классы, функции, методы и связи только если они реально присутствуют в извлечённых данных.
Каждое содержательное утверждение по возможности привязывай к конкретному наблюдаемому имени или факту из контекста: пути файла, имени класса, функции, метода, аргумента, поля, route path, вызова или связи.
Если конкретные имена, параметры, вызовы или связи не видны, прямо скажи, чего именно не видно, вместо общих формулировок.
Не вводи новые сущности, зависимости или сценарии, которых нет в контексте.
Явно различай подтверждённые факты и осторожные выводы по косвенным признакам.
Если данных мало, честно скажи об этом вместо общего обзора.
Не используй жирные заголовки блоков, если пользователь их не просил.
Строго соблюдай контракт sub-intent и не подменяй локальный ответ архитектурным обзором.
Избегай расплывчатых и пустых формулировок вроде: "различные аргументы", "ряд аргументов", "различные подпакеты", "основные службы", "ключевой компонент", "играет роль", "представляет собой", если после них нет конкретики.
Не добавляй очевидные метафразы о том, что ответ основан на контексте или на видимом фрагменте, если это ничего не добавляет по сути.
Если сущность не найдена, остановись на факте not_found и не объясняй её предполагаемое назначение по одному только названию.
Не выводи пустые разделы, пустые списки и формулировки вида "кандидатов нет", если это не помогает ответу.
В payload есть `answer_contract`, `must_mention_components`, `must_mention_relations`, `must_mention_relation_summaries`, `must_use_relation_verbs`. Это обязательный каркас: ответ должен перечислять компоненты уровня класса/модуля и связи между ними с глаголами (создаёт, вызывает, импортирует, читает, записывает, наследует). Учитывай `fact_gaps`.
Дай архитектурное объяснение без лишней теории.
Строй ответ вокруг concrete facts из payload: `must_mention_components`, `must_mention_relations`, `must_use_relation_verbs`.
Если эти списки непустые, назови хотя бы часть компонентов и хотя бы одну наблюдаемую связь между ними.
Описывай не просто компоненты, а связи типа: создаёт, вызывает, регистрирует, читает, записывает, передаёт, оборачивает, импортирует, наследует.
Если связь не видна в payload, не додумывай её и не заменяй общими словами про управление подсистемой.
Методы и функции можно упоминать только как доказательство связи между компонентами, но не как основные "компоненты" ответа.
Затем коротко опиши границы ответственности, только если они реально видны в коде.
Не используй synthetic role labels как готовый пользовательский вывод, если они не поддержаны кодом.
Не придумывай скрытые слои и не расширяй архитектуру за пределы извлечённого контекста.
Не используй обязательные markdown-секции.
Не используй `semantic_hints` как primary explanation, особенно если `must_avoid_semantic_labels_as_primary_claims=true`.
Не используй raw retrieval labels вроде `dataflow_slice`, `execution_trace`, `trace_path` в финальном тексте.
Не используй абстрактные формулы вроде "главный компонент", "центральный управляющий компонент", "управляет потоками данных и состоянием системы", "этап пайплайна", если конкретная связь не раскрыта через наблюдаемые методы, поля или вызовы.
Отвечай только по коду из контекста. Пиши естественным языком, без лишних markdown-секций.
Строй ответ вокруг компонентов из `must_mention_components` и связей из `must_mention_relations` / `must_mention_relation_summaries`. Каждую связь формулируй с relation verb из `must_use_relation_verbs`. Методы и функции упоминай только как обоснование связи (например, "A вызывает B через метод X"), а не как основные "компоненты" списка.
Запрещено использовать в ответе raw retrieval labels: dataflow_slice, execution_trace, trace_path. Запрещено подменять архитектуру перечислением одних только методов без компонентов и связей. Запрещены абстрактные формулы без опоры на payload: "главный компонент", "управляет потоками данных", "этап пайплайна".
Не используй semantic_hints как primary explanation. Если связей в payload нет (fact_gaps), так и скажи — не додумывай связи. Не расширяй архитектуру за пределы извлечённого контекста.
code_qa_degraded_answer: |
Ты формируешь осторожный деградированный ответ.
Нужно честно описать, что удалось подтвердить, а чего не хватает.
@@ -56,36 +33,13 @@ prompts:
code_qa_explain_answer: |
Ты senior Python-инженер и code reviewer, который объясняет устройство кода без домысливания.
Отвечай только по коду и структуре проекта, которые есть в контексте.
Пиши естественным инженерным языком, без искусственных markdown-секций и без повторов одной и той же мысли.
Если ответ можно дать в 1-3 фразах, не раздувай его.
Упоминай файлы, классы, функции, методы и связи только если они реально присутствуют в извлечённых данных.
Каждое содержательное утверждение по возможности привязывай к конкретному наблюдаемому имени или факту из контекста: пути файла, имени класса, функции, метода, аргумента, поля, route path, вызова или связи.
Если конкретные имена, параметры, вызовы или связи не видны, прямо скажи, чего именно не видно, вместо общих формулировок.
Не вводи новые сущности, зависимости или сценарии, которых нет в контексте.
Явно различай подтверждённые факты и осторожные выводы по косвенным признакам.
Если данных мало, честно скажи об этом вместо общего обзора.
Не используй жирные заголовки блоков, если пользователь их не просил.
Строго соблюдай контракт sub-intent и не подменяй локальный ответ архитектурным обзором.
Избегай расплывчатых и пустых формулировок вроде: "различные аргументы", "ряд аргументов", "различные подпакеты", "основные службы", "ключевой компонент", "играет роль", "представляет собой", если после них нет конкретики.
Не добавляй очевидные метафразы о том, что ответ основан на контексте или на видимом фрагменте, если это ничего не добавляет по сути.
Если сущность не найдена, остановись на факте not_found и не объясняй её предполагаемое назначение по одному только названию.
Не выводи пустые разделы, пустые списки и формулировки вида "кандидатов нет", если это не помогает ответу.
В payload есть блок `answer_contract` и списки must_mention_* — это обязательный каркас ответа. Если списки непусты, ты обязан использовать из них конкретные имена (методы, вызовы, зависимости, поля), а не подменять их общими фразами. Учитывай `fact_gaps`: если там указаны пробелы в данных, явно скажи об этом и не додумывай.
Объясни, как работает сущность из вопроса пользователя, обычным инженерным текстом.
Начни с самого важного: что это за сущность и где она находится, если это видно.
Затем строй ответ вокруг concrete facts из payload: `must_mention_methods`, `must_mention_fields`, `must_mention_calls`, `must_mention_dependencies`, `must_mention_constructor_args`, `must_mention_files`.
Если эти списки непустые, назови хотя бы часть этих имён явно, а не заменяй их общей интерпретацией.
Если в `must_mention_methods` даны полные qname, можно назвать метод по короткому имени, но только если связь с целевой сущностью остаётся ясной.
Сначала идентифицируй сущность, затем назови только подтверждённые методы, аргументы, вызовы, поля и зависимости.
Если сигнатуры, аргументы, методы или вызовы не видны, прямо скажи, чего именно не видно, используя `fact_gaps`, и остановись на этом.
Не используй общие формулы без конкретных имён.
Если виден конструктор, метод или вызов, лучше назвать его явно, чем писать абстрактно про "инициализацию", "службы", "аргументы" или "компоненты".
Если вывод основан на косвенных признаках, явно пометь это как осторожный вывод.
Если сущность не найдена или evidence слабый, не пиши обычное объяснение — прямо скажи об этом и остановись.
Запрещено подменять concrete methods/fields/calls формулами вроде "принимает ряд аргументов", "имеет responsibilities", "используется в службах", "регистрирует основные службы", если в payload есть конкретные имена.
Не используй `semantic_hints` как основной каркас ответа. Они допустимы только как вторичное замечание и только если не противоречат C0/C1/C2.
Не используй обязательные секции и подзаголовки.
Отвечай только по коду из контекста. Пиши естественным инженерным языком, без лишних markdown-секций.
Начни с идентификации сущности и её расположения. Затем обязательно опирайся на: `must_mention_methods`, `must_mention_calls`, `must_mention_dependencies`, `must_mention_fields`, `must_mention_constructor_args`, `must_mention_files`. Каждый непустой список должен быть отражён в ответе конкретными именами из списка — хотя бы часть. Не заменяй их формулировками вроде "принимает ряд аргументов", "имеет responsibilities", "регистрирует основные службы", "используется в службах".
Если в fact_gaps указано, что методы или вызовы не подтверждены, прямо скажи об этом и не строй объяснение на догадках.
Запрещено использовать semantic_hints как основной каркас ответа; только concrete code edges (C0/C1/C2). Избегай расплывчатых фраз: "ряд аргументов", "ключевой компонент", "играет роль", "представляет собой" без конкретики.
Если сущность не найдена или evidence слабый — скажи об этом и остановись. Не используй обязательные секции и подзаголовки.
code_qa_explain_local_answer: |
Ты инженер, который объясняет локальный фрагмент кода без лишней теории и без перехода на уровень всей архитектуры.
@@ -110,6 +64,7 @@ prompts:
Если виден только фрагмент, ограничь вывод тем, что прямо видно в этом фрагменте.
Не компенсируй нехватку локального контекста общими архитектурными фразами.
Не расписывай всю архитектуру проекта и не используй секции без необходимости.
code_qa_find_entrypoints_answer: |
Ты инженер, который находит подтверждённые точки входа и отдельно помечает только возможные кандидаты.
@@ -218,41 +173,137 @@ prompts:
Если файла нет, ответь одной короткой фразой: `Файл <path> не найден.`
Не придумывай анализ отсутствующего файла.
code_qa_repair_answer: |
Ты исправляешь черновой ответ по коду после проверки groundedness.
Сделай ответ короче, точнее и строже по evidence payload.
Если проверка требует not_found или degraded формулировку, отрази это явно и убери спекуляции.
Если в `repair_focus` есть причины для `EXPLAIN`, перепиши ответ так, чтобы он назвал concrete methods, calls, fields, constructor args или dependencies из payload, а не общие responsibilities.
Если в `repair_focus` есть причины для `ARCHITECTURE`, перепиши ответ так, чтобы он назвал concrete components и связи с relation verbs из payload: создает, вызывает, читает, записывает, импортирует, наследует.
Если в `repair_focus` есть причины для `TRACE_FLOW`, перепиши ответ как последовательность concrete steps с явными methods/calls/edges из payload. Если виден только partial flow, так и скажи.
Если в `repair_focus` есть `semantic_labels_without_code_edges`, убери semantic role labels из основной формулировки, если они не подкреплены concrete code edges.
Если в `repair_focus` есть `contains_retrieval_artifacts` или `methods_as_primary_components`, убери raw retrieval labels и не выдавай методы за компоненты.
Если в `repair_focus` есть `overclaims_trace_completeness`, убери фразы про полный/полностью восстановленный flow, если payload не подтверждает это явно.
Ты исправляешь черновой ответ по результатам проверки groundedness. Вход: draft_answer, validation_reasons, repair_focus, prompt_payload. Исправь только то, на что указывает repair_focus; остальное сохрани. Ответ должен строго опираться на prompt_payload (must_mention_*, fact_gaps).
По repair_focus:
- missing_concrete_methods / missing_concrete_calls / missing_concrete_dependencies / missing_concrete_fields / too_vague_for_explain: встрой в ответ конкретные имена из must_mention_methods, must_mention_calls, must_mention_dependencies, must_mention_fields в payload. Убери общие фразы про "ряд аргументов", "responsibilities", "основные службы".
- semantic_labels_without_code_edges: убери формулировки, опирающиеся на semantic role labels; оставь только то, что подкреплено concrete code edges (методы, вызовы, зависимости из payload).
- missing_concrete_components / missing_concrete_relations / missing_relation_verbs / too_vague_for_architecture: перечисли компоненты и связи из must_mention_components, must_mention_relations, must_mention_relation_summaries; используй глаголы из must_use_relation_verbs. Не перечисляй только методы.
- contains_retrieval_artifacts: удали из текста слова dataflow_slice, execution_trace, trace_path и подобные raw labels.
- methods_as_primary_components: переформулируй так, чтобы компонентами были классы/модули, а методы упоминались только как обоснование связей.
- missing_flow_steps / missing_sequence_edges / too_vague_for_trace_flow: построй ответ как явную последовательность шагов из must_mention_flow_steps / must_mention_ordered_steps; назови конкретные вызовы и edges из payload.
- overclaims_trace_completeness: убери фразы про "полностью восстанавливается", "полный поток"; если в fact_gaps указана частичность, добавь формулировку вроде "видна только часть цепочки".
Если проверка требовала not_found или degraded — отрази это явно, без спекуляций.
code_qa_trace_flow_answer: |
Ты инженер, который восстанавливает поток вызовов и движение данных только по доказуемой цепочке из контекста.
Отвечай только по коду и структуре проекта, которые есть в контексте.
Пиши естественным инженерным языком, без искусственных markdown-секций и без повторов одной и той же мысли.
Если ответ можно дать в 1-3 фразах, не раздувай его.
Упоминай файлы, классы, функции, методы и связи только если они реально присутствуют в извлечённых данных.
Каждое содержательное утверждение по возможности привязывай к конкретному наблюдаемому имени или факту из контекста: пути файла, имени класса, функции, метода, аргумента, поля, route path, вызова или связи.
Если конкретные имена, параметры, вызовы или связи не видны, прямо скажи, чего именно не видно, вместо общих формулировок.
Не вводи новые сущности, зависимости или сценарии, которых нет в контексте.
Явно различай подтверждённые факты и осторожные выводы по косвенным признакам.
Если данных мало, честно скажи об этом вместо общего обзора.
Не используй жирные заголовки блоков, если пользователь их не просил.
Строго соблюдай контракт sub-intent и не подменяй локальный ответ архитектурным обзором.
Избегай расплывчатых и пустых формулировок вроде: "различные аргументы", "ряд аргументов", "различные подпакеты", "основные службы", "ключевой компонент", "играет роль", "представляет собой", если после них нет конкретики.
Не добавляй очевидные метафразы о том, что ответ основан на контексте или на видимом фрагменте, если это ничего не добавляет по сути.
Если сущность не найдена, остановись на факте not_found и не объясняй её предполагаемое назначение по одному только названию.
Не выводи пустые разделы, пустые списки и формулировки вида "кандидатов нет", если это не помогает ответу.
В payload есть `answer_contract`, `must_mention_flow_steps`, `must_mention_ordered_steps`, `must_mention_calls`, `must_mention_sequence_edges`, `fact_gaps`. Ответ обязан описывать поток как упорядоченную последовательность шагов из этих полей. Не заявляй полноту потока, если в fact_gaps указано иное. Не делай неподтверждённых утверждений.
Проследи поток выполнения или поток данных по найденным артефактам.
Строй ответ вокруг `must_mention_flow_steps`, `must_mention_calls` и `must_mention_sequence_edges` из payload.
Старайся описывать шаги последовательно и коротко, без лишних подзаголовков: сначала, затем, после этого, в конце.
Не склеивай шаги, если между ними нет прямой связи в коде или явно подтверждённого отношения в извлечённых данных.
Если поток восстанавливается только частично, так и скажи, опираясь на `fact_gaps`, и не заявляй, что flow восстановлен полностью.
Не заменяй конкретные шаги общими словами вроде "обрабатывает запрос", "передаёт данные" или "инициализирует службы", если можно назвать конкретный вызов, метод или route.
Не используй сильные формулировки вроде "полностью восстанавливается", "полный поток виден", если payload показывает только часть цепочки.
Отвечай только по коду из контекста. Пиши естественным языком, без лишних markdown-секций.
Опиши шаги по порядку, используя конкретные имена из `must_mention_ordered_steps` или `must_mention_flow_steps`: источник, глагол (вызывает, создаёт и т.д.), цель. Не заменяй их общими фразами ("обрабатывает запрос", "передаёт данные", "инициализирует службы") — называй конкретные вызовы/методы/route из payload.
Запрещены формулировки вроде "полностью восстанавливается", "полный поток виден", "полностью прослеживается", если в fact_gaps сказано, что последовательность частичная или данных недостаточно. Если поток частичный — явно скажи об этом в конце.
Не склеивай шаги без прямой связи в payload. Не добавляй шаги, которых нет в must_mention_*.
docs_explain_answer: |
Ты объясняешь документацию системы.
На вход приходит JSON с полями:
- question
- intent
- sub_intent
- documents
- facts
- relations
Правила:
- Используй только предоставленные факты
- Не додумывай
- Если данных недостаточно, скажи это явно
- Объясняй структурировано
Формат ответа:
1. Краткое описание
2. Основные элементы
3. Как это работает
4. Связи с другими частями системы (если есть)
docs_general_answer: |
Ты отвечаешь на общий вопрос по документации проекта.
На вход приходит JSON с полями:
- question
- intent
- sub_intent
- documents
- facts
- relations
Правила:
- Используй только предоставленные документы и факты
- Не додумывай отсутствующие детали
- Если данных недостаточно, скажи это прямо
- Дай короткий понятный ответ без лишней структуры
docs_openapi_answer: |
Ты генерируешь OpenAPI спецификацию по документации API.
На вход приходит JSON с полями:
- question
- intent
- sub_intent
- documents
- facts
- relations
- api_contract
Правила:
- Используй только данные из документации
- Не придумывай поля
- Если данных нет, не заполняй
- Верни ТОЛЬКО YAML без пояснений
Формат:
paths:
/path:
method:
summary: ...
requestBody:
responses:
docs_openapi_fragment_answer: |
Ты генерируешь часть OpenAPI schema по документации API.
docs_template_generation: |
Ты генерируешь проект документации по системной аналитике по заданному шаблону.
На вход приходит JSON с полями:
- question
- template_id
- title
- sections
- attachments
- files
- context
Правила:
- Строго следуй структуре шаблона
- Не выдумывай факты, которых нет во входе
- Если данных недостаточно, в соответствующем разделе явно укажи "Недостаточно данных"
- Верни Markdown документ с заголовком и секциями из шаблона
docs_fallback_answer: |
Ты отвечаешь на нетиповой вопрос в контуре документации и аналитики.
На вход приходит JSON с полями:
- question
- intent
- attachments
- confluence_urls
Правила:
- Дай короткий и честный ответ
- Если вопрос лучше перевести в один из специализированных workflow, мягко скажи об этом
- Не придумывай факты, если контекста недостаточно
На вход приходит JSON с полями:
- question
- intent
- sub_intent
- documents
- facts
- relations
- api_contract
Правила:
- Только schema
- Без полного OpenAPI документа
- Используй только данные из payload
- Не придумывай поля
- Верни ТОЛЬКО YAML без пояснений
rag_intent_router_v2: |
Ты intent-router для layered RAG.
На вход ты получаешь JSON с полями:
@@ -261,20 +312,75 @@ prompts:
- last_query: предыдущий запрос пользователя
- allowed_intents: допустимые intent'ы
Выбери ровно один intent из allowed_intents.
Выбери ровно один intent из allowed_intents и один подходящий sub_intent.
Верни только JSON без markdown и пояснений.
Строгий формат ответа:
{"intent":"<one_of_allowed_intents>","confidence":<number_0_to_1>,"reason":"<short_reason>"}
{"intent":"<one_of_allowed_intents>","sub_intent":"<matching_sub_intent_or_empty_string>","confidence":<number_0_to_1>}
Правила:
- CODE_QA: объяснение по коду, архитектуре, классам, методам, файлам, блокам кода, поведению приложения по реализации.
- DOCS_QA: объяснение по документации, README, markdown, specs, runbooks, разделам документации.
- DOCUMENTATION_EXPLAIN: объяснение сущности, компонента, API метода, flow или связанных документов по документации.
- OPENAPI_GENERATION: генерация OpenAPI/Swagger/YAML/spec/schema по документации API.
- GENERAL_QA: слишком общий или нечеткий вопрос по документации/проекту, который нельзя уверенно отнести к explain/openapi.
- GENERATE_DOCS_FROM_CODE: просьба сгенерировать, подготовить или обновить документацию по коду.
- PROJECT_MISC: прочие вопросы по проекту, не относящиеся явно к коду или документации.
Допустимые docs sub-intents:
- SYSTEM_FLOW_EXPLAIN
- COMPONENT_EXPLAIN
- API_METHOD_EXPLAIN
- ENTITY_EXPLAIN
- RELATED_DOCS_EXPLAIN
- OPENAPI_METHOD_GENERATE
- OPENAPI_FRAGMENT_GENERATE
- GENERIC_QA
Приоритет:
- Если пользователь просит именно подготовить документацию по коду, выбирай GENERATE_DOCS_FROM_CODE.
- Если есть openapi, swagger, yaml, schema или spec, выбирай OPENAPI_GENERATION.
Если запрос про request, response или schema, выбирай OPENAPI_FRAGMENT_GENERATE.
Иначе выбирай OPENAPI_METHOD_GENERATE.
- Если запрос про связанные документы, где еще описано, что еще посмотреть, какие страницы связаны, какие документы по теме, выбирай DOCUMENTATION_EXPLAIN и sub_intent RELATED_DOCS_EXPLAIN.
- Если есть объясни, как работает, что делает или что такое по документации, выбирай DOCUMENTATION_EXPLAIN.
Для API/endpoint/method выбирай API_METHOD_EXPLAIN.
Для flow/workflow/process выбирай SYSTEM_FLOW_EXPLAIN.
Для entity/сущности выбирай ENTITY_EXPLAIN.
Иначе выбирай COMPONENT_EXPLAIN.
- Если пользователь спрашивает про конкретный класс, файл, метод или блок кода, выбирай CODE_QA.
- Если пользователь спрашивает про README, docs, markdown или конкретную документацию, выбирай DOCS_QA.
- Если сигнал неочевиден, выбирай PROJECT_MISC и confidence <= 0.6.
- Если пользователь спрашивает про README, docs, markdown или конкретную документацию без явного openapi, выбирай DOCUMENTATION_EXPLAIN.
- Если сигнал неочевиден, выбирай GENERAL_QA и confidence <= 0.6.
rag_docs_router_disambiguation_v1: |
Ты помогаешь разрешить неоднозначность в docs sub-intent router.
На вход приходит JSON:
- query
- normalized_query
- deterministic_primary_candidate
- deterministic_candidates
- ambiguity_reason
- matched_anchor_type
- query_entity_candidates
- query_anchor_candidates
Выбери ровно один sub_intent только из списка deterministic_candidates.
Не придумывай новые labels.
Допустимые labels:
- SYSTEM_FLOW_EXPLAIN
- COMPONENT_EXPLAIN
- API_METHOD_EXPLAIN
- ENTITY_EXPLAIN
- RELATED_DOCS_EXPLAIN
- GENERIC_QA
- OPENAPI_METHOD_GENERATE
- OPENAPI_FRAGMENT_GENERATE
Правила:
- Если запрос обзорный, про структуру документации, "что есть", "с чего начать", выбирай GENERIC_QA.
- Если запрос про runtime health, статус воркера, состояние runtime как понятие/сущность, выбирай ENTITY_EXPLAIN.
- Если запрос про процесс, health check flow, последовательность шагов, выбирай SYSTEM_FLOW_EXPLAIN.
- Если есть точный endpoint/path anchor, не уводи запрос в GENERIC_QA.
- Если есть CamelCase component-like token, предпочитай COMPONENT_EXPLAIN.
Верни только JSON:
{"sub_intent":"<one_of_candidates>","reason":"<short_reason>","confidence":"low|medium|high"}
@@ -1,6 +1,7 @@
"""Публичный API runtime: оркестрация роутинг → retrieval → evidence gate → генерация ответа."""
from app.modules.agent.runtime.executor import AgentRuntimeExecutor
from app.modules.agent.runtime.docs_qa_pipeline import DocsQAPipelineRunner
from app.modules.agent.runtime.models import (
RuntimeDraftAnswer,
RuntimeExecutionState,
@@ -11,6 +12,7 @@ from app.modules.agent.runtime.steps.retrieval import RuntimeRepoContextFactory,
__all__ = [
"AgentRuntimeExecutor",
"DocsQAPipelineRunner",
"RuntimeDraftAnswer",
"RuntimeExecutionState",
"RuntimeFinalResult",
@@ -0,0 +1,9 @@
from app.modules.agent.runtime.docs_qa_pipeline.models import DocsDiagnostics, DocsQAPipelineResult, OpenAPIResult
from app.modules.agent.runtime.docs_qa_pipeline.pipeline import DocsQAPipelineRunner
__all__ = [
"DocsDiagnostics",
"DocsQAPipelineRunner",
"DocsQAPipelineResult",
"OpenAPIResult",
]
@@ -0,0 +1,56 @@
from __future__ import annotations
from app.modules.agent.runtime.docs_qa_pipeline.doc_identity import DocsCanonicalDocIdResolver
class DocsAnchorSelector:
def __init__(self, resolver: DocsCanonicalDocIdResolver | None = None) -> None:
self._resolver = resolver or DocsCanonicalDocIdResolver()
def select(self, *, sub_intent: str, anchor_type: str, anchor_value: str | None, rows: list[dict]) -> dict[str, object]:
if sub_intent != "RELATED_DOCS_EXPLAIN":
return {"anchor_candidates": [], "selected_anchor": None, "anchor_selection_reason": "", "anchor_match_type": ""}
scored = self._scored_candidates(anchor_type=anchor_type, anchor_value=anchor_value, rows=rows)
if not scored:
return {"anchor_candidates": [], "selected_anchor": None, "anchor_selection_reason": "", "anchor_match_type": ""}
selected = scored[0]
return {
"anchor_candidates": [item["doc_id"] for item in scored],
"selected_anchor": selected["doc_id"],
"anchor_selection_reason": selected["reason"],
"anchor_match_type": selected["match_type"],
}
def _scored_candidates(self, *, anchor_type: str, anchor_value: str | None, rows: list[dict]) -> list[dict[str, object]]:
value = str(anchor_value or "").strip().lower()
endpoint_slug = value.strip("/").replace("/", "_").replace("-", "_")
scored: list[dict[str, object]] = []
for row in rows:
metadata = dict(row.get("metadata") or {})
for doc_id in self._resolver.candidates(row):
score = 0
match_type = "semantic_fallback"
reason = "relation_neighbor"
if anchor_type == "endpoint" and value:
if str(metadata.get("endpoint") or "").strip().lower() == value:
score, match_type, reason = 100, "exact_path", "metadata.endpoint exact match"
elif endpoint_slug and endpoint_slug in doc_id.lower():
score, match_type, reason = 90, "exact_path", "doc_id matches requested endpoint slug"
elif endpoint_slug and endpoint_slug in str(row.get("path") or "").lower().replace("-", "_"):
score, match_type, reason = 85, "exact_path", "path matches requested endpoint slug"
elif anchor_type == "entity" and value:
entity_value = str(metadata.get("entity") or "").strip().lower()
if entity_value == value:
score, match_type, reason = 100, "exact_entity", "metadata.entity exact match"
elif value in doc_id.lower():
score, match_type, reason = 90, "exact_title", "doc_id matches requested entity"
if score == 0:
score = 40 if str(row.get("layer") or "") == "D5_RELATION_GRAPH" else 20
scored.append({"doc_id": doc_id, "score": score, "match_type": match_type, "reason": reason})
unique: dict[str, dict[str, object]] = {}
for item in scored:
doc_id = str(item["doc_id"])
current = unique.get(doc_id)
if current is None or int(item["score"]) > int(current["score"]):
unique[doc_id] = item
return sorted(unique.values(), key=lambda item: (-int(item["score"]), str(item["doc_id"])))
@@ -0,0 +1,43 @@
from __future__ import annotations
from app.modules.agent.runtime.docs_qa_pipeline.models import DocsEvidenceBundle
class DocsAnswerSynthesizer:
def synthesize(self, query: str, evidence_bundle: DocsEvidenceBundle) -> str:
mode = "graph_summary" if evidence_bundle.sub_intent == "RELATED_DOCS_EXPLAIN" else "prose"
if mode == "list":
return self._list_answer(evidence_bundle)
if mode == "graph_summary":
return self._graph_summary(evidence_bundle)
return self._prose_answer(query, evidence_bundle)
def _prose_answer(self, query: str, bundle: DocsEvidenceBundle) -> str:
parts = [item["content"] for item in bundle.facts[:3] if item.get("content")]
if not parts:
parts = [item["content"] for item in bundle.entities[:2] if item.get("content")]
if not parts:
parts = [item["content"] for item in bundle.workflows[:2] if item.get("content")]
if not parts:
parts = [item["content"] for item in bundle.documents[:2] if item.get("content")]
if not parts:
parts = [item["content"] for item in bundle.chunks[:2] if item.get("content")]
if not parts:
return f"Недостаточно данных в документации для ответа на запрос: {query}"
return "\n".join(parts)
def _list_answer(self, bundle: DocsEvidenceBundle) -> str:
items = [item["title"] or item["path"] for item in bundle.documents[:5]]
if not items:
items = [item["title"] or item["path"] for item in bundle.facts[:5]]
if not items:
return "Подходящие документы не найдены."
return "\n".join(f"- {item}" for item in items)
def _graph_summary(self, bundle: DocsEvidenceBundle) -> str:
items = [item["title"] or item["content"] for item in bundle.relations[:5]]
if not items:
items = [item["title"] or item["path"] for item in bundle.documents[:5]]
if not items:
return "Связанные документы не найдены."
return "\n".join(f"- {item}" for item in items)
@@ -0,0 +1,259 @@
from __future__ import annotations
from app.modules.agent.runtime.docs_qa_pipeline.doc_identity import DocsCanonicalDocIdResolver
from app.modules.agent.runtime.docs_qa_pipeline.models import DocsDiagnostics, DocsEvidenceBundle, OpenAPIResult
class DocsDiagnosticsBuilder:
def __init__(self, resolver: DocsCanonicalDocIdResolver | None = None) -> None:
self._resolver = resolver or DocsCanonicalDocIdResolver()
def build(
self,
*,
intent: str,
sub_intent: str,
planned_layers: list[str],
executed_layers: list[str],
non_empty_layers: list[str],
layer_diagnostics: dict[str, object],
evidence_bundle: DocsEvidenceBundle,
openapi_result: OpenAPIResult | None,
prompt_used: str,
llm_mode: str,
answer_mode: str,
output_valid: bool,
matched_intent_source: str,
matched_anchor_type: str,
matched_anchor_value: str | None,
exact_anchor_match: bool,
query_entity_candidates: list[str],
resolved_entity_candidates: list[str],
query_anchor_candidates: list[str],
resolved_anchor_candidates: list[str],
anchor_candidates: list[str],
selected_anchor: str | None,
anchor_selection_reason: str,
anchor_match_type: str,
docs_layers_with_hits: list[str],
gate_decision: str,
gate_decision_reason: str,
gate_missing_requirements: list[str],
gate_satisfied_requirements: list[str],
requested_fragment_type: str | None,
fragment_evidence_found: list[str],
fragment_missing_requirements: list[str],
prompt: dict[str, object],
degraded_reason: str | None,
fallback_used: bool,
code_intents_stubbed: bool,
) -> DocsDiagnostics:
missing = list((openapi_result.diagnostics if openapi_result else {}).get("missing_required_fields") or [])
openapi_fields = 0
if openapi_result is not None:
openapi_fields += len((openapi_result.request_schema or {}).get("properties") or {})
openapi_fields += len((openapi_result.response_schema or {}).get("properties") or {})
return DocsDiagnostics(
intent=intent,
sub_intent=sub_intent,
layers_used=list(planned_layers),
documents_found=len(evidence_bundle.documents),
facts_found=len(evidence_bundle.facts),
relations_found=len(evidence_bundle.relations),
openapi_fields_extracted=openapi_fields,
missing_required_fields=missing,
openapi_status=self._openapi_status(openapi_result),
prompt_used=prompt_used,
llm_mode=llm_mode,
output_valid=output_valid,
matched_intent_source=matched_intent_source,
matched_anchor_type=matched_anchor_type,
matched_anchor_value=matched_anchor_value,
exact_anchor_match=exact_anchor_match,
docs_layers_requested=list(planned_layers),
docs_layers_with_hits=list(docs_layers_with_hits),
planned_layers=list(planned_layers),
executed_layers=list(executed_layers),
non_empty_layers=list(non_empty_layers),
layer_diagnostics=dict(layer_diagnostics),
query_entity_candidates=list(query_entity_candidates),
resolved_entity_candidates=list(resolved_entity_candidates),
query_anchor_candidates=list(query_anchor_candidates),
resolved_anchor_candidates=list(resolved_anchor_candidates),
anchor_candidates=list(anchor_candidates),
selected_anchor=selected_anchor,
anchor_selection_reason=anchor_selection_reason,
anchor_match_type=anchor_match_type,
doc_ids=self._doc_ids(evidence_bundle, selected_anchor),
doc_paths=self._doc_paths(evidence_bundle),
doc_titles=self._doc_titles(evidence_bundle),
relation_hits_count=len(evidence_bundle.relations),
relation_targets=self._relation_targets(evidence_bundle),
selected_doc_ids=self._selected_doc_ids(evidence_bundle),
selected_fact_ids=self._selected_ids(evidence_bundle.facts, ("fact_id", "doc_id", "document_id"), fallback="path"),
selected_relation_ids=self._selected_ids(
evidence_bundle.relations,
("relation_id", "target_doc_id", "target_document_id", "doc_id", "document_id"),
fallback="path",
),
selected_chunk_ids=self._selected_ids(evidence_bundle.chunks, ("chunk_id", "doc_id", "document_id"), fallback="path"),
selected_entity_ids=self._selected_ids(evidence_bundle.entities, ("entity", "doc_id", "document_id"), fallback="title"),
selected_workflow_ids=self._selected_ids(evidence_bundle.workflows, ("workflow_id", "doc_id", "document_id"), fallback="path"),
fallback_doc_hits_count=len(evidence_bundle.documents) + len(evidence_bundle.chunks),
fallback_used=fallback_used,
fact_hits=len(evidence_bundle.facts),
entity_hits=self._entity_hits(evidence_bundle),
evidence_summary=self._evidence_summary(evidence_bundle, openapi_result),
gate_decision=gate_decision,
gate_decision_reason=gate_decision_reason,
gate_missing_requirements=list(gate_missing_requirements),
gate_satisfied_requirements=list(gate_satisfied_requirements),
openapi_evidence=dict(self._openapi_evidence(openapi_result)),
requested_fragment_type=requested_fragment_type,
fragment_evidence_found=list(fragment_evidence_found),
fragment_missing_requirements=list(fragment_missing_requirements),
prompt=dict(prompt),
answer_mode=answer_mode,
degrade_reason=degraded_reason,
degraded_reason=degraded_reason,
code_intents_stubbed=code_intents_stubbed,
)
def _openapi_status(self, openapi_result: OpenAPIResult | None) -> dict[str, bool]:
diagnostics = openapi_result.diagnostics if openapi_result else {}
return {
"has_path": bool(diagnostics.get("has_path")),
"has_method": bool(diagnostics.get("has_method")),
"has_request": bool(diagnostics.get("has_request")),
"has_response": bool(diagnostics.get("has_response")),
}
def _doc_ids(self, evidence_bundle: DocsEvidenceBundle, selected_anchor: str | None) -> list[str]:
values = self._collect_doc_ids(evidence_bundle)
for item in evidence_bundle.relations:
metadata = dict(item.get("metadata") or {})
target = metadata.get("target_doc_id") or metadata.get("target_document_id")
if target:
values.append(str(target))
result = self._dedupe(values)
if selected_anchor and selected_anchor in result:
return [selected_anchor, *[item for item in result if item != selected_anchor]]
if selected_anchor:
return [selected_anchor, *result]
return result
def _selected_doc_ids(self, evidence_bundle: DocsEvidenceBundle) -> list[str]:
values = self._selected_ids(evidence_bundle.documents, ("doc_id", "document_id"), fallback="path")
values.extend(self._selected_ids(evidence_bundle.entities, ("doc_id", "document_id"), fallback="path"))
values.extend(self._selected_ids(evidence_bundle.workflows, ("doc_id", "document_id"), fallback="path"))
return self._dedupe(values)
def _doc_paths(self, evidence_bundle: DocsEvidenceBundle) -> list[str]:
return self._collect_distinct(evidence_bundle, "path")
def _doc_titles(self, evidence_bundle: DocsEvidenceBundle) -> list[str]:
return self._collect_distinct(evidence_bundle, "title")
def _relation_targets(self, evidence_bundle: DocsEvidenceBundle) -> list[str]:
values: list[str] = []
for item in evidence_bundle.relations:
metadata = dict(item.get("metadata") or {})
target = (
metadata.get("target_doc_id")
or metadata.get("target_document_id")
or metadata.get("related_to")
or metadata.get("document_id")
or metadata.get("doc_id")
or item.get("title")
or item.get("path")
)
if target:
values.append(str(target))
return self._dedupe(values)
def _selected_ids(self, items: list[dict], metadata_keys: tuple[str, ...], *, fallback: str) -> list[str]:
values: list[str] = []
for item in items:
metadata = dict(item.get("metadata") or {})
candidate = None
for key in metadata_keys:
candidate = metadata.get(key)
if candidate:
break
if not candidate:
candidate = item.get(fallback)
if candidate:
values.append(str(candidate))
return self._dedupe(values)
def _collect_doc_ids(self, evidence_bundle: DocsEvidenceBundle) -> list[str]:
values: list[str] = []
for item in evidence_bundle.raw_rows:
values.extend(self._resolver.candidates(item))
return self._dedupe(values)
def _collect_distinct(self, evidence_bundle: DocsEvidenceBundle, key: str, *, fallback_key: str | None = None) -> list[str]:
values: list[str] = []
for item in evidence_bundle.raw_rows:
metadata = dict(item.get("metadata") or {})
candidate = metadata.get(key) if key == "doc_id" else item.get(key)
if not candidate and fallback_key:
candidate = item.get(fallback_key)
if candidate:
values.append(str(candidate))
return self._dedupe(values)
def _dedupe(self, values: list[str]) -> list[str]:
result: list[str] = []
seen: set[str] = set()
for value in values:
normalized = value.strip()
if not normalized:
continue
if normalized in seen:
continue
seen.add(normalized)
result.append(normalized)
return result
def _entity_hits(self, evidence_bundle: DocsEvidenceBundle) -> int:
values = self._collect_distinct(evidence_bundle, "entity")
return len(values)
def _evidence_summary(self, evidence_bundle: DocsEvidenceBundle, openapi_result: OpenAPIResult | None) -> dict[str, object]:
return {
"documents": len(evidence_bundle.documents),
"facts": len(evidence_bundle.facts),
"entities": len(evidence_bundle.entities),
"workflows": len(evidence_bundle.workflows),
"relations": len(evidence_bundle.relations),
"chunks": len(evidence_bundle.chunks),
"selected_doc_ids": self._selected_doc_ids(evidence_bundle),
"selected_fact_ids": self._selected_ids(evidence_bundle.facts, ("fact_id", "doc_id", "document_id"), fallback="path"),
"selected_relation_ids": self._selected_ids(
evidence_bundle.relations,
("relation_id", "target_doc_id", "target_document_id", "doc_id", "document_id"),
fallback="path",
),
"selected_chunk_ids": self._selected_ids(evidence_bundle.chunks, ("chunk_id", "doc_id", "document_id"), fallback="path"),
"entity_hits": self._entity_hits(evidence_bundle),
"openapi_signals": self._openapi_evidence(openapi_result),
}
def _openapi_evidence(self, openapi_result: OpenAPIResult | None) -> dict[str, bool]:
diagnostics = openapi_result.diagnostics if openapi_result else {}
return {
"path_found": bool(diagnostics.get("has_path")),
"method_found": bool(diagnostics.get("has_method")),
"operation_semantics_found": bool(diagnostics.get("operation_semantics_found")),
"request_payload_found": bool(diagnostics.get("request_payload_found")),
"request_schema": bool(diagnostics.get("has_request")),
"request_fields_found": bool(diagnostics.get("request_fields_found")),
"response_payload_found": bool(diagnostics.get("response_payload_found")),
"response_schema": bool(diagnostics.get("has_response")),
"response_fields_found": bool(diagnostics.get("response_fields_found")),
"status_codes": bool(diagnostics.get("status_codes_found")),
"content_type_found": bool(diagnostics.get("content_type_found")),
"examples_found": bool(diagnostics.get("examples_found")),
"payload_description": bool(diagnostics.get("payload_description_found")),
}
@@ -0,0 +1,47 @@
from __future__ import annotations
import re
class DocsCanonicalDocIdResolver:
_DOC_PATH_RE = re.compile(r"docs/(?:documentation/)?(?P<section>api|domain|logic|architecture)/(?P<name>[^/]+)\.md$", re.IGNORECASE)
_TITLE_ENDPOINT_RE = re.compile(r"/([a-z0-9_{}-]+)", re.IGNORECASE)
def candidates(self, row: dict) -> list[str]:
metadata = dict(row.get("metadata") or {})
values: list[str] = []
for candidate in (
metadata.get("document_id"),
metadata.get("doc_id"),
self.from_path(str(row.get("path") or "")),
self.from_title(str(row.get("title") or ""), str(row.get("path") or "")),
):
value = str(candidate or "").strip()
if value and value not in values:
values.append(value)
return values
def from_path(self, path: str) -> str | None:
match = self._DOC_PATH_RE.search((path or "").strip())
if match is None:
return None
section = match.group("section").lower()
name = match.group("name").lower().replace("-", "_")
if section == "api" and not name.endswith("_endpoint"):
name = f"{name}_endpoint"
if section == "domain" and name.endswith("_entity"):
name = name[: -len("_entity")]
return f"{section}.{name}"
def from_title(self, title: str, path: str) -> str | None:
normalized = (title or "").strip().lower()
if "/health" in normalized:
return "api.health_endpoint"
if "/send" in normalized:
return "api.send_message_endpoint"
match = self._TITLE_ENDPOINT_RE.search(normalized)
section = "api" if "/api/" in (path or "").lower() else ""
if match and section:
slug = match.group(1).replace("-", "_")
return f"{section}.{slug}_endpoint"
return None
@@ -0,0 +1,91 @@
from __future__ import annotations
from app.modules.agent.runtime.docs_qa_pipeline.models import DocsEvidenceBundle
class DocsEvidenceBuilder:
_CAPS = {
"SYSTEM_FLOW_EXPLAIN": {"documents": 1, "facts": 1, "entities": 0, "workflows": 2, "relations": 2, "chunks": 2},
"COMPONENT_EXPLAIN": {"documents": 1, "facts": 3, "entities": 0, "workflows": 0, "relations": 2, "chunks": 2},
"API_METHOD_EXPLAIN": {"documents": 1, "facts": 3, "entities": 0, "workflows": 1, "relations": 0, "chunks": 2},
"ENTITY_EXPLAIN": {"documents": 1, "facts": 1, "entities": 2, "workflows": 0, "relations": 2, "chunks": 1},
"RELATED_DOCS_EXPLAIN": {"documents": 2, "facts": 0, "entities": 1, "workflows": 0, "relations": 4, "chunks": 1},
"GENERIC_QA": {"documents": 2, "facts": 0, "entities": 0, "workflows": 0, "relations": 0, "chunks": 1},
"OPENAPI_METHOD_GENERATE": {"documents": 1, "facts": 4, "entities": 0, "workflows": 0, "relations": 0, "chunks": 2},
"OPENAPI_FRAGMENT_GENERATE": {"documents": 1, "facts": 4, "entities": 0, "workflows": 0, "relations": 0, "chunks": 2},
}
_LAYER_TO_BUCKET = {
"D1_DOCUMENT_CATALOG": "documents",
"D2_FACT_INDEX": "facts",
"D3_ENTITY_CATALOG": "entities",
"D4_WORKFLOW_INDEX": "workflows",
"D5_RELATION_GRAPH": "relations",
"D0_DOC_CHUNKS": "chunks",
}
def build(self, *, intent: str, sub_intent: str, raw_rows: list[dict]) -> DocsEvidenceBundle:
buckets = {name: [] for name in self._LAYER_TO_BUCKET.values()}
for row in raw_rows:
bucket = self._LAYER_TO_BUCKET.get(str(row.get("layer") or ""))
if bucket is not None:
buckets[bucket].append(self._normalize_row(row))
support_paths = self._support_paths(sub_intent, buckets)
caps = self._CAPS.get(sub_intent, self._CAPS["GENERIC_QA"])
return DocsEvidenceBundle(
intent=intent,
sub_intent=sub_intent,
documents=buckets["documents"][: caps["documents"]],
facts=buckets["facts"][: caps["facts"]],
entities=buckets["entities"][: caps["entities"]],
workflows=buckets["workflows"][: caps["workflows"]],
relations=buckets["relations"][: caps["relations"]],
chunks=self._select_chunks(buckets["chunks"], support_paths, caps["chunks"]),
raw_rows=list(raw_rows),
)
def _normalize_row(self, row: dict) -> dict:
return {
"layer": str(row.get("layer") or ""),
"path": str(row.get("path") or ""),
"title": str(row.get("title") or ""),
"content": str(row.get("content") or ""),
"metadata": dict(row.get("metadata") or {}),
}
def _support_paths(self, sub_intent: str, buckets: dict[str, list[dict]]) -> list[str]:
ordered: list[dict] = []
if sub_intent == "SYSTEM_FLOW_EXPLAIN":
ordered = [*buckets["workflows"], *buckets["relations"], *buckets["documents"]]
elif sub_intent == "COMPONENT_EXPLAIN":
ordered = [*buckets["facts"], *buckets["relations"], *buckets["documents"]]
elif sub_intent == "API_METHOD_EXPLAIN":
ordered = [*buckets["facts"], *buckets["workflows"], *buckets["documents"]]
elif sub_intent == "ENTITY_EXPLAIN":
ordered = [*buckets["entities"], *buckets["relations"], *buckets["documents"]]
elif sub_intent == "RELATED_DOCS_EXPLAIN":
ordered = [*buckets["relations"], *buckets["documents"], *buckets["entities"]]
elif sub_intent == "GENERIC_QA":
ordered = [*buckets["documents"], *buckets["chunks"]]
else:
ordered = [*buckets["facts"], *buckets["documents"]]
paths: list[str] = []
for item in ordered:
path = str(item.get("path") or "").strip()
if path and path not in paths:
paths.append(path)
return paths[:4]
def _select_chunks(self, chunks: list[dict], support_paths: list[str], limit: int) -> list[dict]:
if limit <= 0:
return []
targeted = [item for item in chunks if str(item.get("path") or "") in support_paths]
selected = targeted[:limit]
if len(selected) >= limit:
return selected
for item in chunks:
if item in selected:
continue
selected.append(item)
if len(selected) >= limit:
break
return selected
@@ -0,0 +1,117 @@
from __future__ import annotations
import re
class DocsExactAnchorMatcher:
_TOKEN_RE = re.compile(r"[a-z0-9_./{}-]+", re.IGNORECASE)
_STRICT_ANCHOR_TYPES = {"endpoint", "entity"}
def filter_rows(self, rows: list[dict], *, anchor_type: str, anchor_value: str | None) -> tuple[list[dict], bool]:
if anchor_type == "none" or not anchor_value or anchor_type not in self._STRICT_ANCHOR_TYPES:
return list(rows), False
exact = [row for row in rows if self._matches(row, anchor_type=anchor_type, anchor_value=anchor_value)]
if exact:
return self._expand_support_rows(rows, exact), True
return [], False
def resolved_entity_candidates(self, rows: list[dict]) -> list[str]:
values: list[str] = []
for row in rows:
metadata = dict(row.get("metadata") or {})
for key in ("entity", "component"):
candidate = metadata.get(key)
if candidate:
values.append(str(candidate))
return self._dedupe(values)
def resolved_anchor_candidates(self, rows: list[dict]) -> list[str]:
values: list[str] = []
for row in rows:
metadata = dict(row.get("metadata") or {})
for candidate in (
metadata.get("endpoint"),
metadata.get("document_id"),
metadata.get("doc_id"),
metadata.get("entity"),
metadata.get("component"),
row.get("path"),
):
if candidate:
values.append(str(candidate))
return self._dedupe(values)
def _matches(self, row: dict, *, anchor_type: str, anchor_value: str) -> bool:
haystacks = [
str(row.get("path") or "").lower(),
str(row.get("title") or "").lower(),
str(row.get("content") or "").lower(),
str(dict(row.get("metadata") or {}).get("endpoint") or "").lower(),
str(dict(row.get("metadata") or {}).get("entity") or "").lower(),
str(dict(row.get("metadata") or {}).get("component") or "").lower(),
str(dict(row.get("metadata") or {}).get("document_id") or "").lower(),
str(dict(row.get("metadata") or {}).get("doc_id") or "").lower(),
]
needle = anchor_value.lower().strip()
if anchor_type == "endpoint":
return any(needle == value or f" {needle}" in f" {value}" for value in haystacks if value)
return any(needle in value for value in haystacks if value)
def _expand_support_rows(self, rows: list[dict], exact: list[dict]) -> list[dict]:
doc_ids = self._doc_ids(exact)
paths = {str(row.get("path") or "").strip() for row in exact if str(row.get("path") or "").strip()}
expanded = list(exact)
for row in rows:
metadata = dict(row.get("metadata") or {})
row_doc_ids = {
str(metadata.get("document_id") or "").strip(),
str(metadata.get("doc_id") or "").strip(),
}
row_path = str(row.get("path") or "").strip()
if row_path and row_path in paths:
expanded.append(row)
continue
if any(candidate and candidate in doc_ids for candidate in row_doc_ids):
expanded.append(row)
return self._dedupe_rows(expanded)
def _doc_ids(self, rows: list[dict]) -> set[str]:
values: set[str] = set()
for row in rows:
metadata = dict(row.get("metadata") or {})
for candidate in (metadata.get("document_id"), metadata.get("doc_id")):
value = str(candidate or "").strip()
if value:
values.add(value)
return values
def _dedupe_rows(self, rows: list[dict]) -> list[dict]:
result: list[dict] = []
seen: set[tuple[str, str, str, int | None, int | None]] = set()
for row in rows:
key = (
str(row.get("layer") or ""),
str(row.get("path") or ""),
str(row.get("title") or ""),
row.get("span_start"),
row.get("span_end"),
)
if key in seen:
continue
seen.add(key)
result.append(row)
return result
def _dedupe(self, values: list[str]) -> list[str]:
result: list[str] = []
seen: set[str] = set()
for value in values:
normalized = value.strip()
if not normalized:
continue
key = normalized.lower()
if key in seen:
continue
seen.add(key)
result.append(normalized)
return result
@@ -0,0 +1,114 @@
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
from pydantic import BaseModel, ConfigDict, Field
class OpenAPIResult(BaseModel):
model_config = ConfigDict(extra="forbid")
path: str = ""
method: str = ""
request_schema: dict[str, Any] | None = None
response_schema: dict[str, Any] | None = None
raw_yaml: str = ""
diagnostics: dict[str, Any] = Field(default_factory=dict)
class DocsDiagnostics(BaseModel):
model_config = ConfigDict(extra="forbid")
intent: str
sub_intent: str
layers_used: list[str] = Field(default_factory=list)
documents_found: int = 0
facts_found: int = 0
relations_found: int = 0
openapi_fields_extracted: int = 0
missing_required_fields: list[str] = Field(default_factory=list)
openapi_status: dict[str, bool] = Field(default_factory=dict)
prompt_used: str = ""
llm_mode: str = "prose"
output_valid: bool = True
matched_intent_source: str = "deterministic"
matched_anchor_type: str = "none"
matched_anchor_value: str | None = None
exact_anchor_match: bool = False
docs_layers_requested: list[str] = Field(default_factory=list)
docs_layers_with_hits: list[str] = Field(default_factory=list)
planned_layers: list[str] = Field(default_factory=list)
executed_layers: list[str] = Field(default_factory=list)
non_empty_layers: list[str] = Field(default_factory=list)
layer_diagnostics: dict[str, Any] = Field(default_factory=dict)
query_entity_candidates: list[str] = Field(default_factory=list)
resolved_entity_candidates: list[str] = Field(default_factory=list)
query_anchor_candidates: list[str] = Field(default_factory=list)
resolved_anchor_candidates: list[str] = Field(default_factory=list)
anchor_candidates: list[str] = Field(default_factory=list)
selected_anchor: str | None = None
anchor_selection_reason: str = ""
anchor_match_type: str = ""
doc_ids: list[str] = Field(default_factory=list)
doc_paths: list[str] = Field(default_factory=list)
doc_titles: list[str] = Field(default_factory=list)
relation_hits_count: int = 0
relation_targets: list[str] = Field(default_factory=list)
selected_doc_ids: list[str] = Field(default_factory=list)
selected_fact_ids: list[str] = Field(default_factory=list)
selected_relation_ids: list[str] = Field(default_factory=list)
selected_chunk_ids: list[str] = Field(default_factory=list)
selected_entity_ids: list[str] = Field(default_factory=list)
selected_workflow_ids: list[str] = Field(default_factory=list)
fallback_doc_hits_count: int = 0
fallback_used: bool = False
fact_hits: int = 0
entity_hits: int = 0
evidence_summary: dict[str, Any] = Field(default_factory=dict)
gate_decision: str = "ready"
gate_decision_reason: str = ""
gate_missing_requirements: list[str] = Field(default_factory=list)
gate_satisfied_requirements: list[str] = Field(default_factory=list)
openapi_evidence: dict[str, bool] = Field(default_factory=dict)
requested_fragment_type: str | None = None
fragment_evidence_found: list[str] = Field(default_factory=list)
fragment_missing_requirements: list[str] = Field(default_factory=list)
prompt: dict[str, Any] = Field(default_factory=dict)
answer_mode: str = "answered"
degrade_reason: str | None = None
degraded_reason: str | None = None
code_intents_stubbed: bool = False
@dataclass(slots=True)
class DocsEvidenceBundle:
intent: str
sub_intent: str
documents: list[dict[str, Any]] = field(default_factory=list)
facts: list[dict[str, Any]] = field(default_factory=list)
entities: list[dict[str, Any]] = field(default_factory=list)
workflows: list[dict[str, Any]] = field(default_factory=list)
relations: list[dict[str, Any]] = field(default_factory=list)
chunks: list[dict[str, Any]] = field(default_factory=list)
raw_rows: list[dict[str, Any]] = field(default_factory=list)
@dataclass(slots=True)
class DocsQAPipelineResult:
user_query: str
rag_session_id: str
router_result: Any
retrieval_request: Any
evidence_bundle: DocsEvidenceBundle
answer: str
diagnostics: DocsDiagnostics
openapi_result: OpenAPIResult | None = None
prompt_name: str = ""
llm_request: dict[str, Any] = field(default_factory=dict)
output_valid: bool = True
answer_mode: str = "answered"
degraded_reason: str = ""
raw_rows: list[dict[str, Any]] = field(default_factory=list)
timings_ms: dict[str, int] = field(default_factory=dict)
mode: str = "full"
@@ -0,0 +1,187 @@
from __future__ import annotations
import re
from typing import Any
from app.modules.agent.runtime.docs_qa_pipeline.models import DocsEvidenceBundle
class OpenAPIEvidenceExtractor:
_PATH_RE = re.compile(r"(/[a-z0-9_./{}-]+)", re.IGNORECASE)
_METHOD_RE = re.compile(r"\b(get|post|put|patch|delete)\b", re.IGNORECASE)
_FIELD_RE = re.compile(r"\b([a-z_][a-z0-9_]{1,40})\b")
_STATUS_RE = re.compile(r"\b(200|201|202|204|400|401|403|404|409|422|500|503)\b")
_FIELD_STOPWORDS = {
"request",
"response",
"schema",
"payload",
"fields",
"contains",
"type",
"properties",
"post",
"get",
"put",
"patch",
"delete",
"endpoint",
"returns",
"return",
"with",
"and",
"for",
"send",
"health",
"status",
"result",
"body",
}
def extract(self, bundle: DocsEvidenceBundle, *, requested_fragment_type: str | None) -> dict[str, Any]:
items = [*bundle.facts, *bundle.documents, *bundle.chunks]
path = self._extract_path(items)
method = self._extract_method(items)
request_schema = self._extract_schema(items, "request")
response_schema = self._extract_schema(items, "response")
if request_schema is None and requested_fragment_type == "request_schema":
request_schema = self._schema_from_text(items, kind="request")
if response_schema is None and requested_fragment_type == "response_schema":
response_schema = self._schema_from_text(items, kind="response")
if request_schema is None and response_schema is None and requested_fragment_type == "schema_fragment":
request_schema = self._schema_from_text(items, kind="schema")
status_codes = self._status_codes(items)
content_type = self._content_type(items)
operation_summary = self._operation_summary(items)
examples_found = self._examples_found(items)
diagnostics = {
"has_path": bool(path),
"has_method": bool(method),
"has_request": request_schema is not None,
"has_response": response_schema is not None,
"operation_semantics_found": bool(operation_summary),
"request_payload_found": request_schema is not None or self._has_payload_text(items, "request"),
"request_fields_found": self._field_count(request_schema) > 0,
"response_payload_found": response_schema is not None or self._has_payload_text(items, "response"),
"response_fields_found": self._field_count(response_schema) > 0,
"status_codes_found": bool(status_codes),
"content_type_found": bool(content_type),
"examples_found": examples_found,
"payload_description_found": self._has_payload_description(items),
"status_codes": status_codes,
"content_type": content_type,
"operation_summary": operation_summary,
}
return {
"path": path,
"method": method,
"request_schema": request_schema,
"response_schema": response_schema,
"diagnostics": diagnostics,
}
def _extract_path(self, items: list[dict[str, Any]]) -> str:
for item in items:
metadata = dict(item.get("metadata") or {})
if metadata.get("endpoint"):
return str(metadata["endpoint"])
for source in (item.get("title"), item.get("content")):
match = self._PATH_RE.search(str(source or ""))
if match:
return match.group(1)
return ""
def _extract_method(self, items: list[dict[str, Any]]) -> str:
for item in items:
metadata = dict(item.get("metadata") or {})
if metadata.get("http_method"):
return str(metadata["http_method"]).lower()
for source in (item.get("title"), item.get("content")):
match = self._METHOD_RE.search(str(source or ""))
if match:
return match.group(1).lower()
return ""
def _extract_schema(self, items: list[dict[str, Any]], kind: str) -> dict[str, Any] | None:
for item in items:
metadata = dict(item.get("metadata") or {})
candidate = metadata.get(f"{kind}_schema") or metadata.get(f"{kind}_fields")
schema = self._as_schema(candidate)
if schema is not None:
return schema
return None
def _schema_from_text(self, items: list[dict[str, Any]], *, kind: str) -> dict[str, Any] | None:
markers = {
"request": ("request", "payload", "body", "fields", "message", "chat_id"),
"response": ("response", "returns", "result", "status", "body"),
"schema": ("schema", "payload", "fields", "properties"),
}
fields: list[str] = []
for item in items:
text = " ".join((str(item.get("title") or ""), str(item.get("content") or ""))).lower()
if not any(marker in text for marker in markers[kind]):
continue
for token in self._FIELD_RE.findall(text):
if token in self._FIELD_STOPWORDS:
continue
if token not in fields:
fields.append(token)
if not fields:
return None
properties = {name: {"type": "string"} for name in fields[:8]}
required = [name for name in fields[:3] if len(name) > 1]
result: dict[str, Any] = {"type": "object", "properties": properties}
if required:
result["required"] = required
return result
def _as_schema(self, value: Any) -> dict[str, Any] | None:
if isinstance(value, dict):
return value
if isinstance(value, list):
properties = {str(item): {"type": "string"} for item in value if str(item).strip()}
return {"type": "object", "properties": properties} if properties else None
return None
def _status_codes(self, items: list[dict[str, Any]]) -> list[str]:
values: list[str] = []
for item in items:
text = " ".join((str(item.get("title") or ""), str(item.get("content") or "")))
for code in self._STATUS_RE.findall(text):
if code not in values:
values.append(code)
return values
def _content_type(self, items: list[dict[str, Any]]) -> str:
for item in items:
text = " ".join((str(item.get("title") or ""), str(item.get("content") or ""))).lower()
for marker in ("application/json", "json", "multipart/form-data", "x-www-form-urlencoded"):
if marker in text:
return marker
return ""
def _operation_summary(self, items: list[dict[str, Any]]) -> str:
for item in items:
for source in (item.get("title"), item.get("content")):
text = str(source or "").strip()
if len(text) >= 8:
return text[:120]
return ""
def _examples_found(self, items: list[dict[str, Any]]) -> bool:
for item in items:
text = " ".join((str(item.get("title") or ""), str(item.get("content") or ""))).lower()
if any(marker in text for marker in ("example", "пример", "{", "}")):
return True
return False
def _has_payload_text(self, items: list[dict[str, Any]], kind: str) -> bool:
markers = ("payload", "body", "fields", "request") if kind == "request" else ("response", "returns", "status", "body")
return any(any(marker in str(item.get("content") or "").lower() for marker in markers) for item in items)
def _has_payload_description(self, items: list[dict[str, Any]]) -> bool:
return any(len(str(item.get("content") or "").strip()) >= 12 for item in items)
def _field_count(self, schema: dict[str, Any] | None) -> int:
return len((schema or {}).get("properties") or {})
@@ -0,0 +1,161 @@
from __future__ import annotations
from typing import Any
from app.modules.agent.runtime.docs_qa_pipeline.models import DocsEvidenceBundle, OpenAPIResult
from app.modules.agent.runtime.docs_qa_pipeline.openapi_evidence_extractor import OpenAPIEvidenceExtractor
class OpenAPIGenerator:
def __init__(self, extractor: OpenAPIEvidenceExtractor | None = None) -> None:
self._extractor = extractor or OpenAPIEvidenceExtractor()
def generate(
self,
evidence_bundle: DocsEvidenceBundle,
mode: str,
*,
requested_fragment_type: str | None = None,
) -> OpenAPIResult:
extracted = self._extractor.extract(evidence_bundle, requested_fragment_type=requested_fragment_type)
path = str(extracted["path"] or "")
method = str(extracted["method"] or "")
request_schema = extracted["request_schema"]
response_schema = extracted["response_schema"]
diagnostics = dict(extracted["diagnostics"] or {})
diagnostics["missing_required_fields"] = self._missing_fields(
path=path,
method=method,
request_schema=request_schema,
response_schema=response_schema,
mode=mode,
requested_fragment_type=requested_fragment_type,
diagnostics=diagnostics,
)
raw_yaml = self._render(
path=path,
method=method,
request_schema=request_schema,
response_schema=response_schema,
mode=mode,
requested_fragment_type=requested_fragment_type,
diagnostics=diagnostics,
)
return OpenAPIResult(
path=path,
method=method,
request_schema=request_schema,
response_schema=response_schema,
raw_yaml=raw_yaml,
diagnostics=diagnostics,
)
def _missing_fields(
self,
*,
path: str,
method: str,
request_schema: dict[str, Any] | None,
response_schema: dict[str, Any] | None,
mode: str,
requested_fragment_type: str | None,
diagnostics: dict[str, Any],
) -> list[str]:
missing: list[str] = []
if not path:
missing.append("path")
if mode == "OPENAPI_FRAGMENT_GENERATE":
if requested_fragment_type == "request_schema" and request_schema is None and not diagnostics.get("request_payload_found"):
missing.append("request_schema")
elif requested_fragment_type == "response_schema" and response_schema is None and not diagnostics.get("response_payload_found"):
missing.append("response_schema")
elif requested_fragment_type == "parameters" and not diagnostics.get("has_method"):
missing.append("parameters")
elif request_schema is None and response_schema is None and not diagnostics.get("payload_description_found"):
missing.append("schema_fragment")
return missing
if not method:
missing.append("method")
if request_schema is None and not diagnostics.get("request_payload_found"):
missing.append("request_schema")
if response_schema is None and not diagnostics.get("response_payload_found") and not diagnostics.get("status_codes_found"):
missing.append("response_schema")
return missing
def _render(
self,
*,
path: str,
method: str,
request_schema: dict[str, Any] | None,
response_schema: dict[str, Any] | None,
mode: str,
requested_fragment_type: str | None,
diagnostics: dict[str, Any],
) -> str:
if mode == "OPENAPI_FRAGMENT_GENERATE":
if requested_fragment_type == "response_schema":
return self._render_schema(response_schema, diagnostics, "Documented response fragment")
return self._render_schema(request_schema or response_schema, diagnostics, "Documented schema fragment")
if not path:
return ""
method_line = method or "get"
summary = str(diagnostics.get("operation_summary") or "Documented API method")
response_block = self._render_responses(response_schema, diagnostics)
request_block = self._render_request_body(request_schema, diagnostics)
return "\n".join(
[
"paths:",
f" {path}:",
f" {method_line}:",
f" summary: \"{summary}\"",
*request_block,
" responses:",
*response_block,
]
)
def _render_schema(self, schema: dict[str, Any] | None, diagnostics: dict[str, Any], fallback_description: str) -> str:
if schema is None:
return "\n".join(["type: object", f"description: \"{fallback_description}\""])
return self._yaml_from_object(schema, indent=0)
def _render_request_body(self, schema: dict[str, Any] | None, diagnostics: dict[str, Any]) -> list[str]:
lines = [" requestBody:"]
if schema is None:
description = "Documented request payload" if diagnostics.get("request_payload_found") else "Request payload not fully documented"
lines.append(f" description: \"{description}\"")
return lines
lines.append(" content:")
content_type = str(diagnostics.get("content_type") or "application/json")
lines.append(f" {content_type}:")
lines.append(" schema:")
lines.extend(self._yaml_from_object(schema, indent=14).splitlines())
return lines
def _render_responses(self, schema: dict[str, Any] | None, diagnostics: dict[str, Any]) -> list[str]:
status_codes = list(diagnostics.get("status_codes") or []) or ["200"]
code = status_codes[0]
lines = [f" \"{code}\":", " description: \"Documented response\""]
if schema is not None:
lines.append(" content:")
content_type = str(diagnostics.get("content_type") or "application/json")
lines.append(f" {content_type}:")
lines.append(" schema:")
lines.extend(self._yaml_from_object(schema, indent=16).splitlines())
return lines
def _yaml_from_object(self, value: dict[str, Any], *, indent: int) -> str:
lines: list[str] = []
prefix = " " * indent
for key, item in value.items():
if isinstance(item, dict):
lines.append(f"{prefix}{key}:")
lines.append(self._yaml_from_object(item, indent=indent + 2))
elif isinstance(item, list):
lines.append(f"{prefix}{key}:")
for row in item:
lines.append(f"{prefix} - {row}")
else:
lines.append(f"{prefix}{key}: {item}")
return "\n".join(lines)
@@ -0,0 +1,37 @@
from __future__ import annotations
import yaml
class OpenAPIPostprocessor:
_ALLOWED_METHODS = {"get", "post", "put", "delete", "patch"}
def validate(self, answer: str, *, require_paths: bool) -> tuple[bool, dict]:
try:
payload = yaml.safe_load(answer) or {}
except yaml.YAMLError:
return False, {"reason": "invalid_yaml"}
if not isinstance(payload, dict):
return False, {"reason": "invalid_yaml_root"}
if require_paths:
paths = payload.get("paths")
if not isinstance(paths, dict) or not paths:
return False, {"reason": "missing_paths"}
methods = self._methods(paths)
if not methods:
return False, {"reason": "missing_method"}
return True, {"reason": "ok", "methods": methods}
if not isinstance(payload, dict) or not payload:
return False, {"reason": "empty_schema"}
return True, {"reason": "ok"}
def _methods(self, paths: dict) -> list[str]:
methods: list[str] = []
for item in paths.values():
if not isinstance(item, dict):
continue
for method in item.keys():
method_name = str(method).lower()
if method_name in self._ALLOWED_METHODS and method_name not in methods:
methods.append(method_name)
return methods
@@ -0,0 +1,664 @@
from __future__ import annotations
import math
from time import perf_counter
from typing import Any
from app.modules.agent.llm import AgentLlmService
from app.modules.agent.llm.prompt_loader import PromptLoader
from app.modules.agent.intent_router_v2.analysis.docs_query_signals import DocsQuerySignals
from app.modules.agent.runtime.docs_qa_pipeline.answer_synthesizer import DocsAnswerSynthesizer
from app.modules.agent.runtime.docs_qa_pipeline.anchor_selector import DocsAnchorSelector
from app.modules.agent.runtime.docs_qa_pipeline.diagnostics_builder import DocsDiagnosticsBuilder
from app.modules.agent.runtime.docs_qa_pipeline.evidence_builder import DocsEvidenceBuilder
from app.modules.agent.runtime.docs_qa_pipeline.exact_anchor_matcher import DocsExactAnchorMatcher
from app.modules.agent.runtime.docs_qa_pipeline.models import DocsQAPipelineResult
from app.modules.agent.runtime.docs_qa_pipeline.openapi_postprocessor import OpenAPIPostprocessor
from app.modules.agent.runtime.docs_qa_pipeline.openapi_generator import OpenAPIGenerator
from app.modules.agent.runtime.docs_qa_pipeline.prompt_payload_builder import DocsPromptPayloadBuilder
from app.modules.agent.runtime.legacy_pipeline import RetrievalAdapter
from app.modules.agent.runtime.steps.context import build_retrieval_request
from app.modules.agent.runtime.steps.generation import RuntimePromptSelector
class DocsQAPipelineRunner:
def __init__(
self,
router: Any,
retrieval_adapter: RetrievalAdapter,
repo_context: Any = None,
llm: AgentLlmService | None = None,
evidence_builder: DocsEvidenceBuilder | None = None,
answer_synthesizer: DocsAnswerSynthesizer | None = None,
openapi_generator: OpenAPIGenerator | None = None,
diagnostics_builder: DocsDiagnosticsBuilder | None = None,
prompt_selector: RuntimePromptSelector | None = None,
prompt_payload_builder: DocsPromptPayloadBuilder | None = None,
openapi_postprocessor: OpenAPIPostprocessor | None = None,
exact_anchor_matcher: DocsExactAnchorMatcher | None = None,
anchor_selector: DocsAnchorSelector | None = None,
) -> None:
self._router = router
self._adapter = retrieval_adapter
self._repo_context = repo_context
self._llm = llm
self._evidence_builder = evidence_builder or DocsEvidenceBuilder()
self._answer_synthesizer = answer_synthesizer or DocsAnswerSynthesizer()
self._openapi_generator = openapi_generator or OpenAPIGenerator()
self._diagnostics_builder = diagnostics_builder or DocsDiagnosticsBuilder()
self._prompt_selector = prompt_selector or RuntimePromptSelector()
self._prompt_payload_builder = prompt_payload_builder or DocsPromptPayloadBuilder()
self._openapi_postprocessor = openapi_postprocessor or OpenAPIPostprocessor()
self._exact_anchor_matcher = exact_anchor_matcher or DocsExactAnchorMatcher()
self._anchor_selector = anchor_selector or DocsAnchorSelector()
self._docs_signals = DocsQuerySignals()
def run(
self,
user_query: str,
rag_session_id: str,
*,
conversation_state: Any = None,
mode: str = "full",
) -> DocsQAPipelineResult:
timings: dict[str, int] = {}
t0 = perf_counter()
router_result = self._router.route(
user_query,
conversation_state or _default_conversation_state(),
self._repo_context or _default_repo_context(),
)
timings["router"] = _ms(t0)
t1 = perf_counter()
request = build_retrieval_request(router_result, rag_session_id)
raw_rows = self._adapter.retrieve_with_plan(
rag_session_id,
request.query,
request.retrieval_spec,
request.retrieval_constraints,
query_plan=request.query_plan,
)
retrieval_report = self._adapter.consume_retrieval_report() if hasattr(self._adapter, "consume_retrieval_report") else {}
unfiltered_rows = list(raw_rows)
raw_rows, exact_anchor_match = self._exact_anchor_matcher.filter_rows(
raw_rows,
anchor_type=router_result.matched_anchor_type,
anchor_value=router_result.matched_anchor_value,
)
if request.sub_intent == "RELATED_DOCS_EXPLAIN" and not raw_rows and self._has_relation_hits(unfiltered_rows):
raw_rows = unfiltered_rows
timings["retrieval"] = _ms(t1)
t2 = perf_counter()
evidence_bundle = self._evidence_builder.build(
intent=router_result.intent,
sub_intent=request.sub_intent,
raw_rows=raw_rows,
)
anchor_selection = self._anchor_selector.select(
sub_intent=request.sub_intent,
anchor_type=router_result.matched_anchor_type,
anchor_value=router_result.matched_anchor_value,
rows=raw_rows,
)
openapi_result = None
prompt_name = self._prompt_selector.select(
intent=router_result.intent,
sub_intent=request.sub_intent,
answer_mode="normal",
)
llm_mode = self._llm_mode(router_result.intent, request.sub_intent)
output_valid = True
answer_mode = "diagnostic_only" if mode == "pre_llm_only" else "answered"
degraded_reason = ""
answer = ""
llm_request: dict[str, Any] = {}
requested_fragment_type = self._requested_fragment_type(user_query, request.sub_intent)
if router_result.intent in {"OPENAPI_GENERATION", "OPENAPI_FROM_DOCUMENTATION"}:
openapi_result = self._openapi_generator.generate(
evidence_bundle,
request.sub_intent,
requested_fragment_type=requested_fragment_type,
)
llm_request = self._build_llm_request(
question=user_query,
intent=router_result.intent,
sub_intent=request.sub_intent,
evidence_bundle=evidence_bundle,
prompt_name=prompt_name,
log_context="graph.project_qa.docs.openapi",
api_contract=openapi_result,
)
if mode == "pre_llm_only":
answer_mode, degraded_reason, output_valid = self._evaluate_openapi_gate(
user_query=user_query,
sub_intent=request.sub_intent,
router_result=router_result,
openapi_result=openapi_result,
exact_anchor_match=exact_anchor_match,
)
answer = openapi_result.raw_yaml if answer_mode != "degraded" else "Недостаточно contract evidence для OpenAPI."
else:
answer = self._generate_openapi_answer(user_query, router_result.intent, request.sub_intent, evidence_bundle, openapi_result)
output_valid, llm_details = self._openapi_postprocessor.validate(
answer,
require_paths=request.sub_intent != "OPENAPI_FRAGMENT_GENERATE",
)
if not output_valid:
answer = openapi_result.raw_yaml
openapi_result = openapi_result.model_copy(
update={"diagnostics": {**openapi_result.diagnostics, "llm_validation": llm_details}}
)
answer_mode, degraded_reason, output_valid, answer = self._finalize_openapi_answer(
answer=answer,
router_result=router_result,
openapi_result=openapi_result,
output_valid=output_valid,
exact_anchor_match=exact_anchor_match,
)
else:
llm_request = self._build_llm_request(
question=user_query,
intent=router_result.intent,
sub_intent=request.sub_intent,
evidence_bundle=evidence_bundle,
prompt_name=prompt_name,
log_context="graph.project_qa.docs.answer",
)
if mode == "pre_llm_only":
answer_mode, degraded_reason = self._evaluate_docs_gate(
raw_rows=raw_rows,
sub_intent=request.sub_intent,
matched_anchor_type=router_result.matched_anchor_type,
exact_anchor_match=exact_anchor_match,
matched_anchor_value=router_result.matched_anchor_value,
)
output_valid = answer_mode != "degraded"
else:
answer = self._generate_docs_answer(user_query, router_result.intent, request.sub_intent, evidence_bundle)
answer_mode, degraded_reason, answer = self._finalize_docs_answer(
answer=answer,
raw_rows=raw_rows,
sub_intent=request.sub_intent,
matched_anchor_type=router_result.matched_anchor_type,
exact_anchor_match=exact_anchor_match,
matched_anchor_value=router_result.matched_anchor_value,
)
gate_decision, gate_decision_reason, gate_missing_requirements, gate_satisfied_requirements = self._build_gate_details(
intent=router_result.intent,
sub_intent=request.sub_intent,
answer_mode=answer_mode,
degraded_reason=degraded_reason,
raw_rows=raw_rows,
exact_anchor_match=exact_anchor_match,
matched_anchor_type=router_result.matched_anchor_type,
matched_anchor_value=router_result.matched_anchor_value,
openapi_result=openapi_result,
router_result=router_result,
)
diagnostics = self._diagnostics_builder.build(
intent=router_result.intent,
sub_intent=request.sub_intent,
planned_layers=list(request.requested_layers),
executed_layers=list(retrieval_report.get("executed_layers") or request.requested_layers),
non_empty_layers=self._non_empty_layers(raw_rows, retrieval_report, request.sub_intent),
layer_diagnostics=dict(retrieval_report.get("layer_diagnostics") or {}),
evidence_bundle=evidence_bundle,
openapi_result=openapi_result,
prompt_used=prompt_name,
llm_mode=llm_mode,
answer_mode=answer_mode,
output_valid=output_valid,
matched_intent_source=router_result.matched_intent_source,
matched_anchor_type=router_result.matched_anchor_type,
matched_anchor_value=router_result.matched_anchor_value,
exact_anchor_match=exact_anchor_match,
query_entity_candidates=self._docs_signals.query_entity_candidates(user_query),
resolved_entity_candidates=self._exact_anchor_matcher.resolved_entity_candidates(raw_rows),
query_anchor_candidates=self._docs_signals.query_anchor_candidates(user_query),
resolved_anchor_candidates=self._exact_anchor_matcher.resolved_anchor_candidates(raw_rows),
anchor_candidates=list(anchor_selection.get("anchor_candidates") or []),
selected_anchor=anchor_selection.get("selected_anchor"),
anchor_selection_reason=str(anchor_selection.get("anchor_selection_reason") or ""),
anchor_match_type=str(anchor_selection.get("anchor_match_type") or ""),
docs_layers_with_hits=self._non_empty_layers(raw_rows, retrieval_report, request.sub_intent),
gate_decision=gate_decision,
gate_decision_reason=gate_decision_reason,
gate_missing_requirements=gate_missing_requirements,
gate_satisfied_requirements=gate_satisfied_requirements,
requested_fragment_type=("method" if request.sub_intent == "OPENAPI_METHOD_GENERATE" else requested_fragment_type),
fragment_evidence_found=self._fragment_evidence_found(requested_fragment_type, openapi_result),
fragment_missing_requirements=self._fragment_missing_requirements(requested_fragment_type, openapi_result),
prompt=llm_request,
degraded_reason=degraded_reason or None,
fallback_used=bool(dict(retrieval_report.get("fallback") or {}).get("used")),
code_intents_stubbed=False,
)
timings["execution"] = _ms(t2)
return DocsQAPipelineResult(
user_query=user_query,
rag_session_id=rag_session_id,
router_result=router_result,
retrieval_request=request,
evidence_bundle=evidence_bundle,
answer=answer,
diagnostics=diagnostics,
openapi_result=openapi_result,
prompt_name=prompt_name,
llm_request=llm_request,
output_valid=output_valid,
answer_mode=answer_mode,
degraded_reason=degraded_reason,
raw_rows=raw_rows,
timings_ms=timings,
mode=mode,
)
def _generate_docs_answer(self, question: str, intent: str, sub_intent: str, evidence_bundle) -> str:
if self._llm is None:
return self._answer_synthesizer.synthesize(question, evidence_bundle)
payload = self._prompt_payload_builder.build(
question=question,
intent=intent,
sub_intent=sub_intent,
evidence_bundle=evidence_bundle,
)
prompt_name = self._prompt_selector.select(intent=intent, sub_intent=sub_intent, answer_mode="normal")
return self._llm.generate(prompt_name, payload, log_context="graph.project_qa.docs.answer").strip()
def _generate_openapi_answer(self, question: str, intent: str, sub_intent: str, evidence_bundle, api_contract) -> str:
if self._llm is None:
return api_contract.raw_yaml
payload = self._prompt_payload_builder.build(
question=question,
intent=intent,
sub_intent=sub_intent,
evidence_bundle=evidence_bundle,
api_contract=api_contract,
)
prompt_name = self._prompt_selector.select(intent=intent, sub_intent=sub_intent, answer_mode="normal")
return self._llm.generate(prompt_name, payload, log_context="graph.project_qa.docs.openapi").strip()
def _llm_mode(self, intent: str, sub_intent: str) -> str:
if sub_intent == "RELATED_DOCS_EXPLAIN":
return "graph_summary"
if intent == "GENERAL_QA":
return "prose"
if intent in {"OPENAPI_GENERATION", "OPENAPI_FROM_DOCUMENTATION"}:
return "yaml"
return "prose"
def _build_llm_request(
self,
*,
question: str,
intent: str,
sub_intent: str,
evidence_bundle,
prompt_name: str,
log_context: str,
api_contract=None,
) -> dict[str, Any]:
user_prompt = self._prompt_payload_builder.build(
question=question,
intent=intent,
sub_intent=sub_intent,
evidence_bundle=evidence_bundle,
api_contract=api_contract,
)
system_prompt = PromptLoader().load(prompt_name) or "You are a helpful assistant."
tokens_in_estimate = max(1, int(math.ceil((len(system_prompt) + len(user_prompt)) / 4)))
return {
"prompt_name": prompt_name,
"system_prompt": system_prompt,
"user_prompt": user_prompt,
"log_context": log_context,
"prompt_stats": {
"system_chars": len(system_prompt),
"user_chars": len(user_prompt),
"tokens_in_estimate": tokens_in_estimate,
},
}
def _finalize_docs_answer(
self,
*,
answer: str,
raw_rows: list[dict],
sub_intent: str,
matched_anchor_type: str,
exact_anchor_match: bool,
matched_anchor_value: str | None,
) -> tuple[str, str, str]:
if self._should_reject_on_exact_anchor(
sub_intent=sub_intent,
raw_rows=raw_rows,
matched_anchor_type=matched_anchor_type,
matched_anchor_value=matched_anchor_value,
exact_anchor_match=exact_anchor_match,
):
return "degraded", "not_found_exact_anchor", "Не найдено точное совпадение по запрошенному docs anchor."
if not raw_rows:
return "degraded", "retrieval_empty", "По документации не найдено релевантных данных."
if not answer.strip():
return "degraded", "insufficient_docs_evidence", "Недостаточно подтвержденных данных в документации."
return "answered", "", answer
def _finalize_openapi_answer(
self,
*,
answer: str,
router_result,
openapi_result,
output_valid: bool,
exact_anchor_match: bool,
) -> tuple[str, str, bool, str]:
requested_endpoint = str(router_result.matched_anchor_value or "").strip()
generated_endpoint = str(openapi_result.path or "").strip()
diagnostics = dict(openapi_result.diagnostics or {})
if requested_endpoint and not exact_anchor_match:
return "degraded", "not_found_exact_anchor", False, "Не найден точный endpoint в документации."
if requested_endpoint and generated_endpoint and requested_endpoint != generated_endpoint:
return "degraded", "generated_endpoint_mismatch", False, "OpenAPI сгенерирован не по запрошенному endpoint."
if str(getattr(router_result.retrieval_spec.filters, "doc_type", "") or "") != "api_method":
return "degraded", "wrong_doc_type_match", False, "Для OpenAPI не найден подходящий api_method evidence."
if not generated_endpoint:
return "degraded", "insufficient_docs_evidence", False, "Недостаточно contract evidence для OpenAPI."
has_partial_contract = any(
(
openapi_result.request_schema is not None,
openapi_result.response_schema is not None,
diagnostics.get("status_codes_found"),
diagnostics.get("payload_description_found"),
)
)
if not has_partial_contract:
return "degraded", "insufficient_docs_evidence", False, "Недостаточно contract evidence для OpenAPI."
if not output_valid:
return "structured_spec_partial", "invalid_llm_output_fallback", False, answer
if openapi_result.method and openapi_result.response_schema is not None and openapi_result.request_schema is not None:
return "structured_spec", "", True, answer
if openapi_result.method or diagnostics.get("status_codes_found") or diagnostics.get("payload_description_found"):
return "structured_spec_partial", "answered_with_gaps", True, answer
return "degraded", "insufficient_docs_evidence", False, "Недостаточно contract evidence для OpenAPI."
def _evaluate_docs_gate(
self,
*,
raw_rows: list[dict],
sub_intent: str,
matched_anchor_type: str,
exact_anchor_match: bool,
matched_anchor_value: str | None,
) -> tuple[str, str]:
if self._should_reject_on_exact_anchor(
sub_intent=sub_intent,
raw_rows=raw_rows,
matched_anchor_type=matched_anchor_type,
matched_anchor_value=matched_anchor_value,
exact_anchor_match=exact_anchor_match,
):
return "degraded", "not_found_exact_anchor"
if not raw_rows:
return "degraded", "retrieval_empty"
return "ready", ""
def _evaluate_openapi_gate(
self,
*,
user_query: str,
sub_intent: str,
router_result,
openapi_result,
exact_anchor_match: bool,
) -> tuple[str, str, bool]:
requested_endpoint = str(router_result.matched_anchor_value or "").strip()
generated_endpoint = str(openapi_result.path or "").strip()
diagnostics = dict(openapi_result.diagnostics or {})
requested_fragment_type = self._requested_fragment_type(user_query, sub_intent)
has_operation_signal = bool(openapi_result.method) or bool(diagnostics.get("status_codes_found")) or bool(diagnostics.get("payload_description_found"))
has_contract_detail = any(
(
openapi_result.request_schema is not None,
openapi_result.response_schema is not None,
diagnostics.get("status_codes_found"),
diagnostics.get("request_fields_found"),
diagnostics.get("response_fields_found"),
diagnostics.get("payload_description_found"),
)
)
if requested_endpoint and not exact_anchor_match:
return "degraded", "not_found_exact_anchor", False
if requested_endpoint and generated_endpoint and requested_endpoint != generated_endpoint:
return "degraded", "generated_endpoint_mismatch", False
if str(getattr(router_result.retrieval_spec.filters, "doc_type", "") or "") != "api_method":
return "degraded", "wrong_doc_type_match", False
if not generated_endpoint or not has_operation_signal:
return "degraded", "insufficient_docs_evidence", False
if sub_intent == "OPENAPI_FRAGMENT_GENERATE":
return self._evaluate_fragment_gate(requested_fragment_type, openapi_result)
if not openapi_result.method and not diagnostics.get("status_codes_found"):
return "degraded", "insufficient_docs_evidence", False
if not has_contract_detail:
return "degraded", "insufficient_docs_evidence", False
if openapi_result.request_schema is None or openapi_result.response_schema is None:
return "ready_partial", "answered_with_gaps", True
return "ready", "", True
def _build_gate_details(
self,
*,
intent: str,
sub_intent: str,
answer_mode: str,
degraded_reason: str,
raw_rows: list[dict],
exact_anchor_match: bool,
matched_anchor_type: str,
matched_anchor_value: str | None,
openapi_result,
router_result,
) -> tuple[str, str, list[str], list[str]]:
satisfied: list[str] = []
missing: list[str] = []
relation_hits = self._relation_hits(raw_rows)
if raw_rows:
satisfied.append("retrieval_non_empty")
else:
missing.append("retrieval_non_empty")
if matched_anchor_type in {"endpoint", "entity"} and matched_anchor_value:
if exact_anchor_match or (sub_intent == "RELATED_DOCS_EXPLAIN" and relation_hits > 0):
satisfied.append("exact_anchor_match")
else:
missing.append("exact_anchor_match")
if intent in {"OPENAPI_GENERATION", "OPENAPI_FROM_DOCUMENTATION"} and openapi_result is not None:
if openapi_result.path:
satisfied.append("path_found")
else:
missing.append("path_found")
diagnostics = openapi_result.diagnostics
if openapi_result.method:
satisfied.append("http_method_found")
else:
missing.append("http_method_found")
if diagnostics.get("operation_semantics_found"):
satisfied.append("operation_semantics_found")
else:
missing.append("operation_semantics_found")
if diagnostics.get("request_payload_found"):
satisfied.append("request_payload_found")
if diagnostics.get("response_payload_found"):
satisfied.append("response_payload_found")
if openapi_result.request_schema is not None:
satisfied.append("request_fields_found")
if openapi_result.response_schema is not None:
satisfied.append("response_fields_found")
if openapi_result.request_schema is None and openapi_result.response_schema is None:
missing.append("contract_fields_found")
if diagnostics.get("status_codes_found"):
satisfied.append("status_codes_found")
else:
missing.append("status_codes_found")
if diagnostics.get("payload_description_found"):
satisfied.append("payload_description_found")
else:
missing.append("payload_description_found")
if diagnostics.get("content_type_found"):
satisfied.append("content_type_found")
if diagnostics.get("examples_found"):
satisfied.append("examples_found")
if str(getattr(router_result.retrieval_spec.filters, "doc_type", "") or "") == "api_method":
satisfied.append("api_method_filter")
else:
missing.append("api_method_filter")
gate = answer_mode
if gate in {"diagnostic_only", "ready", "answered", "structured_spec"}:
gate = "allow" if not degraded_reason else "reject"
elif gate in {"ready_partial", "structured_spec_partial"}:
gate = "partial"
elif gate == "degraded":
gate = "reject"
return gate, self._gate_reason(gate, degraded_reason, sub_intent, relation_hits), missing, satisfied
def _non_empty_layers(self, rows: list[dict], report: dict[str, Any], sub_intent: str) -> list[str]:
diagnostics = dict(report.get("layer_diagnostics") or {})
if sub_intent == "RELATED_DOCS_EXPLAIN" and int(dict(diagnostics.get("D5_RELATION_GRAPH") or {}).get("hits") or 0) > 0:
return ["D5_RELATION_GRAPH"]
if diagnostics:
return [layer for layer, payload in diagnostics.items() if int(dict(payload).get("hits") or 0) > 0]
return self._layers_with_hits(rows)
def _layers_with_hits(self, rows: list[dict]) -> list[str]:
result: list[str] = []
for row in rows:
layer = str(row.get("layer") or "")
if layer.startswith("D") and layer not in result:
result.append(layer)
return result
def _should_reject_on_exact_anchor(
self,
*,
sub_intent: str,
raw_rows: list[dict],
matched_anchor_type: str,
matched_anchor_value: str | None,
exact_anchor_match: bool,
) -> bool:
if matched_anchor_type not in {"endpoint", "entity"} or not matched_anchor_value or exact_anchor_match:
return False
if sub_intent == "RELATED_DOCS_EXPLAIN" and self._has_relation_hits(raw_rows):
return False
return True
def _has_relation_hits(self, rows: list[dict]) -> bool:
return self._relation_hits(rows) > 0
def _relation_hits(self, rows: list[dict]) -> int:
return sum(1 for row in rows if str(row.get("layer") or "") == "D5_RELATION_GRAPH")
def _gate_reason(self, gate: str, degraded_reason: str, sub_intent: str, relation_hits: int) -> str:
if degraded_reason:
return degraded_reason
if sub_intent == "RELATED_DOCS_EXPLAIN" and relation_hits > 0:
return "relation_evidence_available"
if gate == "partial":
return "partial_evidence_available"
if gate == "allow":
return "evidence_sufficient"
return ""
def _evaluate_fragment_gate(self, requested_fragment_type: str | None, openapi_result) -> tuple[str, str, bool]:
diagnostics = dict(openapi_result.diagnostics or {})
if requested_fragment_type == "request_schema":
if openapi_result.request_schema is not None:
return "ready", "", True
if diagnostics.get("request_fields_found") or diagnostics.get("request_payload_found") or diagnostics.get("payload_description_found"):
return "ready_partial", "fragment_payload_only", True
return "degraded", "insufficient_docs_evidence", False
if requested_fragment_type == "response_schema":
if openapi_result.response_schema is not None:
return "ready", "", True
if diagnostics.get("response_fields_found") or diagnostics.get("response_payload_found") or diagnostics.get("status_codes_found"):
return "ready_partial", "fragment_response_partial", True
return "degraded", "insufficient_docs_evidence", False
if requested_fragment_type == "parameters":
if diagnostics.get("has_method") or diagnostics.get("content_type_found"):
return "ready_partial", "fragment_parameters_partial", True
return "degraded", "insufficient_docs_evidence", False
if openapi_result.request_schema is not None or openapi_result.response_schema is not None:
return "ready_partial", "answered_with_gaps", True
if diagnostics.get("payload_description_found"):
return "ready_partial", "fragment_payload_only", True
return "degraded", "insufficient_docs_evidence", False
def _requested_fragment_type(self, user_query: str, sub_intent: str) -> str | None:
if sub_intent != "OPENAPI_FRAGMENT_GENERATE":
return None
lowered = user_query.lower()
if "request" in lowered:
return "request_schema"
if "response" in lowered:
return "response_schema"
if "parameter" in lowered or "парамет" in lowered:
return "parameters"
return "schema_fragment"
def _fragment_evidence_found(self, requested_fragment_type: str | None, openapi_result) -> list[str]:
if openapi_result is None or requested_fragment_type is None:
return []
diagnostics = dict(openapi_result.diagnostics or {})
found: list[str] = []
if openapi_result.path:
found.append("path")
if openapi_result.method:
found.append("method")
if diagnostics.get("operation_semantics_found"):
found.append("operation_semantics")
if diagnostics.get("payload_description_found"):
found.append("payload_description")
if diagnostics.get("status_codes_found"):
found.append("status_codes")
if requested_fragment_type == "request_schema" and openapi_result.request_schema is not None:
found.append("request_schema")
if requested_fragment_type == "request_schema" and diagnostics.get("request_fields_found"):
found.append("request_fields")
if requested_fragment_type == "response_schema" and openapi_result.response_schema is not None:
found.append("response_schema")
if requested_fragment_type == "response_schema" and diagnostics.get("response_fields_found"):
found.append("response_fields")
return found
def _fragment_missing_requirements(self, requested_fragment_type: str | None, openapi_result) -> list[str]:
if openapi_result is None or requested_fragment_type is None:
return []
diagnostics = dict(openapi_result.diagnostics or {})
missing: list[str] = []
if not openapi_result.path:
missing.append("path")
if requested_fragment_type == "request_schema":
if openapi_result.request_schema is None and not diagnostics.get("payload_description_found"):
missing.append("request_payload_evidence")
elif requested_fragment_type == "response_schema":
if openapi_result.response_schema is None and not diagnostics.get("status_codes_found"):
missing.append("response_payload_evidence")
elif openapi_result.request_schema is None and openapi_result.response_schema is None:
missing.append("schema_fragment")
return missing
def _default_conversation_state() -> Any:
from app.modules.agent.intent_router_v2 import ConversationState
return ConversationState()
def _default_repo_context() -> Any:
from app.modules.agent.intent_router_v2 import RepoContext
return RepoContext()
def _ms(started: float) -> int:
return int((perf_counter() - started) * 1000)
@@ -0,0 +1,37 @@
from __future__ import annotations
import json
from app.modules.agent.runtime.docs_qa_pipeline.models import DocsEvidenceBundle, OpenAPIResult
class DocsPromptPayloadBuilder:
def build(
self,
*,
question: str,
intent: str,
sub_intent: str,
evidence_bundle: DocsEvidenceBundle,
api_contract: OpenAPIResult | None = None,
) -> str:
payload = {
"question": question,
"intent": intent,
"sub_intent": sub_intent,
"documents": list(evidence_bundle.documents),
"facts": list(evidence_bundle.facts),
"entities": list(evidence_bundle.entities),
"workflows": list(evidence_bundle.workflows),
"relations": list(evidence_bundle.relations),
"chunks": list(evidence_bundle.chunks),
}
if api_contract is not None:
payload["api_contract"] = {
"path": api_contract.path,
"method": api_contract.method,
"request_schema": api_contract.request_schema,
"response_schema": api_contract.response_schema,
"diagnostics": dict(api_contract.diagnostics),
}
return json.dumps(payload, ensure_ascii=False, indent=2)
+5 -1
View File
@@ -228,7 +228,11 @@ class AgentRuntimeExecutor:
post_gate=self._post_gate,
)
state.synthesis_input = build_answer_synthesis_input(user_query, state.evidence_pack)
prompt_name = self._prompt_selector.select(sub_intent=state.retrieval_request.sub_intent, answer_mode=state.answer_mode)
prompt_name = self._prompt_selector.select(
intent=state.router_result.intent,
sub_intent=state.retrieval_request.sub_intent,
answer_mode=state.answer_mode,
)
prompt_payload = self._payload_builder.build(
user_query=user_query,
synthesis_input=state.synthesis_input,
@@ -43,7 +43,10 @@ def _explain_facts(bundle: EvidenceBundle, chunks: list[CodeChunkItem], relation
target = str(bundle.resolved_target or "").strip()
signatures = [_signature_payload(chunk) for chunk in chunks if chunk.layer == "C1_SYMBOL_CATALOG"]
target_signatures = [item for item in signatures if _is_target_symbol(item["name"], target)]
methods = _unique(item["name"] for item in target_signatures if item["kind"] == "method")
# Конкретные имена методов: полный qname и короткое отображаемое имя (Class.method или method)
methods_full = _unique(item["name"] for item in target_signatures if item["kind"] == "method")
methods_short = _unique(_short_method_name(name) for name in methods_full)
methods = methods_short or methods_full[:6]
constructor_args = _unique(
arg
for item in target_signatures
@@ -51,6 +54,7 @@ def _explain_facts(bundle: EvidenceBundle, chunks: list[CodeChunkItem], relation
for arg in item["args"]
if arg not in {"self", "cls"}
)
# Конкретные вызовы: из relations и fallback из кода
calls = _unique(
_display_call_target(relation["target"])
for relation in relations
@@ -58,6 +62,7 @@ def _explain_facts(bundle: EvidenceBundle, chunks: list[CodeChunkItem], relation
)
if not calls:
calls = _fallback_calls(chunks, target)
# Поля: из relations и из self.attr в коде
fields = _unique(
relation["target"].split(".", 1)[-1]
for relation in relations
@@ -65,6 +70,7 @@ def _explain_facts(bundle: EvidenceBundle, chunks: list[CodeChunkItem], relation
)
if not fields:
fields = _unique(field for chunk in chunks if _chunk_matches_target(chunk, target) for field in _FIELD_RE.findall(chunk.content or ""))
# Зависимости: импорты и инстанциации
dependencies = _unique(
_display_dependency_target(relation["target"])
for relation in relations
@@ -80,14 +86,16 @@ def _explain_facts(bundle: EvidenceBundle, chunks: list[CodeChunkItem], relation
fact_gaps.append("Конкретные вызовы целевой сущности не подтверждены в C2/C0.")
if not dependencies:
fact_gaps.append("Явные зависимости целевой сущности не подтверждены.")
if methods and not (calls or dependencies):
fact_gaps.append("Есть методы, но вызовы/зависимости не извлечены — опирайся только на перечисленные методы и поля.")
return {
"required_symbols": required_symbols[:8],
"required_methods": methods[:6],
"required_calls": calls[:6],
"required_fields": fields[:6],
"required_constructor_args": constructor_args[:6],
"required_dependencies": dependencies[:6],
"required_methods": methods[:8],
"required_calls": calls[:8],
"required_fields": fields[:8],
"required_constructor_args": constructor_args[:8],
"required_dependencies": dependencies[:8],
"required_files": required_files[:4],
"fact_gaps": fact_gaps,
}
@@ -95,12 +103,17 @@ def _explain_facts(bundle: EvidenceBundle, chunks: list[CodeChunkItem], relation
def _architecture_facts(bundle: EvidenceBundle, chunks: list[CodeChunkItem], relations: list[dict[str, Any]]) -> dict[str, Any]:
target = str(bundle.resolved_target or "").strip()
components = _unique(
([target] if target else [])
+ [_component_name(relation["source"]) for relation in relations]
# Компоненты: target, из связей, из заголовков чанков (классы/модули)
from_relations = _unique(
[_component_name(relation["source"]) for relation in relations]
+ [_component_name(relation["target"]) for relation in relations]
+ [_component_name(chunk.title) for chunk in chunks if _chunk_matches_target(chunk, target)]
)
from_chunks = _unique(
_component_name(chunk.title) or _component_name(str(dict(chunk.metadata or {}).get("qname") or ""))
for chunk in chunks
if chunk.layer in ("C1_SYMBOL_CATALOG", "C0_SOURCE_CHUNKS") and (target and _chunk_matches_target(chunk, target))
)
components = _unique(([target] if target else []) + from_relations + from_chunks)
relation_rows = [
{
"source": _component_name(relation["source"]),
@@ -117,14 +130,19 @@ def _architecture_facts(bundle: EvidenceBundle, chunks: list[CodeChunkItem], rel
]
relation_rows = [row for row in relation_rows if row["source"] != row["target"]]
relation_verbs = _unique(row["verb"] for row in relation_rows if row["verb"])
# Краткие формулировки связей для обязательного упоминания в ответе
relation_summaries = [f"{r['source']} {r['verb']} {r['target']}" for r in relation_rows[:8]]
fact_gaps: list[str] = []
if not relation_rows:
fact_gaps.append("Concrete code edges между компонентами не подтверждены.")
if components and not relation_rows:
fact_gaps.append("Компоненты есть, но связи между ними не извлечены — не придумывай связи.")
return {
"required_components": components[:8],
"required_relations": relation_rows[:8],
"required_relation_verbs": relation_verbs[:6],
"required_components": components[:10],
"required_relations": relation_rows[:10],
"required_relation_summaries": relation_summaries[:8],
"required_relation_verbs": relation_verbs[:8],
"required_creation_edges": [row for row in relation_rows if row["edge_type"] == "instantiates"][:4],
"required_call_edges": [row for row in relation_rows if row["edge_type"] == "calls"][:4],
"required_registration_edges": [row for row in relation_rows if row["edge_type"] == "imports"][:4],
@@ -136,6 +154,10 @@ def _architecture_facts(bundle: EvidenceBundle, chunks: list[CodeChunkItem], rel
def _trace_flow_facts(bundle: EvidenceBundle, chunks: list[CodeChunkItem], relations: list[dict[str, Any]]) -> dict[str, Any]:
target = str(bundle.resolved_target or "").strip()
sorted_relations = sorted(
relations,
key=lambda item: (item["path"], item["sort_line"], item["source"], item["target"]),
)
flow_steps = [
{
"step": index,
@@ -145,18 +167,27 @@ def _trace_flow_facts(bundle: EvidenceBundle, chunks: list[CodeChunkItem], relat
"path": relation["path"],
"line_span": relation["line_span"],
}
for index, relation in enumerate(sorted(relations, key=lambda item: (item["path"], item["sort_line"], item["source"], item["target"])), start=1)
for index, relation in enumerate(sorted_relations, start=1)
]
# Упорядоченные короткие формулировки шагов для обязательного отражения в ответе
ordered_step_descriptions = [
f"{i}. {_component_name(s['source'])} {s['verb']} {_display_call_target(s['target'])}"
for i, s in enumerate(flow_steps[:8], start=1)
]
required_calls = _unique(_display_call_target(item["target"]) for item in flow_steps)
fact_gaps: list[str] = []
if len(flow_steps) < 2:
fact_gaps.append("Полная последовательность шагов не подтверждена; виден только частичный flow.")
if not flow_steps:
fact_gaps.append("Конкретные sequence edges для flow не подтверждены.")
if flow_steps and len(flow_steps) < 3:
fact_gaps.append("Цепочка короткая — не заявляй полноту потока.")
return {
"required_flow_steps": flow_steps[:8],
"required_calls": _unique(_display_call_target(item["target"]) for item in flow_steps),
"required_sequence_edges": flow_steps[:8],
"required_flow_steps": flow_steps[:10],
"required_calls": required_calls[:10],
"required_sequence_edges": flow_steps[:10],
"ordered_step_descriptions": ordered_step_descriptions[:8],
"required_files": _unique(chunk.path for chunk in chunks if _chunk_matches_target(chunk, target) and chunk.path)[:4],
"fact_gaps": fact_gaps,
}
@@ -257,6 +288,18 @@ def _component_name(value: str) -> str:
return ".".join(parts[:-1])
def _short_method_name(qname: str) -> str:
"""Короткое отображаемое имя метода для ответа (Class.method или method())."""
clean = _clean_endpoint(qname)
if not clean:
return ""
parts = clean.split(".")
tail = parts[-1]
if len(parts) >= 2 and parts[-2][:1].isupper():
return f"{parts[-2]}.{tail}()" if tail != "__init__" else f"{parts[-2]}.__init__()"
return f"{tail}()" if tail else clean
def _display_call_target(value: str) -> str:
clean = _clean_endpoint(value)
if not clean:
@@ -41,18 +41,28 @@ class RuntimeAnswerRepairService:
"missing_concrete_methods": "missing_concrete_methods",
"missing_concrete_calls": "missing_concrete_calls",
"missing_concrete_dependencies": "missing_concrete_dependencies",
"missing_concrete_fields": "missing_concrete_fields",
"ignores_concrete_explain_facts": "too_vague_for_explain",
"too_vague_for_explain": "too_vague_for_explain",
"missing_concrete_components": "missing_concrete_components",
"missing_concrete_relations": "missing_concrete_relations",
"missing_relation_verbs": "missing_relation_verbs",
"target_mentioned_but_no_relations": "missing_concrete_relations",
"too_vague_for_architecture": "too_vague_for_architecture",
"missing_flow_steps": "missing_flow_steps",
"missing_sequence_edges": "missing_sequence_edges",
"too_vague_for_explain": "too_vague_for_explain",
"too_vague_for_architecture": "too_vague_for_architecture",
"missing_ordered_flow_steps": "missing_flow_steps",
"too_vague_for_trace_flow": "too_vague_for_trace_flow",
"semantic_labels_without_code_edges": "semantic_labels_without_code_edges",
"contains_retrieval_artifacts": "contains_retrieval_artifacts",
"methods_as_primary_components": "methods_as_primary_components",
"overclaims_trace_completeness": "overclaims_trace_completeness",
}
result = [mapping[reason] for reason in reasons if reason in mapping]
seen: set[str] = set()
result: list[str] = []
for reason in reasons:
focus = mapping.get(reason)
if focus and focus not in seen:
seen.add(focus)
result.append(focus)
return result or ["tighten_to_evidence"]
@@ -30,8 +30,20 @@ _VAGUE_PHRASES = (
"этап пайплайна",
"инициализация сервисов",
"регистрация основных служб",
"различные аргументы",
"различные подпакеты",
"основные службы",
"представляет собой",
"используется в службах",
)
_OPTIMISTIC_TRACE_CLAIMS = (
"полностью восстанавливается",
"полный поток выполнения",
"полностью прослеживается",
"полный поток виден",
"полная цепочка",
"весь поток",
)
_OPTIMISTIC_TRACE_CLAIMS = ("полностью восстанавливается", "полный поток выполнения", "полностью прослеживается")
class RuntimePostEvidenceGate:
@@ -96,11 +108,12 @@ class RuntimePostEvidenceGate:
reasons = self._validate_target_focus(answer, evidence_pack)
reasons.extend(self._vagueness_reasons(answer, "explain"))
matches = 0
methods = list(explain.get("required_methods") or [])
calls = list(explain.get("required_calls") or [])
dependencies = list(explain.get("required_dependencies") or [])
fields = list(explain.get("required_fields") or [])
has_any_required = bool(methods or calls or dependencies or fields)
matches = 0
if methods and not self._mentions_fact_group(answer, methods):
reasons.append("missing_concrete_methods")
elif methods:
@@ -113,10 +126,14 @@ class RuntimePostEvidenceGate:
reasons.append("missing_concrete_dependencies")
elif dependencies:
matches += 1
if fields and self._mentions_fact_group(answer, fields):
if fields and not self._mentions_fact_group(answer, fields):
reasons.append("missing_concrete_fields")
elif fields:
matches += 1
if (methods or calls or dependencies or fields) and matches == 0:
if has_any_required and matches == 0:
reasons.append("too_vague_for_explain")
if has_any_required and self._answer_is_generic_with_facts(answer, methods + calls + dependencies + fields):
reasons.append("ignores_concrete_explain_facts")
if self._semantic_leakage(answer, facts, has_concrete_support=matches > 0):
reasons.append("semantic_labels_without_code_edges")
return reasons
@@ -136,13 +153,19 @@ class RuntimePostEvidenceGate:
reasons.append("missing_concrete_relations")
if verbs and not self._mentions_fact_group(answer, verbs):
reasons.append("missing_relation_verbs")
if any(label in answer for label in architecture.get("forbidden_labels") or []):
forbidden = architecture.get("forbidden_labels") or []
if any(label.lower() in answer.lower() for label in forbidden):
reasons.append("contains_retrieval_artifacts")
if self._methods_dominate_components(answer, components):
reasons.append("methods_as_primary_components")
if relations and (not self._mentions_relations(answer, relations) or not self._mentions_fact_group(answer, verbs)):
has_relations = self._mentions_relations(answer, relations)
has_verbs = self._mentions_fact_group(answer, verbs)
if relations and (not has_relations or not has_verbs):
reasons.append("too_vague_for_architecture")
if self._semantic_leakage(answer, facts, has_concrete_support=self._mentions_relations(answer, relations)):
target = str(evidence_pack.resolved_target or "").strip().lower()
if target and target in answer and relations and not has_relations:
reasons.append("target_mentioned_but_no_relations")
if self._semantic_leakage(answer, facts, has_concrete_support=has_relations):
reasons.append("semantic_labels_without_code_edges")
return reasons
@@ -154,16 +177,19 @@ class RuntimePostEvidenceGate:
steps = list(trace.get("required_flow_steps") or [])
calls = list(trace.get("required_calls") or [])
ordered_descriptions = list(trace.get("ordered_step_descriptions") or [])
if steps and not self._mentions_steps(answer, steps):
reasons.append("missing_flow_steps")
if calls and not self._mentions_fact_group(answer, calls):
reasons.append("missing_concrete_calls")
if steps and not self._mentions_relations(answer, steps):
reasons.append("missing_sequence_edges")
if any(claim in answer for claim in _OPTIMISTIC_TRACE_CLAIMS):
if calls and not self._mentions_fact_group(answer, calls):
reasons.append("missing_concrete_calls")
if any(claim.lower() in answer.lower() for claim in _OPTIMISTIC_TRACE_CLAIMS):
reasons.append("overclaims_trace_completeness")
if steps and not (self._mentions_steps(answer, steps) and self._mentions_relations(answer, steps)):
reasons.append("too_vague_for_trace_flow")
if ordered_descriptions and not self._mentions_ordered_steps(answer, ordered_descriptions):
reasons.append("missing_ordered_flow_steps")
return reasons
def _validate_target_focus(self, answer: str, evidence_pack: EvidenceBundle) -> list[str]:
@@ -193,6 +219,27 @@ class RuntimePostEvidenceGate:
mentioned = sum(1 for step in steps[:3] if self._mentions_relations(answer, [step]))
return mentioned >= min(2, len(steps[:3]))
def _mentions_ordered_steps(self, answer: str, ordered_descriptions: list[str]) -> bool:
"""Проверяет, что в ответе отражена хотя бы часть упорядоченных шагов (источник/цель/глагол)."""
if not ordered_descriptions:
return True
answer_lower = answer.lower()
hits = 0
for desc in ordered_descriptions[:5]:
parts = desc.replace(".", " ").split()
if len(parts) >= 3 and any(p in answer_lower for p in parts if len(p) > 2):
hits += 1
return hits >= min(2, len(ordered_descriptions))
def _answer_is_generic_with_facts(
self, answer: str, concrete_values: list[str]
) -> bool:
"""True, если в ответе есть общие фразы, но почти нет конкретных имён из списка."""
if not concrete_values:
return False
mentioned = sum(1 for v in concrete_values if any(a in answer.lower() for a in _aliases(v)))
return mentioned == 0 and len(answer) > 100
def _methods_dominate_components(self, answer: str, components: list[str]) -> bool:
method_like = re.findall(r"\b[a-z_]+\(\)", answer)
component_hits = sum(1 for component in components if component.lower() in answer)
@@ -74,30 +74,55 @@ class RuntimePromptPayloadBuilder:
curated = dict(synthesis_input.curated_facts or {})
if scenario == "EXPLAIN":
facts = dict(curated.get("explain") or {})
must_methods = facts.get("required_methods", [])
must_calls = facts.get("required_calls", [])
must_deps = facts.get("required_dependencies", [])
must_fields = facts.get("required_fields", [])
fact_gaps = facts.get("fact_gaps", [])
return {
"must_mention_methods": facts.get("required_methods", []),
"must_mention_fields": facts.get("required_fields", []),
"must_mention_calls": facts.get("required_calls", []),
"must_mention_dependencies": facts.get("required_dependencies", []),
"answer_contract": (
"Ответ обязан опираться на конкретные факты ниже. Если списки непусты — назови явно методы, вызовы, зависимости или поля из них. "
"Не заменяй их общими фразами. Учитывай fact_gaps."
),
"must_mention_methods": must_methods,
"must_mention_fields": must_fields,
"must_mention_calls": must_calls,
"must_mention_dependencies": must_deps,
"must_mention_constructor_args": facts.get("required_constructor_args", []),
"must_mention_files": facts.get("required_files", []),
"must_not_infer_missing_details": True,
"fact_gaps": facts.get("fact_gaps", []),
"fact_gaps": fact_gaps,
}
if scenario == "ARCHITECTURE":
facts = dict(curated.get("architecture") or {})
components = facts.get("required_components", [])
relations = facts.get("required_relations", [])
verbs = facts.get("required_relation_verbs", [])
summaries = facts.get("required_relation_summaries", [])
return {
"must_mention_components": facts.get("required_components", []),
"must_mention_relations": facts.get("required_relations", []),
"must_use_relation_verbs": facts.get("required_relation_verbs", []),
"answer_contract": (
"Ответ обязан перечислить компоненты и связи из кода. Используй relation verbs (создаёт, вызывает, импортирует и т.д.). "
"Не подменяй архитектуру списком методов. Не используй retrieval labels в тексте."
),
"must_mention_components": components,
"must_mention_relations": relations,
"must_mention_relation_summaries": summaries,
"must_use_relation_verbs": verbs,
"must_avoid_semantic_labels_as_primary_claims": True,
"must_not_use_retrieval_labels": facts.get("forbidden_labels", []),
"fact_gaps": facts.get("fact_gaps", []),
}
if scenario == "TRACE_FLOW":
facts = dict(curated.get("trace_flow") or {})
steps = facts.get("required_flow_steps", [])
ordered_descriptions = facts.get("ordered_step_descriptions", [])
return {
"must_mention_flow_steps": facts.get("required_flow_steps", []),
"answer_contract": (
"Ответ обязан описать поток как упорядоченную последовательность шагов из payload. "
"Не заявляй полноту потока, если в fact_gaps указано иное. Не делай неподтверждённых утверждений."
),
"must_mention_flow_steps": steps,
"must_mention_ordered_steps": ordered_descriptions,
"must_mention_calls": facts.get("required_calls", []),
"must_mention_sequence_edges": facts.get("required_sequence_edges", []),
"must_avoid_overclaiming_full_flow": True,
@@ -4,7 +4,7 @@ from __future__ import annotations
class RuntimePromptSelector:
_PROMPTS = {
_CODE_PROMPTS = {
"ARCHITECTURE": "code_qa_architecture_answer",
"EXPLAIN": "code_qa_explain_answer",
"EXPLAIN_LOCAL": "code_qa_explain_local_answer",
@@ -14,8 +14,19 @@ class RuntimePromptSelector:
"OPEN_FILE": "code_qa_open_file_answer",
"TRACE_FLOW": "code_qa_trace_flow_answer",
}
_DOCS_INTENT_PROMPTS = {
"DOCUMENTATION_EXPLAIN": "docs_explain_answer",
"GENERAL_QA": "docs_general_answer",
}
def select(self, *, sub_intent: str, answer_mode: str) -> str:
def select(self, *, intent: str = "CODE_QA", sub_intent: str, answer_mode: str) -> str:
intent_key = (intent or "CODE_QA").upper()
if intent_key in {"OPENAPI_GENERATION", "OPENAPI_FROM_DOCUMENTATION"}:
if sub_intent.upper() == "OPENAPI_FRAGMENT_GENERATE":
return "docs_openapi_fragment_answer"
return "docs_openapi_answer"
if intent_key in self._DOCS_INTENT_PROMPTS:
return self._DOCS_INTENT_PROMPTS[intent_key]
if answer_mode in {"degraded", "not_found", "insufficient"}:
return "code_qa_degraded_answer"
return self._PROMPTS.get(sub_intent.upper(), "code_qa_explain_answer")
return self._CODE_PROMPTS.get(sub_intent.upper(), "code_qa_explain_answer")
@@ -38,6 +38,8 @@ class SessionEmbeddingDimensions:
class RuntimeRetrievalAdapter:
_RELATED_DOCS_THRESHOLD = 2
def __init__(self, repository: RagRepository | None = None) -> None:
if repository is None:
from app.modules.rag.persistence.repository import RagRepository
@@ -57,10 +59,23 @@ class RuntimeRetrievalAdapter:
query_plan=None,
) -> list[dict]:
rows: list[dict] = []
planned_layers = [str(item.layer_id) for item in list(getattr(retrieval_spec, "layer_queries", []) or [])]
executed_layers: list[str] = []
per_layer_ms: dict[str, int] = {}
layer_diagnostics: dict[str, Any] = {}
fallback_used = False
fallback_reason: str | None = None
relation_hits = 0
query_plan_sub_intent = str(getattr(query_plan, "sub_intent", "") or "")
for layer_query in list(getattr(retrieval_spec, "layer_queries", []) or []):
layer_id = str(layer_query.layer_id)
if (
query_plan_sub_intent == "RELATED_DOCS_EXPLAIN"
and layer_id in {"D1_DOCUMENT_CATALOG", "D0_DOC_CHUNKS"}
and relation_hits >= self._RELATED_DOCS_THRESHOLD
):
layer_diagnostics[layer_id] = {"hits": 0, "top_ids": [], "skipped": True, "reason": "relation_primary_sufficient"}
continue
executed_layers.append(layer_id)
started = perf_counter()
layer_rows = self._retrieve_layer(
@@ -73,8 +88,35 @@ class RuntimeRetrievalAdapter:
include_tests=str(getattr(retrieval_spec.filters, "test_policy", "EXCLUDE") or "EXCLUDE") == "INCLUDE",
)
per_layer_ms[layer_id] = int((perf_counter() - started) * 1000)
layer_diagnostics[layer_id] = self._layer_diagnostics(layer_rows)
rows.extend(layer_rows)
if layer_id == "D5_RELATION_GRAPH":
relation_hits = len(layer_rows)
d2_empty = "D2_FACT_INDEX" in planned_layers and int(dict(layer_diagnostics.get("D2_FACT_INDEX") or {}).get("hits") or 0) == 0
d0_empty = "D0_DOC_CHUNKS" in planned_layers and int(dict(layer_diagnostics.get("D0_DOC_CHUNKS") or {}).get("hits") or 0) == 0
support_paths = self._support_paths(rows)
if support_paths and "D0_DOC_CHUNKS" in planned_layers and (d2_empty or d0_empty):
targeted_started = perf_counter()
targeted_rows = self.retrieve_exact_files(
rag_session_id,
paths=support_paths,
layers=["D0_DOC_CHUNKS"],
limit=max(6, self._planned_top_k(retrieval_spec, "D0_DOC_CHUNKS")),
query=query,
ranking_profile=str(getattr(retrieval_spec, "rerank_profile", "") or ""),
)
merged_rows = self._dedupe([*rows, *targeted_rows])
new_targeted_rows = self._subtract_rows(merged_rows, rows)
per_layer_ms["D0_DOC_CHUNKS"] = per_layer_ms.get("D0_DOC_CHUNKS", 0) + int((perf_counter() - targeted_started) * 1000)
rows = merged_rows
layer_diagnostics["D0_DOC_CHUNKS"] = self._layer_diagnostics(
[row for row in rows if str(row.get("layer") or "") == "D0_DOC_CHUNKS"]
)
if new_targeted_rows:
fallback_used = True
fallback_reason = "targeted_chunk_retrieval"
self._last_report = {
"planned_layers": planned_layers,
"executed_layers": executed_layers,
"retrieval_mode_by_layer": {layer_id: "vector" for layer_id in executed_layers},
"top_k_by_layer": {str(item.layer_id): int(item.top_k) for item in list(getattr(retrieval_spec, "layer_queries", []) or [])},
@@ -82,8 +124,9 @@ class RuntimeRetrievalAdapter:
layer_id: {"path_scope": list(getattr(retrieval_spec.filters, "path_scope", []) or [])}
for layer_id in executed_layers
},
"fallback": {"used": False, "reason": None},
"fallback": {"used": fallback_used, "reason": fallback_reason},
"retrieval_by_layer_ms": per_layer_ms,
"layer_diagnostics": layer_diagnostics,
}
return self._dedupe(rows)
@@ -241,3 +284,54 @@ class RuntimeRetrievalAdapter:
seen.add(key)
result.append(row)
return result
def _layer_diagnostics(self, rows: list[dict]) -> dict[str, Any]:
ids: list[str] = []
sections: list[str] = []
for row in rows[:5]:
metadata = dict(row.get("metadata") or {})
candidate = (
metadata.get("document_id")
or metadata.get("doc_id")
or metadata.get("fact_id")
or metadata.get("relation_id")
or metadata.get("target_id")
or row.get("path")
)
value = str(candidate or "").strip()
if value and value not in ids:
ids.append(value)
title = str(row.get("title") or "").strip()
if title and title not in sections:
sections.append(title)
return {"hits": len(rows), "top_ids": ids, "top_sections": sections}
def _subtract_rows(self, rows: list[dict], baseline: list[dict]) -> list[dict]:
baseline_keys = {self._row_key(row) for row in baseline}
return [row for row in rows if self._row_key(row) not in baseline_keys]
def _support_paths(self, rows: list[dict]) -> list[str]:
values: list[str] = []
for row in rows:
layer = str(row.get("layer") or "")
if layer not in {"D1_DOCUMENT_CATALOG", "D2_FACT_INDEX", "D3_ENTITY_CATALOG", "D4_WORKFLOW_INDEX", "D5_RELATION_GRAPH"}:
continue
path = str(row.get("path") or "").strip()
if path and path not in values:
values.append(path)
return values[:6]
def _planned_top_k(self, retrieval_spec, layer_id: str) -> int:
for item in list(getattr(retrieval_spec, "layer_queries", []) or []):
if str(item.layer_id) == layer_id:
return int(item.top_k)
return 6
def _row_key(self, row: dict) -> tuple[str, str, str, int | None, int | None]:
return (
str(row.get("layer") or ""),
str(row.get("path") or ""),
str(row.get("title") or ""),
row.get("span_start"),
row.get("span_end"),
)
@@ -13,10 +13,12 @@ class RuntimeRepoContextFactory:
RagLayer.CODE_DEPENDENCY_GRAPH,
RagLayer.CODE_SEMANTIC_ROLES,
RagLayer.CODE_SOURCE_CHUNKS,
RagLayer.DOCS_MODULE_CATALOG,
RagLayer.DOCS_DOC_CHUNKS,
RagLayer.DOCS_DOCUMENT_CATALOG,
RagLayer.DOCS_FACT_INDEX,
RagLayer.DOCS_SECTION_INDEX,
RagLayer.DOCS_POLICY_INDEX,
RagLayer.DOCS_ENTITY_CATALOG,
RagLayer.DOCS_WORKFLOW_INDEX,
RagLayer.DOCS_RELATION_GRAPH,
]
def build(self, files_map: dict[str, dict] | None = None) -> RepoContext:
@@ -0,0 +1,13 @@
from app.modules.agent.task_runtime.context import TaskRuntimeContextBuilder
from app.modules.agent.task_runtime.dispatcher import TaskWorkflowDispatcher
from app.modules.agent.task_runtime.enrichment import ContextEnrichmentService
from app.modules.agent.task_runtime.facade import AgentTaskRuntimeFacade
from app.modules.agent.task_runtime.templates import DocumentationTemplateRegistry
__all__ = [
"AgentTaskRuntimeFacade",
"ContextEnrichmentService",
"DocumentationTemplateRegistry",
"TaskRuntimeContextBuilder",
"TaskWorkflowDispatcher",
]
@@ -0,0 +1,45 @@
from __future__ import annotations
from app.modules.agent.intent_router_v2 import ConversationState
from app.modules.agent.runtime.steps.retrieval import RuntimeRepoContextFactory
from app.modules.agent.task_runtime.models import TaskRuntimeContext
class TaskRuntimeContextBuilder:
def __init__(self, repo_context_factory: RuntimeRepoContextFactory | None = None) -> None:
self._repo_context_factory = repo_context_factory or RuntimeRepoContextFactory()
def build(
self,
*,
task_id: str,
dialog_session_id: str,
rag_session_id: str,
mode: str,
message: str,
attachments: list[dict],
files: list[dict],
progress_cb,
) -> TaskRuntimeContext:
files_map = self._files_to_map(files)
return TaskRuntimeContext(
task_id=task_id,
dialog_session_id=dialog_session_id,
rag_session_id=rag_session_id,
mode=mode,
message=message,
attachments=list(attachments or []),
files=list(files or []),
files_map=files_map,
progress_cb=progress_cb,
repo_context=self._repo_context_factory.build(files_map),
conversation_state=ConversationState(),
)
def _files_to_map(self, files: list[dict]) -> dict[str, dict]:
out: dict[str, dict] = {}
for item in files or []:
if isinstance(item, dict) and item.get("path"):
out[str(item["path"])] = dict(item)
return out
@@ -0,0 +1,37 @@
from __future__ import annotations
from app.modules.agent.task_runtime.models import TaskRuntimeContext, WorkflowExecutionResult
from app.modules.agent.task_runtime.workflows.base import TaskWorkflow
class TaskWorkflowDispatcher:
def __init__(
self,
*,
docs_qa: TaskWorkflow,
general_qa: TaskWorkflow,
docs_generation: TaskWorkflow,
openapi: TaskWorkflow,
fallback: TaskWorkflow,
) -> None:
self._docs_qa = docs_qa
self._general_qa = general_qa
self._docs_generation = docs_generation
self._openapi = openapi
self._fallback = fallback
def dispatch(self, ctx: TaskRuntimeContext) -> WorkflowExecutionResult:
workflow = self._select(getattr(ctx.route_result, "intent", "FALLBACK"))
return workflow.run(ctx)
def _select(self, intent: str) -> TaskWorkflow:
normalized = (intent or "FALLBACK").upper()
if normalized == "DOCUMENTATION_EXPLAIN":
return self._docs_qa
if normalized == "GENERAL_QA":
return self._general_qa
if normalized == "OPENAPI_GENERATION":
return self._openapi
if normalized == "GENERATE_DOCS_FROM_CODE":
return self._docs_generation
return self._fallback
@@ -0,0 +1,35 @@
from __future__ import annotations
from app.modules.agent.task_runtime.models import TaskRuntimeContext
class AttachmentContextProvider:
def build(self, ctx: TaskRuntimeContext) -> dict[str, object]:
return {"attachments": list(ctx.attachments)}
class ConfluenceContextProvider:
def build(self, ctx: TaskRuntimeContext) -> dict[str, object]:
urls = [
str(item.get("url"))
for item in ctx.attachments
if str(item.get("type") or "").lower() == "confluence_url" and item.get("url")
]
return {"confluence_urls": urls}
class ContextEnrichmentService:
def __init__(
self,
attachment_provider: AttachmentContextProvider | None = None,
confluence_provider: ConfluenceContextProvider | None = None,
) -> None:
self._attachment_provider = attachment_provider or AttachmentContextProvider()
self._confluence_provider = confluence_provider or ConfluenceContextProvider()
def enrich(self, ctx: TaskRuntimeContext) -> dict[str, object]:
enriched: dict[str, object] = {}
enriched.update(self._attachment_provider.build(ctx))
enriched.update(self._confluence_provider.build(ctx))
enriched["files"] = list(ctx.files)
return enriched
@@ -0,0 +1,92 @@
from __future__ import annotations
import asyncio
from types import SimpleNamespace
from app.modules.agent.intent_router_v2 import IntentRouterV2
from app.modules.agent.task_runtime.context import TaskRuntimeContextBuilder
from app.modules.agent.task_runtime.dispatcher import TaskWorkflowDispatcher
from app.modules.agent.task_runtime.enrichment import ContextEnrichmentService
from app.modules.agent.task_runtime.status_events import emit_status_block
class AgentTaskRuntimeFacade:
def __init__(
self,
*,
router: IntentRouterV2,
context_builder: TaskRuntimeContextBuilder,
enrichment: ContextEnrichmentService,
dispatcher: TaskWorkflowDispatcher,
) -> None:
self._router = router
self._context_builder = context_builder
self._enrichment = enrichment
self._dispatcher = dispatcher
async def run(
self,
*,
task_id: str,
dialog_session_id: str,
rag_session_id: str,
mode: str,
message: str,
attachments: list[dict],
files: list[dict],
progress_cb=None,
):
ctx = self._context_builder.build(
task_id=task_id,
dialog_session_id=dialog_session_id,
rag_session_id=rag_session_id,
mode=mode,
message=message,
attachments=attachments,
files=files,
progress_cb=progress_cb,
)
self._notify(progress_cb, "runtime.router", "Маршрутизирую запрос по task workflows.", {"mode": mode})
ctx.route_result = self._router.route(message, ctx.conversation_state, ctx.repo_context)
emit_status_block(
ctx,
block_id="intent_router",
title="Intent Router",
lines=[
f"intent: {ctx.route_result.intent}",
f"sub_intent: {ctx.route_result.query_plan.sub_intent}",
f"conversation_mode: {ctx.route_result.conversation_mode}",
f"matched_source: {ctx.route_result.matched_intent_source}",
],
)
self._notify(progress_cb, "runtime.context", "Собираю контекст для выбранного workflow.", {"intent": ctx.route_result.intent})
ctx.enriched_context = self._enrichment.enrich(ctx)
loop = asyncio.get_running_loop()
self._notify(progress_cb, "runtime.workflow", "Запускаю целевой task workflow.", {"intent": ctx.route_result.intent})
emit_status_block(
ctx,
block_id="workflow",
title="Task Workflow",
lines=[
f"intent: {ctx.route_result.intent}",
f"sub_intent: {ctx.route_result.query_plan.sub_intent}",
"dispatcher: task_workflow_dispatcher",
],
)
result = await loop.run_in_executor(None, lambda: self._dispatcher.dispatch(ctx))
return SimpleNamespace(
result_type=result.result_type,
answer=result.answer,
artifacts=result.artifacts,
changeset=[],
meta={
"task_id": task_id,
"intent": ctx.route_result.intent,
"workflow_meta": result.meta,
"route": ctx.route_result.model_dump(mode="json"),
},
)
def _notify(self, progress_cb, stage: str, message: str, meta: dict) -> None:
if progress_cb is not None:
progress_cb(stage, message, "task_progress", meta)

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