ййй
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
# Analysis Assets
|
||||
|
||||
Этот каталог содержит служебные артефакты для аналитической и генеративной работы агента.
|
||||
|
||||
## Структура
|
||||
|
||||
- `rules/` — правила построения документации, frontmatter и шаблоны документов.
|
||||
|
||||
## Назначение
|
||||
|
||||
Каталог `.analysis/` отделен от `docs/`, чтобы:
|
||||
- хранить служебные policy- и template-материалы вне пользовательской документации;
|
||||
- передавать правила в LLM как отдельный policy-context;
|
||||
- не смешивать документацию проекта и внутренние артефакты анализа.
|
||||
@@ -0,0 +1,32 @@
|
||||
# Documentation Rules
|
||||
|
||||
## Назначение
|
||||
|
||||
Этот файл фиксирует общие правила формирования, обновления и поддержки технической документации проекта.
|
||||
|
||||
Документация проекта должна создаваться как система атомарных, связанных между собой документов, пригодных:
|
||||
- для чтения человеком;
|
||||
- для сопровождения командой;
|
||||
- для индексирования в RAG;
|
||||
- для автоматического обновления агентом на основе кода и существующих артефактов.
|
||||
|
||||
Этот файл задает:
|
||||
- общие принципы документационной архитектуры;
|
||||
- правила декомпозиции документации;
|
||||
- правила размещения файлов;
|
||||
- требования к связям между документами;
|
||||
- требования к качеству markdown-документов;
|
||||
- правила генерации и обновления документации агентом.
|
||||
|
||||
Детальные шаблоны документов и правила frontmatter описываются отдельно:
|
||||
- `.analysis/rules/frontmatter-rules.md`
|
||||
- `.analysis/rules/templates/*.md`
|
||||
|
||||
---
|
||||
|
||||
## Область действия
|
||||
|
||||
Правила из этого файла применяются ко всей проектной документации, размещаемой в:
|
||||
|
||||
```text
|
||||
docs/documentation/
|
||||
@@ -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>)
|
||||
```
|
||||
@@ -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 |
|
||||
|------|--------|---------|
|
||||
| | | |
|
||||
@@ -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 |
|
||||
|------|--------|---------|
|
||||
| | | |
|
||||
```
|
||||
@@ -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 |
|
||||
|------|--------|---------|
|
||||
| | | |
|
||||
@@ -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 недостаточно.
|
||||
Vendored
+25
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 реализуют прикладную логику и контроль качества**.
|
||||
|
||||
Это позволяет одновременно сохранить управляемость системы и обеспечить масштабируемость под новые типы задач.
|
||||
@@ -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.
|
||||
**Как формируется:** поверх `C1–C3` как производный слой.
|
||||
**Статус в 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:** C0–C6 (Source Chunks, Symbol Catalog, Symbol Relations, Entrypoints, Execution Paths, Test Mappings, Code Facts)
|
||||
- **DOCS:** D0–D6 (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 (слои D0–D6 в исполнении 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
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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`. |
|
||||
@@ -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"]
|
||||
@@ -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` на основе текущей реализации. |
|
||||
|
||||
|
||||
@@ -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`. |
|
||||
@@ -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`, строки 74–81.
|
||||
|
||||
- **Условие:** при `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`, строки 47–69, 71–132.
|
||||
|
||||
- **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`, строки 21–41.
|
||||
|
||||
- **Фактическая точка входа 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 (строки 90–91 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`, строки 18–21. При 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`, строки 242–246: `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`, 90–106 — resolved_intent, resolved_sub_intent, resolved_target, target_type, code_chunks, relations, entrypoints, test_evidence, evidence_count, retrieval_summary.
|
||||
- `AnswerSynthesisInput`: `contracts.py`, 109–121 — 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`, 281–298).
|
||||
|
||||
- **Правила по sub_intent (post_gate):**
|
||||
- **EXPLAIN** (93–124): target focus; vagueness (_VAGUE_PHRASES); наличие required_methods/calls/dependencies (хотя бы одна группа); «too_vague_for_explain» при нуле совпадений; semantic_leakage (роли из semantic_hints без опоры на код).
|
||||
- **ARCHITECTURE** (126–150): 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** (152–171): 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.
|
||||
|
||||
---
|
||||
|
||||
*Документ описывает только текущую реализацию по коду после рефакторинга.*
|
||||
@@ -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
|
||||
|
||||
Каждый документ должен быть явно связан как минимум с соответствующим кодом и с соседними документами, если такие связи существуют. Агент должен фиксировать эти связи в метаданных и в тексте документа, чтобы документация образовывала связанную систему знаний, а не набор изолированных файлов.
|
||||
@@ -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`
|
||||
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+10
-1
@@ -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)
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+80
-17
@@ -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'}"
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -0,0 +1,34 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Callable
|
||||
|
||||
from app.schemas.chat import TaskArtifact, TaskResultType
|
||||
|
||||
|
||||
ProgressCallback = Callable[[str, str, str, dict | None], Any]
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class TaskRuntimeContext:
|
||||
task_id: str
|
||||
dialog_session_id: str
|
||||
rag_session_id: str
|
||||
mode: str
|
||||
message: str
|
||||
attachments: list[dict[str, Any]] = field(default_factory=list)
|
||||
files: list[dict[str, Any]] = field(default_factory=list)
|
||||
files_map: dict[str, dict[str, Any]] = field(default_factory=dict)
|
||||
progress_cb: ProgressCallback | None = None
|
||||
repo_context: Any = None
|
||||
conversation_state: Any = None
|
||||
route_result: Any = None
|
||||
enriched_context: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class WorkflowExecutionResult:
|
||||
result_type: TaskResultType
|
||||
answer: str = ""
|
||||
artifacts: list[TaskArtifact] = field(default_factory=list)
|
||||
meta: dict[str, Any] = field(default_factory=dict)
|
||||
@@ -0,0 +1,28 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from app.modules.agent.task_runtime.models import TaskRuntimeContext
|
||||
|
||||
|
||||
def emit_status_block(
|
||||
ctx: TaskRuntimeContext,
|
||||
*,
|
||||
block_id: str,
|
||||
title: str,
|
||||
lines: list[str],
|
||||
append: bool = False,
|
||||
) -> None:
|
||||
if ctx.progress_cb is None:
|
||||
return
|
||||
ctx.progress_cb(
|
||||
f"status.{block_id}",
|
||||
title,
|
||||
"task_progress",
|
||||
{
|
||||
"status_block": {
|
||||
"id": block_id,
|
||||
"title": title,
|
||||
"lines": [line for line in lines if str(line).strip()],
|
||||
"append": append,
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,31 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DocumentationTemplate:
|
||||
template_id: str
|
||||
title: str
|
||||
sections: tuple[str, ...]
|
||||
|
||||
|
||||
class DocumentationTemplateRegistry:
|
||||
def __init__(self) -> None:
|
||||
self._templates = {
|
||||
"system_analytics_v1": DocumentationTemplate(
|
||||
template_id="system_analytics_v1",
|
||||
title="Документация по системной аналитике",
|
||||
sections=(
|
||||
"Назначение",
|
||||
"Контекст и границы",
|
||||
"Основные сущности",
|
||||
"Сценарии и workflow",
|
||||
"Интеграции",
|
||||
"Открытые вопросы и допущения",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
def default(self) -> DocumentationTemplate:
|
||||
return self._templates["system_analytics_v1"]
|
||||
@@ -0,0 +1,11 @@
|
||||
from app.modules.agent.task_runtime.workflows.docs_generation import DocumentationGenerationWorkflow
|
||||
from app.modules.agent.task_runtime.workflows.docs_qa import DocsQaWorkflow
|
||||
from app.modules.agent.task_runtime.workflows.fallback import FallbackWorkflow
|
||||
from app.modules.agent.task_runtime.workflows.openapi import OpenApiWorkflow
|
||||
|
||||
__all__ = [
|
||||
"DocsQaWorkflow",
|
||||
"DocumentationGenerationWorkflow",
|
||||
"FallbackWorkflow",
|
||||
"OpenApiWorkflow",
|
||||
]
|
||||
@@ -0,0 +1,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Protocol
|
||||
|
||||
from app.modules.agent.task_runtime.models import TaskRuntimeContext, WorkflowExecutionResult
|
||||
|
||||
|
||||
class TaskWorkflow(Protocol):
|
||||
workflow_id: str
|
||||
|
||||
def run(self, ctx: TaskRuntimeContext) -> WorkflowExecutionResult: ...
|
||||
@@ -0,0 +1,82 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from app.modules.agent.llm import AgentLlmService
|
||||
from app.modules.agent.task_runtime.models import TaskRuntimeContext, WorkflowExecutionResult
|
||||
from app.modules.agent.task_runtime.status_events import emit_status_block
|
||||
from app.modules.agent.task_runtime.templates import DocumentationTemplateRegistry
|
||||
from app.schemas.chat import TaskArtifact, TaskResultType
|
||||
|
||||
|
||||
class DocumentationGenerationWorkflow:
|
||||
workflow_id = "docs_generation"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
llm: AgentLlmService | None,
|
||||
templates: DocumentationTemplateRegistry | None = None,
|
||||
) -> None:
|
||||
self._llm = llm
|
||||
self._templates = templates or DocumentationTemplateRegistry()
|
||||
|
||||
def run(self, ctx: TaskRuntimeContext) -> WorkflowExecutionResult:
|
||||
template = self._templates.default()
|
||||
emit_status_block(
|
||||
ctx,
|
||||
block_id="rag_retrieval",
|
||||
title="RAG Retrieval",
|
||||
lines=["not used in docs_generation workflow"],
|
||||
)
|
||||
answer = self._generate(ctx, template)
|
||||
emit_status_block(
|
||||
ctx,
|
||||
block_id="workflow",
|
||||
title="Task Workflow",
|
||||
lines=[
|
||||
f"workflow_id: {self.workflow_id}",
|
||||
f"template_id: {template.template_id}",
|
||||
],
|
||||
)
|
||||
emit_status_block(
|
||||
ctx,
|
||||
block_id="evidence_gate",
|
||||
title="Evidence Gate",
|
||||
lines=["not applied in docs_generation workflow"],
|
||||
)
|
||||
artifact = TaskArtifact(
|
||||
artifact_type=TaskResultType.DOCUMENTATION,
|
||||
title=template.title,
|
||||
content=answer,
|
||||
format="markdown",
|
||||
template_id=template.template_id,
|
||||
source_refs=list(ctx.enriched_context.get("confluence_urls") or []),
|
||||
)
|
||||
return WorkflowExecutionResult(
|
||||
result_type=TaskResultType.DOCUMENTATION,
|
||||
answer=answer,
|
||||
artifacts=[artifact],
|
||||
meta={"workflow_id": self.workflow_id, "intent": getattr(ctx.route_result, "intent", "")},
|
||||
)
|
||||
|
||||
def _generate(self, ctx: TaskRuntimeContext, template) -> str:
|
||||
if self._llm is None:
|
||||
return self._fallback(template)
|
||||
payload = json.dumps(
|
||||
{
|
||||
"question": ctx.message,
|
||||
"template_id": template.template_id,
|
||||
"title": template.title,
|
||||
"sections": list(template.sections),
|
||||
"attachments": list(ctx.attachments),
|
||||
"files": list(ctx.files),
|
||||
"context": dict(ctx.enriched_context),
|
||||
},
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
)
|
||||
return self._llm.generate("docs_template_generation", payload, log_context="agent.workflow.docs_generation").strip()
|
||||
|
||||
def _fallback(self, template) -> str:
|
||||
sections = "\n".join(f"## {name}\n\nTBD" for name in template.sections)
|
||||
return f"# {template.title}\n\n{sections}\n"
|
||||
@@ -0,0 +1,80 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from app.modules.agent.runtime.docs_qa_pipeline import DocsQAPipelineRunner
|
||||
from app.modules.agent.task_runtime.models import TaskRuntimeContext, WorkflowExecutionResult
|
||||
from app.modules.agent.task_runtime.status_events import emit_status_block
|
||||
from app.schemas.chat import TaskResultType
|
||||
|
||||
|
||||
class DocsQaWorkflow:
|
||||
workflow_id = "docs_qa"
|
||||
|
||||
def __init__(self, runner: DocsQAPipelineRunner) -> None:
|
||||
self._runner = runner
|
||||
|
||||
def run(self, ctx: TaskRuntimeContext) -> WorkflowExecutionResult:
|
||||
result = self._runner.run(
|
||||
ctx.message,
|
||||
ctx.rag_session_id,
|
||||
conversation_state=ctx.conversation_state,
|
||||
mode="full",
|
||||
)
|
||||
diagnostics = result.diagnostics.model_dump(mode="json")
|
||||
emit_status_block(
|
||||
ctx,
|
||||
block_id="rag_retrieval",
|
||||
title="RAG Retrieval",
|
||||
lines=_retrieval_lines(diagnostics),
|
||||
)
|
||||
emit_status_block(
|
||||
ctx,
|
||||
block_id="workflow",
|
||||
title="Task Workflow",
|
||||
lines=[
|
||||
f"workflow_id: {self.workflow_id}",
|
||||
f"prompt: {result.prompt_name}",
|
||||
f"answer_mode: {result.answer_mode}",
|
||||
],
|
||||
)
|
||||
emit_status_block(
|
||||
ctx,
|
||||
block_id="evidence_gate",
|
||||
title="Evidence Gate",
|
||||
lines=_gate_lines(diagnostics),
|
||||
)
|
||||
return WorkflowExecutionResult(
|
||||
result_type=TaskResultType.ANSWER,
|
||||
answer=result.answer,
|
||||
meta={
|
||||
"workflow_id": self.workflow_id,
|
||||
"intent": result.router_result.intent,
|
||||
"diagnostics": diagnostics,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _retrieval_lines(diagnostics: dict) -> list[str]:
|
||||
lines = [
|
||||
f"planned_layers: {', '.join(diagnostics.get('planned_layers') or []) or '-'}",
|
||||
f"executed_layers: {', '.join(diagnostics.get('executed_layers') or []) or '-'}",
|
||||
]
|
||||
layer_diagnostics = dict(diagnostics.get("layer_diagnostics") or {})
|
||||
for layer_id in diagnostics.get("executed_layers") or []:
|
||||
info = dict(layer_diagnostics.get(layer_id) or {})
|
||||
hits = info.get("hits", 0)
|
||||
lines.append(f"{layer_id}: {hits} hits")
|
||||
return lines
|
||||
|
||||
|
||||
def _gate_lines(diagnostics: dict) -> list[str]:
|
||||
lines = [
|
||||
f"decision: {diagnostics.get('gate_decision') or '-'}",
|
||||
f"reason: {diagnostics.get('gate_decision_reason') or '-'}",
|
||||
]
|
||||
missing = list(diagnostics.get("gate_missing_requirements") or [])
|
||||
satisfied = list(diagnostics.get("gate_satisfied_requirements") or [])
|
||||
if missing:
|
||||
lines.append(f"missing: {', '.join(missing)}")
|
||||
if satisfied:
|
||||
lines.append(f"satisfied: {', '.join(satisfied)}")
|
||||
return lines
|
||||
@@ -0,0 +1,54 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from app.modules.agent.llm import AgentLlmService
|
||||
from app.modules.agent.task_runtime.models import TaskRuntimeContext, WorkflowExecutionResult
|
||||
from app.modules.agent.task_runtime.status_events import emit_status_block
|
||||
from app.schemas.chat import TaskResultType
|
||||
|
||||
|
||||
class FallbackWorkflow:
|
||||
workflow_id = "fallback"
|
||||
|
||||
def __init__(self, llm: AgentLlmService | None) -> None:
|
||||
self._llm = llm
|
||||
|
||||
def run(self, ctx: TaskRuntimeContext) -> WorkflowExecutionResult:
|
||||
emit_status_block(
|
||||
ctx,
|
||||
block_id="rag_retrieval",
|
||||
title="RAG Retrieval",
|
||||
lines=["not used in fallback workflow"],
|
||||
)
|
||||
if self._llm is None:
|
||||
answer = "Пока не удалось подобрать специализированный workflow для этого запроса."
|
||||
else:
|
||||
payload = json.dumps(
|
||||
{
|
||||
"question": ctx.message,
|
||||
"intent": getattr(ctx.route_result, "intent", ""),
|
||||
"attachments": list(ctx.attachments),
|
||||
"confluence_urls": list(ctx.enriched_context.get("confluence_urls") or []),
|
||||
},
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
)
|
||||
answer = self._llm.generate("docs_fallback_answer", payload, log_context="agent.workflow.fallback").strip()
|
||||
emit_status_block(
|
||||
ctx,
|
||||
block_id="workflow",
|
||||
title="Task Workflow",
|
||||
lines=[f"workflow_id: {self.workflow_id}"],
|
||||
)
|
||||
emit_status_block(
|
||||
ctx,
|
||||
block_id="evidence_gate",
|
||||
title="Evidence Gate",
|
||||
lines=["not applied in fallback workflow"],
|
||||
)
|
||||
return WorkflowExecutionResult(
|
||||
result_type=TaskResultType.ANSWER,
|
||||
answer=answer,
|
||||
meta={"workflow_id": self.workflow_id, "intent": getattr(ctx.route_result, "intent", "")},
|
||||
)
|
||||
@@ -0,0 +1,54 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from app.modules.agent.runtime.docs_qa_pipeline import DocsQAPipelineRunner
|
||||
from app.modules.agent.task_runtime.models import TaskRuntimeContext, WorkflowExecutionResult
|
||||
from app.modules.agent.task_runtime.status_events import emit_status_block
|
||||
from app.modules.agent.task_runtime.workflows.docs_qa import _gate_lines, _retrieval_lines
|
||||
from app.schemas.chat import TaskResultType
|
||||
|
||||
|
||||
class GeneralQaWorkflow:
|
||||
workflow_id = "general_qa"
|
||||
|
||||
def __init__(self, runner: DocsQAPipelineRunner) -> None:
|
||||
self._runner = runner
|
||||
|
||||
def run(self, ctx: TaskRuntimeContext) -> WorkflowExecutionResult:
|
||||
result = self._runner.run(
|
||||
ctx.message,
|
||||
ctx.rag_session_id,
|
||||
conversation_state=ctx.conversation_state,
|
||||
mode="full",
|
||||
)
|
||||
diagnostics = result.diagnostics.model_dump(mode="json")
|
||||
emit_status_block(
|
||||
ctx,
|
||||
block_id="rag_retrieval",
|
||||
title="RAG Retrieval",
|
||||
lines=_retrieval_lines(diagnostics),
|
||||
)
|
||||
emit_status_block(
|
||||
ctx,
|
||||
block_id="workflow",
|
||||
title="Task Workflow",
|
||||
lines=[
|
||||
f"workflow_id: {self.workflow_id}",
|
||||
f"prompt: {result.prompt_name}",
|
||||
f"answer_mode: {result.answer_mode}",
|
||||
],
|
||||
)
|
||||
emit_status_block(
|
||||
ctx,
|
||||
block_id="evidence_gate",
|
||||
title="Evidence Gate",
|
||||
lines=_gate_lines(diagnostics),
|
||||
)
|
||||
return WorkflowExecutionResult(
|
||||
result_type=TaskResultType.ANSWER,
|
||||
answer=result.answer,
|
||||
meta={
|
||||
"workflow_id": self.workflow_id,
|
||||
"intent": result.router_result.intent,
|
||||
"diagnostics": diagnostics,
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,63 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from app.modules.agent.runtime.docs_qa_pipeline import DocsQAPipelineRunner
|
||||
from app.modules.agent.task_runtime.models import TaskRuntimeContext, WorkflowExecutionResult
|
||||
from app.modules.agent.task_runtime.status_events import emit_status_block
|
||||
from app.schemas.chat import TaskArtifact, TaskResultType
|
||||
from app.modules.agent.task_runtime.workflows.docs_qa import _gate_lines, _retrieval_lines
|
||||
|
||||
|
||||
class OpenApiWorkflow:
|
||||
workflow_id = "openapi_generation"
|
||||
|
||||
def __init__(self, runner: DocsQAPipelineRunner) -> None:
|
||||
self._runner = runner
|
||||
|
||||
def run(self, ctx: TaskRuntimeContext) -> WorkflowExecutionResult:
|
||||
result = self._runner.run(
|
||||
ctx.message,
|
||||
ctx.rag_session_id,
|
||||
conversation_state=ctx.conversation_state,
|
||||
mode="full",
|
||||
)
|
||||
diagnostics = result.diagnostics.model_dump(mode="json")
|
||||
emit_status_block(
|
||||
ctx,
|
||||
block_id="rag_retrieval",
|
||||
title="RAG Retrieval",
|
||||
lines=_retrieval_lines(diagnostics),
|
||||
)
|
||||
emit_status_block(
|
||||
ctx,
|
||||
block_id="workflow",
|
||||
title="Task Workflow",
|
||||
lines=[
|
||||
f"workflow_id: {self.workflow_id}",
|
||||
f"prompt: {result.prompt_name}",
|
||||
f"answer_mode: {result.answer_mode}",
|
||||
],
|
||||
)
|
||||
emit_status_block(
|
||||
ctx,
|
||||
block_id="evidence_gate",
|
||||
title="Evidence Gate",
|
||||
lines=_gate_lines(diagnostics),
|
||||
)
|
||||
content = (result.openapi_result.raw_yaml if result.openapi_result else "") or result.answer
|
||||
artifact = TaskArtifact(
|
||||
artifact_type=TaskResultType.OPENAPI,
|
||||
title="OpenAPI Specification",
|
||||
content=content,
|
||||
format="yaml",
|
||||
source_refs=list(result.diagnostics.doc_paths),
|
||||
)
|
||||
return WorkflowExecutionResult(
|
||||
result_type=TaskResultType.OPENAPI,
|
||||
answer=content,
|
||||
artifacts=[artifact],
|
||||
meta={
|
||||
"workflow_id": self.workflow_id,
|
||||
"intent": result.router_result.intent,
|
||||
"diagnostics": diagnostics,
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,3 @@
|
||||
from app.modules.agent_api.module import AgentApiModule
|
||||
|
||||
__all__ = ["AgentApiModule"]
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user