Роутер работает нормально в process v2
This commit is contained in:
@@ -915,15 +915,15 @@ flowchart TD
|
|||||||
|
|
||||||
### 4.1.3. Канонический MVP runtime (CODE-first)
|
### 4.1.3. Канонический MVP runtime (CODE-first)
|
||||||
|
|
||||||
Единая точка входа исполнения — пакет `app.modules.agent.runtime`:
|
Единая точка входа исполнения — пакет `app.core.agent.runtime`:
|
||||||
|
|
||||||
- **Роутер:** `app.modules.agent.intent_router_v2`; он отвечает и за routing, и за retrieval planning.
|
- **Роутер:** `app.core.agent.intent_router`; он отвечает и за routing, и за retrieval planning.
|
||||||
- **LLM-слой:** `app.modules.agent.llm`; здесь живут `AgentLlmService`, `PromptLoader` и системные prompt assets.
|
- **LLM-слой:** `app.core.agent.llm`; здесь живут `AgentLlmService`, `PromptLoader` и системные prompt assets.
|
||||||
- **Runtime:** `app.modules.agent.runtime`; внутри него stages разложены по подпакетам `retrieval`, `context`, `gates`, `answer_policy`, `generation`, `finalization`.
|
- **Runtime:** `app.core.agent.runtime`; внутри него stages разложены по подпакетам `retrieval`, `context`, `gates`, `answer_policy`, `generation`, `finalization`.
|
||||||
- **Цепочка:** запрос → `IntentRouterV2` → retrieval planning → runtime retrieval adapter → нормализованный context/evidence → evidence gate 1 → answer policy → LLM generation → evidence gate 2 → finalization → diagnostics.
|
- **Цепочка:** запрос → `IntentRouterV2` → retrieval planning → runtime retrieval adapter → нормализованный context/evidence → evidence gate 1 → answer policy → LLM generation → evidence gate 2 → finalization → diagnostics.
|
||||||
- **Evidence gates:** pre/post проверки достаточности evidence и качества ответа по сценарию.
|
- **Evidence gates:** pre/post проверки достаточности evidence и качества ответа по сценарию.
|
||||||
- **Диагностика:** runtime возвращает machine-readable diagnostics и trace по стадиям.
|
- **Диагностика:** runtime возвращает machine-readable diagnostics и trace по стадиям.
|
||||||
- **RAG:** `app.modules.rag` больше не содержит agent use-case слоев; он остается инфраструктурой indexing/retrieval/storage.
|
- **RAG:** `app.core.rag` больше не содержит agent use-case слоев; он остается инфраструктурой indexing/retrieval/storage.
|
||||||
|
|
||||||
Тесты: `pipeline_setup_v3` и связанные suite-ы проверяют канонический runtime и его stage-based execution.
|
Тесты: `pipeline_setup_v3` и связанные suite-ы проверяют канонический runtime и его stage-based execution.
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
@@ -0,0 +1,59 @@
|
|||||||
|
# Intents
|
||||||
|
|
||||||
|
## Domains
|
||||||
|
|
||||||
|
- `DOCS`
|
||||||
|
- `GENERAL`
|
||||||
|
- `CODE` - временно отключен
|
||||||
|
|
||||||
|
## GENERAL
|
||||||
|
|
||||||
|
### Intent `GENERAL_QA`
|
||||||
|
|
||||||
|
Общий интент для вопросов без точного маршрута.
|
||||||
|
В дальнейшем может использоваться как fallback.
|
||||||
|
|
||||||
|
Subintents:
|
||||||
|
- `SUMMARY` - ответы на общие вопросы по SUMMARY
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## DOCS
|
||||||
|
|
||||||
|
### Intent `ARCHITECTURE`
|
||||||
|
|
||||||
|
Обработка вопросов по архитектуре.
|
||||||
|
Subintents пока отсутствуют.
|
||||||
|
Интент запланирован, без реализации.
|
||||||
|
|
||||||
|
### Intent `DOC_EXPLAIN`
|
||||||
|
|
||||||
|
Объяснение по документации.
|
||||||
|
|
||||||
|
Subintents:
|
||||||
|
- `SUMMARY` - краткое объяснение темы по SUMMARY-блокам документации
|
||||||
|
- `FIND_FILES` - поиск файлов с релевантной информацией
|
||||||
|
- `EXPLAIN_API` - объяснение работы метода
|
||||||
|
- `COMPONENT_INTEGRATIONS` - перечень интеграций компонента, API, UI, сущности, внешних систем
|
||||||
|
- `ENTITY_INTEGRATIONS` - перечень интеграций сущности
|
||||||
|
|
||||||
|
В текущем узком MVP реально реализованы только:
|
||||||
|
|
||||||
|
- `SUMMARY`
|
||||||
|
- `FIND_FILES`
|
||||||
|
|
||||||
|
Для запросов по интеграциям целевым retrieval-слоем является `D6_INTEGRATION_INDEX`.
|
||||||
|
|
||||||
|
### Intent `OPENAPI_GENERATION`
|
||||||
|
|
||||||
|
Генерация OpenAPI-спеки.
|
||||||
|
|
||||||
|
Subintents:
|
||||||
|
- `FULL_SPEC` - создание полной спецификации
|
||||||
|
|
||||||
|
### Intent `DOC_GENERATION`
|
||||||
|
|
||||||
|
Редактирование документации.
|
||||||
|
|
||||||
|
Subintents:
|
||||||
|
- `FROM_FEATURE` - создание документации из системной аналитики на фичу
|
||||||
|
|||||||
@@ -0,0 +1,356 @@
|
|||||||
|
# RAG
|
||||||
|
|
||||||
|
## Состояние as is
|
||||||
|
|
||||||
|
RAG сейчас используется как общее ядро индексации и retrieval по коду и документации.
|
||||||
|
Основной storage - `rag_session` и многослойный индекс в БД.
|
||||||
|
|
||||||
|
## Основные части
|
||||||
|
|
||||||
|
- `RagService` - фасад индексации и retrieval
|
||||||
|
- `DocsIndexingPipeline` - индексация документации
|
||||||
|
- `CodeIndexingPipeline` - индексация кода
|
||||||
|
- `RagRepository` - persistence и retrieval
|
||||||
|
- `IntentRouterV2` - планирование retrieval: слои, фильтры, ограничения
|
||||||
|
- `RuntimeRetrievalAdapter` - выполнение retrieval в runtime
|
||||||
|
|
||||||
|
## Индексация
|
||||||
|
|
||||||
|
Индексация идет по двум направлениям:
|
||||||
|
|
||||||
|
- `DOCS`
|
||||||
|
- `CODE`
|
||||||
|
|
||||||
|
На вход подается snapshot или changes.
|
||||||
|
Для каждого файла выбирается подходящий pipeline.
|
||||||
|
На выходе формируются документы по слоям и сохраняются в RAG-хранилище.
|
||||||
|
|
||||||
|
## Структура БД
|
||||||
|
|
||||||
|
Все слои сохраняются в общую таблицу `rag_chunks`.
|
||||||
|
|
||||||
|
### Общие поля по слоям
|
||||||
|
|
||||||
|
| Поле БД | Назначение |
|
||||||
|
|---|---|
|
||||||
|
| `rag_session_id` | идентификатор сессии индексации |
|
||||||
|
| `path` | путь исходного файла |
|
||||||
|
| `content` | основной текст записи для retrieval |
|
||||||
|
| `layer` | идентификатор слоя |
|
||||||
|
| `title` | короткий заголовок записи |
|
||||||
|
| `lang` | язык исходного содержимого, в основном для code-слоев |
|
||||||
|
| `repo_id` | идентификатор репозитория или проекта |
|
||||||
|
| `commit_sha` | версия кода или документов на момент индексации |
|
||||||
|
| `span_start`, `span_end` | диапазон строк в исходном файле, если он есть |
|
||||||
|
| `embedding` | векторное представление записи |
|
||||||
|
| `metadata_json` | структурированные атрибуты конкретного слоя |
|
||||||
|
|
||||||
|
### Поля со смыслом слоя
|
||||||
|
|
||||||
|
Смысл конкретного слоя хранится в `metadata_json`.
|
||||||
|
Именно эти атрибуты определяют, какой объект был извлечен и как его интерпретировать в retrieval.
|
||||||
|
Домены и поддомены должны храниться в `metadata_json` явно.
|
||||||
|
|
||||||
|
## Слои DOCS
|
||||||
|
|
||||||
|
### `D0_DOC_CHUNKS`
|
||||||
|
|
||||||
|
Задача:
|
||||||
|
Хранит текстовые фрагменты документации для retrieval по содержимому разделов.
|
||||||
|
|
||||||
|
Формирование:
|
||||||
|
Документ сначала разбирается на frontmatter и body, затем body режется на секции через markdown chunker.
|
||||||
|
Для каждой секции создается отдельная запись слоя.
|
||||||
|
Нарезка идет по разделам документа.
|
||||||
|
Только в fallback-сценарии, когда markdown-структура не найдена, используется нарезка по фиксированным текстовым чанкам.
|
||||||
|
|
||||||
|
Фиксация в БД:
|
||||||
|
| Атрибут в `metadata_json` | Описание | Источник |
|
||||||
|
|---|---|---|
|
||||||
|
| `document_id` | идентификатор документа-источника | `frontmatter.id`, иначе путь файла |
|
||||||
|
| `type` | тип документа из frontmatter | `frontmatter.type` |
|
||||||
|
| `module` | модуль документа | `frontmatter.module` |
|
||||||
|
| `domain` | домен документа | `frontmatter.domain` |
|
||||||
|
| `subdomain` | поддомен документа | `frontmatter.subdomain` |
|
||||||
|
| `tags` | теги документа | `frontmatter.tags` |
|
||||||
|
| `section_path` | полный путь секции в иерархии заголовков | результат `MarkdownDocChunker` |
|
||||||
|
| `section_title` | заголовок текущей секции | результат `MarkdownDocChunker` |
|
||||||
|
| `order` | порядок секции внутри документа | результат `MarkdownDocChunker` |
|
||||||
|
| `doc_kind` | классификация документа, например `readme`, `spec`, `runbook` | `DocsClassifier.classify(path)` |
|
||||||
|
| `source_path` | исходный путь документа | путь файла |
|
||||||
|
| `artifact_type` | тип артефакта, здесь `DOCS` | константа builder |
|
||||||
|
|
||||||
|
Связанные классы:
|
||||||
|
`DocsIndexingPipeline`, `DocsContentParser`, `MarkdownDocChunker`, `DocsDocumentBuilder`
|
||||||
|
|
||||||
|
### `D1_DOCUMENT_CATALOG`
|
||||||
|
|
||||||
|
Задача:
|
||||||
|
Хранит карточку документа как точку входа в документ и его краткое описание.
|
||||||
|
|
||||||
|
Формирование:
|
||||||
|
Источник данных - frontmatter, fallback title, summary и doc kind, вычисленный классификатором документации.
|
||||||
|
Данные извлекаются структурированно по атрибутам.
|
||||||
|
В `content` попадает summary документа, а не склейка всех частей документа в сплошной текст.
|
||||||
|
|
||||||
|
Фиксация в БД:
|
||||||
|
| Атрибут в `metadata_json` | Описание | Источник |
|
||||||
|
|---|---|---|
|
||||||
|
| `document_id` | идентификатор документа | `frontmatter.id`, иначе путь файла |
|
||||||
|
| `type` | тип документа из frontmatter | `frontmatter.type` |
|
||||||
|
| `name` | системное имя документа | `frontmatter.name` |
|
||||||
|
| `title` | человекочитаемый заголовок документа | `frontmatter.title`, иначе fallback title |
|
||||||
|
| `module` | модуль документа | `frontmatter.module` |
|
||||||
|
| `domain` | домен документа | `frontmatter.domain` |
|
||||||
|
| `subdomain` | поддомен документа | `frontmatter.subdomain` |
|
||||||
|
| `layer` | логический слой, указанный в frontmatter документа | `frontmatter.layer` |
|
||||||
|
| `status` | статус документа | `frontmatter.status` |
|
||||||
|
| `updated_at` | дата или отметка последнего обновления | `frontmatter.updated_at` |
|
||||||
|
| `tags` | теги документа | `frontmatter.tags` |
|
||||||
|
| `entities` | сущности, связанные с документом | `frontmatter.entities` |
|
||||||
|
| `parent` | родительский документ | `frontmatter.parent` |
|
||||||
|
| `children` | дочерние документы | `frontmatter.children` |
|
||||||
|
| `links` | ссылки на связанные материалы | `frontmatter.links` |
|
||||||
|
| `source_path` | исходный путь документа | путь файла |
|
||||||
|
| `summary_text` | краткое содержание документа | секция `# Summary` |
|
||||||
|
| `doc_kind` | классификация документа, например `readme`, `spec`, `runbook` | `DocsClassifier.classify(path)` |
|
||||||
|
|
||||||
|
Связанные классы:
|
||||||
|
`DocsIndexingPipeline`, `DocsFrontmatterParser`, `DocsClassifier`, `DocsContentParser`, `DocsDocumentBuilder`
|
||||||
|
|
||||||
|
### `D2_FACT_INDEX`
|
||||||
|
|
||||||
|
Задача:
|
||||||
|
Хранит атомарные факты в форме `subject-predicate-object` для точного retrieval по утверждениям.
|
||||||
|
|
||||||
|
Формирование:
|
||||||
|
Факты извлекаются из frontmatter и секций документа, после чего каждая найденная тройка превращается в отдельную запись.
|
||||||
|
|
||||||
|
Фиксация в БД:
|
||||||
|
| Атрибут в `metadata_json` | Описание | Источник |
|
||||||
|
|---|---|---|
|
||||||
|
| `fact_id` | идентификатор факта | вычисляется builder из содержимого факта и пути |
|
||||||
|
| `subject_id` | субъект факта | `DocsFactExtractor` |
|
||||||
|
| `predicate` | предикат или тип связи | `DocsFactExtractor` |
|
||||||
|
| `object` | значение или объект факта | `DocsFactExtractor` |
|
||||||
|
| `object_ref` | ссылка на объект, если она выделена отдельно | `DocsFactExtractor` |
|
||||||
|
| `anchor` | место в документе, откуда взят факт | `DocsFactExtractor` |
|
||||||
|
| `tags` | теги факта | `DocsFactExtractor` |
|
||||||
|
| `source_path` | исходный путь документа | путь файла |
|
||||||
|
|
||||||
|
Связанные классы:
|
||||||
|
`DocsIndexingPipeline`, `DocsFactExtractor`, `DocsDocumentBuilder`
|
||||||
|
|
||||||
|
### `D3_ENTITY_CATALOG`
|
||||||
|
|
||||||
|
Задача:
|
||||||
|
Хранит сущности, найденные в документации, чтобы искать документы и связи вокруг конкретной сущности.
|
||||||
|
|
||||||
|
Формирование:
|
||||||
|
Сущности извлекаются из frontmatter документа, после чего каждая сущность сохраняется отдельной записью.
|
||||||
|
|
||||||
|
Фиксация в БД:
|
||||||
|
| Атрибут в `metadata_json` | Описание | Источник |
|
||||||
|
|---|---|---|
|
||||||
|
| `entity_name` | имя сущности | `DocsEntityExtractor` |
|
||||||
|
| `document_id` | идентификатор документа, где найдена сущность | `frontmatter.id`, иначе путь файла |
|
||||||
|
| `document_type` | тип документа-источника | `frontmatter.type` |
|
||||||
|
| `module` | модуль документа | `frontmatter.module` |
|
||||||
|
| `domain` | домен документа | `frontmatter.domain` |
|
||||||
|
| `subdomain` | поддомен документа | `frontmatter.subdomain` |
|
||||||
|
| `tags` | теги документа или сущности | `frontmatter.tags` |
|
||||||
|
| `source_path` | исходный путь документа | путь файла |
|
||||||
|
|
||||||
|
Связанные классы:
|
||||||
|
`DocsIndexingPipeline`, `DocsEntityExtractor`, `DocsDocumentBuilder`
|
||||||
|
|
||||||
|
### `D4_WORKFLOW_INDEX`
|
||||||
|
|
||||||
|
Задача:
|
||||||
|
Хранит workflow и сценарии из документации для ответов про flow, шаги и жизненный цикл процесса.
|
||||||
|
|
||||||
|
Формирование:
|
||||||
|
Workflow извлекаются из detail sections документа и сохраняются как отдельные сценарии.
|
||||||
|
Извлечение идет из структуры `Details -> ## Сценарий`.
|
||||||
|
|
||||||
|
Фиксация в БД:
|
||||||
|
| Атрибут в `metadata_json` | Описание | Источник |
|
||||||
|
|---|---|---|
|
||||||
|
| `workflow_id` | идентификатор сценария | вычисляется builder из названия, anchor и документа |
|
||||||
|
| `document_id` | идентификатор документа-источника | `frontmatter.id`, иначе путь файла |
|
||||||
|
| `workflow_name` | название сценария | блок `Details -> ## Сценарий -> **Название**` |
|
||||||
|
| `preconditions` | предусловия сценария | блок `Details -> ## Сценарий -> **Предусловия**` |
|
||||||
|
| `trigger` | триггер или событие запуска | блок `Details -> ## Сценарий -> **Триггер**` |
|
||||||
|
| `main_flow` | основной сценарий | блок `Details -> ## Сценарий -> **Основной сценарий**` |
|
||||||
|
| `alternative_flow` | альтернативные ветки | блок `Details -> ## Сценарий -> **Альтернативный сценарий**` |
|
||||||
|
| `error_handling` | обработка ошибок | блок `Details -> ## Сценарий -> **Обработка ошибок**` |
|
||||||
|
| `postconditions` | постусловия | блок `Details -> ## Сценарий -> **Постусловие**` |
|
||||||
|
| `source_path` | исходный путь документа | путь файла |
|
||||||
|
|
||||||
|
Связанные классы:
|
||||||
|
`DocsIndexingPipeline`, `DocsWorkflowExtractor`, `DocsDocumentBuilder`
|
||||||
|
|
||||||
|
### `D5_RELATION_GRAPH`
|
||||||
|
|
||||||
|
Задача:
|
||||||
|
Хранит связи между документами и сущностями документации для navigation и related docs retrieval.
|
||||||
|
|
||||||
|
Формирование:
|
||||||
|
Связи извлекаются из frontmatter и сохраняются как отдельные relation edges.
|
||||||
|
|
||||||
|
Фиксация в БД:
|
||||||
|
| Атрибут в `metadata_json` | Описание | Источник |
|
||||||
|
|---|---|---|
|
||||||
|
| `relation_id` | идентификатор связи | вычисляется builder из source, target, relation type и anchor |
|
||||||
|
| `source_id` | источник связи | `frontmatter.id` или source документа в extractor |
|
||||||
|
| `relation_type` | тип связи | `DocsRelationExtractor` |
|
||||||
|
| `target_id` | целевой объект связи | `DocsRelationExtractor` |
|
||||||
|
| `anchor` | место в документе, где обнаружена связь | `DocsRelationExtractor` |
|
||||||
|
| `source_path` | исходный путь документа | путь файла |
|
||||||
|
|
||||||
|
Связанные классы:
|
||||||
|
`DocsIndexingPipeline`, `DocsRelationExtractor`, `DocsDocumentBuilder`
|
||||||
|
|
||||||
|
### `D6_INTEGRATION_INDEX`
|
||||||
|
|
||||||
|
Задача:
|
||||||
|
Хранит прикладные интеграции компонента, API, UI, сущности или внешней системы.
|
||||||
|
Используется для ответов на вопросы вида "какие интеграции есть у компонента".
|
||||||
|
|
||||||
|
Формирование:
|
||||||
|
Интеграции извлекаются из блока `## Integrations` документа.
|
||||||
|
Одна интеграция должна превращаться в отдельную запись слоя.
|
||||||
|
Описание интеграции может быть развернутым, а структурированные атрибуты должны выделяться в словарь.
|
||||||
|
|
||||||
|
Фиксация в БД:
|
||||||
|
| Атрибут в `metadata_json` | Описание | Источник |
|
||||||
|
|---|---|---|
|
||||||
|
| `integration_id` | идентификатор интеграции | вычисляется builder из source, target и anchor |
|
||||||
|
| `source_id` | идентификатор объекта, для которого описана интеграция | `frontmatter.id` документа-источника |
|
||||||
|
| `source_type` | тип исходного объекта | `frontmatter.doc_type` |
|
||||||
|
| `target` | целевой объект интеграции | блок `## Integrations` |
|
||||||
|
| `target_type` | тип целевого объекта, например `api`, `ui`, `entity`, `service`, `external_system` | блок `## Integrations` |
|
||||||
|
| `direction` | направление интеграции | блок `## Integrations` |
|
||||||
|
| `interaction` | тип взаимодействия | блок `## Integrations` |
|
||||||
|
| `via` | технический канал интеграции | блок `## Integrations` |
|
||||||
|
| `purpose` | назначение интеграции | блок `## Integrations` |
|
||||||
|
| `details` | дополнительные атрибуты интеграции в виде словаря | блок `## Integrations` |
|
||||||
|
| `domain` | домен документа | `frontmatter.domain` |
|
||||||
|
| `subdomain` | поддомен документа | `frontmatter.subdomain` |
|
||||||
|
| `source_path` | исходный путь документа | путь файла |
|
||||||
|
| `anchor` | место в документе, где описана интеграция | блок `## Integrations` |
|
||||||
|
|
||||||
|
Связанные классы:
|
||||||
|
`DocsIndexingPipeline`, `DocsIntegrationExtractor`, `DocsDocumentBuilder`
|
||||||
|
|
||||||
|
## Слои CODE
|
||||||
|
|
||||||
|
### `C0_SOURCE_CHUNKS`
|
||||||
|
|
||||||
|
Задача:
|
||||||
|
Хранит фрагменты исходного кода как базовый слой для цитирования, explain и точечной догрузки кода.
|
||||||
|
|
||||||
|
Формирование:
|
||||||
|
Исходный файл режется на кодовые чанки, и для каждого чанка создается отдельная запись.
|
||||||
|
|
||||||
|
Фиксация в БД:
|
||||||
|
| Атрибут в `metadata_json` | Описание | Источник |
|
||||||
|
|---|---|---|
|
||||||
|
| `chunk_index` | порядковый номер чанка в файле | индекс чанка при нарезке |
|
||||||
|
| `chunk_type` | тип чанка, например функция, класс или текстовый блок | `CodeTextChunker` |
|
||||||
|
| `module_or_unit` | модуль, к которому относится chunk | вычисляется из пути файла |
|
||||||
|
| `is_test` | признак тестового файла | `is_test_path(path)` |
|
||||||
|
|
||||||
|
Связанные классы:
|
||||||
|
`CodeIndexingPipeline`, `CodeTextChunker`, `CodeTextDocumentBuilder`
|
||||||
|
|
||||||
|
### `C1_SYMBOL_CATALOG`
|
||||||
|
|
||||||
|
Задача:
|
||||||
|
Хранит символы кода: классы, функции и методы. Используется для поиска по именам и структуре кода.
|
||||||
|
|
||||||
|
Формирование:
|
||||||
|
Символы извлекаются `SymbolExtractor`, и каждый символ сохраняется как отдельная запись.
|
||||||
|
|
||||||
|
Фиксация в БД:
|
||||||
|
| Атрибут в `metadata_json` | Описание | Источник |
|
||||||
|
|---|---|---|
|
||||||
|
| `symbol_id` | идентификатор символа | `SymbolExtractor` |
|
||||||
|
| `qname` | полное квалифицированное имя | `SymbolExtractor` |
|
||||||
|
| `kind` | тип символа: класс, функция, метод | `SymbolExtractor` |
|
||||||
|
| `signature` | сигнатура символа | `SymbolExtractor` |
|
||||||
|
| `parent_symbol_id` | родительский символ | `SymbolExtractor` |
|
||||||
|
| `package_or_module` | модуль или пакет символа | вычисляется из пути файла |
|
||||||
|
| `is_test` | признак тестового файла | `is_test_path(path)` |
|
||||||
|
|
||||||
|
Связанные классы:
|
||||||
|
`CodeIndexingPipeline`, `PythonAstParser`, `SymbolExtractor`, `SymbolDocumentBuilder`
|
||||||
|
|
||||||
|
### `C2_DEPENDENCY_GRAPH`
|
||||||
|
|
||||||
|
Задача:
|
||||||
|
Хранит связи между символами кода: вызовы, импорты, наследование. Используется для анализа зависимостей и flow.
|
||||||
|
|
||||||
|
Формирование:
|
||||||
|
Связи строятся `EdgeExtractor` по AST и списку символов, после чего каждая связь сохраняется отдельной записью.
|
||||||
|
|
||||||
|
Фиксация в БД:
|
||||||
|
| Атрибут в `metadata_json` | Описание | Источник |
|
||||||
|
|---|---|---|
|
||||||
|
| `edge_id` | идентификатор связи | `EdgeExtractor` |
|
||||||
|
| `edge_type` | тип связи: вызов, импорт, наследование | `EdgeExtractor` |
|
||||||
|
| `src_symbol_id` | исходный символ | `EdgeExtractor` |
|
||||||
|
| `src_qname` | полное имя исходного символа | `EdgeExtractor` |
|
||||||
|
| `dst_symbol_id` | целевой символ, если он разрешен | `EdgeExtractor` |
|
||||||
|
| `dst_ref` | текстовая ссылка на целевой символ | `EdgeExtractor` |
|
||||||
|
| `resolution` | статус разрешения связи | `EdgeExtractor` |
|
||||||
|
| `is_test` | признак тестового файла | `is_test_path(path)` |
|
||||||
|
|
||||||
|
Связанные классы:
|
||||||
|
`CodeIndexingPipeline`, `EdgeExtractor`, `EdgeDocumentBuilder`
|
||||||
|
|
||||||
|
### `C3_ENTRYPOINTS`
|
||||||
|
|
||||||
|
Задача:
|
||||||
|
Хранит точки входа приложения: HTTP routes, CLI commands и другие entrypoints.
|
||||||
|
|
||||||
|
Формирование:
|
||||||
|
Детекторы ищут HTTP и CLI точки входа по символам файла, после чего каждый найденный entrypoint сохраняется отдельной записью.
|
||||||
|
|
||||||
|
Фиксация в БД:
|
||||||
|
| Атрибут в `metadata_json` | Описание | Источник |
|
||||||
|
|---|---|---|
|
||||||
|
| `entry_id` | идентификатор точки входа | detector entrypoint model |
|
||||||
|
| `entry_type` | тип точки входа | detector entrypoint model |
|
||||||
|
| `framework` | framework, в котором найдена точка входа | detector entrypoint model |
|
||||||
|
| `route_or_command` | route или команда | detector entrypoint model |
|
||||||
|
| `handler_symbol_id` | идентификатор обработчика | detector entrypoint model |
|
||||||
|
| `handler_symbol` | имя обработчика | detector entrypoint model |
|
||||||
|
| `declaring_symbol` | символ, в котором объявлен entrypoint | detector entrypoint model |
|
||||||
|
| `entrypoint_kind` | вид точки входа | detector entrypoint model |
|
||||||
|
| `http_method` | HTTP-метод | detector entrypoint model |
|
||||||
|
| `route_path` | путь маршрута | detector entrypoint model |
|
||||||
|
| `decorator_text` | текст декоратора или объявления | detector entrypoint model |
|
||||||
|
| `summary_text` | краткое описание точки входа | detector entrypoint model |
|
||||||
|
| `is_test` | признак тестового файла | `is_test_path(path)` |
|
||||||
|
| `lang_payload` | дополнительные данные детектора | detector metadata |
|
||||||
|
| `artifact_type` | тип артефакта, здесь `CODE` | константа builder |
|
||||||
|
|
||||||
|
Связанные классы:
|
||||||
|
`CodeIndexingPipeline`, `EntrypointDetectorRegistry`, `FastApiEntrypointDetector`, `FlaskEntrypointDetector`, `TyperClickEntrypointDetector`, `EntrypointDocumentBuilder`
|
||||||
|
|
||||||
|
### `C4_SEMANTIC_ROLES`
|
||||||
|
|
||||||
|
Задача:
|
||||||
|
Слой объявлен в enum и retrieval-планах как слой семантических ролей кода.
|
||||||
|
|
||||||
|
Формирование:
|
||||||
|
Слой формируется на основе символов, связей, dataflow slices и execution traces.
|
||||||
|
В текущем runtime этот слой не используется как активный маршрут, так как домен `CODE` отключен.
|
||||||
|
|
||||||
|
Фиксация в БД:
|
||||||
|
Смысловые атрибуты слоя сохраняются в `rag_chunks.metadata_json`.
|
||||||
|
Точное краткое описание состава этих атрибутов в текущем документе пока не зафиксировано.
|
||||||
|
|
||||||
|
Связанные классы:
|
||||||
|
`CodeIndexingPipeline`, `SemanticRoleBuilder`, `SemanticRoleDocumentBuilder`
|
||||||
@@ -314,11 +314,71 @@ LLM не должна каждый раз тонуть в полном доку
|
|||||||
- функциональные требования;
|
- функциональные требования;
|
||||||
- UI;
|
- UI;
|
||||||
- API;
|
- API;
|
||||||
|
- integrations;
|
||||||
- ошибки;
|
- ошибки;
|
||||||
- НФТ;
|
- НФТ;
|
||||||
- связи;
|
- связи;
|
||||||
- кодовые привязки.
|
- кодовые привязки.
|
||||||
|
|
||||||
|
### Блок `## Integrations`
|
||||||
|
|
||||||
|
Если у объекта есть интеграции, они должны быть выделены в отдельный блок `## Integrations`.
|
||||||
|
Интеграции не нужно дублировать во frontmatter.
|
||||||
|
Основное описание хранится в body документа.
|
||||||
|
|
||||||
|
Ожидаемый принцип:
|
||||||
|
- одна интеграция = одна отдельная запись внутри блока;
|
||||||
|
- у интеграции есть краткое имя;
|
||||||
|
- у интеграции есть структурированные атрибуты;
|
||||||
|
- дополнительные детали допускаются в свободной форме через вложенный словарь.
|
||||||
|
|
||||||
|
Рекомендуемые атрибуты интеграции:
|
||||||
|
- `target`
|
||||||
|
- `target_type`
|
||||||
|
- `direction`
|
||||||
|
- `interaction`
|
||||||
|
- `via`
|
||||||
|
- `purpose`
|
||||||
|
- `details`
|
||||||
|
|
||||||
|
Где:
|
||||||
|
- `target` - идентификатор или имя целевого объекта;
|
||||||
|
- `target_type` - тип цели: `api`, `ui`, `entity`, `service`, `queue`, `db`, `external_system`;
|
||||||
|
- `direction` - направление: `inbound`, `outbound`, `bidirectional`;
|
||||||
|
- `interaction` - тип взаимодействия: `calls`, `reads`, `writes`, `emits`, `consumes`, `depends_on`;
|
||||||
|
- `via` - технический канал интеграции;
|
||||||
|
- `purpose` - зачем нужна интеграция;
|
||||||
|
- `details` - словарь с гибкой структурой под дополнительные параметры.
|
||||||
|
|
||||||
|
Пример:
|
||||||
|
|
||||||
|
```md
|
||||||
|
## Integrations
|
||||||
|
|
||||||
|
### Orders API
|
||||||
|
- target: api.orders.create
|
||||||
|
- target_type: api
|
||||||
|
- direction: outbound
|
||||||
|
- interaction: calls
|
||||||
|
- via: POST /api/orders
|
||||||
|
- purpose: создание заказа
|
||||||
|
- details:
|
||||||
|
- auth: service-token
|
||||||
|
- retry: true
|
||||||
|
|
||||||
|
### Order Entity
|
||||||
|
- target: domain.order
|
||||||
|
- target_type: entity
|
||||||
|
- direction: outbound
|
||||||
|
- interaction: writes
|
||||||
|
- via: repository
|
||||||
|
- purpose: сохранение состояния заказа
|
||||||
|
- details:
|
||||||
|
- transaction: required
|
||||||
|
```
|
||||||
|
|
||||||
|
Этот блок должен быть пригоден и для чтения человеком, и для последующего извлечения в отдельный RAG-слой интеграций.
|
||||||
|
|
||||||
## 1.10. Общие требования к markdown body
|
## 1.10. Общие требования к markdown body
|
||||||
|
|
||||||
1. В документе должен быть один `H1`, совпадающий с `title`.
|
1. В документе должен быть один `H1`, совпадающий с `title`.
|
||||||
@@ -428,6 +488,7 @@ UI-элементы должны храниться в **табличном** и
|
|||||||
## Технический use case
|
## Технический use case
|
||||||
## Функциональные требования
|
## Функциональные требования
|
||||||
## Contract
|
## Contract
|
||||||
|
## Integrations
|
||||||
## Errors
|
## Errors
|
||||||
## Нефункциональные требования
|
## Нефункциональные требования
|
||||||
## Связанные блоки логики
|
## Связанные блоки логики
|
||||||
@@ -454,6 +515,7 @@ UI-элементы должны храниться в **табличном** и
|
|||||||
## Контекст
|
## Контекст
|
||||||
## Технический use case
|
## Технический use case
|
||||||
## Функциональные требования
|
## Функциональные требования
|
||||||
|
## Integrations
|
||||||
## Ограничения и условия вызова
|
## Ограничения и условия вызова
|
||||||
## Нефункциональные требования
|
## Нефункциональные требования
|
||||||
## Связанные API / UI / integration points
|
## Связанные API / UI / integration points
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
# MVP: процесс v1
|
||||||
|
|
||||||
|
## 1. Общее описание
|
||||||
|
|
||||||
|
Запрос пользователя обрабатывается цепочкой API → рантайм агента → зарегистрированный процесс версии `v1` → один workflow из трёх последовательных шагов. Процесс **не** обращается к RAG и **не** маршрутизирует интенты: текст сообщения передаётся в LLM по фиксированному промпту. Ответ агента — результат генерации с лёгкой постобработкой (trim).
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
subgraph api [API]
|
||||||
|
RS[RequestService]
|
||||||
|
end
|
||||||
|
subgraph runtime [Agent runtime]
|
||||||
|
AR[AgentRuntime]
|
||||||
|
PR[ProcessRunner]
|
||||||
|
end
|
||||||
|
subgraph v1 [Процесс v1]
|
||||||
|
P1[V1Process]
|
||||||
|
WG[V1FlowMainGraph]
|
||||||
|
end
|
||||||
|
subgraph wf [Workflow v1.flow_main]
|
||||||
|
S1[PrepareUserMessageStep]
|
||||||
|
S2[GenerateAnswerStep]
|
||||||
|
S3[FinalizeAnswerStep]
|
||||||
|
end
|
||||||
|
LLM[AgentLlmService]
|
||||||
|
RS --> AR
|
||||||
|
AR --> PR
|
||||||
|
PR --> P1
|
||||||
|
P1 --> WG
|
||||||
|
WG --> S1 --> S2 --> S3
|
||||||
|
S2 --> LLM
|
||||||
|
```
|
||||||
|
|
||||||
|
Клиент создаёт запрос с `process_version: v1`. `AgentRuntime` поднимает `RuntimeExecutionContext` (запрос, сессия, publisher, trace), выбирает `V1Process` из реестра и вызывает `run`. `V1Process` собирает `V1FlowContext` и прогоняет линейный граф: подготовка текста, один вызов LLM, финализация строки ответа. Итог попадает в `ProcessResult.answer` и дальше в ответ пользователю.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Шаги и контракты
|
||||||
|
|
||||||
|
### 2.1. Вход в процесс: `V1Process.run`
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|--|--|
|
||||||
|
| **Название** | Запуск процесса v1 |
|
||||||
|
| **Задача** | Собрать контекст workflow и выполнить граф до готового ответа. |
|
||||||
|
| **Вход** | `RuntimeExecutionContext`: `request` (в т.ч. `message`), `session`, `publisher`, `trace`. |
|
||||||
|
| **Выход** | `ProcessResult` с полем `answer: str`. |
|
||||||
|
| **Как работает** | Создаётся `V1FlowContext` с `prompt_name` по умолчанию `v1_flow_main.answer`. Вызывается `V1FlowMainGraph.run`. Возвращается ответ из контекста workflow. |
|
||||||
|
|
||||||
|
Код: `src/app/core/agent/processes/v1/process.py`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2.2. Шаг workflow: `PrepareUserMessageStep`
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|--|--|
|
||||||
|
| **Название** | Подготовка сообщения пользователя |
|
||||||
|
| **Задача** | Сформировать строку, которая уйдёт в LLM как пользовательский ввод. |
|
||||||
|
| **Вход** | `V1FlowContext` с заполненным `runtime` и `prompt_name`. |
|
||||||
|
| **Выход** | Тот же контекст с `prepared_message: str`. |
|
||||||
|
| **Как работает** | Берётся `context.runtime.request.message` и обрезаются пробелы по краям (`strip`). Результат пишется в `prepared_message`. Других преобразований нет. |
|
||||||
|
|
||||||
|
Код: `src/app/core/agent/processes/v1/workflow/flow_main/steps/prepare_user_message_step.py`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2.3. Шаг workflow: `GenerateAnswerStep`
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|--|--|
|
||||||
|
| **Название** | Вызов LLM |
|
||||||
|
| **Задача** | Сгенерировать ответ по выбранному промпту и подготовленному сообщению. |
|
||||||
|
| **Вход** | `V1FlowContext` с `prepared_message`, `prompt_name`, `runtime.trace` для модуля LLM. |
|
||||||
|
| **Выход** | Контекст с `answer: str` (сырой ответ модели). |
|
||||||
|
| **Как работает** | Асинхронно в пуле потоков вызывается `AgentLlmService.generate(prompt_name, prepared_message, ...)`. В trace подключается модуль `workflow.v1.llm`. Идентификатор запроса передаётся в `log_context` для логов. |
|
||||||
|
|
||||||
|
Код: `src/app/core/agent/processes/v1/workflow/flow_main/steps/generate_answer_step.py`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2.4. Шаг workflow: `FinalizeAnswerStep`
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|--|--|
|
||||||
|
| **Название** | Финализация ответа |
|
||||||
|
| **Задача** | Нормализовать строку ответа перед выдачей пользователю. |
|
||||||
|
| **Вход** | `V1FlowContext` с заполненным `answer` после LLM. |
|
||||||
|
| **Выход** | Контекст с обновлённым `answer`. |
|
||||||
|
| **Как работает** | К ответу применяется `strip()` по краям. Другой логики нет. |
|
||||||
|
|
||||||
|
Код: `src/app/core/agent/processes/v1/workflow/flow_main/steps/finalize_answer_step.py`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2.5. Транспорт: `WorkflowGraph` (v1)
|
||||||
|
|
||||||
|
Граф для v1 использует стандартный `WorkflowGraph`: на каждом шаге пишутся события `workflow_started`, `step_started`, `step_completed`, `workflow_completed` в `runtime_traces` через `context.runtime.trace`.
|
||||||
|
|
||||||
|
Код: `src/app/core/agent/utils/workflow/graph.py`, обёртка `V1FlowMainGraph` в `src/app/core/agent/processes/v1/workflow/flow_main/graph.py`.
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
# MVP: процесс v2
|
||||||
|
|
||||||
|
## 1. Общее описание
|
||||||
|
|
||||||
|
Процесс v2 в текущем MVP ориентирован в первую очередь на **документацию проекта**, но роутер также поддерживает `GENERAL / GENERAL_QA / SUMMARY` для общих обзорных вопросов. Для документных веток нужна активная RAG-сессия с проиндексированными документами.
|
||||||
|
|
||||||
|
Это **узкий MVP**, а не полная target architecture. Поддерживаются три маршрута:
|
||||||
|
|
||||||
|
- `GENERAL`
|
||||||
|
- `GENERAL_QA`
|
||||||
|
- `SUMMARY`
|
||||||
|
- `DOCS`
|
||||||
|
- `DOC_EXPLAIN`
|
||||||
|
- `SUMMARY`
|
||||||
|
- `FIND_FILES`
|
||||||
|
|
||||||
|
Запрос проходит следующие смысловые этапы:
|
||||||
|
|
||||||
|
1. проверка готовности сессии;
|
||||||
|
2. intent routing;
|
||||||
|
3. формирование retrieval-параметров;
|
||||||
|
4. retrieval из `DOCS RAG`;
|
||||||
|
5. минимальная сборка evidence;
|
||||||
|
6. запуск task-focused workflow нужной ветки;
|
||||||
|
7. формирование ответа.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TB
|
||||||
|
subgraph api [API]
|
||||||
|
RS[RequestService]
|
||||||
|
end
|
||||||
|
subgraph runtime [Agent runtime]
|
||||||
|
AR[AgentRuntime]
|
||||||
|
PR[ProcessRunner]
|
||||||
|
end
|
||||||
|
subgraph v2 [Процесс v2]
|
||||||
|
P2[V2Process]
|
||||||
|
IR[V2IntentRouter]
|
||||||
|
POL[V2RetrievalPolicyResolver]
|
||||||
|
AD[V2RagRetrievalAdapter]
|
||||||
|
RSR[RagSessionRetriever]
|
||||||
|
ASM[DocsEvidenceAssembler]
|
||||||
|
end
|
||||||
|
subgraph rag [Пакет rag]
|
||||||
|
RR[RagRepository]
|
||||||
|
end
|
||||||
|
subgraph wf [Workflow]
|
||||||
|
SUM[DocsExplainSummaryGraph]
|
||||||
|
FF[DocsExplainFindFilesGraph]
|
||||||
|
end
|
||||||
|
LLM[AgentLlmService]
|
||||||
|
RS --> AR --> PR --> P2
|
||||||
|
P2 --> IR --> POL --> AD --> RSR --> RR
|
||||||
|
AD --> ASM
|
||||||
|
ASM --> SUM
|
||||||
|
ASM --> FF
|
||||||
|
SUM --> LLM
|
||||||
|
```
|
||||||
|
|
||||||
|
Клиент указывает `process_version: v2`. Без `active_rag_session_id` в сессии процесс возвращает сообщение об ошибке. Иначе выполняется цепочка:
|
||||||
|
|
||||||
|
маршрутизация → `RetrievalPlan` → retrieval строк из `DOCS RAG` → минимальная сборка evidence → ветвление по `subintent` → запуск workflow.
|
||||||
|
|
||||||
|
### Реализованные домены, интенты и сабинтенты
|
||||||
|
|
||||||
|
В коде заданы константы `V2Domain`, `V2Intent`, `V2Subintent`. Сейчас процесс intentionally ограничен одной рабочей областью.
|
||||||
|
|
||||||
|
| Уровень | Значение (строка) | Реализация |
|
||||||
|
|--------|-------------------|------------|
|
||||||
|
| **Домен (routing_domain)** | `DOCS` | Единственный поддерживаемый домен: документация проекта. |
|
||||||
|
| **Интент** | `DOC_EXPLAIN` | Единственный интент: объяснение по документации. |
|
||||||
|
| **Сабинтент** | `SUMMARY` | Объяснение темы по SUMMARY-блокам документации. |
|
||||||
|
| **Сабинтент** | `FIND_FILES` | Поиск путей к документам, где описана нужная сущность или тема. |
|
||||||
|
|
||||||
|
Итого в текущем MVP реализована **одна** рабочая тройка домен×интент: `DOCS` + `DOC_EXPLAIN`, с **двумя** ветками по сабинтенту.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Этапы вне workflow (внутри `V2Process.run`)
|
||||||
|
|
||||||
|
### 2.1. `V2IntentRouter.route`
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|--|--|
|
||||||
|
| **Название** | Маршрутизация запроса (v2) |
|
||||||
|
| **Задача** | Определить домен, интент, subintent и извлечь якоря из текста. |
|
||||||
|
| **Вход** | `user_query: str` (текст сообщения пользователя). |
|
||||||
|
| **Выход** | `V2RouteResult`: `routing_domain`, `intent`, `subintent`, `user_query`, `normalized_query`, `target_terms`, `anchors` (`V2RouteAnchors`), `confidence`. |
|
||||||
|
| **Как работает** | Router реализован по схеме **LLM-first**: `normalization` → `target_terms`/`anchors extraction` → `LLM router` → `deterministic validator` → `fallback`. LLM является **основным селектором маршрута**. Deterministic-слой больше не выбирает маршрут по умолчанию: он отвечает только за extraction, валидацию enum/комбинаций и fallback при сломанном или невалидном ответе LLM. В trace пишется событие `intent_routed`. |
|
||||||
|
|
||||||
|
Код: `src/app/core/agent/processes/v2/intent_router/router.py`, `modules/normalizer.py`, `modules/target_terms.py`, `modules/anchors.py`, `routers/llm.py`, `routers/validator.py`, `routers/fallback.py`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2.2. `V2RetrievalPolicyResolver.resolve`
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|--|--|
|
||||||
|
| **Название** | Политика retrieval для v2 |
|
||||||
|
| **Задача** | По результату роутинга выбрать профиль, список слоёв RAG и лимит строк выдачи. |
|
||||||
|
| **Вход** | `V2RouteResult`. |
|
||||||
|
| **Выход** | `RetrievalPlan`: `profile`, `layers`, `limit`, опционально `filters`. |
|
||||||
|
| **Как работает** | Это отдельный смысловой шаг между routing и retrieval. Он не ходит в БД и не извлекает данные, а только подготавливает параметры поиска. Для `FIND_FILES` выбирается один профиль слоёв и лимит, для `SUMMARY` — другой. Лог: `retrieval_plan_resolved`. |
|
||||||
|
|
||||||
|
Код: `src/app/core/agent/processes/v2/retrieval/policy_resolver.py`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2.3. `V2RagRetrievalAdapter` → `RagSessionRetriever.retrieve`
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|--|--|
|
||||||
|
| **Название** | Загрузка сырых строк из RAG по плану |
|
||||||
|
| **Задача** | Делегировать поиск в единственную реализацию retrieval в пакете `rag`. |
|
||||||
|
| **Вход** | `rag_session_id`, `query_text` (нормализованный запрос), `RetrievalPlan`. |
|
||||||
|
| **Выход** | `list[dict]` — строки чанков в формате `RagRepository.retrieve` (поля `path`, `layer`, `metadata`, и т.д.). |
|
||||||
|
| **Как работает** | Выполняется retrieval по уже сформированному плану: профиль, список слоёв и лимит. На этом шаге происходит только извлечение сырых строк из `DOCS RAG`. Лог: `rag_rows_fetched`. |
|
||||||
|
|
||||||
|
Код адаптера: `src/app/core/agent/processes/v2/retrieval/v2_rag_adapter.py`.
|
||||||
|
Код API: `src/app/core/rag/retrieval/session_retriever.py`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2.4. `DocsEvidenceAssembler`
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|--|--|
|
||||||
|
| **Название** | Сборка evidence для задачи |
|
||||||
|
| **Задача** | Превратить сырые строки retrieval в списки summary или кандидатов файлов с дедупом и скорингом. |
|
||||||
|
| **Вход** | Список строк `rows`, `V2RouteResult` (для `target_terms`). |
|
||||||
|
| **Выход** | `list[RetrievedSummary]` или `list[RetrievedFile]`. |
|
||||||
|
| **Как работает** | Это **минимальная evidence-проверка**, достаточная для MVP. Для `SUMMARY` отбрасываются записи без summary-текста и summary-like секции, затем применяется дедуп и простой скоринг по терминам. Для `FIND_FILES` остаются только релевантные пути документов, также с дедупом и простым скорингом. Здесь нет сложной многоступенчатой валидации: задача шага — отфильтровать очевидный шум и передать в workflow компактное evidence. Лог: `evidence_assembled`. |
|
||||||
|
|
||||||
|
Код: `src/app/core/agent/processes/v2/evidence/assembler.py`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Шаги workflow
|
||||||
|
|
||||||
|
Текущие workflow являются **task-focused**: каждая ветка решает одну узкую прикладную задачу и не содержит общей универсальной логики для всех типов вопросов.
|
||||||
|
|
||||||
|
### 3.1. Ветка `SUMMARY`: `GenerateSummaryAnswerStep`
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|--|--|
|
||||||
|
| **Название** | Сборка ответа по summary |
|
||||||
|
| **Задача** | Сформировать ответ пользователю по найденным SUMMARY-блокам или сообщить об отсутствии. |
|
||||||
|
| **Вход** | `DocsExplainSummaryContext`: `runtime`, `route`, `rag_session_id`, `prompt_name`, `documents` (список `RetrievedSummary`). |
|
||||||
|
| **Выход** | Контекст с `answer: str`, `prompt_input` при успешном вызове LLM. |
|
||||||
|
| **Как работает** | Workflow получает уже отобранные summary-документы. Если документов нет — возвращает честный fallback-ответ. Иначе собирает prompt input из запроса пользователя и найденных summary-блоков и вызывает LLM. Workflow не занимается retrieval и не строит retrieval-план: он решает только задачу генерации ответа по уже подготовленному evidence. |
|
||||||
|
|
||||||
|
Код: `src/app/core/agent/processes/v2/workflows/docs_explain_summary/steps/generate_summary_answer_step.py`.
|
||||||
|
Граф: `DocsExplainSummaryGraph` (`V2WorkflowGraph`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.2. Ветка `FIND_FILES`: `FinalizeFindFilesAnswerStep`
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|--|--|
|
||||||
|
| **Название** | Сборка списка файлов |
|
||||||
|
| **Задача** | Вывести пользователю markdown-список путей к файлам документации. |
|
||||||
|
| **Вход** | `DocsExplainFindFilesContext`: `runtime`, `route`, `rag_session_id`, `files` (`RetrievedFile`). |
|
||||||
|
| **Выход** | Контекст с `answer: str`. |
|
||||||
|
| **Как работает** | Workflow получает уже собранный список файлов и формирует финальный ответ. Если файлов нет — возвращает fallback. Если файлы есть — отдает детерминированный список путей. Эта ветка intentionally не использует LLM, потому что задача сводится к выдаче путей, а не к генерации объяснения. |
|
||||||
|
|
||||||
|
Код: `src/app/core/agent/processes/v2/workflows/docs_explain_find_files/steps/finalize_find_files_answer_step.py`.
|
||||||
|
Граф: `DocsExplainFindFilesGraph` (`V2WorkflowGraph`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.3. Транспорт: `V2WorkflowGraph`
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|--|--|
|
||||||
|
| **Название** | Workflow v2 с буфером trace |
|
||||||
|
| **Задача** | Выполнить шаги без пошаговых `step_started`/`step_completed` в trace; один раз сбросить сводку. |
|
||||||
|
| **Вход** | Контекст workflow (`DocsExplainSummaryContext` или `DocsExplainFindFilesContext`). |
|
||||||
|
| **Выход** | Обновлённый контекст. |
|
||||||
|
| **Как работает** | Для каждого шага: `trace_input` до `run`, затем `run`, затем `trace_output`; записи копятся в список. В trace уходят `workflow_started`, затем `workflow_trace_flushed` с массивом шагов, затем `workflow_completed`. Статусы пользователю публикуются через `publisher` как и раньше. |
|
||||||
|
|
||||||
|
Код: `src/app/core/agent/processes/v2/workflows/v2_workflow_graph.py`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Сборка в приложении
|
||||||
|
|
||||||
|
В `ModularApplication` создаются `RagSessionRetriever`, `V2RagRetrievalAdapter`, `V2RetrievalPolicyResolver`, `DocsEvidenceAssembler` и передаются в `V2Process` (см. `src/app/core/application.py`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Итоговая концептуальная схема текущего MVP
|
||||||
|
|
||||||
|
В концептуальном виде текущий `v2` работает так:
|
||||||
|
|
||||||
|
1. **Session check**
|
||||||
|
Проверка, что есть активная RAG-сессия проекта.
|
||||||
|
|
||||||
|
2. **LLM-first intent routing**
|
||||||
|
Нормализация, extraction (`target_terms`, `anchors`), затем основной выбор маршрута через LLM.
|
||||||
|
|
||||||
|
3. **Deterministic validation + fallback**
|
||||||
|
Проверка enum/комбинации маршрута и fallback только если LLM не ответил или вернул невалидный маршрут.
|
||||||
|
|
||||||
|
4. **Retrieval parameter planning**
|
||||||
|
Формирование профиля поиска, слоёв и лимитов.
|
||||||
|
|
||||||
|
5. **RAG retrieval**
|
||||||
|
Загрузка сырых строк из `DOCS RAG`.
|
||||||
|
|
||||||
|
6. **Minimal evidence assembly**
|
||||||
|
Дедуп, базовый скоринг, отбор полезных summary или файлов.
|
||||||
|
|
||||||
|
7. **Task-focused workflow**
|
||||||
|
Узкая ветка `SUMMARY` или `FIND_FILES`.
|
||||||
|
|
||||||
|
8. **Final response**
|
||||||
|
Либо explanation через LLM, либо детерминированный список файлов.
|
||||||
|
|
||||||
|
Это и есть актуальная архитектура **узкого MVP**, синхронизированная с текущей реализацией.
|
||||||
@@ -0,0 +1,269 @@
|
|||||||
|
# V2IntentRouter Architecture
|
||||||
|
|
||||||
|
## 1. Архитектура
|
||||||
|
|
||||||
|
Текущий `V2IntentRouter` состоит из следующих компонентов:
|
||||||
|
|
||||||
|
- `router.py`
|
||||||
|
Главная точка входа и оркестратор.
|
||||||
|
|
||||||
|
- `modules/normalizer.py`
|
||||||
|
Нормализация текста запроса в `normalized_query`.
|
||||||
|
|
||||||
|
- `modules/target_terms.py`
|
||||||
|
Извлечение `target_terms`, `endpoint_paths`, `matched_aliases`, `alias_docs`.
|
||||||
|
|
||||||
|
- `modules/anchors.py`
|
||||||
|
Извлечение `anchors` и вспомогательных marker-сигналов.
|
||||||
|
|
||||||
|
- `routers/docs_subintent_resolver.py`
|
||||||
|
Определение `subintent`.
|
||||||
|
|
||||||
|
- `routers/deterministic.py`
|
||||||
|
Детерминированное определение `routing_domain`, `intent`, `subintent`, `confidence`, `routing_mode`, `llm_router_used`, `reason_short`.
|
||||||
|
|
||||||
|
- `routers/llm.py`
|
||||||
|
LLM-based определение `routing_domain`, `intent`, `subintent`, `confidence`, `reason_short`.
|
||||||
|
|
||||||
|
- `routers/prompts.yml`
|
||||||
|
Prompt для LLM-router.
|
||||||
|
|
||||||
|
## 2. Контракт
|
||||||
|
|
||||||
|
### Вход
|
||||||
|
|
||||||
|
- `user_query: str`
|
||||||
|
|
||||||
|
### Выход
|
||||||
|
|
||||||
|
`V2RouteResult`:
|
||||||
|
|
||||||
|
- `routing_domain: str`
|
||||||
|
- `intent: str`
|
||||||
|
- `subintent: str`
|
||||||
|
- `user_query: str`
|
||||||
|
- `normalized_query: str`
|
||||||
|
- `target_terms: list[str]`
|
||||||
|
- `anchors: V2RouteAnchors`
|
||||||
|
- `confidence: float`
|
||||||
|
- `routing_mode: str`
|
||||||
|
- `llm_router_used: bool`
|
||||||
|
- `reason_short: str`
|
||||||
|
|
||||||
|
`V2RouteAnchors`:
|
||||||
|
|
||||||
|
- `entity_names: list[str]`
|
||||||
|
- `terms: list[str]`
|
||||||
|
- `file_names: list[str]`
|
||||||
|
- `endpoint_paths: list[str]`
|
||||||
|
- `target_doc_hints: list[str]`
|
||||||
|
- `matched_aliases: list[str]`
|
||||||
|
- `process_domain: str | None`
|
||||||
|
- `process_subdomain: str | None`
|
||||||
|
|
||||||
|
## 3. Поддерживаемые домены, интенты и сабинтенты
|
||||||
|
|
||||||
|
### Домены
|
||||||
|
|
||||||
|
- `DOCS`
|
||||||
|
- `GENERAL`
|
||||||
|
|
||||||
|
### Интенты
|
||||||
|
|
||||||
|
- `DOC_EXPLAIN`
|
||||||
|
- `GENERAL_QA`
|
||||||
|
|
||||||
|
### Сабинтенты
|
||||||
|
|
||||||
|
- `SUMMARY`
|
||||||
|
- `FIND_FILES`
|
||||||
|
|
||||||
|
### Поддерживаемые маршруты
|
||||||
|
|
||||||
|
- `GENERAL / GENERAL_QA / SUMMARY`
|
||||||
|
- `DOCS / DOC_EXPLAIN / SUMMARY`
|
||||||
|
- `DOCS / DOC_EXPLAIN / FIND_FILES`
|
||||||
|
|
||||||
|
## 4. Флоу обработки запроса
|
||||||
|
|
||||||
|
1. `router.py` принимает `user_query`.
|
||||||
|
2. `modules/normalizer.py` строит `normalized_query`.
|
||||||
|
3. `modules/target_terms.py` извлекает ключевые термы и alias-based сигналы.
|
||||||
|
4. `modules/anchors.py` строит `anchors` и marker-сигналы.
|
||||||
|
5. `router.py` собирает `QueryFeatures`.
|
||||||
|
6. `routers/deterministic.py` пытается определить маршрут детерминированно.
|
||||||
|
7. Если deterministic route найден, он сразу возвращается.
|
||||||
|
8. Если deterministic route не найден, `router.py` вызывает `routers/llm.py`.
|
||||||
|
9. Если LLM вернул валидный маршрут, собирается `V2RouteResult` с `routing_mode="llm_assisted"`.
|
||||||
|
10. Если LLM недоступен или не вернул валидный маршрут, используется fallback:
|
||||||
|
`GENERAL / GENERAL_QA / SUMMARY` с `routing_mode="llm_fallback"`.
|
||||||
|
|
||||||
|
## 5. Компоненты по флоу
|
||||||
|
|
||||||
|
### `router.py`
|
||||||
|
|
||||||
|
- Задача
|
||||||
|
Собрать весь процесс роутинга в одной входной точке.
|
||||||
|
|
||||||
|
- Как решает
|
||||||
|
Последовательно вызывает normalizer, target terms extractor, anchors extractor, deterministic router и при необходимости LLM router.
|
||||||
|
|
||||||
|
- Вход
|
||||||
|
`user_query: str`
|
||||||
|
|
||||||
|
- Выход
|
||||||
|
`V2RouteResult`
|
||||||
|
|
||||||
|
### `modules/normalizer.py`
|
||||||
|
|
||||||
|
- Задача
|
||||||
|
Привести запрос к стабильной форме для дальнейшего анализа.
|
||||||
|
|
||||||
|
- Как решает
|
||||||
|
Схлопывает лишние пробелы через `" ".join(...split())`.
|
||||||
|
|
||||||
|
- Вход
|
||||||
|
`user_query: str`
|
||||||
|
|
||||||
|
- Выход
|
||||||
|
`normalized_query: str`
|
||||||
|
|
||||||
|
### `modules/target_terms.py`
|
||||||
|
|
||||||
|
- Задача
|
||||||
|
Выделить ключевые термы и retrieval-сигналы из запроса.
|
||||||
|
|
||||||
|
- Как решает
|
||||||
|
Использует:
|
||||||
|
- regex для path/entity-like фрагментов
|
||||||
|
- список stop-words
|
||||||
|
- alias rules с фразами и каноническими термами
|
||||||
|
- эвристику для `/health`
|
||||||
|
|
||||||
|
- Вход
|
||||||
|
`normalized_query: str`
|
||||||
|
|
||||||
|
- Выход
|
||||||
|
`TargetTermsAnalysis`:
|
||||||
|
- `target_terms`
|
||||||
|
- `endpoint_paths`
|
||||||
|
- `matched_aliases`
|
||||||
|
- `alias_docs`
|
||||||
|
|
||||||
|
### `modules/anchors.py`
|
||||||
|
|
||||||
|
- Задача
|
||||||
|
Построить полный набор `anchors` и doc-oriented marker-сигналов.
|
||||||
|
|
||||||
|
- Как решает
|
||||||
|
Использует:
|
||||||
|
- regex для `entity_names` и `file_names`
|
||||||
|
- словари marker-фраз:
|
||||||
|
- file markers
|
||||||
|
- architecture markers
|
||||||
|
- logic markers
|
||||||
|
- domain markers
|
||||||
|
- endpoint markers
|
||||||
|
- map `endpoint -> target_doc_hint`
|
||||||
|
- alias docs из `TargetTermsAnalysis`
|
||||||
|
|
||||||
|
- Вход
|
||||||
|
- `normalized_query: str`
|
||||||
|
- `TargetTermsAnalysis`
|
||||||
|
|
||||||
|
- Выход
|
||||||
|
`AnchorAnalysis`:
|
||||||
|
- `anchors`
|
||||||
|
- `file_markers`
|
||||||
|
- `architecture_markers`
|
||||||
|
- `logic_markers`
|
||||||
|
- `domain_markers`
|
||||||
|
- `endpoint_markers`
|
||||||
|
|
||||||
|
### `routers/docs_subintent_resolver.py`
|
||||||
|
|
||||||
|
- Задача
|
||||||
|
Определить `subintent`.
|
||||||
|
|
||||||
|
- Как решает
|
||||||
|
Эвристика:
|
||||||
|
- если есть `file_markers` -> `FIND_FILES`
|
||||||
|
- если есть doc-signals (`endpoint_paths`, `endpoint_markers`, `architecture_markers`, `logic_markers`, `domain_markers`, `target_doc_hints`) -> `SUMMARY`
|
||||||
|
- иначе `None`
|
||||||
|
|
||||||
|
- Вход
|
||||||
|
`QueryFeatures`
|
||||||
|
|
||||||
|
- Выход
|
||||||
|
`subintent: str | None`
|
||||||
|
|
||||||
|
### `routers/deterministic.py`
|
||||||
|
|
||||||
|
- Задача
|
||||||
|
Детерминированно определить маршрут без LLM там, где это возможно.
|
||||||
|
|
||||||
|
- Как решает
|
||||||
|
Использует:
|
||||||
|
- `DocsSubintentResolver`
|
||||||
|
- проверку conflicting doc anchors
|
||||||
|
- список general markers
|
||||||
|
|
||||||
|
Правила:
|
||||||
|
- `FIND_FILES` -> `DOCS / DOC_EXPLAIN / FIND_FILES`
|
||||||
|
- `subintent != None` и нет конфликта doc-signals -> `DOCS / DOC_EXPLAIN / SUMMARY`
|
||||||
|
- general marker -> `GENERAL / GENERAL_QA / SUMMARY`
|
||||||
|
|
||||||
|
- Вход
|
||||||
|
- `user_query: str`
|
||||||
|
- `QueryFeatures`
|
||||||
|
- `anchors: V2RouteAnchors`
|
||||||
|
|
||||||
|
- Выход
|
||||||
|
`V2RouteResult | None`
|
||||||
|
|
||||||
|
### `routers/llm.py`
|
||||||
|
|
||||||
|
- Задача
|
||||||
|
Определить маршрут через LLM, если deterministic routing не дал результата.
|
||||||
|
|
||||||
|
- Как решает
|
||||||
|
Формирует JSON payload из:
|
||||||
|
- `user_query`
|
||||||
|
- `normalized_query`
|
||||||
|
- `target_terms`
|
||||||
|
- `anchors`
|
||||||
|
- списка допустимых маршрутов
|
||||||
|
|
||||||
|
Затем:
|
||||||
|
- вызывает LLM
|
||||||
|
- парсит JSON
|
||||||
|
- валидирует маршрут по whitelist
|
||||||
|
- нормализует `confidence`
|
||||||
|
|
||||||
|
- Вход
|
||||||
|
- `user_query: str`
|
||||||
|
- `normalized_query: str`
|
||||||
|
- `target_terms: list[str]`
|
||||||
|
- `anchors: dict`
|
||||||
|
|
||||||
|
- Выход
|
||||||
|
`dict | None`:
|
||||||
|
- `routing_domain`
|
||||||
|
- `intent`
|
||||||
|
- `subintent`
|
||||||
|
- `confidence`
|
||||||
|
- `reason_short`
|
||||||
|
|
||||||
|
### `routers/prompts.yml`
|
||||||
|
|
||||||
|
- Задача
|
||||||
|
Задать LLM-router формальный контракт ответа.
|
||||||
|
|
||||||
|
- Как решает
|
||||||
|
Описывает допустимые маршруты и требует вернуть только JSON.
|
||||||
|
|
||||||
|
- Вход
|
||||||
|
Payload от `routers/llm.py`
|
||||||
|
|
||||||
|
- Выход
|
||||||
|
Структурированный JSON-ответ LLM
|
||||||
@@ -37,11 +37,11 @@ tags:
|
|||||||
|
|
||||||
- Scope: модуль индексации проектных файлов, хранения RAG-слоёв и выдачи retrieval-контекста.
|
- Scope: модуль индексации проектных файлов, хранения RAG-слоёв и выдачи retrieval-контекста.
|
||||||
- Purpose: построить индекс по документации и Python-коду и дать runtime доступ к релевантным фрагментам.
|
- Purpose: построить индекс по документации и Python-коду и дать runtime доступ к релевантным фрагментам.
|
||||||
- Main modules: `RagModule`, `RagService`, `IndexingOrchestrator`, `RagRepository`, `RepoWebhookService`.
|
- Main modules: `RagModule`, `RagService`, `IndexingOrchestrator`, `RagRepository`.
|
||||||
- Main domains: RAG-сессии, задачи индексации, документы индекса, blob-cache, retrieval.
|
- Main domains: RAG-сессии, задачи индексации, документы индекса, blob-cache, retrieval.
|
||||||
- Main integrations: PostgreSQL/pgvector, GigaChat embeddings, FastAPI, EventBus, story context.
|
- 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 entrypoints: `/api/rag/sessions`, `/api/rag/sessions/{rag_session_id}/changes`, `/api/rag/sessions/{rag_session_id}/jobs/{index_job_id}`, `/api/rag/sessions/{rag_session_id}/jobs/{index_job_id}/events`.
|
||||||
- Key data flows: snapshot indexing, incremental reindex, retrieval из `rag_chunks`, webhook-нормализация коммитов.
|
- Key data flows: snapshot indexing, incremental reindex, retrieval из `rag_chunks`.
|
||||||
- Source of truth: код `src/app/modules/rag/*`.
|
- Source of truth: код `src/app/modules/rag/*`.
|
||||||
|
|
||||||
## Назначение
|
## Назначение
|
||||||
@@ -50,7 +50,7 @@ tags:
|
|||||||
|
|
||||||
## Контекст
|
## Контекст
|
||||||
|
|
||||||
Модуль используется как инфраструктурный слой для agent/runtime и смежных интеграций. На вход он принимает либо список файлов проекта, либо webhook репозитория. На выходе формирует устойчивый индекс, ассоциированный с `rag_session_id`, и статус задач индексации, пригодный для опроса и SSE-подписки.
|
Модуль используется как инфраструктурный слой для agent/runtime. На вход он принимает snapshot и изменения файлов проекта. На выходе формирует устойчивый индекс, ассоциированный с `rag_session_id`, и статус задач индексации, пригодный для опроса и SSE-подписки.
|
||||||
|
|
||||||
## Границы системы
|
## Границы системы
|
||||||
|
|
||||||
@@ -74,7 +74,7 @@ tags:
|
|||||||
|
|
||||||
## Архитектурная схема
|
## Архитектурная схема
|
||||||
|
|
||||||
`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.
|
`RagModule` собирает зависимости модуля и публикует HTTP endpoints. Для индексации он использует `RagSessionStore`, `IndexJobStore`, `IndexingOrchestrator` и `RagService`. `RagService` выбирает docs/code pipeline, обогащает документы метаданными файла, запрашивает embeddings и записывает результат через `RagRepository`. `RagRepository` агрегирует schema/session/job/document/cache/query репозитории.
|
||||||
|
|
||||||
## Основные модули
|
## Основные модули
|
||||||
|
|
||||||
@@ -87,7 +87,6 @@ tags:
|
|||||||
| `DocsIndexingPipeline` | построение слоёв документации `D1-D4` | classifier, chunker, document builder | `src/app/modules/rag/indexing/docs/pipeline.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` |
|
| `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` |
|
| `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` |
|
|
||||||
|
|
||||||
|
|
||||||
## Основные доменные области
|
## Основные доменные области
|
||||||
@@ -104,9 +103,8 @@ tags:
|
|||||||
| ------------------------ | --------- | --------------------------------------------------- | ---------------------------------- | -------------------------------------------------------------------------- |
|
| ------------------------ | --------- | --------------------------------------------------- | ---------------------------------- | -------------------------------------------------------------------------- |
|
||||||
| PostgreSQL + pgvector | outbound | хранение документов, jobs, sessions и vector search | SQLAlchemy / SQL / pgvector | `logic-rag-retrieval` |
|
| PostgreSQL + pgvector | outbound | хранение документов, jobs, sessions и vector search | SQLAlchemy / SQL / pgvector | `logic-rag-retrieval` |
|
||||||
| GigaChat embeddings | outbound | получение embedding для batch документов | HTTP client через `GigaChatClient` | `logic-rag-indexing` |
|
| 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` |
|
| FastAPI | inbound | публичный HTTP 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` |
|
| EventBus | outbound | публикация прогресса индексации и terminal events | in-process async events / SSE | `api-rag-session-job` |
|
||||||
| Story context repository | outbound | запись webhook-коммитов для story | Python interface | `logic-rag-indexing` |
|
|
||||||
|
|
||||||
|
|
||||||
## Основные потоки
|
## Основные потоки
|
||||||
@@ -138,7 +136,7 @@ tags:
|
|||||||
|
|
||||||
- Code indexing поддерживает только Python-файлы.
|
- Code indexing поддерживает только Python-файлы.
|
||||||
- Docs indexing ориентирован на markdown и frontmatter YAML.
|
- Docs indexing ориентирован на markdown и frontmatter YAML.
|
||||||
- Deprecated endpoint `/internal/rag/retrieve` не используется для рабочего retrieval.
|
- HTTP retrieval endpoint в модуле не публикуется.
|
||||||
- Реальное retrieval API доступно через repository/runtime adapters, а не через публичный HTTP endpoint модуля.
|
- Реальное retrieval API доступно через repository/runtime adapters, а не через публичный HTTP endpoint модуля.
|
||||||
|
|
||||||
### Risks
|
### Risks
|
||||||
@@ -152,7 +150,6 @@ tags:
|
|||||||
### Security
|
### Security
|
||||||
|
|
||||||
- Публичные endpoints не содержат собственной бизнес-авторизации внутри модуля и полагаются на внешний слой приложения.
|
- Публичные endpoints не содержат собственной бизнес-авторизации внутри модуля и полагаются на внешний слой приложения.
|
||||||
- Webhook provider определяется по headers/payload без явной проверки подписи в самом `RepoWebhookService`.
|
|
||||||
|
|
||||||
### Reliability
|
### Reliability
|
||||||
|
|
||||||
@@ -164,7 +161,7 @@ tags:
|
|||||||
- Logs: `RagService` пишет предупреждения по cache hit/miss и skipped files.
|
- Logs: `RagService` пишет предупреждения по cache hit/miss и skipped files.
|
||||||
- Metrics: явные метрики не выделены.
|
- Metrics: явные метрики не выделены.
|
||||||
- Traces: явная трассировка не реализована.
|
- Traces: явная трассировка не реализована.
|
||||||
- Audit: job status и webhook commit binding сохраняются в БД.
|
- Audit: job status сохраняется в БД.
|
||||||
|
|
||||||
### Performance
|
### Performance
|
||||||
|
|
||||||
@@ -191,16 +188,13 @@ tags:
|
|||||||
- `src/app/modules/rag/indexing_service.py`
|
- `src/app/modules/rag/indexing_service.py`
|
||||||
- `src/app/modules/rag/persistence/repository.py`
|
- `src/app/modules/rag/persistence/repository.py`
|
||||||
- `src/app/modules/rag/persistence/schema_repository.py`
|
- `src/app/modules/rag/persistence/schema_repository.py`
|
||||||
- `src/app/modules/rag/webhook_service.py`
|
|
||||||
|
|
||||||
### Symbols
|
### Symbols
|
||||||
|
|
||||||
- `RagModule`
|
- `RagModule`
|
||||||
- `RagRepoModule`
|
|
||||||
- `RagService`
|
- `RagService`
|
||||||
- `IndexingOrchestrator`
|
- `IndexingOrchestrator`
|
||||||
- `RagRepository`
|
- `RagRepository`
|
||||||
- `RepoWebhookService`
|
|
||||||
|
|
||||||
## Связанные документы
|
## Связанные документы
|
||||||
|
|
||||||
@@ -218,5 +212,3 @@ tags:
|
|||||||
| Date | Source | Changes |
|
| Date | Source | Changes |
|
||||||
| ---------- | ------ | ------------------------------------------------------------------- |
|
| ---------- | ------ | ------------------------------------------------------------------- |
|
||||||
| 2026-03-13 | code | Создан обзор архитектуры пакета `rag` на основе текущей реализации. |
|
| 2026-03-13 | code | Создан обзор архитектуры пакета `rag` на основе текущей реализации. |
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -125,8 +125,6 @@ tags:
|
|||||||
|
|
||||||
- `POST /api/rag/sessions`
|
- `POST /api/rag/sessions`
|
||||||
- `POST /api/rag/sessions/{rag_session_id}/changes`
|
- `POST /api/rag/sessions/{rag_session_id}/changes`
|
||||||
- `POST /internal/rag/index/snapshot`
|
|
||||||
- `POST /internal/rag/index/changes`
|
|
||||||
|
|
||||||
## Связанные сущности
|
## Связанные сущности
|
||||||
|
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ tags:
|
|||||||
|
|
||||||
- Retrieval работает только внутри одной `rag_session_id` и не агрегирует несколько сессий.
|
- Retrieval работает только внутри одной `rag_session_id` и не агрегирует несколько сессий.
|
||||||
- Layer ranking зашит в код SQL-builder и требует явного обновления при появлении новых слоёв.
|
- Layer ranking зашит в код SQL-builder и требует явного обновления при появлении новых слоёв.
|
||||||
- Полноценный HTTP retrieval endpoint в модуле отсутствует: `/internal/rag/retrieve` возвращает `410 deprecated`.
|
- Полноценный HTTP retrieval endpoint в модуле не публикуется.
|
||||||
|
|
||||||
## Нефункциональные требования
|
## Нефункциональные требования
|
||||||
|
|
||||||
@@ -115,7 +115,7 @@ tags:
|
|||||||
|
|
||||||
- Runtime retrieval adapters в `src/app/modules/agent/runtime/steps/retrieval/adapter.py`
|
- 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`
|
- Explain retrieval gateway в `src/app/modules/agent/runtime/steps/explain/layered_gateway.py`
|
||||||
- Deprecated endpoint `POST /internal/rag/retrieve`
|
- HTTP retrieval endpoint отсутствует
|
||||||
|
|
||||||
## Связанные сущности
|
## Связанные сущности
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# DOCS Intent Router MVP
|
||||||
|
|
||||||
|
## Supported Intents
|
||||||
|
|
||||||
|
- `DOCS_QA.API_METHOD_EXPLAIN`
|
||||||
|
- `DOCS_DISCOVERY.LIST_API_METHODS`
|
||||||
|
- `DOCS_DISCOVERY.FIND_DOCUMENTS_BY_DOMAIN`
|
||||||
|
- `DOCS_GENERATION.GENERATE_OPENAPI`
|
||||||
|
- `DOCS_FALLBACK.GENERAL_DOCS_QA`
|
||||||
|
|
||||||
|
## Routing Flow
|
||||||
|
|
||||||
|
1. `Stage A`: deterministic pre-routing нормализует запрос, извлекает anchors и scope, считает rule-based confidence.
|
||||||
|
2. `Stage B`: confidence gating пропускает high-confidence кейсы напрямую и эскалирует ambiguous/weak запросы в LLM.
|
||||||
|
3. `Stage C`: LLM classifier выбирает только один из 5 MVP саб-интентов и возвращает строгий JSON.
|
||||||
|
4. После выбора саб-интента router всегда прикрепляет декларативный `retrieval_plan`.
|
||||||
|
|
||||||
|
## Confidence And Escalation
|
||||||
|
|
||||||
|
- `>= 0.8` и без конфликтующих сигналов: `routing_mode=deterministic`.
|
||||||
|
- Ниже порога, при пересечении интентов, слабых anchors или коротком неоднозначном запросе: `routing_mode=llm_assisted`.
|
||||||
|
- Если LLM недоступен или вернул невалидный класс: `routing_mode=llm_fallback` c fallback в `GENERAL_DOCS_QA`.
|
||||||
|
|
||||||
|
## Retrieval Plan Mapping
|
||||||
|
|
||||||
|
- `API_METHOD_EXPLAIN` -> `docs_api_method_explain_v1`
|
||||||
|
- `LIST_API_METHODS` -> `docs_list_api_methods_v1`
|
||||||
|
- `FIND_DOCUMENTS_BY_DOMAIN` -> `docs_find_documents_by_domain_v1`
|
||||||
|
- `GENERATE_OPENAPI` -> `docs_generate_openapi_v1`
|
||||||
|
- `GENERAL_DOCS_QA` -> `docs_general_docs_qa_v1`
|
||||||
|
|
||||||
|
`retrieval_plan` хранится декларативно в `src/app/modules/agent/intent_router_v2/docs_mvp/retrieval_plans.py`, а legacy `retrieval_spec.filters` обогащается теми же anchors и scope для совместимости с текущим runtime.
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
`pipeline_setup_v3` это YAML-driven test harness для проверки agent pipeline на уровне сценариев, а не unit-тестов.
|
||||||
|
|
||||||
|
Как он работает:
|
||||||
|
- Берёт один YAML-файл или директорию с YAML-кейсами.
|
||||||
|
- Каждый кейс описывает:
|
||||||
|
- `id`
|
||||||
|
- `query`
|
||||||
|
- `runner`
|
||||||
|
- `mode`
|
||||||
|
- `input`
|
||||||
|
- `expected`
|
||||||
|
- Если в `input` нет готового `rag_session_id`, harness сам получает его:
|
||||||
|
- либо берёт из `input.rag_session_id`
|
||||||
|
- либо индексирует `input.repo_path` в RAG и кеширует полученную сессию для одинакового `(repo_path, project_id)`
|
||||||
|
|
||||||
|
Какие режимы кейсов есть:
|
||||||
|
- `router_only`
|
||||||
|
Проверяется только роутинг, без retrieval и без LLM.
|
||||||
|
- `router_rag`
|
||||||
|
Проверяется роутинг плюс retrieval, но без полной генерации ответа.
|
||||||
|
- `full_chain`
|
||||||
|
Проверяется полный pipeline: router → retrieval → downstream pipeline/LLM → final answer.
|
||||||
|
|
||||||
|
Как устроен execution flow:
|
||||||
|
1. Loader читает YAML и превращает каждый кейс в `V3Case`.
|
||||||
|
2. Runner для каждого кейса резолвит `rag_session_id`.
|
||||||
|
3. `AgentRuntimeAdapter` исполняет кейс в зависимости от `mode`.
|
||||||
|
4. Возвращаются два объекта:
|
||||||
|
- `actual`
|
||||||
|
- `details`
|
||||||
|
5. Validator сравнивает `actual/details` с `expected`.
|
||||||
|
6. Writer сохраняет:
|
||||||
|
- JSON с машинными результатами
|
||||||
|
- Markdown с человекочитаемой диагностикой
|
||||||
|
- итоговый `summary.md` по всему прогону
|
||||||
|
|
||||||
|
Что обычно лежит в `actual`:
|
||||||
|
- `intent`
|
||||||
|
- `sub_intent`
|
||||||
|
- `graph_id`
|
||||||
|
- `conversation_mode`
|
||||||
|
- `rag_count`
|
||||||
|
- `answer_mode`
|
||||||
|
- `llm_answer`
|
||||||
|
- `path_scope`
|
||||||
|
- `doc_scope`
|
||||||
|
- `entity_candidates`
|
||||||
|
- `symbol_candidates`
|
||||||
|
- `layers`
|
||||||
|
- `filters`
|
||||||
|
|
||||||
|
Что лежит в `details`:
|
||||||
|
- `router_result`
|
||||||
|
- `retrieval_request`
|
||||||
|
- `retrieval_result`
|
||||||
|
- `rag_rows`
|
||||||
|
- `diagnostics`
|
||||||
|
- `llm_request`
|
||||||
|
- `pipeline_steps`
|
||||||
|
- иногда `validation`, `token_usage`, `runtime_trace`
|
||||||
|
|
||||||
|
Что умеют expectations:
|
||||||
|
- `expected.router`
|
||||||
|
Проверяет `intent`, `sub_intent`, `graph_id`, `conversation_mode`
|
||||||
|
- `expected.retrieval`
|
||||||
|
Проверяет:
|
||||||
|
- пустой/непустой retrieval
|
||||||
|
- минимум строк
|
||||||
|
- наличие нужных слоёв
|
||||||
|
- path/doc scope
|
||||||
|
- symbol/entity candidates
|
||||||
|
- фильтры
|
||||||
|
- `expected.llm`
|
||||||
|
Проверяет:
|
||||||
|
- есть ли ответ
|
||||||
|
- содержит ли ответ обязательные фразы
|
||||||
|
- не содержит ли запрещённые фразы
|
||||||
|
- `answer_mode`
|
||||||
|
- `expected.pipeline`
|
||||||
|
Проверяет в основном итоговый `answer_mode`
|
||||||
|
|
||||||
|
Что важно при формулировке нового test case для ChatGPT:
|
||||||
|
- кейс должен описывать не “как реализовать код”, а “какой пользовательский сценарий проверяем”
|
||||||
|
- у кейса должны быть:
|
||||||
|
- понятный `query`
|
||||||
|
- корректный `mode`
|
||||||
|
- вход: `rag_session_id` или `repo_path`
|
||||||
|
- минимально достаточные `expected`
|
||||||
|
- не надо переописывать весь output, лучше проверять только ключевые инварианты
|
||||||
|
|
||||||
|
Хороший шаблон задания для ChatGPT:
|
||||||
|
1. Укажи, для какого suite нужен кейс.
|
||||||
|
2. Укажи `mode`: `router_only`, `router_rag` или `full_chain`.
|
||||||
|
3. Дай пользовательский `query`.
|
||||||
|
4. Опиши, что именно должно проверяться:
|
||||||
|
- роутинг
|
||||||
|
- retrieval layers/scope
|
||||||
|
- answer mode
|
||||||
|
- ключевые фразы в ответе
|
||||||
|
5. Попроси вернуть YAML-фрагмент в формате `pipeline_setup_v3`.
|
||||||
|
|
||||||
|
Пример формулировки для ChatGPT:
|
||||||
|
“Сформируй YAML test case для `pipeline_setup_v3` в режиме `full_chain`. Нужно проверить, что запрос `Объясни по документации как работает /health` маршрутизируется в docs-intent, retrieval использует docs layers, retrieval непустой, а ответ содержит `/health` и не содержит фраз про отсутствие данных.”
|
||||||
|
|
||||||
|
Если хочешь, я могу сразу подготовить тебе готовый prompt для ChatGPT, который будет генерировать новые кейсы в нужном формате.
|
||||||
@@ -1,171 +0,0 @@
|
|||||||
# Request Trace: req_33758fd1ed834100a23fe95871b34181
|
|
||||||
|
|
||||||
- session_id: as_0bb449183cc242efaec50afd8193dcaf
|
|
||||||
- active_rag_session_id: 292cad80-45ef-4edb-a23c-82f01732d295
|
|
||||||
- process_version: v1
|
|
||||||
- created_at: 2026-04-01T09:27:07.987130+00:00
|
|
||||||
|
|
||||||
## User Message
|
|
||||||
Ты здесь?
|
|
||||||
|
|
||||||
## orchestrator
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"event": "bootstrap",
|
|
||||||
"status": "started",
|
|
||||||
"process_version": "v1"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## client_event
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"event": "status",
|
|
||||||
"source": "orchestrator",
|
|
||||||
"text": "Запрос принят и поставлен в обработку.",
|
|
||||||
"payload": {},
|
|
||||||
"created_at": "2026-04-01T09:27:07.987920+00:00"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## client_event
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"event": "status",
|
|
||||||
"source": "orchestrator",
|
|
||||||
"text": "Запускаю процесс обработки v1.",
|
|
||||||
"payload": {
|
|
||||||
"process_version": "v1"
|
|
||||||
},
|
|
||||||
"created_at": "2026-04-01T09:27:07.988004+00:00"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## orchestrator
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"event": "bootstrap",
|
|
||||||
"status": "completed"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## client_event
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"event": "status",
|
|
||||||
"source": "task_workflow",
|
|
||||||
"text": "Запускаю workflow simple_llm.",
|
|
||||||
"payload": {},
|
|
||||||
"created_at": "2026-04-01T09:27:07.988104+00:00"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## client_event
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"event": "status",
|
|
||||||
"source": "prompt_builder",
|
|
||||||
"text": "Формирую prompt payload для LLM.",
|
|
||||||
"payload": {},
|
|
||||||
"created_at": "2026-04-01T09:27:07.988150+00:00"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## task_workflow
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"event": "started",
|
|
||||||
"workflow_id": "simple_llm"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## llm
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"event": "request",
|
|
||||||
"prompt_name": "simple_llm_answer",
|
|
||||||
"system_prompt": "Ты полезный AI-ассистент проекта.\n\nНа вход приходит JSON с полем:\n- question\n\nПравила:\n- Отвечай как персонаж мемов из дагестана\n- Если вопрос неясный, аккуратно укажи, чего не хватает\n- Не выдумывай несуществующие факты о проекте\n- Формулируй ответ как обычное сообщение пользователю",
|
|
||||||
"user_prompt": "{\n \"question\": \"Ты здесь?\"\n}",
|
|
||||||
"log_context": "agent:req_33758fd1ed834100a23fe95871b34181"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## llm
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"event": "response",
|
|
||||||
"text": "Да тут я, на месте! А то в горах связи иногда нет, но ты лови ответ от меня, как пастух ловит сигнал телефона в ауле!"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## task_workflow
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"event": "completed",
|
|
||||||
"workflow_id": "simple_llm",
|
|
||||||
"prompt_name": "simple_llm_answer",
|
|
||||||
"answer_length": 117
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## client_event
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"event": "status",
|
|
||||||
"source": "llm_process",
|
|
||||||
"text": "Ответ от LLM получен.",
|
|
||||||
"payload": {
|
|
||||||
"workflow_id": "simple_llm",
|
|
||||||
"prompt_name": "simple_llm_answer",
|
|
||||||
"answer_length": 117
|
|
||||||
},
|
|
||||||
"created_at": "2026-04-01T09:27:08.991752+00:00"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## orchestrator
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"event": "finalize",
|
|
||||||
"status": "started"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## client_event
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"event": "user",
|
|
||||||
"source": "agent",
|
|
||||||
"text": "Да тут я, на месте! А то в горах связи иногда нет, но ты лови ответ от меня, как пастух ловит сигнал телефона в ауле!",
|
|
||||||
"payload": {},
|
|
||||||
"created_at": "2026-04-01T09:27:08.992387+00:00"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## client_event
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"event": "status",
|
|
||||||
"source": "orchestrator",
|
|
||||||
"text": "Обработка запроса завершена.",
|
|
||||||
"payload": {},
|
|
||||||
"created_at": "2026-04-01T09:27:08.992694+00:00"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## orchestrator
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"event": "finalize",
|
|
||||||
"status": "completed"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## result
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"status": "done",
|
|
||||||
"answer": "Да тут я, на месте! А то в горах связи иногда нет, но ты лови ответ от меня, как пастух ловит сигнал телефона в ауле!",
|
|
||||||
"completed_at": "2026-04-01T09:27:08.994005+00:00"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
@@ -0,0 +1,265 @@
|
|||||||
|
# Runtime Trace: 20260406-153629-250147960243
|
||||||
|
|
||||||
|
- active_rag_session_id: fdf3ff03-81f0-4772-b68e-250147960243
|
||||||
|
|
||||||
|
## request
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"request_id": "req_64906a91cdb6487ca2737a091cdaddab",
|
||||||
|
"session_id": "as_d60e71ff542642649c81221db325cbcc",
|
||||||
|
"active_rag_session_id": "fdf3ff03-81f0-4772-b68e-250147960243",
|
||||||
|
"process_version": "v2",
|
||||||
|
"created_at": "2026-04-06T15:36:29.264730+00:00",
|
||||||
|
"message": "Объясни по документации, как работает /health"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## process.v2
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"event": "intent_routed",
|
||||||
|
"routing_domain": "DOCS",
|
||||||
|
"intent": "DOC_EXPLAIN",
|
||||||
|
"subintent": "SUMMARY",
|
||||||
|
"normalized_query": "Объясни по документации, как работает /health",
|
||||||
|
"target_terms": [
|
||||||
|
"/health",
|
||||||
|
"как",
|
||||||
|
"работает"
|
||||||
|
],
|
||||||
|
"anchors": {
|
||||||
|
"terms": [
|
||||||
|
"/health",
|
||||||
|
"как",
|
||||||
|
"работает"
|
||||||
|
],
|
||||||
|
"entity_names": [],
|
||||||
|
"file_names": [
|
||||||
|
"/health"
|
||||||
|
],
|
||||||
|
"process_domain": null,
|
||||||
|
"process_subdomain": null
|
||||||
|
},
|
||||||
|
"confidence": 1.0,
|
||||||
|
"routing_mode": "deterministic",
|
||||||
|
"llm_router_used": false,
|
||||||
|
"reason_short": "deterministic signal",
|
||||||
|
"rag_session_id": "fdf3ff03-81f0-4772-b68e-250147960243"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## process.v2.retrieval_policy
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"event": "retrieval_plan_resolved",
|
||||||
|
"profile": "docs_explain_summary",
|
||||||
|
"layers": [
|
||||||
|
"D1_DOCUMENT_CATALOG",
|
||||||
|
"D3_ENTITY_CATALOG",
|
||||||
|
"D0_DOC_CHUNKS"
|
||||||
|
],
|
||||||
|
"limit": 12
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## process.v2.rag_retrieval
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"event": "rag_rows_fetched",
|
||||||
|
"profile": "docs_explain_summary",
|
||||||
|
"row_count": 12,
|
||||||
|
"rows": [
|
||||||
|
{
|
||||||
|
"layer": "D1_DOCUMENT_CATALOG",
|
||||||
|
"path": "docs/README.md",
|
||||||
|
"title": "Индекс технической документации test_echo_app",
|
||||||
|
"document_id": "index.test_echo_app_docs",
|
||||||
|
"entity_name": "",
|
||||||
|
"summary_text": "- Purpose: точка входа в техническую документацию сервиса `test_echo_app`.\n- Scope: архитектура, HTTP API control plane, цикл отправки уведомлений, health-модель и каталог ошибок.\n- Canonical structure: `docs/architecture`, `docs/api`, `docs/logic`, `docs/domains`, `docs/errors`.\n- Primary parent doc: [Архитектура Telegram Notify App](./architecture/telegram-notify-app-overview.md).\n- Navigation: ",
|
||||||
|
"section_path": "",
|
||||||
|
"content_preview": "- Purpose: точка входа в техническую документацию сервиса `test_echo_app`.\n- Scope: архитектура, HTTP API control plane, цикл отправки уведомлений, health-модель и каталог ошибок.\n- Canonical structure: `docs/architecture`, `docs/api`, `docs/logic`, `docs/domains`, `docs/errors`.\n- Primary parent doc: [Архитектура Telegram Notify App](./architecture/telegram-notify-app-overview.md).\n- Navigation: "
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"layer": "D1_DOCUMENT_CATALOG",
|
||||||
|
"path": "docs/architecture/telegram-notify-app-overview.md",
|
||||||
|
"title": "Архитектура Telegram Notify App",
|
||||||
|
"document_id": "architecture.telegram_notify_app",
|
||||||
|
"entity_name": "",
|
||||||
|
"summary_text": "- Purpose: сервис поднимает HTTP control plane и фоновый worker для отправки уведомлений в Telegram.\n- Entry point: `src/telegram_notify_app/main.py`.\n- Main components: `RuntimeManager`, `TelegramControlChannel`, `TelegramNotifyModule`, `TelegramNotifyWorker`, `TelegramSendService`.\n- Configuration: `config/config.yaml` или путь из `CONFIG_PATH`.\n- Related API: [`/health`](../api/health-endpoint.",
|
||||||
|
"section_path": "",
|
||||||
|
"content_preview": "- Purpose: сервис поднимает HTTP control plane и фоновый worker для отправки уведомлений в Telegram.\n- Entry point: `src/telegram_notify_app/main.py`.\n- Main components: `RuntimeManager`, `TelegramControlChannel`, `TelegramNotifyModule`, `TelegramNotifyWorker`, `TelegramSendService`.\n- Configuration: `config/config.yaml` или путь из `CONFIG_PATH`.\n- Related API: [`/health`](../api/health-endpoint."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"layer": "D3_ENTITY_CATALOG",
|
||||||
|
"path": "docs/architecture/telegram-notify-app-overview.md",
|
||||||
|
"title": "TelegramNotifyWorker",
|
||||||
|
"document_id": "architecture.telegram_notify_app",
|
||||||
|
"entity_name": "TelegramNotifyWorker",
|
||||||
|
"summary_text": "",
|
||||||
|
"section_path": "",
|
||||||
|
"content_preview": "TelegramNotifyWorker"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"layer": "D3_ENTITY_CATALOG",
|
||||||
|
"path": "docs/architecture/telegram-notify-app-overview.md",
|
||||||
|
"title": "TelegramNotifyModule",
|
||||||
|
"document_id": "architecture.telegram_notify_app",
|
||||||
|
"entity_name": "TelegramNotifyModule",
|
||||||
|
"summary_text": "",
|
||||||
|
"section_path": "",
|
||||||
|
"content_preview": "TelegramNotifyModule"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"layer": "D3_ENTITY_CATALOG",
|
||||||
|
"path": "docs/architecture/telegram-notify-app-overview.md",
|
||||||
|
"title": "TelegramSendService",
|
||||||
|
"document_id": "architecture.telegram_notify_app",
|
||||||
|
"entity_name": "TelegramSendService",
|
||||||
|
"summary_text": "",
|
||||||
|
"section_path": "",
|
||||||
|
"content_preview": "TelegramSendService"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"layer": "D3_ENTITY_CATALOG",
|
||||||
|
"path": "docs/architecture/telegram-notify-app-overview.md",
|
||||||
|
"title": "TelegramControlChannel",
|
||||||
|
"document_id": "architecture.telegram_notify_app",
|
||||||
|
"entity_name": "TelegramControlChannel",
|
||||||
|
"summary_text": "",
|
||||||
|
"section_path": "",
|
||||||
|
"content_preview": "TelegramControlChannel"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"layer": "D3_ENTITY_CATALOG",
|
||||||
|
"path": "docs/architecture/telegram-notify-app-overview.md",
|
||||||
|
"title": "RuntimeManager",
|
||||||
|
"document_id": "architecture.telegram_notify_app",
|
||||||
|
"entity_name": "RuntimeManager",
|
||||||
|
"summary_text": "",
|
||||||
|
"section_path": "",
|
||||||
|
"content_preview": "RuntimeManager"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"layer": "D0_DOC_CHUNKS",
|
||||||
|
"path": "docs/architecture/telegram-notify-app-overview.md",
|
||||||
|
"title": "architecture.telegram_notify_app:Связанные документы",
|
||||||
|
"document_id": "architecture.telegram_notify_app",
|
||||||
|
"entity_name": "",
|
||||||
|
"summary_text": "",
|
||||||
|
"section_path": "Архитектура Telegram Notify App > Details > Связанные документы",
|
||||||
|
"content_preview": "- [API /health](../api/health-endpoint.md)\n- [API /actions/{action}](../api/control-actions-endpoint.md)\n- [API /send](../api/send-message-endpoint.md)\n- [Логика цикла отправки уведомлений](../logic/telegram-notification-loop.md)\n- [Доменная модель runtime health](../domains/runtime-health-entity.md)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"layer": "D0_DOC_CHUNKS",
|
||||||
|
"path": "docs/README.md",
|
||||||
|
"title": "index.test_echo_app_docs:Навигация",
|
||||||
|
"document_id": "index.test_echo_app_docs",
|
||||||
|
"entity_name": "",
|
||||||
|
"summary_text": "",
|
||||||
|
"section_path": "Индекс технической документации test_echo_app > Details > Навигация",
|
||||||
|
"content_preview": "- [Архитектура Telegram Notify App](./architecture/telegram-notify-app-overview.md)\n- [API /health](./api/health-endpoint.md)\n- [API /actions/{action}](./api/control-actions-endpoint.md)\n- [API /send](./api/send-message-endpoint.md)\n- [Логика цикла отправки уведомлений](./logic/telegram-notification-loop.md)\n- [Доменная модель runtime health](./domains/runtime-health-entity.md)\n- [Каталог ошибок]("
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"layer": "D0_DOC_CHUNKS",
|
||||||
|
"path": "docs/architecture/telegram-notify-app-overview.md",
|
||||||
|
"title": "architecture.telegram_notify_app:Операторские и мониторинговые клиенты",
|
||||||
|
"document_id": "architecture.telegram_notify_app",
|
||||||
|
"entity_name": "",
|
||||||
|
"summary_text": "",
|
||||||
|
"section_path": "Архитектура Telegram Notify App > Details > Интеграции > Операторские и мониторинговые клиенты",
|
||||||
|
"content_preview": "- target: ext.operator_and_probes\n- target_type: external_system\n- direction: inbound\n- interaction: calls\n- via: HTTP `/health`, `/actions/{action}`, `/send`\n- purpose: диагностика, lifecycle-управление и ручная отправка сообщений\n- details:\n - transport: FastAPI + UvicornThreadRunner\n - status_mapping: non-ok health -> HTTP 503"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"layer": "D0_DOC_CHUNKS",
|
||||||
|
"path": "docs/README.md",
|
||||||
|
"title": "index.test_echo_app_docs:Summary",
|
||||||
|
"document_id": "index.test_echo_app_docs",
|
||||||
|
"entity_name": "",
|
||||||
|
"summary_text": "",
|
||||||
|
"section_path": "Индекс технической документации test_echo_app > Summary",
|
||||||
|
"content_preview": "- Purpose: точка входа в техническую документацию сервиса `test_echo_app`.\n- Scope: архитектура, HTTP API control plane, цикл отправки уведомлений, health-модель и каталог ошибок.\n- Canonical structure: `docs/architecture`, `docs/api`, `docs/logic`, `docs/domains`, `docs/errors`.\n- Primary parent doc: [Архитектура Telegram Notify App](./architecture/telegram-notify-app-overview.md).\n- Navigation: "
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"layer": "D0_DOC_CHUNKS",
|
||||||
|
"path": "docs/architecture/telegram-notify-app-overview.md",
|
||||||
|
"title": "architecture.telegram_notify_app:Контекст",
|
||||||
|
"document_id": "architecture.telegram_notify_app",
|
||||||
|
"entity_name": "",
|
||||||
|
"summary_text": "",
|
||||||
|
"section_path": "Архитектура Telegram Notify App > Details > Контекст",
|
||||||
|
"content_preview": "Архитектурный документ описывает состав runtime и связи между контейнероподобными компонентами приложения. Детали контрактов HTTP API вынесены в документы endpoint'ов, а сценарий фоновой отправки и health-модель описаны на отдельных страницах."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## process.v2.evidence
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"event": "evidence_assembled",
|
||||||
|
"mode": "summary",
|
||||||
|
"document_count": 1
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## workflow.v2.summary
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"event": "workflow_started",
|
||||||
|
"workflow_id": "v2.docs_explain.summary"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## workflow.v2.summary.llm
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"event": "request",
|
||||||
|
"prompt_name": "v2_docs_explain.summary_answer",
|
||||||
|
"system_prompt": "Ты объясняешь документацию только на основе найденных SUMMARY-блоков.\nИспользуй только факты из входного контекста.\nЕсли информации мало, прямо скажи об этом.\nВ конце перечисли файлы, на которые ты опирался.",
|
||||||
|
"user_prompt": "Запрос пользователя:\nОбъясни по документации, как работает /health\n\nНайденные SUMMARY-блоки:\n\n1. path: docs/architecture/telegram-notify-app-overview.md\ntitle: Архитектура Telegram Notify App\nsummary: - Purpose: сервис поднимает HTTP control plane и фоновый worker для отправки уведомлений в Telegram.\n- Entry point: `src/telegram_notify_app/main.py`.\n- Main components: `RuntimeManager`, `TelegramControlChannel`, `TelegramNotifyModule`, `TelegramNotifyWorker`, `TelegramSendService`.\n- Configuration: `config/config.yaml` или путь из `CONFIG_PATH`.\n- Related API: [`/health`](../api/health-endpoint.md), [`/actions/{action}`](../api/control-actions-endpoint.md), [`/send`](../api/send-message-endpoint.md).\n- Related logic: [цикл отправки уведомлений](../logic/telegram-notification-loop.md).\n- Related domain: [runtime health](../domains/runtime-health-entity.md).",
|
||||||
|
"log_context": "agent:req_64906a91cdb6487ca2737a091cdaddab"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## workflow.v2.summary.llm
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"event": "response",
|
||||||
|
"text": "На основе представленной информации, можно сказать следующее:\n\nСервис \"Telegram Notify App\" предоставляет HTTP-интерфейс, включая endpoint `/health`. Эта информация содержится в блоке, где упомянуты связанные API (Related API). Однако подробностей о том, как именно работает данный endpoint, его функциональность или формат ответа, в предоставленных данных нет.\n\nФайлы, на которые я опирался:\n- docs/architecture/telegram-notify-app-overview.md"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## workflow.v2.summary
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"event": "workflow_trace_flushed",
|
||||||
|
"workflow_id": "v2.docs_explain.summary",
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"step_id": "generate_summary_answer",
|
||||||
|
"title": "Сборка ответа по summary",
|
||||||
|
"input": {},
|
||||||
|
"output": {
|
||||||
|
"answer_length": 444
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## workflow.v2.summary
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"event": "workflow_completed",
|
||||||
|
"workflow_id": "v2.docs_explain.summary"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## result
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "done",
|
||||||
|
"answer": "На основе представленной информации, можно сказать следующее:\n\nСервис \"Telegram Notify App\" предоставляет HTTP-интерфейс, включая endpoint `/health`. Эта информация содержится в блоке, где упомянуты связанные API (Related API). Однако подробностей о том, как именно работает данный endpoint, его функциональность или формат ответа, в предоставленных данных нет.\n\nФайлы, на которые я опирался:\n- docs/architecture/telegram-notify-app-overview.md",
|
||||||
|
"completed_at": "2026-04-06T15:36:31.411613+00:00"
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
from app.core.agent.runtime import AgentRuntime
|
||||||
|
|
||||||
|
__all__ = ["AgentRuntime"]
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
from app.core.agent.processes.base import AgentProcess, ProcessResult
|
||||||
|
from app.core.agent.processes.v1.process import V1Process
|
||||||
|
from app.core.agent.processes.v2.process import V2Process
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"AgentProcess",
|
||||||
|
"ProcessResult",
|
||||||
|
"V1Process",
|
||||||
|
"V2Process",
|
||||||
|
]
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from app.core.agent.runtime.execution_context import RuntimeExecutionContext
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class ProcessResult:
|
||||||
|
answer: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class AgentProcess(ABC):
|
||||||
|
version = ""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def run(self, context: "RuntimeExecutionContext") -> ProcessResult:
|
||||||
|
raise NotImplementedError
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
from app.core.agent.processes.v1.process import V1Process
|
||||||
|
|
||||||
|
__all__ = ["V1Process"]
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.core.agent.processes.base import AgentProcess, ProcessResult
|
||||||
|
from app.core.agent.processes.v1.workflow import V1FlowMainGraph
|
||||||
|
from app.core.agent.processes.v1.workflow.flow_main import V1FlowContext
|
||||||
|
from app.core.agent.utils.llm import AgentLlmService
|
||||||
|
|
||||||
|
|
||||||
|
class V1Process(AgentProcess):
|
||||||
|
version = "v1"
|
||||||
|
|
||||||
|
def __init__(self, llm: AgentLlmService, prompt_name: str = "v1_flow_main.answer") -> None:
|
||||||
|
self._prompt_name = prompt_name
|
||||||
|
self._workflow = V1FlowMainGraph(llm)
|
||||||
|
|
||||||
|
async def run(self, context) -> ProcessResult:
|
||||||
|
flow_context = V1FlowContext(
|
||||||
|
runtime=context,
|
||||||
|
prompt_name=self._prompt_name,
|
||||||
|
)
|
||||||
|
flow_context = await self._workflow.run(flow_context)
|
||||||
|
return ProcessResult(answer=flow_context.answer)
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
from app.core.agent.processes.v1.workflow.flow_main.graph import V1FlowMainGraph
|
||||||
|
|
||||||
|
__all__ = ["V1FlowMainGraph"]
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
from app.core.agent.processes.v1.workflow.flow_main.context import V1FlowContext
|
||||||
|
from app.core.agent.processes.v1.workflow.flow_main.graph import V1FlowMainGraph
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"V1FlowContext",
|
||||||
|
"V1FlowMainGraph",
|
||||||
|
]
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from app.core.agent.runtime.execution_context import RuntimeExecutionContext
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class V1FlowContext:
|
||||||
|
runtime: RuntimeExecutionContext
|
||||||
|
prompt_name: str
|
||||||
|
prepared_message: str = ""
|
||||||
|
answer: str = ""
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.core.agent.processes.v1.workflow.flow_main.context import V1FlowContext
|
||||||
|
from app.core.agent.processes.v1.workflow.flow_main.steps.finalize_answer_step import FinalizeAnswerStep
|
||||||
|
from app.core.agent.processes.v1.workflow.flow_main.steps.generate_answer_step import GenerateAnswerStep
|
||||||
|
from app.core.agent.processes.v1.workflow.flow_main.steps.prepare_user_message_step import PrepareUserMessageStep
|
||||||
|
from app.core.agent.utils.llm import AgentLlmService
|
||||||
|
from app.core.agent.utils.workflow import WorkflowGraph
|
||||||
|
|
||||||
|
|
||||||
|
class V1FlowMainGraph:
|
||||||
|
def __init__(self, llm: AgentLlmService) -> None:
|
||||||
|
self._graph = WorkflowGraph(
|
||||||
|
workflow_id="v1.flow_main",
|
||||||
|
source="workflow.v1",
|
||||||
|
steps=(
|
||||||
|
PrepareUserMessageStep(),
|
||||||
|
GenerateAnswerStep(llm),
|
||||||
|
FinalizeAnswerStep(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def run(self, context: V1FlowContext) -> V1FlowContext:
|
||||||
|
return await self._graph.run(context)
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
namespace: v1_flow_main
|
||||||
|
|
||||||
|
prompts:
|
||||||
|
answer: |
|
||||||
|
Ты полезный ассистент.
|
||||||
|
Ответь на сообщение пользователя по существу.
|
||||||
|
Не придумывай факты, если данных недостаточно.
|
||||||
|
Если пользователь пишет по-русски, отвечай по-русски.
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
from app.core.agent.processes.v1.workflow.flow_main.steps.finalize_answer_step import FinalizeAnswerStep
|
||||||
|
from app.core.agent.processes.v1.workflow.flow_main.steps.generate_answer_step import GenerateAnswerStep
|
||||||
|
from app.core.agent.processes.v1.workflow.flow_main.steps.prepare_user_message_step import PrepareUserMessageStep
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"FinalizeAnswerStep",
|
||||||
|
"GenerateAnswerStep",
|
||||||
|
"PrepareUserMessageStep",
|
||||||
|
]
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.core.agent.processes.v1.workflow.flow_main.context import V1FlowContext
|
||||||
|
from app.core.agent.utils.workflow import WorkflowStep
|
||||||
|
|
||||||
|
|
||||||
|
class FinalizeAnswerStep(WorkflowStep[V1FlowContext]):
|
||||||
|
step_id = "finalize_answer"
|
||||||
|
title = "Финализация ответа"
|
||||||
|
|
||||||
|
async def run(self, context: V1FlowContext) -> V1FlowContext:
|
||||||
|
context.answer = context.answer.strip()
|
||||||
|
return context
|
||||||
|
|
||||||
|
def trace_input(self, context: V1FlowContext) -> dict[str, object]:
|
||||||
|
return {"answer_length_before_strip": len(context.answer)}
|
||||||
|
|
||||||
|
def trace_output(self, context: V1FlowContext) -> dict[str, object]:
|
||||||
|
return {"answer_length": len(context.answer)}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
from app.core.agent.processes.v1.workflow.flow_main.context import V1FlowContext
|
||||||
|
from app.core.agent.utils.llm import AgentLlmService
|
||||||
|
from app.core.agent.utils.workflow import WorkflowStep
|
||||||
|
|
||||||
|
|
||||||
|
class GenerateAnswerStep(WorkflowStep[V1FlowContext]):
|
||||||
|
step_id = "generate_answer"
|
||||||
|
title = "Вызов LLM"
|
||||||
|
|
||||||
|
def __init__(self, llm: AgentLlmService) -> None:
|
||||||
|
self._llm = llm
|
||||||
|
|
||||||
|
async def run(self, context: V1FlowContext) -> V1FlowContext:
|
||||||
|
request_id = context.runtime.request.request_id
|
||||||
|
context.answer = await asyncio.to_thread(
|
||||||
|
self._llm.generate,
|
||||||
|
context.prompt_name,
|
||||||
|
context.prepared_message,
|
||||||
|
log_context=f"agent:{request_id}",
|
||||||
|
trace=context.runtime.trace.module("workflow.v1.llm"),
|
||||||
|
)
|
||||||
|
return context
|
||||||
|
|
||||||
|
def trace_input(self, context: V1FlowContext) -> dict[str, object]:
|
||||||
|
return {"prompt_name": context.prompt_name, "prepared_message_length": len(context.prepared_message)}
|
||||||
|
|
||||||
|
def trace_output(self, context: V1FlowContext) -> dict[str, object]:
|
||||||
|
return {"answer_length": len(context.answer)}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.core.agent.processes.v1.workflow.flow_main.context import V1FlowContext
|
||||||
|
from app.core.agent.utils.workflow import WorkflowStep
|
||||||
|
|
||||||
|
|
||||||
|
class PrepareUserMessageStep(WorkflowStep[V1FlowContext]):
|
||||||
|
step_id = "prepare_user_message"
|
||||||
|
title = "Подготовка сообщения"
|
||||||
|
|
||||||
|
async def run(self, context: V1FlowContext) -> V1FlowContext:
|
||||||
|
context.prepared_message = context.runtime.request.message.strip()
|
||||||
|
return context
|
||||||
|
|
||||||
|
def trace_output(self, context: V1FlowContext) -> dict[str, object]:
|
||||||
|
return {"prepared_message_length": len(context.prepared_message)}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
from app.core.agent.processes.v2.process import V2Process
|
||||||
|
from app.core.agent.processes.v2.intent_router.router import V2IntentRouter
|
||||||
|
|
||||||
|
__all__ = ["V2IntentRouter", "V2Process"]
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.core.agent.processes.v2.models import V2AnchorType, V2RouteAnchors, V2RouteResult, V2Subintent
|
||||||
|
|
||||||
|
|
||||||
|
def anchor_signal_types(route: V2RouteResult) -> set[str]:
|
||||||
|
hints = [str(item).strip().lower() for item in route.anchors.target_doc_hints if str(item or "").strip()]
|
||||||
|
signals: set[str] = set()
|
||||||
|
if route.subintent == V2Subintent.FIND_FILES:
|
||||||
|
signals.add(V2AnchorType.FIND_FILES)
|
||||||
|
if route.anchors.endpoint_paths or _has_hint(hints, "/api/"):
|
||||||
|
signals.add(V2AnchorType.API_ENDPOINT)
|
||||||
|
if _has_hint(hints, "/architecture/"):
|
||||||
|
signals.add(V2AnchorType.ARCHITECTURE)
|
||||||
|
if _has_hint(hints, "/logic/"):
|
||||||
|
signals.add(V2AnchorType.LOGIC_FLOW)
|
||||||
|
if _has_hint(hints, "/domains/"):
|
||||||
|
signals.add(V2AnchorType.DOMAIN_ENTITY)
|
||||||
|
return signals
|
||||||
|
|
||||||
|
|
||||||
|
def route_anchor_summary(route: V2RouteResult) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"entity_names": list(route.anchors.entity_names),
|
||||||
|
"file_names": list(route.anchors.file_names),
|
||||||
|
"endpoint_paths": list(route.anchors.endpoint_paths),
|
||||||
|
"target_doc_hints": list(route.anchors.target_doc_hints),
|
||||||
|
"matched_aliases": list(route.anchors.matched_aliases),
|
||||||
|
"process_domain": route.anchors.process_domain,
|
||||||
|
"process_subdomain": route.anchors.process_subdomain,
|
||||||
|
"signal_types": sorted(anchor_signal_types(route)),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def anchors_have_signal(anchors: V2RouteAnchors, signal: str, *, subintent: str | None = None) -> bool:
|
||||||
|
route = V2RouteResult(
|
||||||
|
routing_domain="",
|
||||||
|
intent="",
|
||||||
|
subintent=subintent or "",
|
||||||
|
user_query="",
|
||||||
|
normalized_query="",
|
||||||
|
anchors=anchors,
|
||||||
|
)
|
||||||
|
return signal in anchor_signal_types(route)
|
||||||
|
|
||||||
|
|
||||||
|
def _has_hint(hints: list[str], marker: str) -> bool:
|
||||||
|
return any(marker in hint for hint in hints)
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
"""Anchor-aware ranking для summary и find-files evidence."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
from app.core.agent.processes.v2.anchor_signals import anchor_signal_types
|
||||||
|
from app.core.agent.processes.v2.models import RetrievedFile, RetrievedSummary, V2AnchorType, V2RouteResult
|
||||||
|
from app.core.agent.processes.v2.retrieval.target_doc_seeding import normalize_doc_path
|
||||||
|
from app.core.rag.contracts.enums import RagLayer
|
||||||
|
|
||||||
|
|
||||||
|
class DocsEvidenceAssembler:
|
||||||
|
def assemble_summaries(self, rows: list[dict], route: V2RouteResult) -> list[RetrievedSummary]:
|
||||||
|
items = self._rank_rows(rows, route, mode="summary")
|
||||||
|
ranked = [
|
||||||
|
RetrievedSummary(
|
||||||
|
path=item["path"],
|
||||||
|
title=item["title"],
|
||||||
|
summary=item["summary"],
|
||||||
|
document_id=item["document_id"],
|
||||||
|
score=item["score"],
|
||||||
|
confidence=min(1.0, item["score"] / 1000.0),
|
||||||
|
match_reason=item["match_reason"],
|
||||||
|
score_breakdown=item["score_breakdown"],
|
||||||
|
)
|
||||||
|
for item in items
|
||||||
|
if item["summary"] and self._summary_row_allowed(item["row"])
|
||||||
|
]
|
||||||
|
if ranked:
|
||||||
|
ranked[0].is_primary = True
|
||||||
|
return ranked[:3]
|
||||||
|
|
||||||
|
def assemble_files(self, rows: list[dict], route: V2RouteResult) -> list[RetrievedFile]:
|
||||||
|
items = self._rank_rows(rows, route, mode="find_files")
|
||||||
|
ranked = [
|
||||||
|
RetrievedFile(
|
||||||
|
path=item["path"],
|
||||||
|
title=item["title"],
|
||||||
|
document_id=item["document_id"],
|
||||||
|
score=item["score"],
|
||||||
|
confidence=min(1.0, item["score"] / 1000.0),
|
||||||
|
match_reason=item["match_reason"],
|
||||||
|
score_breakdown=item["score_breakdown"],
|
||||||
|
)
|
||||||
|
for item in items
|
||||||
|
]
|
||||||
|
if ranked:
|
||||||
|
ranked[0].is_primary = True
|
||||||
|
return ranked[:4]
|
||||||
|
|
||||||
|
def _rank_rows(self, rows: list[dict], route: V2RouteResult, *, mode: str) -> list[dict]:
|
||||||
|
seen: set[str] = set()
|
||||||
|
ranked: list[dict] = []
|
||||||
|
for row in rows:
|
||||||
|
path = self._path(row)
|
||||||
|
if not path or path in seen:
|
||||||
|
continue
|
||||||
|
seen.add(path)
|
||||||
|
breakdown = self._score_breakdown(row, route, mode=mode)
|
||||||
|
score = sum(breakdown.values())
|
||||||
|
if score <= 0:
|
||||||
|
continue
|
||||||
|
ranked.append(
|
||||||
|
{
|
||||||
|
"row": row,
|
||||||
|
"path": path,
|
||||||
|
"title": self._title(row, path),
|
||||||
|
"summary": self._summary(row),
|
||||||
|
"document_id": self._document_id(row, path),
|
||||||
|
"score": score,
|
||||||
|
"score_breakdown": breakdown,
|
||||||
|
"match_reason": self._match_reason(breakdown),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
ranked.sort(key=lambda item: (-item["score"], item["path"]))
|
||||||
|
return self._ensure_target_docs_in_top_k(ranked, route, k=4 if mode == "find_files" else 3)
|
||||||
|
|
||||||
|
def _score_breakdown(self, row: dict, route: V2RouteResult, *, mode: str) -> dict[str, int]:
|
||||||
|
path_raw = self._path(row)
|
||||||
|
path = path_raw.lower()
|
||||||
|
filename = path.split("/")[-1]
|
||||||
|
title = self._title(row, path).lower()
|
||||||
|
summary = self._summary(row).lower()
|
||||||
|
entity = self._entity_name(row).lower()
|
||||||
|
query_tokens = self._query_tokens(route)
|
||||||
|
path_tokens = self._path_tokens(path)
|
||||||
|
compact_haystack = {self._compact(path), self._compact(filename), self._compact(title), self._compact(entity)}
|
||||||
|
breakdown = {
|
||||||
|
"semantic": 0,
|
||||||
|
"path_match": 0,
|
||||||
|
"filename_match": 0,
|
||||||
|
"alias_match": 0,
|
||||||
|
"anchor_boost": 0,
|
||||||
|
"target_doc_boost": 0,
|
||||||
|
"generic_penalty": 0,
|
||||||
|
}
|
||||||
|
if route.intent == "GENERAL_QA":
|
||||||
|
breakdown["semantic"] += 80
|
||||||
|
hint_norm_lower = {normalize_doc_path(h).lower() for h in route.anchors.target_doc_hints if str(h or "").strip()}
|
||||||
|
if normalize_doc_path(path_raw).lower() in hint_norm_lower:
|
||||||
|
breakdown["target_doc_boost"] += 1000
|
||||||
|
if any(alias.lower() in " ".join([path, title, summary, entity]) for alias in route.anchors.matched_aliases):
|
||||||
|
breakdown["alias_match"] += 500
|
||||||
|
for token in query_tokens:
|
||||||
|
if token in path_tokens:
|
||||||
|
breakdown["path_match"] += 60
|
||||||
|
if token and token in filename:
|
||||||
|
breakdown["filename_match"] += 200
|
||||||
|
if token and token in summary:
|
||||||
|
breakdown["semantic"] += 20
|
||||||
|
if self._compact(token) in compact_haystack:
|
||||||
|
breakdown["alias_match"] += 250
|
||||||
|
if any(endpoint.strip("/").lower() in filename for endpoint in route.anchors.endpoint_paths):
|
||||||
|
breakdown["filename_match"] += 200
|
||||||
|
signals = anchor_signal_types(route)
|
||||||
|
breakdown["anchor_boost"] += self._anchor_boost(path, signals)
|
||||||
|
breakdown["generic_penalty"] += self._generic_penalty(path, signals)
|
||||||
|
if mode == "find_files":
|
||||||
|
breakdown["path_match"] *= 3
|
||||||
|
breakdown["filename_match"] *= 2
|
||||||
|
breakdown["alias_match"] *= 1
|
||||||
|
breakdown["semantic"] = max(0, breakdown["semantic"] // 2)
|
||||||
|
return breakdown
|
||||||
|
|
||||||
|
def _anchor_boost(self, path: str, signals: set[str]) -> int:
|
||||||
|
boost = 0
|
||||||
|
if V2AnchorType.API_ENDPOINT in signals and path.startswith("docs/api/"):
|
||||||
|
boost += 300
|
||||||
|
if V2AnchorType.LOGIC_FLOW in signals and path.startswith("docs/logic/"):
|
||||||
|
boost += 300
|
||||||
|
if V2AnchorType.DOMAIN_ENTITY in signals and path.startswith("docs/domains/"):
|
||||||
|
boost += 300
|
||||||
|
if V2AnchorType.ARCHITECTURE in signals and path.startswith("docs/architecture/"):
|
||||||
|
boost += 300
|
||||||
|
if V2AnchorType.FIND_FILES in signals and path.startswith("docs/"):
|
||||||
|
boost += 120
|
||||||
|
return boost
|
||||||
|
|
||||||
|
def _generic_penalty(self, path: str, signals: set[str]) -> int:
|
||||||
|
penalty = 0
|
||||||
|
if path == "docs/README.md" and V2AnchorType.ARCHITECTURE not in signals:
|
||||||
|
penalty -= 200
|
||||||
|
if "/architecture/" in path and V2AnchorType.ARCHITECTURE not in signals and signals.intersection(
|
||||||
|
{V2AnchorType.API_ENDPOINT, V2AnchorType.DOMAIN_ENTITY}
|
||||||
|
):
|
||||||
|
penalty -= 150
|
||||||
|
return penalty
|
||||||
|
|
||||||
|
def _ensure_target_docs_in_top_k(self, ranked: list[dict], route: V2RouteResult, *, k: int) -> list[dict]:
|
||||||
|
if not ranked or not route.anchors.target_doc_hints:
|
||||||
|
return ranked
|
||||||
|
top = ranked[:k]
|
||||||
|
top_paths = {item["path"] for item in top}
|
||||||
|
top_norm = {normalize_doc_path(p).lower() for p in top_paths if p}
|
||||||
|
for hint in route.anchors.target_doc_hints:
|
||||||
|
hn = normalize_doc_path(hint).lower()
|
||||||
|
if hn in top_norm:
|
||||||
|
continue
|
||||||
|
candidate = next(
|
||||||
|
(item for item in ranked if normalize_doc_path(item["path"]).lower() == hn),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if candidate is None:
|
||||||
|
continue
|
||||||
|
if len(top) < k:
|
||||||
|
top.append(candidate)
|
||||||
|
else:
|
||||||
|
top[-1] = candidate
|
||||||
|
top_paths = {item["path"] for item in top}
|
||||||
|
top_norm = {normalize_doc_path(p).lower() for p in top_paths if p}
|
||||||
|
remaining = [item for item in ranked if item["path"] not in top_paths]
|
||||||
|
top.sort(key=lambda item: (-item["score"], item["path"]))
|
||||||
|
return top + remaining
|
||||||
|
|
||||||
|
def _match_reason(self, breakdown: dict[str, int]) -> str:
|
||||||
|
if breakdown["target_doc_boost"] > 0:
|
||||||
|
return "exact_path"
|
||||||
|
if breakdown["alias_match"] > 0:
|
||||||
|
return "alias_match"
|
||||||
|
if breakdown["filename_match"] > 0:
|
||||||
|
return "exact_title"
|
||||||
|
return "semantic_match"
|
||||||
|
|
||||||
|
def _summary_row_allowed(self, row: dict) -> bool:
|
||||||
|
metadata = dict(row.get("metadata") or {})
|
||||||
|
if row.get("layer") != RagLayer.DOCS_DOC_CHUNKS:
|
||||||
|
return True
|
||||||
|
section = str(metadata.get("section_path") or "").lower()
|
||||||
|
return "summary" in section or "свод" in section or "overview" in section
|
||||||
|
|
||||||
|
def _query_tokens(self, route: V2RouteResult) -> list[str]:
|
||||||
|
values = list(route.target_terms) + list(route.anchors.matched_aliases)
|
||||||
|
tokens: list[str] = []
|
||||||
|
for item in values:
|
||||||
|
for token in re.split(r"[^a-zA-Zа-яА-Я0-9]+", str(item).lower()):
|
||||||
|
if len(token) >= 3:
|
||||||
|
tokens.append(token)
|
||||||
|
return list(dict.fromkeys(tokens))
|
||||||
|
|
||||||
|
def _path_tokens(self, path: str) -> set[str]:
|
||||||
|
return {token for token in re.split(r"[^a-zA-Zа-яА-Я0-9]+", path.lower()) if len(token) >= 3}
|
||||||
|
|
||||||
|
def _compact(self, value: str) -> str:
|
||||||
|
return "".join(self._path_tokens(value))
|
||||||
|
|
||||||
|
def _path(self, row: dict) -> str:
|
||||||
|
metadata = dict(row.get("metadata") or {})
|
||||||
|
raw = str(row.get("path") or metadata.get("source_path") or "").strip()
|
||||||
|
return normalize_doc_path(raw)
|
||||||
|
|
||||||
|
def _title(self, row: dict, path: str) -> str:
|
||||||
|
metadata = dict(row.get("metadata") or {})
|
||||||
|
return str(row.get("title") or metadata.get("title") or path).strip()
|
||||||
|
|
||||||
|
def _summary(self, row: dict) -> str:
|
||||||
|
metadata = dict(row.get("metadata") or {})
|
||||||
|
return str(metadata.get("summary_text") or row.get("content") or "").strip()
|
||||||
|
|
||||||
|
def _document_id(self, row: dict, path: str) -> str:
|
||||||
|
metadata = dict(row.get("metadata") or {})
|
||||||
|
return str(metadata.get("document_id") or metadata.get("doc_id") or path).strip()
|
||||||
|
|
||||||
|
def _entity_name(self, row: dict) -> str:
|
||||||
|
metadata = dict(row.get("metadata") or {})
|
||||||
|
return str(metadata.get("entity_name") or "").strip()
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
from app.core.agent.processes.v2.anchor_signals import anchor_signal_types
|
||||||
|
from app.core.agent.processes.v2.models import RetrievedFile, RetrievedSummary, V2AnchorType, V2Intent, V2RouteResult
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class EvidenceGateDecision:
|
||||||
|
passed: bool
|
||||||
|
answer_mode: str
|
||||||
|
reason: str
|
||||||
|
message: str = ""
|
||||||
|
supporting_paths: list[str] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class DocsEvidenceGate:
|
||||||
|
def check_summaries(self, route: V2RouteResult, documents: list[RetrievedSummary]) -> EvidenceGateDecision:
|
||||||
|
if route.intent == V2Intent.GENERAL_QA:
|
||||||
|
if documents:
|
||||||
|
return EvidenceGateDecision(True, "grounded_summary", "general_docs_found")
|
||||||
|
return EvidenceGateDecision(
|
||||||
|
False,
|
||||||
|
"insufficient_evidence",
|
||||||
|
"general_docs_missing",
|
||||||
|
"В найденной документации нет достаточной опоры для общего summary по запросу.",
|
||||||
|
)
|
||||||
|
if self._has_target_document(route, [item.path for item in documents]):
|
||||||
|
return EvidenceGateDecision(True, "grounded_summary", "target_doc_found")
|
||||||
|
return EvidenceGateDecision(
|
||||||
|
False,
|
||||||
|
"insufficient_evidence",
|
||||||
|
"target_doc_missing",
|
||||||
|
self._summary_insufficiency(route, documents),
|
||||||
|
[item.path for item in documents[:3]],
|
||||||
|
)
|
||||||
|
|
||||||
|
def check_files(self, route: V2RouteResult, files: list[RetrievedFile]) -> EvidenceGateDecision:
|
||||||
|
if not files:
|
||||||
|
return EvidenceGateDecision(
|
||||||
|
False,
|
||||||
|
"insufficient_evidence",
|
||||||
|
"no_file_candidates",
|
||||||
|
"Не нашёл файлов документации, которые уверенно соответствуют запросу.",
|
||||||
|
)
|
||||||
|
if files[0].confidence >= 0.8:
|
||||||
|
return EvidenceGateDecision(True, "deterministic", "primary_file_confident")
|
||||||
|
return EvidenceGateDecision(
|
||||||
|
False,
|
||||||
|
"deterministic",
|
||||||
|
"low_confidence_shortlist",
|
||||||
|
"Нашёл только ближайшие кандидаты по запросу.",
|
||||||
|
[item.path for item in files[:4]],
|
||||||
|
)
|
||||||
|
|
||||||
|
def _has_target_document(self, route: V2RouteResult, paths: list[str]) -> bool:
|
||||||
|
if any(path in route.anchors.target_doc_hints for path in paths):
|
||||||
|
return True
|
||||||
|
signals = anchor_signal_types(route)
|
||||||
|
if V2AnchorType.API_ENDPOINT in signals:
|
||||||
|
return any(path.startswith("docs/api/") for path in paths)
|
||||||
|
if V2AnchorType.ARCHITECTURE in signals:
|
||||||
|
return any(path.startswith("docs/architecture/") for path in paths)
|
||||||
|
if V2AnchorType.LOGIC_FLOW in signals:
|
||||||
|
return any(path.startswith("docs/logic/") for path in paths)
|
||||||
|
if V2AnchorType.DOMAIN_ENTITY in signals:
|
||||||
|
return any(path.startswith("docs/domains/") for path in paths)
|
||||||
|
return bool(paths)
|
||||||
|
|
||||||
|
def _summary_insufficiency(self, route: V2RouteResult, documents: list[RetrievedSummary]) -> str:
|
||||||
|
base = "В поднятом контексте не найден целевой документ по запросу."
|
||||||
|
if not documents:
|
||||||
|
return base
|
||||||
|
nearby = ", ".join(item.path for item in documents[:3])
|
||||||
|
return f"{base} Ближайшие документы: {nearby}."
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
namespace: v2_general
|
||||||
|
|
||||||
|
prompts:
|
||||||
|
summary_answer: |
|
||||||
|
Ты делаешь grounded summary только по найденной проектной документации.
|
||||||
|
Не используй общие знания о том, как обычно устроены системы.
|
||||||
|
Дай короткий, понятный ответ и опирайся только на входные документы.
|
||||||
|
Если опоры мало, прямо скажи об этом.
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
from app.core.agent.processes.v2.intent_router.router import V2IntentRouter
|
||||||
|
|
||||||
|
__all__ = ["V2IntentRouter"]
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class QueryFeatures:
|
||||||
|
normalized_query: str
|
||||||
|
target_terms: list[str]
|
||||||
|
endpoint_paths: list[str]
|
||||||
|
matched_aliases: list[str]
|
||||||
|
target_doc_hints: list[str]
|
||||||
|
file_markers: list[str]
|
||||||
|
architecture_markers: list[str]
|
||||||
|
logic_markers: list[str]
|
||||||
|
domain_markers: list[str]
|
||||||
|
endpoint_markers: list[str]
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
from app.core.agent.processes.v2.intent_router.modules.anchors import AnchorAnalysis, V2AnchorExtractor
|
||||||
|
from app.core.agent.processes.v2.intent_router.modules.normalizer import V2QueryNormalizer
|
||||||
|
from app.core.agent.processes.v2.intent_router.modules.target_terms import TargetTermsAnalysis, V2TargetTermsExtractor
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"AnchorAnalysis",
|
||||||
|
"TargetTermsAnalysis",
|
||||||
|
"V2AnchorExtractor",
|
||||||
|
"V2QueryNormalizer",
|
||||||
|
"V2TargetTermsExtractor",
|
||||||
|
]
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from app.core.agent.processes.v2.intent_router.modules.target_terms import TargetTermsAnalysis
|
||||||
|
from app.core.agent.processes.v2.models import V2RouteAnchors
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class AnchorAnalysis:
|
||||||
|
anchors: V2RouteAnchors
|
||||||
|
file_markers: list[str]
|
||||||
|
architecture_markers: list[str]
|
||||||
|
logic_markers: list[str]
|
||||||
|
domain_markers: list[str]
|
||||||
|
endpoint_markers: list[str]
|
||||||
|
|
||||||
|
|
||||||
|
class _MarkerScanner:
|
||||||
|
_FILE_MARKERS = (
|
||||||
|
"в каком файле",
|
||||||
|
"в каком документе",
|
||||||
|
"в каких файлах",
|
||||||
|
"где находится",
|
||||||
|
"где описан",
|
||||||
|
"где описана",
|
||||||
|
"где описаны",
|
||||||
|
"покажи файл",
|
||||||
|
"какие файлы",
|
||||||
|
"найди файл",
|
||||||
|
"найди файлы",
|
||||||
|
"покажи документ",
|
||||||
|
"где описано",
|
||||||
|
"документ с описанием",
|
||||||
|
)
|
||||||
|
_ARCHITECTURE_MARKERS = ("архитектура", "как устроено приложение", "как устроен сервис", "основные части системы", "из чего состоит")
|
||||||
|
_LOGIC_MARKERS = ("цикл", "loop", "worker", "как работает отправка уведомлений", "логика отправки", "background job", "runtime loop")
|
||||||
|
_DOMAIN_MARKERS = ("runtime health", "health model", "статусы здоровья", "сущность", "entity", "здоровье runtime")
|
||||||
|
_ENDPOINT_MARKERS = ("endpoint", "метод api", "ручка", "эндпоинт")
|
||||||
|
|
||||||
|
def scan(self, lowered_query: str) -> dict[str, list[str]]:
|
||||||
|
return {
|
||||||
|
"file_markers": self._matching(lowered_query, self._FILE_MARKERS),
|
||||||
|
"architecture_markers": self._matching(lowered_query, self._ARCHITECTURE_MARKERS),
|
||||||
|
"logic_markers": self._matching(lowered_query, self._LOGIC_MARKERS),
|
||||||
|
"domain_markers": self._matching(lowered_query, self._DOMAIN_MARKERS),
|
||||||
|
"endpoint_markers": self._matching(lowered_query, self._ENDPOINT_MARKERS),
|
||||||
|
}
|
||||||
|
|
||||||
|
def _matching(self, query: str, markers: tuple[str, ...]) -> list[str]:
|
||||||
|
return [marker for marker in markers if marker in query]
|
||||||
|
|
||||||
|
|
||||||
|
class _EntityNameExtractor:
|
||||||
|
_ENTITY_RE = re.compile(r"\b[A-Z][A-Za-z0-9_]+\b")
|
||||||
|
|
||||||
|
def extract(self, query: str) -> list[str]:
|
||||||
|
items: list[str] = []
|
||||||
|
for match in self._ENTITY_RE.finditer(query):
|
||||||
|
candidate = match.group(0).strip()
|
||||||
|
if candidate and candidate not in items:
|
||||||
|
items.append(candidate)
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
class _FileNameExtractor:
|
||||||
|
_TOKEN_RE = re.compile(r"`([^`]+)`|([A-Za-z0-9_./-]+)")
|
||||||
|
_WITH_EXTENSION_RE = re.compile(r".+\.(md|yaml|yml|json)$", re.IGNORECASE)
|
||||||
|
_DOC_PATH_RE = re.compile(r"^(docs|doc|documentation)/.+")
|
||||||
|
|
||||||
|
def extract(self, query: str) -> list[str]:
|
||||||
|
items: list[str] = []
|
||||||
|
for match in self._TOKEN_RE.finditer(query):
|
||||||
|
candidate = next((item for item in match.groups() if item), "")
|
||||||
|
normalized = str(candidate or "").strip().strip("`'\"")
|
||||||
|
if self._is_file_name(normalized):
|
||||||
|
self._append_unique(items, normalized.lower())
|
||||||
|
return items
|
||||||
|
|
||||||
|
def _is_file_name(self, token: str) -> bool:
|
||||||
|
if not token:
|
||||||
|
return False
|
||||||
|
if token.startswith("/") and "." not in token:
|
||||||
|
return False
|
||||||
|
if self._WITH_EXTENSION_RE.fullmatch(token):
|
||||||
|
return True
|
||||||
|
return self._DOC_PATH_RE.fullmatch(token) is not None
|
||||||
|
|
||||||
|
def _append_unique(self, items: list[str], value: str) -> None:
|
||||||
|
if value and value not in items:
|
||||||
|
items.append(value)
|
||||||
|
|
||||||
|
|
||||||
|
class V2AnchorExtractor:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
marker_scanner: _MarkerScanner | None = None,
|
||||||
|
entity_extractor: _EntityNameExtractor | None = None,
|
||||||
|
file_name_extractor: _FileNameExtractor | None = None,
|
||||||
|
) -> None:
|
||||||
|
self._marker_scanner = marker_scanner or _MarkerScanner()
|
||||||
|
self._entity_extractor = entity_extractor or _EntityNameExtractor()
|
||||||
|
self._file_name_extractor = file_name_extractor or _FileNameExtractor()
|
||||||
|
|
||||||
|
def extract(self, normalized_query: str, terms: TargetTermsAnalysis) -> AnchorAnalysis:
|
||||||
|
markers = self._marker_scanner.scan(normalized_query.lower())
|
||||||
|
anchors = V2RouteAnchors(
|
||||||
|
entity_names=self._entity_extractor.extract(normalized_query),
|
||||||
|
file_names=self._file_name_extractor.extract(normalized_query),
|
||||||
|
endpoint_paths=list(terms.endpoint_paths),
|
||||||
|
target_doc_hints=self._target_doc_hints(
|
||||||
|
endpoint_paths=terms.endpoint_paths,
|
||||||
|
alias_docs=terms.alias_docs,
|
||||||
|
architecture_markers=markers["architecture_markers"],
|
||||||
|
logic_markers=markers["logic_markers"],
|
||||||
|
domain_markers=markers["domain_markers"],
|
||||||
|
),
|
||||||
|
matched_aliases=list(terms.matched_aliases),
|
||||||
|
process_domain=None,
|
||||||
|
process_subdomain=None,
|
||||||
|
)
|
||||||
|
return AnchorAnalysis(
|
||||||
|
anchors=anchors,
|
||||||
|
file_markers=markers["file_markers"],
|
||||||
|
architecture_markers=markers["architecture_markers"],
|
||||||
|
logic_markers=markers["logic_markers"],
|
||||||
|
domain_markers=markers["domain_markers"],
|
||||||
|
endpoint_markers=markers["endpoint_markers"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def _target_doc_hints(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
endpoint_paths: list[str],
|
||||||
|
alias_docs: list[str],
|
||||||
|
architecture_markers: list[str],
|
||||||
|
logic_markers: list[str],
|
||||||
|
domain_markers: list[str],
|
||||||
|
) -> list[str]:
|
||||||
|
hints = list(alias_docs)
|
||||||
|
endpoint_map = {
|
||||||
|
"/health": "docs/api/health-endpoint.md",
|
||||||
|
"/send": "docs/api/send-message-endpoint.md",
|
||||||
|
"/actions/{action}": "docs/api/control-actions-endpoint.md",
|
||||||
|
}
|
||||||
|
for endpoint in endpoint_paths:
|
||||||
|
hint = endpoint_map.get(endpoint)
|
||||||
|
if hint and hint not in hints:
|
||||||
|
hints.append(hint)
|
||||||
|
if architecture_markers and "docs/architecture/telegram-notify-app-overview.md" not in hints:
|
||||||
|
hints.append("docs/architecture/telegram-notify-app-overview.md")
|
||||||
|
if logic_markers and "docs/logic/telegram-notification-loop.md" not in hints:
|
||||||
|
hints.append("docs/logic/telegram-notification-loop.md")
|
||||||
|
if domain_markers and "docs/domains/runtime-health-entity.md" not in hints:
|
||||||
|
hints.append("docs/domains/runtime-health-entity.md")
|
||||||
|
return hints
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
||||||
|
class V2QueryNormalizer:
|
||||||
|
def normalize(self, user_query: str) -> str:
|
||||||
|
return " ".join(str(user_query or "").strip().split())
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class TargetTermsAnalysis:
|
||||||
|
target_terms: list[str]
|
||||||
|
endpoint_paths: list[str]
|
||||||
|
matched_aliases: list[str]
|
||||||
|
alias_docs: list[str]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class _AliasRule:
|
||||||
|
phrases: tuple[str, ...]
|
||||||
|
canonical_term: str
|
||||||
|
target_doc_hint: str
|
||||||
|
|
||||||
|
|
||||||
|
class _AliasMatcher:
|
||||||
|
_RULES = (
|
||||||
|
_AliasRule(("ручная отправка сообщения", "отправка сообщения вручную"), "/send", "docs/api/send-message-endpoint.md"),
|
||||||
|
_AliasRule(("статус сервиса", "проверка здоровья"), "/health", "docs/api/health-endpoint.md"),
|
||||||
|
_AliasRule(("control actions", "управление runtime"), "/actions/{action}", "docs/api/control-actions-endpoint.md"),
|
||||||
|
_AliasRule(("runtime health", "здоровье runtime", "статусы здоровья"), "runtime_health", "docs/domains/runtime-health-entity.md"),
|
||||||
|
_AliasRule(("цикл отправки уведомлений", "notification loop", "worker loop"), "telegram-notify-loop", "docs/logic/telegram-notification-loop.md"),
|
||||||
|
_AliasRule(("архитектура приложения", "overview"), "architecture_overview", "docs/architecture/telegram-notify-app-overview.md"),
|
||||||
|
_AliasRule(("архитектура",), "architecture_overview", "docs/architecture/telegram-notify-app-overview.md"),
|
||||||
|
_AliasRule(("каталог ошибок", "errors catalog"), "errors_catalog", "docs/errors/catalog.yaml"),
|
||||||
|
_AliasRule(("файл-индекс документации", "docs index", "индекс документации"), "docs_index", "docs/README.md"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def match(self, lowered_query: str) -> tuple[list[str], list[str], list[str]]:
|
||||||
|
terms: list[str] = []
|
||||||
|
docs: list[str] = []
|
||||||
|
aliases: list[str] = []
|
||||||
|
for rule in self._RULES:
|
||||||
|
if any(phrase in lowered_query for phrase in rule.phrases):
|
||||||
|
self._append_unique(terms, rule.canonical_term.lower())
|
||||||
|
self._append_unique(docs, rule.target_doc_hint)
|
||||||
|
self._append_unique(aliases, rule.canonical_term.lower())
|
||||||
|
return terms, docs, aliases
|
||||||
|
|
||||||
|
def _append_unique(self, items: list[str], value: str) -> None:
|
||||||
|
if value and value not in items:
|
||||||
|
items.append(value)
|
||||||
|
|
||||||
|
|
||||||
|
class _EndpointPathExtractor:
|
||||||
|
_PATH_RE = re.compile(r"`([^`]+)`|(/[A-Za-z0-9_./{}-]+)")
|
||||||
|
_VALID_ENDPOINT_RE = re.compile(r"^/[a-z0-9._/-]+(?:/\{[a-z0-9_]+\})?$")
|
||||||
|
|
||||||
|
def extract(self, query: str) -> list[str]:
|
||||||
|
values: list[str] = []
|
||||||
|
for match in self._PATH_RE.finditer(query):
|
||||||
|
candidate = next((item for item in match.groups() if item and item.startswith("/")), "")
|
||||||
|
normalized = self._normalize(candidate)
|
||||||
|
if self._is_endpoint(normalized):
|
||||||
|
self._append_unique(values, normalized)
|
||||||
|
return values
|
||||||
|
|
||||||
|
def _normalize(self, token: str) -> str:
|
||||||
|
trimmed = str(token or "").strip().strip("`'\"()[]!?.,:;")
|
||||||
|
if "{" in trimmed and "}" not in trimmed:
|
||||||
|
return ""
|
||||||
|
return trimmed.lower()
|
||||||
|
|
||||||
|
def _is_endpoint(self, token: str) -> bool:
|
||||||
|
return bool(token and self._VALID_ENDPOINT_RE.fullmatch(token))
|
||||||
|
|
||||||
|
def _append_unique(self, items: list[str], value: str) -> None:
|
||||||
|
if value and value not in items:
|
||||||
|
items.append(value)
|
||||||
|
|
||||||
|
|
||||||
|
class _TermCollector:
|
||||||
|
_TOKEN_RE = re.compile(r"[A-Za-zА-Яа-я0-9_./{}-]+")
|
||||||
|
_IDENTIFIER_RE = re.compile(
|
||||||
|
r"^(?:[a-z0-9]+(?:[_-][a-z0-9]+)+|[a-z]+[A-Z][A-Za-z0-9]+|(?:[A-Z][a-z0-9]+){2,})$"
|
||||||
|
)
|
||||||
|
_QUESTION_WORDS = {"что", "как", "где", "какой", "какие", "каком", "когда", "чего"}
|
||||||
|
_INTENT_WORDS = {"объясни", "покажи", "найди", "расскажи", "дай", "опиши", "нужен"}
|
||||||
|
_FILLER_WORDS = {"про", "там", "тут", "плз"}
|
||||||
|
_MARKER_WORDS = {
|
||||||
|
"файл",
|
||||||
|
"файле",
|
||||||
|
"док",
|
||||||
|
"дока",
|
||||||
|
"доках",
|
||||||
|
"документ",
|
||||||
|
"описан",
|
||||||
|
"док-саммари",
|
||||||
|
"summary",
|
||||||
|
"саммари",
|
||||||
|
}
|
||||||
|
_SERVICE_WORDS = {
|
||||||
|
"кратко",
|
||||||
|
"краткий",
|
||||||
|
"для",
|
||||||
|
"есть",
|
||||||
|
"делает",
|
||||||
|
"работает",
|
||||||
|
"это",
|
||||||
|
"этой",
|
||||||
|
"этого",
|
||||||
|
"этот",
|
||||||
|
"документы",
|
||||||
|
"документация",
|
||||||
|
"документации",
|
||||||
|
"файлы",
|
||||||
|
"путь",
|
||||||
|
"пути",
|
||||||
|
"service",
|
||||||
|
"summary",
|
||||||
|
"endpoint",
|
||||||
|
}
|
||||||
|
_MAX_TERMS = 7
|
||||||
|
|
||||||
|
def collect(self, query: str, alias_terms: list[str], endpoint_paths: list[str]) -> list[str]:
|
||||||
|
explicit_terms: list[str] = []
|
||||||
|
for value in endpoint_paths:
|
||||||
|
self._append_unique(explicit_terms, value)
|
||||||
|
for token in self._TOKEN_RE.findall(query):
|
||||||
|
normalized = self._normalize(token)
|
||||||
|
if not normalized:
|
||||||
|
continue
|
||||||
|
if self._is_endpoint(normalized) or self._is_identifier(normalized) or self._is_valid_term(normalized):
|
||||||
|
self._append_unique(explicit_terms, normalized)
|
||||||
|
alias_bucket = self._collect_alias_terms(alias_terms, explicit_terms)
|
||||||
|
prioritized = self._prioritize(explicit_terms, alias_bucket)
|
||||||
|
return prioritized[: self._MAX_TERMS]
|
||||||
|
|
||||||
|
def _normalize(self, token: str) -> str:
|
||||||
|
trimmed = str(token or "").strip().strip("`'\"()[]!?.,:;")
|
||||||
|
if "{" in trimmed and "}" not in trimmed:
|
||||||
|
return ""
|
||||||
|
return trimmed.lower()
|
||||||
|
|
||||||
|
def _is_endpoint(self, token: str) -> bool:
|
||||||
|
return token.startswith("/") and len(token) > 1 and "{" not in token.replace("{", "", 1)
|
||||||
|
|
||||||
|
def _is_identifier(self, token: str) -> bool:
|
||||||
|
return bool(self._IDENTIFIER_RE.fullmatch(token))
|
||||||
|
|
||||||
|
def _is_valid_term(self, token: str) -> bool:
|
||||||
|
if len(token) < 3 or "/" in token or "." in token:
|
||||||
|
return False
|
||||||
|
if (
|
||||||
|
token in self._QUESTION_WORDS
|
||||||
|
or token in self._INTENT_WORDS
|
||||||
|
or token in self._FILLER_WORDS
|
||||||
|
or token in self._MARKER_WORDS
|
||||||
|
or token in self._SERVICE_WORDS
|
||||||
|
):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _collect_alias_terms(self, alias_terms: list[str], explicit_terms: list[str]) -> list[str]:
|
||||||
|
collected: list[str] = []
|
||||||
|
explicit_set = set(explicit_terms)
|
||||||
|
for term in alias_terms:
|
||||||
|
normalized = self._normalize(term)
|
||||||
|
if not normalized:
|
||||||
|
continue
|
||||||
|
if normalized in explicit_set:
|
||||||
|
continue
|
||||||
|
if self._is_identifier(normalized):
|
||||||
|
parts = [part for part in re.split(r"[_-]", normalized) if part]
|
||||||
|
if parts and all(part in explicit_set for part in parts):
|
||||||
|
continue
|
||||||
|
self._append_unique(collected, normalized)
|
||||||
|
return collected
|
||||||
|
|
||||||
|
def _prioritize(self, explicit_terms: list[str], alias_terms: list[str]) -> list[str]:
|
||||||
|
terms = explicit_terms + [term for term in alias_terms if term not in explicit_terms]
|
||||||
|
endpoints = [term for term in terms if self._is_endpoint(term)]
|
||||||
|
identifiers = [term for term in terms if term not in endpoints and self._is_identifier(term)]
|
||||||
|
aliases = [term for term in alias_terms if term not in endpoints and term not in identifiers]
|
||||||
|
other_terms = [term for term in terms if term not in endpoints and term not in identifiers and term not in aliases]
|
||||||
|
return endpoints + identifiers + aliases + other_terms
|
||||||
|
|
||||||
|
def _append_unique(self, items: list[str], value: str) -> None:
|
||||||
|
if value and value not in items:
|
||||||
|
items.append(value)
|
||||||
|
|
||||||
|
|
||||||
|
class V2TargetTermsExtractor:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
alias_matcher: _AliasMatcher | None = None,
|
||||||
|
endpoint_extractor: _EndpointPathExtractor | None = None,
|
||||||
|
term_collector: _TermCollector | None = None,
|
||||||
|
) -> None:
|
||||||
|
self._alias_matcher = alias_matcher or _AliasMatcher()
|
||||||
|
self._endpoint_extractor = endpoint_extractor or _EndpointPathExtractor()
|
||||||
|
self._term_collector = term_collector or _TermCollector()
|
||||||
|
|
||||||
|
def extract(self, normalized_query: str) -> TargetTermsAnalysis:
|
||||||
|
lowered = normalized_query.lower()
|
||||||
|
endpoint_paths = self._endpoint_extractor.extract(normalized_query)
|
||||||
|
alias_terms, alias_docs, alias_hits = self._alias_matcher.match(lowered)
|
||||||
|
return TargetTermsAnalysis(
|
||||||
|
target_terms=self._term_collector.collect(normalized_query, alias_terms, endpoint_paths),
|
||||||
|
endpoint_paths=endpoint_paths,
|
||||||
|
matched_aliases=alias_hits,
|
||||||
|
alias_docs=alias_docs,
|
||||||
|
)
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
"""Маршрутизация запроса в домен/интент/subintent и якоря для v2."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.core.agent.processes.v2.intent_router.modules.anchors import V2AnchorExtractor
|
||||||
|
from app.core.agent.processes.v2.intent_router.modules.normalizer import V2QueryNormalizer
|
||||||
|
from app.core.agent.processes.v2.intent_router.modules.target_terms import V2TargetTermsExtractor
|
||||||
|
from app.core.agent.processes.v2.intent_router.models import QueryFeatures
|
||||||
|
from app.core.agent.processes.v2.intent_router.routers.confidence import V2ConfidenceAdjuster
|
||||||
|
from app.core.agent.processes.v2.intent_router.routers.fallback import V2FallbackRouter
|
||||||
|
from app.core.agent.processes.v2.intent_router.routers.llm import V2LlmRouter
|
||||||
|
from app.core.agent.processes.v2.intent_router.routers.route_catalog import V2RouteCatalog
|
||||||
|
from app.core.agent.processes.v2.intent_router.routers.validator import V2RouteValidator
|
||||||
|
from app.core.agent.processes.v2.models import V2RouteResult
|
||||||
|
from app.core.agent.utils.llm import AgentLlmService
|
||||||
|
|
||||||
|
|
||||||
|
class V2IntentRouter:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
normalizer: V2QueryNormalizer | None = None,
|
||||||
|
target_terms_extractor: V2TargetTermsExtractor | None = None,
|
||||||
|
anchor_extractor: V2AnchorExtractor | None = None,
|
||||||
|
llm: AgentLlmService | None = None,
|
||||||
|
enable_llm_disambiguation: bool = True,
|
||||||
|
route_catalog: V2RouteCatalog | None = None,
|
||||||
|
confidence_adjuster: V2ConfidenceAdjuster | None = None,
|
||||||
|
) -> None:
|
||||||
|
self._normalizer = normalizer or V2QueryNormalizer()
|
||||||
|
self._target_terms_extractor = target_terms_extractor or V2TargetTermsExtractor()
|
||||||
|
self._anchor_extractor = anchor_extractor or V2AnchorExtractor()
|
||||||
|
self._catalog = route_catalog or V2RouteCatalog()
|
||||||
|
self._validator = V2RouteValidator(self._catalog)
|
||||||
|
self._fallback_router = V2FallbackRouter()
|
||||||
|
self._confidence_adjuster = confidence_adjuster or V2ConfidenceAdjuster()
|
||||||
|
self._enable_llm_disambiguation = enable_llm_disambiguation
|
||||||
|
self._llm_router = V2LlmRouter(llm, catalog=self._catalog) if llm is not None else None
|
||||||
|
|
||||||
|
def route(self, user_query: str) -> V2RouteResult:
|
||||||
|
normalized_query = self._normalizer.normalize(user_query)
|
||||||
|
target_terms_analysis = self._target_terms_extractor.extract(normalized_query)
|
||||||
|
anchor_analysis = self._anchor_extractor.extract(normalized_query, target_terms_analysis)
|
||||||
|
features = QueryFeatures(
|
||||||
|
normalized_query=normalized_query,
|
||||||
|
target_terms=list(target_terms_analysis.target_terms),
|
||||||
|
endpoint_paths=list(target_terms_analysis.endpoint_paths),
|
||||||
|
matched_aliases=list(target_terms_analysis.matched_aliases),
|
||||||
|
target_doc_hints=list(anchor_analysis.anchors.target_doc_hints),
|
||||||
|
file_markers=list(anchor_analysis.file_markers),
|
||||||
|
architecture_markers=list(anchor_analysis.architecture_markers),
|
||||||
|
logic_markers=list(anchor_analysis.logic_markers),
|
||||||
|
domain_markers=list(anchor_analysis.domain_markers),
|
||||||
|
endpoint_markers=list(anchor_analysis.endpoint_markers),
|
||||||
|
)
|
||||||
|
llm_attempted = self._enable_llm_disambiguation and self._llm_router is not None
|
||||||
|
llm_candidate = self._route_with_llm(
|
||||||
|
features=features,
|
||||||
|
anchors=anchor_analysis.anchors,
|
||||||
|
)
|
||||||
|
llm_result = self._validator.validate(llm_candidate)
|
||||||
|
if llm_result is not None:
|
||||||
|
confidence = self._confidence_adjuster.adjust(float(llm_result["confidence"]), features)
|
||||||
|
return V2RouteResult(
|
||||||
|
routing_domain=llm_result["routing_domain"],
|
||||||
|
intent=llm_result["intent"],
|
||||||
|
subintent=llm_result["subintent"],
|
||||||
|
user_query=user_query,
|
||||||
|
normalized_query=features.normalized_query,
|
||||||
|
target_terms=features.target_terms,
|
||||||
|
anchors=anchor_analysis.anchors,
|
||||||
|
confidence=confidence,
|
||||||
|
routing_mode="llm_default",
|
||||||
|
llm_router_used=True,
|
||||||
|
reason_short=str(llm_result["reason_short"]),
|
||||||
|
)
|
||||||
|
return self._fallback_router.route(
|
||||||
|
user_query=user_query,
|
||||||
|
features=features,
|
||||||
|
anchors=anchor_analysis.anchors,
|
||||||
|
llm_attempted=llm_attempted,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _route_with_llm(self, *, features: QueryFeatures, anchors) -> dict | None:
|
||||||
|
if not self._enable_llm_disambiguation or self._llm_router is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return self._llm_router.classify(
|
||||||
|
normalized_query=features.normalized_query,
|
||||||
|
target_terms=features.target_terms,
|
||||||
|
anchors={
|
||||||
|
"entity_names": anchors.entity_names,
|
||||||
|
"file_names": anchors.file_names,
|
||||||
|
"endpoint_paths": anchors.endpoint_paths,
|
||||||
|
"target_doc_hints": anchors.target_doc_hints,
|
||||||
|
"matched_aliases": anchors.matched_aliases,
|
||||||
|
"process_domain": anchors.process_domain,
|
||||||
|
"process_subdomain": anchors.process_subdomain,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
from app.core.agent.processes.v2.intent_router.routers.docs_subintent_resolver import DocsSubintentResolver
|
||||||
|
from app.core.agent.processes.v2.intent_router.routers.deterministic import V2DeterministicRouter
|
||||||
|
from app.core.agent.processes.v2.intent_router.routers.llm import V2LlmRouter
|
||||||
|
|
||||||
|
__all__ = ["DocsSubintentResolver", "V2DeterministicRouter", "V2LlmRouter"]
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.core.agent.processes.v2.intent_router.models import QueryFeatures
|
||||||
|
|
||||||
|
|
||||||
|
class V2ConfidenceAdjuster:
|
||||||
|
def adjust(self, confidence: float, features: QueryFeatures) -> float:
|
||||||
|
adjusted = confidence
|
||||||
|
if not self._has_strong_anchor(features):
|
||||||
|
adjusted -= 0.1
|
||||||
|
if self._is_short_or_vague(features):
|
||||||
|
adjusted -= 0.1
|
||||||
|
if self._has_explicit_signal(features):
|
||||||
|
adjusted += 0.05
|
||||||
|
return min(max(adjusted, 0.0), 1.0)
|
||||||
|
|
||||||
|
def _has_strong_anchor(self, features: QueryFeatures) -> bool:
|
||||||
|
return any((features.file_markers, features.endpoint_paths, features.target_doc_hints, features.matched_aliases))
|
||||||
|
|
||||||
|
def _is_short_or_vague(self, features: QueryFeatures) -> bool:
|
||||||
|
token_count = len([token for token in features.normalized_query.split() if token.strip()])
|
||||||
|
return token_count <= 3 or len(features.target_terms) <= 1
|
||||||
|
|
||||||
|
def _has_explicit_signal(self, features: QueryFeatures) -> bool:
|
||||||
|
return bool(features.file_markers or features.endpoint_paths or features.endpoint_markers)
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.core.agent.processes.v2.intent_router.models import QueryFeatures
|
||||||
|
from app.core.agent.processes.v2.models import V2Domain, V2Intent, V2RouteResult, V2Subintent
|
||||||
|
from app.core.agent.processes.v2.intent_router.routers.docs_subintent_resolver import DocsSubintentResolver
|
||||||
|
|
||||||
|
|
||||||
|
class V2DeterministicRouter:
|
||||||
|
_GENERAL_MARKERS = (
|
||||||
|
"что это за сервис",
|
||||||
|
"для чего нужен",
|
||||||
|
"какую задачу решает",
|
||||||
|
"что входит в документацию",
|
||||||
|
"какие документы стоит читать сначала",
|
||||||
|
"дай короткое summary",
|
||||||
|
"с чего начать",
|
||||||
|
"что тут есть кроме api",
|
||||||
|
"как в целом устроено приложение",
|
||||||
|
"какие основные части есть",
|
||||||
|
"из чего состоит telegram notify app",
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(self, subintent_resolver: DocsSubintentResolver | None = None) -> None:
|
||||||
|
self._subintent_resolver = subintent_resolver or DocsSubintentResolver()
|
||||||
|
|
||||||
|
def route(self, user_query: str, features: QueryFeatures, anchors) -> V2RouteResult | None:
|
||||||
|
subintent = self._subintent_resolver.resolve(features)
|
||||||
|
if subintent == V2Subintent.FIND_FILES:
|
||||||
|
return self._build_docs_route(user_query, features, anchors, subintent, "deterministic file anchor")
|
||||||
|
if subintent is not None and not self._has_conflicting_doc_anchors(features):
|
||||||
|
return self._build_docs_route(user_query, features, anchors, subintent, "deterministic signal")
|
||||||
|
if self._is_general_summary(features.normalized_query):
|
||||||
|
return V2RouteResult(
|
||||||
|
routing_domain=V2Domain.GENERAL,
|
||||||
|
intent=V2Intent.GENERAL_QA,
|
||||||
|
subintent=V2Subintent.SUMMARY,
|
||||||
|
user_query=user_query,
|
||||||
|
normalized_query=features.normalized_query,
|
||||||
|
target_terms=features.target_terms,
|
||||||
|
anchors=anchors,
|
||||||
|
confidence=1.0,
|
||||||
|
routing_mode="deterministic",
|
||||||
|
llm_router_used=False,
|
||||||
|
reason_short="general fallback signal",
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _build_docs_route(self, user_query: str, features: QueryFeatures, anchors, subintent: str, reason: str) -> V2RouteResult:
|
||||||
|
return V2RouteResult(
|
||||||
|
routing_domain=V2Domain.DOCS,
|
||||||
|
intent=V2Intent.DOC_EXPLAIN,
|
||||||
|
subintent=subintent,
|
||||||
|
user_query=user_query,
|
||||||
|
normalized_query=features.normalized_query,
|
||||||
|
target_terms=features.target_terms,
|
||||||
|
anchors=anchors,
|
||||||
|
confidence=1.0,
|
||||||
|
routing_mode="deterministic",
|
||||||
|
llm_router_used=False,
|
||||||
|
reason_short=reason,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _is_general_summary(self, normalized_query: str) -> bool:
|
||||||
|
query = normalized_query.lower()
|
||||||
|
return any(marker in query for marker in self._GENERAL_MARKERS)
|
||||||
|
|
||||||
|
def _has_conflicting_doc_anchors(self, features: QueryFeatures) -> bool:
|
||||||
|
signals = 0
|
||||||
|
signals += 1 if features.endpoint_paths or features.endpoint_markers else 0
|
||||||
|
signals += 1 if features.architecture_markers else 0
|
||||||
|
signals += 1 if features.logic_markers else 0
|
||||||
|
signals += 1 if features.domain_markers else 0
|
||||||
|
return signals > 1
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.core.agent.processes.v2.intent_router.models import QueryFeatures
|
||||||
|
from app.core.agent.processes.v2.models import V2Subintent
|
||||||
|
|
||||||
|
|
||||||
|
class DocsSubintentResolver:
|
||||||
|
def resolve(self, features: QueryFeatures) -> str | None:
|
||||||
|
if features.file_markers:
|
||||||
|
return V2Subintent.FIND_FILES
|
||||||
|
if any(
|
||||||
|
(
|
||||||
|
features.endpoint_paths,
|
||||||
|
features.endpoint_markers,
|
||||||
|
features.architecture_markers,
|
||||||
|
features.logic_markers,
|
||||||
|
features.domain_markers,
|
||||||
|
features.target_doc_hints,
|
||||||
|
)
|
||||||
|
):
|
||||||
|
return V2Subintent.SUMMARY
|
||||||
|
return None
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.core.agent.processes.v2.intent_router.models import QueryFeatures
|
||||||
|
from app.core.agent.processes.v2.models import V2Domain, V2Intent, V2RouteResult, V2Subintent
|
||||||
|
|
||||||
|
|
||||||
|
class V2FallbackRouter:
|
||||||
|
def route(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
user_query: str,
|
||||||
|
features: QueryFeatures,
|
||||||
|
anchors,
|
||||||
|
llm_attempted: bool,
|
||||||
|
) -> V2RouteResult:
|
||||||
|
if features.file_markers:
|
||||||
|
return self._build_docs_result(
|
||||||
|
user_query=user_query,
|
||||||
|
features=features,
|
||||||
|
anchors=anchors,
|
||||||
|
subintent=V2Subintent.FIND_FILES,
|
||||||
|
llm_attempted=llm_attempted,
|
||||||
|
reason="fallback file markers",
|
||||||
|
)
|
||||||
|
if self._has_docs_signal(features):
|
||||||
|
return self._build_docs_result(
|
||||||
|
user_query=user_query,
|
||||||
|
features=features,
|
||||||
|
anchors=anchors,
|
||||||
|
subintent=V2Subintent.SUMMARY,
|
||||||
|
llm_attempted=llm_attempted,
|
||||||
|
reason="fallback docs summary",
|
||||||
|
)
|
||||||
|
return V2RouteResult(
|
||||||
|
routing_domain=V2Domain.GENERAL,
|
||||||
|
intent=V2Intent.GENERAL_QA,
|
||||||
|
subintent=V2Subintent.SUMMARY,
|
||||||
|
user_query=user_query,
|
||||||
|
normalized_query=features.normalized_query,
|
||||||
|
target_terms=features.target_terms,
|
||||||
|
anchors=anchors,
|
||||||
|
confidence=0.0,
|
||||||
|
routing_mode=self._routing_mode(llm_attempted),
|
||||||
|
llm_router_used=llm_attempted,
|
||||||
|
reason_short="fallback general summary",
|
||||||
|
)
|
||||||
|
|
||||||
|
def _build_docs_result(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
user_query: str,
|
||||||
|
features: QueryFeatures,
|
||||||
|
anchors,
|
||||||
|
subintent: str,
|
||||||
|
llm_attempted: bool,
|
||||||
|
reason: str,
|
||||||
|
) -> V2RouteResult:
|
||||||
|
return V2RouteResult(
|
||||||
|
routing_domain=V2Domain.DOCS,
|
||||||
|
intent=V2Intent.DOC_EXPLAIN,
|
||||||
|
subintent=subintent,
|
||||||
|
user_query=user_query,
|
||||||
|
normalized_query=features.normalized_query,
|
||||||
|
target_terms=features.target_terms,
|
||||||
|
anchors=anchors,
|
||||||
|
confidence=0.0,
|
||||||
|
routing_mode=self._routing_mode(llm_attempted),
|
||||||
|
llm_router_used=llm_attempted,
|
||||||
|
reason_short=reason,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _has_docs_signal(self, features: QueryFeatures) -> bool:
|
||||||
|
return any(
|
||||||
|
(
|
||||||
|
features.endpoint_paths,
|
||||||
|
features.target_doc_hints,
|
||||||
|
features.endpoint_markers,
|
||||||
|
features.architecture_markers,
|
||||||
|
features.logic_markers,
|
||||||
|
features.domain_markers,
|
||||||
|
features.matched_aliases,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def _routing_mode(self, llm_attempted: bool) -> str:
|
||||||
|
return "llm_fallback" if llm_attempted else "deterministic_fallback"
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
from app.core.agent.processes.v2.intent_router.routers.route_catalog import V2RouteCatalog
|
||||||
|
from app.core.agent.utils.llm import AgentLlmService
|
||||||
|
|
||||||
|
|
||||||
|
class V2LlmRouter:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
llm: AgentLlmService,
|
||||||
|
prompt_name: str = "v2_intent_router.route",
|
||||||
|
catalog: V2RouteCatalog | None = None,
|
||||||
|
) -> None:
|
||||||
|
self._llm = llm
|
||||||
|
self._prompt_name = prompt_name
|
||||||
|
self._catalog = catalog or V2RouteCatalog()
|
||||||
|
|
||||||
|
def classify(self, *, normalized_query: str, target_terms: list[str], anchors: dict) -> dict | None:
|
||||||
|
payload = {
|
||||||
|
"normalized_query": normalized_query,
|
||||||
|
"target_terms": target_terms,
|
||||||
|
"anchors": anchors,
|
||||||
|
"allowed_routes": self._catalog.allowed_routes(),
|
||||||
|
}
|
||||||
|
raw = self._llm.generate(
|
||||||
|
self._prompt_name,
|
||||||
|
json.dumps(payload, ensure_ascii=False, indent=2),
|
||||||
|
log_context="v2_intent_router",
|
||||||
|
)
|
||||||
|
return self._parse(raw)
|
||||||
|
|
||||||
|
def _parse(self, raw: str) -> dict | None:
|
||||||
|
try:
|
||||||
|
data = json.loads(str(raw or "").strip())
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"routing_domain": str(data.get("routing_domain") or "").strip(),
|
||||||
|
"intent": str(data.get("intent") or "").strip(),
|
||||||
|
"subintent": str(data.get("subintent") or "").strip(),
|
||||||
|
"confidence": data.get("confidence"),
|
||||||
|
"reason_short": str(data.get("reason_short") or "").strip(),
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
namespace: v2_intent_router
|
||||||
|
|
||||||
|
prompts:
|
||||||
|
route: |
|
||||||
|
Ты выбираешь маршрут для узкого процесса v2.
|
||||||
|
Основной принцип:
|
||||||
|
- DOCS / DOC_EXPLAIN / FIND_FILES: запрос просит найти файл, документ или путь.
|
||||||
|
- DOCS / DOC_EXPLAIN / SUMMARY: запрос просит объяснить документацию, endpoint, архитектуру, процесс или сущность.
|
||||||
|
- GENERAL / GENERAL_QA / SUMMARY: общий обзорный вопрос без явного запроса к документации.
|
||||||
|
|
||||||
|
Используй только маршруты из поля `allowed_routes`.
|
||||||
|
Верни confidence:
|
||||||
|
- 0.9-1.0 для явного кейса
|
||||||
|
- 0.7-0.9 для нормального кейса
|
||||||
|
- меньше 0.7 для неоднозначного кейса
|
||||||
|
|
||||||
|
Ответь только JSON-объектом вида:
|
||||||
|
{
|
||||||
|
"routing_domain": "GENERAL" | "DOCS",
|
||||||
|
"intent": "GENERAL_QA" | "DOC_EXPLAIN",
|
||||||
|
"subintent": "SUMMARY" | "FIND_FILES",
|
||||||
|
"confidence": 0.0-1.0,
|
||||||
|
"reason_short": "короткая причина"
|
||||||
|
}
|
||||||
|
|
||||||
|
Не добавляй markdown, комментарии и текст вне JSON.
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.core.agent.processes.v2.models import V2Domain, V2Intent, V2Subintent
|
||||||
|
|
||||||
|
|
||||||
|
class V2RouteCatalog:
|
||||||
|
_ALLOWED_ROUTES = (
|
||||||
|
(V2Domain.DOCS, V2Intent.DOC_EXPLAIN, V2Subintent.FIND_FILES),
|
||||||
|
(V2Domain.DOCS, V2Intent.DOC_EXPLAIN, V2Subintent.SUMMARY),
|
||||||
|
(V2Domain.GENERAL, V2Intent.GENERAL_QA, V2Subintent.SUMMARY),
|
||||||
|
)
|
||||||
|
|
||||||
|
def allowed_routes(self) -> list[dict[str, str]]:
|
||||||
|
return [
|
||||||
|
{"routing_domain": domain, "intent": intent, "subintent": subintent}
|
||||||
|
for domain, intent, subintent in self._ALLOWED_ROUTES
|
||||||
|
]
|
||||||
|
|
||||||
|
def is_allowed(self, routing_domain: str, intent: str, subintent: str) -> bool:
|
||||||
|
return (routing_domain, intent, subintent) in self._ALLOWED_ROUTES
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.core.agent.processes.v2.intent_router.routers.route_catalog import V2RouteCatalog
|
||||||
|
|
||||||
|
|
||||||
|
class V2RouteValidator:
|
||||||
|
def __init__(self, catalog: V2RouteCatalog | None = None) -> None:
|
||||||
|
self._catalog = catalog or V2RouteCatalog()
|
||||||
|
|
||||||
|
def validate(self, candidate: dict | None) -> dict | None:
|
||||||
|
if not isinstance(candidate, dict):
|
||||||
|
return None
|
||||||
|
routing_domain = self._value(candidate, "routing_domain")
|
||||||
|
intent = self._value(candidate, "intent")
|
||||||
|
subintent = self._value(candidate, "subintent")
|
||||||
|
if not self._catalog.is_allowed(routing_domain, intent, subintent):
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"routing_domain": routing_domain,
|
||||||
|
"intent": intent,
|
||||||
|
"subintent": subintent,
|
||||||
|
"confidence": self._coerce_confidence(candidate.get("confidence")),
|
||||||
|
"reason_short": self._value(candidate, "reason_short"),
|
||||||
|
}
|
||||||
|
|
||||||
|
def _value(self, candidate: dict, key: str) -> str:
|
||||||
|
return str(candidate.get(key) or "").strip()
|
||||||
|
|
||||||
|
def _coerce_confidence(self, value: object) -> float:
|
||||||
|
try:
|
||||||
|
confidence = float(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return 0.0
|
||||||
|
return max(0.0, min(1.0, confidence))
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
"""Типы маршрута и выдачи retrieval для процесса v2."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
|
||||||
|
class V2Domain:
|
||||||
|
DOCS = "DOCS"
|
||||||
|
GENERAL = "GENERAL"
|
||||||
|
|
||||||
|
|
||||||
|
class V2Intent:
|
||||||
|
DOC_EXPLAIN = "DOC_EXPLAIN"
|
||||||
|
GENERAL_QA = "GENERAL_QA"
|
||||||
|
|
||||||
|
|
||||||
|
class V2Subintent:
|
||||||
|
SUMMARY = "SUMMARY"
|
||||||
|
FIND_FILES = "FIND_FILES"
|
||||||
|
|
||||||
|
|
||||||
|
class V2AnchorType:
|
||||||
|
GENERAL_OVERVIEW = "GENERAL_OVERVIEW"
|
||||||
|
API_ENDPOINT = "API_ENDPOINT"
|
||||||
|
ARCHITECTURE = "ARCHITECTURE"
|
||||||
|
LOGIC_FLOW = "LOGIC_FLOW"
|
||||||
|
DOMAIN_ENTITY = "DOMAIN_ENTITY"
|
||||||
|
FIND_FILES = "FIND_FILES"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class V2RouteAnchors:
|
||||||
|
"""Якоря из запроса для retrieval и downstream."""
|
||||||
|
|
||||||
|
entity_names: list[str] = field(default_factory=list)
|
||||||
|
file_names: list[str] = field(default_factory=list)
|
||||||
|
endpoint_paths: list[str] = field(default_factory=list)
|
||||||
|
target_doc_hints: list[str] = field(default_factory=list)
|
||||||
|
matched_aliases: list[str] = field(default_factory=list)
|
||||||
|
process_domain: str | None = None
|
||||||
|
process_subdomain: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class V2RouteResult:
|
||||||
|
routing_domain: str
|
||||||
|
intent: str
|
||||||
|
subintent: str
|
||||||
|
user_query: str
|
||||||
|
normalized_query: str
|
||||||
|
target_terms: list[str] = field(default_factory=list)
|
||||||
|
anchors: V2RouteAnchors = field(default_factory=V2RouteAnchors)
|
||||||
|
confidence: float = 1.0
|
||||||
|
routing_mode: str = "deterministic"
|
||||||
|
llm_router_used: bool = False
|
||||||
|
reason_short: str = ""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def domain(self) -> str:
|
||||||
|
"""Совместимость с полем ``domain`` в логах и вызовах."""
|
||||||
|
return self.routing_domain
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class RetrievedSummary:
|
||||||
|
path: str
|
||||||
|
title: str
|
||||||
|
summary: str
|
||||||
|
document_id: str
|
||||||
|
score: int
|
||||||
|
confidence: float = 0.0
|
||||||
|
match_reason: str = "semantic_match"
|
||||||
|
is_primary: bool = False
|
||||||
|
score_breakdown: dict[str, int] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class RetrievedFile:
|
||||||
|
path: str
|
||||||
|
title: str
|
||||||
|
document_id: str
|
||||||
|
score: int
|
||||||
|
confidence: float
|
||||||
|
match_reason: str
|
||||||
|
is_primary: bool = False
|
||||||
|
score_breakdown: dict[str, int] = field(default_factory=dict)
|
||||||
@@ -0,0 +1,357 @@
|
|||||||
|
"""Процесс v2: роутинг, план retrieval, вызов rag API, сборка evidence и workflow."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.core.agent.processes.v2.anchor_signals import route_anchor_summary
|
||||||
|
from app.core.agent.processes.v2.evidence.assembler import DocsEvidenceAssembler
|
||||||
|
from app.core.agent.processes.v2.evidence.gate import DocsEvidenceGate
|
||||||
|
from app.core.agent.processes.v2.intent_router import V2IntentRouter
|
||||||
|
from app.core.agent.processes.v2.models import V2Intent, V2Subintent
|
||||||
|
from app.core.agent.processes.v2.retrieval import DocsMetadataLookupIndex
|
||||||
|
from app.core.agent.processes.v2.retrieval.policy_resolver import V2RetrievalPolicyResolver
|
||||||
|
from app.core.agent.processes.v2.retrieval.target_doc_seeding import (
|
||||||
|
RagRowIndex,
|
||||||
|
merge_row_lists,
|
||||||
|
normalize_doc_path,
|
||||||
|
normalized_path_set,
|
||||||
|
path_variants_for_rag_query,
|
||||||
|
row_path,
|
||||||
|
seed_candidates_from_target_hints,
|
||||||
|
)
|
||||||
|
from app.core.agent.processes.v2.retrieval.v2_rag_adapter import V2RagRetrievalAdapter
|
||||||
|
from app.core.agent.processes.v2.workflows.docs_explain_find_files.context import DocsExplainFindFilesContext
|
||||||
|
from app.core.agent.processes.v2.workflows.docs_explain_find_files.graph import DocsExplainFindFilesGraph
|
||||||
|
from app.core.agent.processes.v2.workflows.docs_explain_summary.context import DocsExplainSummaryContext
|
||||||
|
from app.core.agent.processes.v2.workflows.docs_explain_summary.graph import DocsExplainSummaryGraph
|
||||||
|
from app.core.agent.processes.v2.workflows.general_summary.context import GeneralSummaryContext
|
||||||
|
from app.core.agent.processes.v2.workflows.general_summary.graph import GeneralSummaryGraph
|
||||||
|
from app.core.agent.processes.base import AgentProcess, ProcessResult
|
||||||
|
from app.core.agent.utils.llm import AgentLlmService
|
||||||
|
|
||||||
|
|
||||||
|
class V2Process(AgentProcess):
|
||||||
|
version = "v2"
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
llm: AgentLlmService,
|
||||||
|
policy_resolver: V2RetrievalPolicyResolver,
|
||||||
|
rag_adapter: V2RagRetrievalAdapter,
|
||||||
|
evidence_assembler: DocsEvidenceAssembler,
|
||||||
|
evidence_gate: DocsEvidenceGate | None = None,
|
||||||
|
router: V2IntentRouter | None = None,
|
||||||
|
docs_summary_prompt_name: str = "v2_docs_explain.summary_answer",
|
||||||
|
general_summary_prompt_name: str = "v2_general.summary_answer",
|
||||||
|
workflow_llm_enabled: bool = True,
|
||||||
|
) -> None:
|
||||||
|
self._router = router or V2IntentRouter()
|
||||||
|
self._policy_resolver = policy_resolver
|
||||||
|
self._rag_adapter = rag_adapter
|
||||||
|
self._evidence_assembler = evidence_assembler
|
||||||
|
self._evidence_gate = evidence_gate or DocsEvidenceGate()
|
||||||
|
self._docs_summary_prompt_name = docs_summary_prompt_name
|
||||||
|
self._general_summary_prompt_name = general_summary_prompt_name
|
||||||
|
self._workflow_llm_enabled = workflow_llm_enabled
|
||||||
|
self._summary_graph = DocsExplainSummaryGraph(llm)
|
||||||
|
self._find_files_graph = DocsExplainFindFilesGraph()
|
||||||
|
self._general_summary_graph = GeneralSummaryGraph(llm)
|
||||||
|
|
||||||
|
async def run(self, context) -> ProcessResult:
|
||||||
|
route = self._router.route(context.request.message)
|
||||||
|
rag_session_id = context.session.active_rag_session_id
|
||||||
|
context.trace.module("process.v2").log(
|
||||||
|
"intent_routed",
|
||||||
|
{
|
||||||
|
"routing_domain": route.routing_domain,
|
||||||
|
"intent": route.intent,
|
||||||
|
"subintent": route.subintent,
|
||||||
|
"normalized_query": route.normalized_query,
|
||||||
|
"target_terms": route.target_terms,
|
||||||
|
"anchors": route_anchor_summary(route),
|
||||||
|
"confidence": route.confidence,
|
||||||
|
"routing_mode": route.routing_mode,
|
||||||
|
"llm_router_used": route.llm_router_used,
|
||||||
|
"reason_short": route.reason_short,
|
||||||
|
"rag_session_id": rag_session_id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self._log_step(
|
||||||
|
context,
|
||||||
|
"router_resolved",
|
||||||
|
{
|
||||||
|
"domain": route.routing_domain,
|
||||||
|
"intent": route.intent,
|
||||||
|
"subintent": route.subintent,
|
||||||
|
"confidence": route.confidence,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self._log_step(
|
||||||
|
context,
|
||||||
|
"anchors_extracted",
|
||||||
|
{
|
||||||
|
"signal_types": route_anchor_summary(route)["signal_types"],
|
||||||
|
"endpoint_paths": route.anchors.endpoint_paths,
|
||||||
|
"target_doc_hints": route.anchors.target_doc_hints,
|
||||||
|
"matched_aliases": route.anchors.matched_aliases,
|
||||||
|
"target_terms": route.target_terms,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self._log_step(
|
||||||
|
context,
|
||||||
|
"alias_resolution",
|
||||||
|
{
|
||||||
|
"resolved_aliases": route.anchors.matched_aliases,
|
||||||
|
"target_doc_hints": route.anchors.target_doc_hints,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if not rag_session_id:
|
||||||
|
if route.intent == V2Intent.GENERAL_QA:
|
||||||
|
answer = "Не могу собрать grounded summary без активной RAG-сессии с проиндексированной документацией."
|
||||||
|
self._log_step(context, "evidence_gate_checked", {"passed": False, "reason": "missing_rag_session"})
|
||||||
|
self._log_step(context, "answer_generated", {"answer_mode": "insufficient_evidence"})
|
||||||
|
return ProcessResult(answer=answer)
|
||||||
|
return ProcessResult(answer="Для процесса v2 нужна активная RAG-сессия проекта с проиндексированной документацией.")
|
||||||
|
plan = self._policy_resolver.resolve(route)
|
||||||
|
context.trace.module("process.v2.retrieval_policy").log(
|
||||||
|
"retrieval_plan_resolved",
|
||||||
|
{"profile": plan.profile, "layers": plan.layers, "limit": plan.limit, "filters": plan.filters},
|
||||||
|
)
|
||||||
|
self._log_step(
|
||||||
|
context,
|
||||||
|
"retrieval_profile_selected",
|
||||||
|
{"profile": plan.profile, "layers": plan.layers, "filters": plan.filters},
|
||||||
|
)
|
||||||
|
seeded_rows = await self._seed_candidates_from_target_hints(rag_session_id, plan.layers, route)
|
||||||
|
semantic_rows = await self._rag_adapter.fetch_rows(rag_session_id, route.normalized_query, plan)
|
||||||
|
metadata_rows = self._metadata_lookup_candidates([*seeded_rows, *semantic_rows], route)
|
||||||
|
rows = self._merge_candidate_rows(seeded_rows, metadata_rows, semantic_rows)
|
||||||
|
rows = await self._ensure_target_hints_in_pool(rag_session_id, rows, route)
|
||||||
|
rows = seed_candidates_from_target_hints(rows, route.anchors.target_doc_hints, RagRowIndex(rows))
|
||||||
|
self._print_missing_target_hints(route, rows)
|
||||||
|
context.trace.module("process.v2.rag_retrieval").log(
|
||||||
|
"rag_rows_fetched",
|
||||||
|
{
|
||||||
|
"profile": plan.profile,
|
||||||
|
"row_count": len(rows),
|
||||||
|
"rows": [self._trace_row(row) for row in rows],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self._log_step(
|
||||||
|
context,
|
||||||
|
"candidate_generation",
|
||||||
|
{
|
||||||
|
"query": route.user_query,
|
||||||
|
"profile": plan.profile,
|
||||||
|
"details": {
|
||||||
|
"target_doc_hints": list(route.anchors.target_doc_hints),
|
||||||
|
"candidates_before_ranking": [row_path(row) for row in rows if row_path(row)],
|
||||||
|
},
|
||||||
|
"resolved_aliases": route.anchors.matched_aliases,
|
||||||
|
"target_doc_hints": route.anchors.target_doc_hints,
|
||||||
|
"candidate_docs_before_ranking": [self._trace_row(row) for row in rows[:8]],
|
||||||
|
"sources": {
|
||||||
|
"seeded": [self._trace_row(row) for row in seeded_rows[:5]],
|
||||||
|
"metadata_lookup": [self._trace_row(row) for row in metadata_rows[:5]],
|
||||||
|
"semantic": [self._trace_row(row) for row in semantic_rows[:5]],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self._log_step(
|
||||||
|
context,
|
||||||
|
"retrieval_executed",
|
||||||
|
{
|
||||||
|
"query": route.user_query,
|
||||||
|
"profile": plan.profile,
|
||||||
|
"row_count": len(rows),
|
||||||
|
"target_doc_hints": route.anchors.target_doc_hints,
|
||||||
|
"top_results": [self._trace_row(row) for row in rows[:5]],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if route.subintent == V2Subintent.FIND_FILES:
|
||||||
|
files = self._evidence_assembler.assemble_files(rows, route)
|
||||||
|
gate = self._evidence_gate.check_files(route, files)
|
||||||
|
context.trace.module("process.v2.evidence").log(
|
||||||
|
"evidence_assembled",
|
||||||
|
{"mode": "find_files", "file_count": len(files), "files": [file.path for file in files]},
|
||||||
|
)
|
||||||
|
self._log_step(
|
||||||
|
context,
|
||||||
|
"evidence_assembled",
|
||||||
|
{"mode": "find_files", "primary_file": files[0].path if files else None, "file_count": len(files)},
|
||||||
|
)
|
||||||
|
self._log_ranking(context, files)
|
||||||
|
self._log_step(
|
||||||
|
context,
|
||||||
|
"evidence_gate_checked",
|
||||||
|
{"passed": gate.passed, "reason": gate.reason, "answer_mode": gate.answer_mode},
|
||||||
|
)
|
||||||
|
flow_context = DocsExplainFindFilesContext(
|
||||||
|
runtime=context,
|
||||||
|
route=route,
|
||||||
|
rag_session_id=rag_session_id,
|
||||||
|
files=files,
|
||||||
|
gate_decision=gate,
|
||||||
|
)
|
||||||
|
flow_context = await self._find_files_graph.run(flow_context)
|
||||||
|
self._log_step(context, "answer_generated", {"answer_mode": gate.answer_mode, "answer_length": len(flow_context.answer)})
|
||||||
|
return ProcessResult(answer=flow_context.answer)
|
||||||
|
documents = self._evidence_assembler.assemble_summaries(rows, route)
|
||||||
|
gate = self._evidence_gate.check_summaries(route, documents)
|
||||||
|
context.trace.module("process.v2.evidence").log(
|
||||||
|
"evidence_assembled",
|
||||||
|
{"mode": "summary", "document_count": len(documents), "documents": [item.path for item in documents]},
|
||||||
|
)
|
||||||
|
self._log_step(
|
||||||
|
context,
|
||||||
|
"evidence_assembled",
|
||||||
|
{"mode": "summary", "primary_doc": documents[0].path if documents else None, "document_count": len(documents)},
|
||||||
|
)
|
||||||
|
self._log_ranking(context, documents)
|
||||||
|
self._log_step(
|
||||||
|
context,
|
||||||
|
"evidence_gate_checked",
|
||||||
|
{"passed": gate.passed, "reason": gate.reason, "answer_mode": gate.answer_mode},
|
||||||
|
)
|
||||||
|
if route.intent == V2Intent.GENERAL_QA:
|
||||||
|
flow_context = GeneralSummaryContext(
|
||||||
|
runtime=context,
|
||||||
|
route=route,
|
||||||
|
prompt_name=self._general_summary_prompt_name,
|
||||||
|
workflow_llm_enabled=self._workflow_llm_enabled,
|
||||||
|
documents=documents,
|
||||||
|
gate_decision=gate,
|
||||||
|
)
|
||||||
|
flow_context = await self._general_summary_graph.run(flow_context)
|
||||||
|
self._log_step(context, "answer_generated", {"answer_mode": gate.answer_mode, "answer_length": len(flow_context.answer)})
|
||||||
|
return ProcessResult(answer=flow_context.answer)
|
||||||
|
flow_context = DocsExplainSummaryContext(
|
||||||
|
runtime=context,
|
||||||
|
route=route,
|
||||||
|
rag_session_id=rag_session_id,
|
||||||
|
prompt_name=self._docs_summary_prompt_name,
|
||||||
|
workflow_llm_enabled=self._workflow_llm_enabled,
|
||||||
|
documents=documents,
|
||||||
|
gate_decision=gate,
|
||||||
|
)
|
||||||
|
flow_context = await self._summary_graph.run(flow_context)
|
||||||
|
self._log_step(context, "answer_generated", {"answer_mode": gate.answer_mode, "answer_length": len(flow_context.answer)})
|
||||||
|
return ProcessResult(answer=flow_context.answer)
|
||||||
|
|
||||||
|
def _trace_row(self, row: dict) -> dict[str, object]:
|
||||||
|
metadata = row.get("metadata") or {}
|
||||||
|
content = str(row.get("content") or "").strip()
|
||||||
|
return {
|
||||||
|
"layer": str(row.get("layer") or ""),
|
||||||
|
"path": str(row.get("path") or ""),
|
||||||
|
"title": str(row.get("title") or ""),
|
||||||
|
"document_id": str(metadata.get("document_id") or metadata.get("doc_id") or ""),
|
||||||
|
"entity_name": str(metadata.get("entity_name") or ""),
|
||||||
|
"summary_text": str(metadata.get("summary_text") or "")[:400],
|
||||||
|
"section_path": str(metadata.get("section_path") or ""),
|
||||||
|
"content_preview": content[:400],
|
||||||
|
}
|
||||||
|
|
||||||
|
def _log_step(self, context, step: str, payload: dict[str, object]) -> None:
|
||||||
|
context.trace.module("process.v2.pipeline").log(step, payload)
|
||||||
|
|
||||||
|
def _print_missing_target_hints(self, route, rows: list[dict]) -> None:
|
||||||
|
if not route.anchors.target_doc_hints:
|
||||||
|
return
|
||||||
|
candidate_paths = normalized_path_set(rows)
|
||||||
|
for hint in route.anchors.target_doc_hints:
|
||||||
|
if not str(hint or "").strip():
|
||||||
|
continue
|
||||||
|
normalized = normalize_doc_path(hint)
|
||||||
|
if normalized not in candidate_paths:
|
||||||
|
print("ERROR: target doc missing from candidates:", normalized)
|
||||||
|
|
||||||
|
async def _ensure_target_hints_in_pool(self, rag_session_id: str, rows: list[dict], route) -> list[dict]:
|
||||||
|
hints_raw = [str(item).strip() for item in route.anchors.target_doc_hints if str(item or "").strip()]
|
||||||
|
if not hints_raw:
|
||||||
|
return rows
|
||||||
|
pool = normalized_path_set(rows)
|
||||||
|
missing_hints = [h for h in hints_raw if normalize_doc_path(h) not in pool]
|
||||||
|
if not missing_hints:
|
||||||
|
return rows
|
||||||
|
variant_paths: list[str] = []
|
||||||
|
for h in missing_hints:
|
||||||
|
variant_paths.extend(path_variants_for_rag_query(h))
|
||||||
|
variant_paths = list(dict.fromkeys(variant_paths))
|
||||||
|
extra_exact = await self._rag_adapter.fetch_exact_paths(rag_session_id, paths=variant_paths, layers=None)
|
||||||
|
pool2 = normalized_path_set(extra_exact)
|
||||||
|
still_missing = [h for h in missing_hints if normalize_doc_path(h) not in pool2]
|
||||||
|
fallback_rows: list[dict] = []
|
||||||
|
if still_missing:
|
||||||
|
needles = [normalize_doc_path(h).split("/")[-1] for h in still_missing]
|
||||||
|
needles = list(dict.fromkeys(n for n in needles if n))
|
||||||
|
if needles:
|
||||||
|
fallback_rows = await self._rag_adapter.fetch_chunks_by_path_substrings(
|
||||||
|
rag_session_id,
|
||||||
|
path_needles=needles,
|
||||||
|
layers=None,
|
||||||
|
)
|
||||||
|
return merge_row_lists(rows, extra_exact, fallback_rows)
|
||||||
|
|
||||||
|
async def _seed_candidates_from_target_hints(self, rag_session_id: str, layers: list[str], route) -> list[dict]:
|
||||||
|
del layers # seed по пути должен видеть все слои (иначе D0-only чанки теряются при file_lookup).
|
||||||
|
hints_raw = [str(item).strip() for item in route.anchors.target_doc_hints if str(item or "").strip()]
|
||||||
|
if not hints_raw:
|
||||||
|
return []
|
||||||
|
variant_paths: list[str] = []
|
||||||
|
for h in hints_raw:
|
||||||
|
variant_paths.extend(path_variants_for_rag_query(h))
|
||||||
|
variant_paths = list(dict.fromkeys(variant_paths))
|
||||||
|
exact_rows = await self._rag_adapter.fetch_exact_paths(rag_session_id, paths=variant_paths, layers=None)
|
||||||
|
paths_found = normalized_path_set(exact_rows)
|
||||||
|
missing = [h for h in hints_raw if normalize_doc_path(h) not in paths_found]
|
||||||
|
if not missing:
|
||||||
|
return exact_rows
|
||||||
|
needles = [normalize_doc_path(h).split("/")[-1] for h in missing]
|
||||||
|
needles = list(dict.fromkeys(n for n in needles if n))
|
||||||
|
if not needles:
|
||||||
|
return exact_rows
|
||||||
|
fallback_rows = await self._rag_adapter.fetch_chunks_by_path_substrings(
|
||||||
|
rag_session_id,
|
||||||
|
path_needles=needles,
|
||||||
|
layers=None,
|
||||||
|
)
|
||||||
|
return merge_row_lists(exact_rows, fallback_rows)
|
||||||
|
|
||||||
|
def _metadata_lookup_candidates(self, rows: list[dict], route) -> list[dict]:
|
||||||
|
return DocsMetadataLookupIndex(rows).lookup(route)
|
||||||
|
|
||||||
|
def _merge_candidate_rows(self, *groups: list[dict]) -> list[dict]:
|
||||||
|
return merge_row_lists(*groups)
|
||||||
|
|
||||||
|
def _log_ranking(self, context, items: list) -> None:
|
||||||
|
top_docs: list[dict[str, object]] = []
|
||||||
|
for item in items[:4]:
|
||||||
|
top_docs.append(
|
||||||
|
{
|
||||||
|
"doc": getattr(item, "path", ""),
|
||||||
|
"score": getattr(item, "score", 0),
|
||||||
|
"match_reason": getattr(item, "match_reason", ""),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
context.trace.module("process.v2.pipeline").log(
|
||||||
|
"ranking_explained",
|
||||||
|
{
|
||||||
|
"doc": getattr(item, "path", ""),
|
||||||
|
"score_breakdown": getattr(item, "score_breakdown", {}),
|
||||||
|
"score": getattr(item, "score", 0),
|
||||||
|
"match_reason": getattr(item, "match_reason", ""),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
context.trace.module("process.v2.pipeline").log(
|
||||||
|
"ranking_explained",
|
||||||
|
{
|
||||||
|
"top_docs_after_ranking": top_docs,
|
||||||
|
"ranking_score_breakdown": [
|
||||||
|
{
|
||||||
|
"doc": getattr(item, "path", ""),
|
||||||
|
"score_breakdown": getattr(item, "score_breakdown", {}),
|
||||||
|
}
|
||||||
|
for item in items[:4]
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
namespace: v2_docs_explain
|
||||||
|
|
||||||
|
prompts:
|
||||||
|
summary_answer: |
|
||||||
|
Ты объясняешь документацию только на основе найденных SUMMARY-блоков.
|
||||||
|
Используй только факты из входного контекста.
|
||||||
|
Если информации мало, прямо скажи об этом и не додумывай детали.
|
||||||
|
В конце перечисли файлы, на которые ты опирался.
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
from app.core.agent.processes.v2.retrieval.metadata_lookup import DocsMetadataLookupIndex
|
||||||
|
from app.core.agent.processes.v2.retrieval.policy_resolver import V2RetrievalPolicyResolver
|
||||||
|
from app.core.agent.processes.v2.retrieval.target_doc_seeding import (
|
||||||
|
RagRowIndex,
|
||||||
|
normalize_doc_path,
|
||||||
|
seed_candidates_from_target_hints,
|
||||||
|
)
|
||||||
|
from app.core.agent.processes.v2.retrieval.v2_rag_adapter import V2RagRetrievalAdapter
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"V2RetrievalPolicyResolver",
|
||||||
|
"V2RagRetrievalAdapter",
|
||||||
|
"DocsMetadataLookupIndex",
|
||||||
|
"normalize_doc_path",
|
||||||
|
"RagRowIndex",
|
||||||
|
"seed_candidates_from_target_hints",
|
||||||
|
]
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from collections import defaultdict
|
||||||
|
|
||||||
|
from app.core.agent.processes.v2.models import V2RouteResult
|
||||||
|
|
||||||
|
|
||||||
|
class DocsMetadataLookupIndex:
|
||||||
|
def __init__(self, rows: list[dict]) -> None:
|
||||||
|
self._rows_by_path: dict[str, dict] = {}
|
||||||
|
self._rows_by_basename: dict[str, list[dict]] = defaultdict(list)
|
||||||
|
self._rows_by_slug: dict[str, list[dict]] = defaultdict(list)
|
||||||
|
self._rows_by_title_token: dict[str, list[dict]] = defaultdict(list)
|
||||||
|
self._rows_by_compact: dict[str, list[dict]] = defaultdict(list)
|
||||||
|
for row in rows:
|
||||||
|
path = str(row.get("path") or "").strip()
|
||||||
|
if not path or path in self._rows_by_path:
|
||||||
|
continue
|
||||||
|
self._rows_by_path[path] = row
|
||||||
|
basename = path.split("/")[-1].lower()
|
||||||
|
slug = basename.removesuffix(".md").removesuffix(".yaml").removesuffix(".yml")
|
||||||
|
self._rows_by_basename[basename].append(row)
|
||||||
|
self._rows_by_slug[slug].append(row)
|
||||||
|
self._rows_by_compact[self._compact(slug)].append(row)
|
||||||
|
title = str(row.get("title") or "").lower()
|
||||||
|
for token in self._tokens(title):
|
||||||
|
self._rows_by_title_token[token].append(row)
|
||||||
|
self._rows_by_compact[self._compact(title)].append(row)
|
||||||
|
entity_name = str(dict(row.get("metadata") or {}).get("entity_name") or "").lower()
|
||||||
|
if entity_name:
|
||||||
|
self._rows_by_compact[self._compact(entity_name)].append(row)
|
||||||
|
|
||||||
|
def lookup(self, route: V2RouteResult) -> list[dict]:
|
||||||
|
candidates: list[dict] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for path in route.anchors.target_doc_hints:
|
||||||
|
self._append(candidates, seen, self._rows_by_path.get(path))
|
||||||
|
lookup_tokens = list(route.target_terms) + list(route.anchors.matched_aliases) + list(route.anchors.endpoint_paths)
|
||||||
|
for token in self._tokens(" ".join(lookup_tokens)):
|
||||||
|
for bucket in (
|
||||||
|
self._rows_by_basename.get(token, []),
|
||||||
|
self._rows_by_slug.get(token, []),
|
||||||
|
self._rows_by_title_token.get(token, []),
|
||||||
|
):
|
||||||
|
for row in bucket:
|
||||||
|
self._append(candidates, seen, row)
|
||||||
|
for compact in {self._compact(item) for item in lookup_tokens if item}:
|
||||||
|
for row in self._rows_by_compact.get(compact, []):
|
||||||
|
self._append(candidates, seen, row)
|
||||||
|
return candidates
|
||||||
|
|
||||||
|
def _append(self, items: list[dict], seen: set[str], row: dict | None) -> None:
|
||||||
|
if row is None:
|
||||||
|
return
|
||||||
|
path = str(row.get("path") or "").strip()
|
||||||
|
if not path or path in seen:
|
||||||
|
return
|
||||||
|
seen.add(path)
|
||||||
|
items.append(row)
|
||||||
|
|
||||||
|
def _tokens(self, value: str) -> list[str]:
|
||||||
|
return [token for token in re.split(r"[^a-zA-Zа-яА-Я0-9]+", str(value or "").lower()) if len(token) >= 3]
|
||||||
|
|
||||||
|
def _compact(self, value: str) -> str:
|
||||||
|
return "".join(self._tokens(value))
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
"""Intent-aware retrieval policy resolver для процесса v2."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.core.agent.processes.v2.anchor_signals import anchor_signal_types
|
||||||
|
from app.core.agent.processes.v2.models import V2AnchorType, V2Intent, V2RouteResult, V2Subintent
|
||||||
|
from app.core.rag.contracts.enums import RagLayer
|
||||||
|
from app.core.rag.retrieval.session_retriever import RetrievalPlan
|
||||||
|
|
||||||
|
|
||||||
|
class V2RetrievalPolicyResolver:
|
||||||
|
_SUMMARY_LAYERS = [
|
||||||
|
RagLayer.DOCS_DOCUMENT_CATALOG,
|
||||||
|
RagLayer.DOCS_ENTITY_CATALOG,
|
||||||
|
RagLayer.DOCS_DOC_CHUNKS,
|
||||||
|
]
|
||||||
|
_GENERAL_LAYERS = [
|
||||||
|
RagLayer.DOCS_DOCUMENT_CATALOG,
|
||||||
|
RagLayer.DOCS_DOC_CHUNKS,
|
||||||
|
]
|
||||||
|
|
||||||
|
def resolve(self, route: V2RouteResult) -> RetrievalPlan:
|
||||||
|
if route.intent == V2Intent.GENERAL_QA:
|
||||||
|
return RetrievalPlan(
|
||||||
|
profile="general_qa_grounded_summary",
|
||||||
|
layers=list(self._GENERAL_LAYERS),
|
||||||
|
limit=8,
|
||||||
|
filters=self._general_filters(route),
|
||||||
|
)
|
||||||
|
if route.subintent == V2Subintent.FIND_FILES:
|
||||||
|
return RetrievalPlan(
|
||||||
|
profile="file_lookup",
|
||||||
|
layers=[RagLayer.DOCS_DOCUMENT_CATALOG, RagLayer.DOCS_ENTITY_CATALOG],
|
||||||
|
limit=12,
|
||||||
|
filters=self._find_files_filters(route),
|
||||||
|
)
|
||||||
|
return RetrievalPlan(
|
||||||
|
profile=self._summary_profile(route),
|
||||||
|
layers=list(self._SUMMARY_LAYERS),
|
||||||
|
limit=8,
|
||||||
|
filters=self._summary_filters(route),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _summary_profile(self, route: V2RouteResult) -> str:
|
||||||
|
signals = anchor_signal_types(route)
|
||||||
|
if len(signals - {V2AnchorType.FIND_FILES}) != 1:
|
||||||
|
return "docs_summary_generic"
|
||||||
|
mapping = {
|
||||||
|
V2AnchorType.API_ENDPOINT: "docs_summary_api_endpoint",
|
||||||
|
V2AnchorType.ARCHITECTURE: "docs_summary_architecture",
|
||||||
|
V2AnchorType.LOGIC_FLOW: "docs_summary_logic_flow",
|
||||||
|
V2AnchorType.DOMAIN_ENTITY: "docs_summary_domain_entity",
|
||||||
|
}
|
||||||
|
signal = next(iter(signals - {V2AnchorType.FIND_FILES}), None)
|
||||||
|
return mapping.get(signal, "docs_summary_generic")
|
||||||
|
|
||||||
|
def _general_filters(self, route: V2RouteResult) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"prefer_path_prefixes": ["docs/architecture/", "docs/"],
|
||||||
|
"prefer_like_patterns": ["%README.md%", "%overview%"],
|
||||||
|
"target_doc_hints": list(route.anchors.target_doc_hints),
|
||||||
|
}
|
||||||
|
|
||||||
|
def _summary_filters(self, route: V2RouteResult) -> dict[str, object]:
|
||||||
|
filters: dict[str, object] = {
|
||||||
|
"prefer_path_prefixes": self._summary_prefixes(route),
|
||||||
|
"prefer_like_patterns": self._prefer_like_patterns(route),
|
||||||
|
"target_doc_hints": list(route.anchors.target_doc_hints),
|
||||||
|
}
|
||||||
|
if V2AnchorType.API_ENDPOINT in anchor_signal_types(route):
|
||||||
|
filters["path_prefixes"] = ["docs/api/", "docs/architecture/", "docs/"]
|
||||||
|
return filters
|
||||||
|
|
||||||
|
def _find_files_filters(self, route: V2RouteResult) -> dict[str, object]:
|
||||||
|
filters: dict[str, object] = {
|
||||||
|
"prefer_path_prefixes": self._find_files_prefixes(route),
|
||||||
|
"prefer_like_patterns": self._prefer_like_patterns(route),
|
||||||
|
"target_doc_hints": list(route.anchors.target_doc_hints),
|
||||||
|
}
|
||||||
|
if route.anchors.target_doc_hints:
|
||||||
|
filters["prefer_like_patterns"] = [f"%{path.split('/')[-1]}%" for path in route.anchors.target_doc_hints]
|
||||||
|
return filters
|
||||||
|
|
||||||
|
def _prefer_like_patterns(self, route: V2RouteResult) -> list[str]:
|
||||||
|
patterns: list[str] = []
|
||||||
|
for path in route.anchors.target_doc_hints:
|
||||||
|
patterns.append(f"%{path.split('/')[-1]}%")
|
||||||
|
for endpoint in route.anchors.endpoint_paths:
|
||||||
|
patterns.append(f"%{endpoint}%")
|
||||||
|
return patterns
|
||||||
|
|
||||||
|
def _find_files_prefixes(self, route: V2RouteResult) -> list[str]:
|
||||||
|
if route.anchors.target_doc_hints:
|
||||||
|
prefixes = ["/".join(path.split("/")[:-1]) + "/" for path in route.anchors.target_doc_hints]
|
||||||
|
return [prefix for prefix in prefixes if prefix]
|
||||||
|
signals = anchor_signal_types(route)
|
||||||
|
if V2AnchorType.API_ENDPOINT in signals:
|
||||||
|
return ["docs/api/", "docs/"]
|
||||||
|
if V2AnchorType.ARCHITECTURE in signals:
|
||||||
|
return ["docs/architecture/", "docs/"]
|
||||||
|
if V2AnchorType.LOGIC_FLOW in signals:
|
||||||
|
return ["docs/logic/", "docs/"]
|
||||||
|
if V2AnchorType.DOMAIN_ENTITY in signals:
|
||||||
|
return ["docs/domains/", "docs/"]
|
||||||
|
return ["docs/"]
|
||||||
|
|
||||||
|
def _summary_prefixes(self, route: V2RouteResult) -> list[str]:
|
||||||
|
signals = anchor_signal_types(route)
|
||||||
|
prefixes: list[str] = []
|
||||||
|
if V2AnchorType.API_ENDPOINT in signals:
|
||||||
|
prefixes.extend(["docs/api/", "docs/"])
|
||||||
|
if V2AnchorType.ARCHITECTURE in signals:
|
||||||
|
prefixes.extend(["docs/architecture/", "docs/"])
|
||||||
|
if V2AnchorType.LOGIC_FLOW in signals:
|
||||||
|
prefixes.extend(["docs/logic/", "docs/architecture/", "docs/"])
|
||||||
|
if V2AnchorType.DOMAIN_ENTITY in signals:
|
||||||
|
prefixes.extend(["docs/domains/", "docs/api/", "docs/architecture/"])
|
||||||
|
return list(dict.fromkeys(prefixes or ["docs/"]))
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_doc_path(path: str | None) -> str:
|
||||||
|
value = str(path or "").strip().replace("\\", "/")
|
||||||
|
if not value:
|
||||||
|
return ""
|
||||||
|
while "//" in value:
|
||||||
|
value = value.replace("//", "/")
|
||||||
|
while value.startswith("./"):
|
||||||
|
value = value[2:]
|
||||||
|
value = value.lstrip("/")
|
||||||
|
docs_idx = value.lower().find("docs/")
|
||||||
|
if docs_idx >= 0:
|
||||||
|
value = value[docs_idx:]
|
||||||
|
elif "/" not in value and value.lower().endswith(".md"):
|
||||||
|
value = f"docs/{value}"
|
||||||
|
return value.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def row_path(row: dict) -> str:
|
||||||
|
metadata = dict(row.get("metadata") or {})
|
||||||
|
raw = row.get("path") or metadata.get("source_path") or ""
|
||||||
|
return normalize_doc_path(str(raw))
|
||||||
|
|
||||||
|
|
||||||
|
def normalized_path_set(rows: list[dict]) -> set[str]:
|
||||||
|
return {path for row in rows if (path := row_path(row))}
|
||||||
|
|
||||||
|
|
||||||
|
def path_variants_for_rag_query(path: str | None) -> list[str]:
|
||||||
|
normalized = normalize_doc_path(path)
|
||||||
|
if not normalized:
|
||||||
|
return []
|
||||||
|
variants = [normalized]
|
||||||
|
if normalized.startswith("docs/"):
|
||||||
|
variants.append(normalized.removeprefix("docs/"))
|
||||||
|
else:
|
||||||
|
variants.append(f"docs/{normalized}")
|
||||||
|
basename = normalized.split("/")[-1]
|
||||||
|
if basename and basename not in variants:
|
||||||
|
variants.append(basename)
|
||||||
|
return list(dict.fromkeys(variants))
|
||||||
|
|
||||||
|
|
||||||
|
def merge_row_lists(*groups: list[dict]) -> list[dict]:
|
||||||
|
merged: list[dict] = []
|
||||||
|
seen: set[tuple[str, str, str]] = set()
|
||||||
|
for rows in groups:
|
||||||
|
for row in rows:
|
||||||
|
metadata = dict(row.get("metadata") or {})
|
||||||
|
key = (
|
||||||
|
row_path(row),
|
||||||
|
str(row.get("layer") or ""),
|
||||||
|
str(metadata.get("section_path") or ""),
|
||||||
|
)
|
||||||
|
if key in seen:
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
merged.append(row)
|
||||||
|
return merged
|
||||||
|
|
||||||
|
|
||||||
|
class RagRowIndex:
|
||||||
|
def __init__(self, rows: list[dict]) -> None:
|
||||||
|
self._by_path: dict[str, list[dict]] = {}
|
||||||
|
self._by_name: dict[str, list[dict]] = {}
|
||||||
|
for row in rows:
|
||||||
|
normalized = row_path(row)
|
||||||
|
if not normalized:
|
||||||
|
continue
|
||||||
|
self._by_path.setdefault(normalized.lower(), []).append(row)
|
||||||
|
basename = normalized.split("/")[-1].lower()
|
||||||
|
self._by_name.setdefault(basename, []).append(row)
|
||||||
|
|
||||||
|
def lookup(self, hint: str | None) -> list[dict]:
|
||||||
|
matches: list[dict] = []
|
||||||
|
seen_ids: set[int] = set()
|
||||||
|
for variant in path_variants_for_rag_query(hint):
|
||||||
|
key = variant.lower()
|
||||||
|
for row in self._by_path.get(key, []):
|
||||||
|
row_id = id(row)
|
||||||
|
if row_id in seen_ids:
|
||||||
|
continue
|
||||||
|
seen_ids.add(row_id)
|
||||||
|
matches.append(row)
|
||||||
|
basename = normalize_doc_path(hint).split("/")[-1].lower()
|
||||||
|
for row in self._by_name.get(basename, []):
|
||||||
|
row_id = id(row)
|
||||||
|
if row_id in seen_ids:
|
||||||
|
continue
|
||||||
|
seen_ids.add(row_id)
|
||||||
|
matches.append(row)
|
||||||
|
return matches
|
||||||
|
|
||||||
|
|
||||||
|
def seed_candidates_from_target_hints(rows: list[dict], hints: list[str], index: RagRowIndex | None = None) -> list[dict]:
|
||||||
|
hints_raw = [str(hint).strip() for hint in hints if str(hint or "").strip()]
|
||||||
|
if not hints_raw or not rows:
|
||||||
|
return rows
|
||||||
|
rag_index = index or RagRowIndex(rows)
|
||||||
|
seeded = [match for hint in hints_raw for match in rag_index.lookup(hint)]
|
||||||
|
return merge_row_lists(seeded, rows)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"RagRowIndex",
|
||||||
|
"merge_row_lists",
|
||||||
|
"normalize_doc_path",
|
||||||
|
"normalized_path_set",
|
||||||
|
"path_variants_for_rag_query",
|
||||||
|
"row_path",
|
||||||
|
"seed_candidates_from_target_hints",
|
||||||
|
]
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
"""Адаптер v2 к :class:`RagSessionRetriever` для подстановки в тестах."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.core.rag.retrieval.session_retriever import RagSessionRetriever, RetrievalPlan
|
||||||
|
|
||||||
|
|
||||||
|
class V2RagRetrievalAdapter:
|
||||||
|
"""Обёртка над :class:`RagSessionRetriever` для подмены в тестах."""
|
||||||
|
|
||||||
|
def __init__(self, retriever: RagSessionRetriever) -> None:
|
||||||
|
self._retriever = retriever
|
||||||
|
|
||||||
|
async def fetch_rows(self, rag_session_id: str, query_text: str, plan: RetrievalPlan) -> list[dict]:
|
||||||
|
return await self._retriever.retrieve(rag_session_id, query_text, plan)
|
||||||
|
|
||||||
|
async def fetch_exact_paths(self, rag_session_id: str, *, paths: list[str], layers: list[str] | None = None) -> list[dict]:
|
||||||
|
return await self._retriever.retrieve_exact_files(rag_session_id, paths=paths, layers=layers)
|
||||||
|
|
||||||
|
async def fetch_chunks_by_path_substrings(
|
||||||
|
self,
|
||||||
|
rag_session_id: str,
|
||||||
|
*,
|
||||||
|
path_needles: list[str],
|
||||||
|
layers: list[str] | None = None,
|
||||||
|
limit: int = 200,
|
||||||
|
) -> list[dict]:
|
||||||
|
return await self._retriever.retrieve_chunks_by_path_substrings(
|
||||||
|
rag_session_id,
|
||||||
|
path_needles=path_needles,
|
||||||
|
layers=layers,
|
||||||
|
limit=limit,
|
||||||
|
)
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
from app.core.agent.processes.v2.workflows.docs_explain_find_files.graph import DocsExplainFindFilesGraph
|
||||||
|
|
||||||
|
__all__ = ["DocsExplainFindFilesGraph"]
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
from app.core.agent.processes.v2.evidence.gate import EvidenceGateDecision
|
||||||
|
from app.core.agent.processes.v2.models import RetrievedFile, V2RouteResult
|
||||||
|
from app.core.agent.runtime.execution_context import RuntimeExecutionContext
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class DocsExplainFindFilesContext:
|
||||||
|
runtime: RuntimeExecutionContext
|
||||||
|
route: V2RouteResult
|
||||||
|
rag_session_id: str
|
||||||
|
files: list[RetrievedFile] = field(default_factory=list)
|
||||||
|
gate_decision: EvidenceGateDecision | None = None
|
||||||
|
answer: str = ""
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.core.agent.processes.v2.workflows.docs_explain_find_files.context import DocsExplainFindFilesContext
|
||||||
|
from app.core.agent.processes.v2.workflows.docs_explain_find_files.steps.finalize_find_files_answer_step import (
|
||||||
|
FinalizeFindFilesAnswerStep,
|
||||||
|
)
|
||||||
|
from app.core.agent.processes.v2.workflows.v2_workflow_graph import V2WorkflowGraph
|
||||||
|
|
||||||
|
|
||||||
|
class DocsExplainFindFilesGraph(V2WorkflowGraph[DocsExplainFindFilesContext]):
|
||||||
|
def __init__(self) -> None:
|
||||||
|
super().__init__(
|
||||||
|
workflow_id="v2.docs_explain.find_files",
|
||||||
|
source="workflow.v2.find_files",
|
||||||
|
steps=[FinalizeFindFilesAnswerStep()],
|
||||||
|
)
|
||||||
+25
@@ -0,0 +1,25 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.core.agent.processes.v2.workflows.docs_explain_find_files.context import DocsExplainFindFilesContext
|
||||||
|
from app.core.agent.utils.workflow import WorkflowStep
|
||||||
|
|
||||||
|
|
||||||
|
class FinalizeFindFilesAnswerStep(WorkflowStep[DocsExplainFindFilesContext]):
|
||||||
|
step_id = "finalize_find_files_answer"
|
||||||
|
title = "Сборка списка файлов"
|
||||||
|
|
||||||
|
async def run(self, context: DocsExplainFindFilesContext) -> DocsExplainFindFilesContext:
|
||||||
|
if not context.files:
|
||||||
|
context.answer = "Не нашёл файлов документации, которые уверенно соответствуют запросу."
|
||||||
|
return context
|
||||||
|
if context.gate_decision is not None and context.gate_decision.reason == "low_confidence_shortlist":
|
||||||
|
context.answer = "\n".join(item.path for item in context.files[:4])
|
||||||
|
return context
|
||||||
|
if len(context.files) == 1:
|
||||||
|
context.answer = context.files[0].path
|
||||||
|
return context
|
||||||
|
context.answer = "\n".join(item.path for item in context.files[:4])
|
||||||
|
return context
|
||||||
|
|
||||||
|
def trace_output(self, context: DocsExplainFindFilesContext) -> dict[str, object]:
|
||||||
|
return {"answer_length": len(context.answer)}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
from app.core.agent.processes.v2.workflows.docs_explain_summary.graph import DocsExplainSummaryGraph
|
||||||
|
|
||||||
|
__all__ = ["DocsExplainSummaryGraph"]
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
from app.core.agent.processes.v2.evidence.gate import EvidenceGateDecision
|
||||||
|
from app.core.agent.processes.v2.models import RetrievedSummary, V2RouteResult
|
||||||
|
from app.core.agent.runtime.execution_context import RuntimeExecutionContext
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class DocsExplainSummaryContext:
|
||||||
|
runtime: RuntimeExecutionContext
|
||||||
|
route: V2RouteResult
|
||||||
|
rag_session_id: str
|
||||||
|
prompt_name: str
|
||||||
|
workflow_llm_enabled: bool = True
|
||||||
|
documents: list[RetrievedSummary] = field(default_factory=list)
|
||||||
|
gate_decision: EvidenceGateDecision | None = None
|
||||||
|
prompt_input: str = ""
|
||||||
|
answer: str = ""
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.core.agent.processes.v2.workflows.docs_explain_summary.context import DocsExplainSummaryContext
|
||||||
|
from app.core.agent.processes.v2.workflows.docs_explain_summary.steps.generate_summary_answer_step import (
|
||||||
|
GenerateSummaryAnswerStep,
|
||||||
|
)
|
||||||
|
from app.core.agent.processes.v2.workflows.v2_workflow_graph import V2WorkflowGraph
|
||||||
|
from app.core.agent.utils.llm import AgentLlmService
|
||||||
|
|
||||||
|
|
||||||
|
class DocsExplainSummaryGraph(V2WorkflowGraph[DocsExplainSummaryContext]):
|
||||||
|
def __init__(self, llm: AgentLlmService) -> None:
|
||||||
|
super().__init__(
|
||||||
|
workflow_id="v2.docs_explain.summary",
|
||||||
|
source="workflow.v2.summary",
|
||||||
|
steps=[GenerateSummaryAnswerStep(llm)],
|
||||||
|
)
|
||||||
+68
@@ -0,0 +1,68 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
|
||||||
|
from app.core.agent.processes.v2.anchor_signals import route_anchor_summary
|
||||||
|
from app.core.agent.utils.llm import AgentLlmService
|
||||||
|
from app.core.agent.processes.v2.workflows.docs_explain_summary.context import DocsExplainSummaryContext
|
||||||
|
from app.core.agent.utils.workflow import WorkflowStep
|
||||||
|
|
||||||
|
|
||||||
|
class GenerateSummaryAnswerStep(WorkflowStep[DocsExplainSummaryContext]):
|
||||||
|
step_id = "generate_summary_answer"
|
||||||
|
title = "Сборка ответа по summary"
|
||||||
|
|
||||||
|
def __init__(self, llm: AgentLlmService) -> None:
|
||||||
|
self._llm = llm
|
||||||
|
|
||||||
|
async def run(self, context: DocsExplainSummaryContext) -> DocsExplainSummaryContext:
|
||||||
|
if context.gate_decision is not None and not context.gate_decision.passed:
|
||||||
|
context.answer = context.gate_decision.message
|
||||||
|
return context
|
||||||
|
if not context.workflow_llm_enabled:
|
||||||
|
context.answer = self._build_deterministic_answer(context)
|
||||||
|
return context
|
||||||
|
if not context.documents:
|
||||||
|
context.answer = "Не нашёл подходящих SUMMARY-блоков в документации по этому запросу."
|
||||||
|
return context
|
||||||
|
context.prompt_input = self._build_prompt_input(context)
|
||||||
|
request_id = context.runtime.request.request_id
|
||||||
|
context.answer = await asyncio.to_thread(
|
||||||
|
self._llm.generate,
|
||||||
|
context.prompt_name,
|
||||||
|
context.prompt_input,
|
||||||
|
log_context=f"agent:{request_id}",
|
||||||
|
trace=context.runtime.trace.module("workflow.v2.summary.llm"),
|
||||||
|
)
|
||||||
|
return context
|
||||||
|
|
||||||
|
def _build_prompt_input(self, context: DocsExplainSummaryContext) -> str:
|
||||||
|
blocks = [
|
||||||
|
f"Запрос пользователя:\n{context.route.user_query}",
|
||||||
|
"Сигналы запроса:\n" + json.dumps(route_anchor_summary(context.route), ensure_ascii=False, indent=2),
|
||||||
|
"Найденные SUMMARY-блоки:",
|
||||||
|
]
|
||||||
|
for index, item in enumerate(context.documents, start=1):
|
||||||
|
blocks.append(
|
||||||
|
f"{index}. path: {item.path}\n"
|
||||||
|
f"title: {item.title}\n"
|
||||||
|
f"match_reason: {item.match_reason}\n"
|
||||||
|
f"summary: {item.summary}"
|
||||||
|
)
|
||||||
|
return "\n\n".join(blocks)
|
||||||
|
|
||||||
|
def _build_deterministic_answer(self, context: DocsExplainSummaryContext) -> str:
|
||||||
|
if not context.documents:
|
||||||
|
return "Не нашёл подходящих SUMMARY-блоков в документации по этому запросу."
|
||||||
|
lines = []
|
||||||
|
primary = context.documents[0]
|
||||||
|
lines.append(primary.summary)
|
||||||
|
lines.append("")
|
||||||
|
lines.append("Файлы-источники:")
|
||||||
|
for item in context.documents:
|
||||||
|
lines.append(f"- {item.path}")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
def trace_output(self, context: DocsExplainSummaryContext) -> dict[str, object]:
|
||||||
|
return {"answer_length": len(context.answer)}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
from app.core.agent.processes.v2.workflows.general_summary.graph import GeneralSummaryGraph
|
||||||
|
|
||||||
|
__all__ = ["GeneralSummaryGraph"]
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
from app.core.agent.processes.v2.evidence.gate import EvidenceGateDecision
|
||||||
|
from app.core.agent.processes.v2.models import RetrievedSummary, V2RouteResult
|
||||||
|
from app.core.agent.runtime.execution_context import RuntimeExecutionContext
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class GeneralSummaryContext:
|
||||||
|
runtime: RuntimeExecutionContext
|
||||||
|
route: V2RouteResult
|
||||||
|
prompt_name: str
|
||||||
|
workflow_llm_enabled: bool = True
|
||||||
|
documents: list[RetrievedSummary] = field(default_factory=list)
|
||||||
|
gate_decision: EvidenceGateDecision | None = None
|
||||||
|
prompt_input: str = ""
|
||||||
|
answer: str = ""
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.core.agent.processes.v2.workflows.general_summary.context import GeneralSummaryContext
|
||||||
|
from app.core.agent.processes.v2.workflows.general_summary.steps.generate_general_summary_answer_step import (
|
||||||
|
GenerateGeneralSummaryAnswerStep,
|
||||||
|
)
|
||||||
|
from app.core.agent.processes.v2.workflows.v2_workflow_graph import V2WorkflowGraph
|
||||||
|
from app.core.agent.utils.llm import AgentLlmService
|
||||||
|
|
||||||
|
|
||||||
|
class GeneralSummaryGraph(V2WorkflowGraph[GeneralSummaryContext]):
|
||||||
|
def __init__(self, llm: AgentLlmService) -> None:
|
||||||
|
super().__init__(
|
||||||
|
workflow_id="v2.general_qa.summary",
|
||||||
|
source="workflow.v2.general_summary",
|
||||||
|
steps=[GenerateGeneralSummaryAnswerStep(llm)],
|
||||||
|
)
|
||||||
+57
@@ -0,0 +1,57 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
from app.core.agent.processes.v2.workflows.general_summary.context import GeneralSummaryContext
|
||||||
|
from app.core.agent.utils.llm import AgentLlmService
|
||||||
|
from app.core.agent.utils.workflow import WorkflowStep
|
||||||
|
|
||||||
|
|
||||||
|
class GenerateGeneralSummaryAnswerStep(WorkflowStep[GeneralSummaryContext]):
|
||||||
|
step_id = "generate_general_summary_answer"
|
||||||
|
title = "Общий ответ через LLM"
|
||||||
|
|
||||||
|
def __init__(self, llm: AgentLlmService) -> None:
|
||||||
|
self._llm = llm
|
||||||
|
|
||||||
|
async def run(self, context: GeneralSummaryContext) -> GeneralSummaryContext:
|
||||||
|
if context.gate_decision is not None and not context.gate_decision.passed:
|
||||||
|
context.answer = context.gate_decision.message
|
||||||
|
return context
|
||||||
|
if not context.workflow_llm_enabled:
|
||||||
|
context.answer = self._build_deterministic_answer(context)
|
||||||
|
return context
|
||||||
|
context.prompt_input = self._build_prompt_input(context)
|
||||||
|
request_id = context.runtime.request.request_id
|
||||||
|
context.answer = await asyncio.to_thread(
|
||||||
|
self._llm.generate,
|
||||||
|
context.prompt_name,
|
||||||
|
context.prompt_input,
|
||||||
|
log_context=f"agent:{request_id}",
|
||||||
|
trace=context.runtime.trace.module("workflow.v2.general_summary.llm"),
|
||||||
|
)
|
||||||
|
return context
|
||||||
|
|
||||||
|
def _build_prompt_input(self, context: GeneralSummaryContext) -> str:
|
||||||
|
blocks = [
|
||||||
|
f"Запрос пользователя:\n{context.route.user_query}",
|
||||||
|
"Опорные документы:",
|
||||||
|
]
|
||||||
|
for index, item in enumerate(context.documents, start=1):
|
||||||
|
blocks.append(
|
||||||
|
f"{index}. path: {item.path}\n"
|
||||||
|
f"title: {item.title}\n"
|
||||||
|
f"summary: {item.summary}"
|
||||||
|
)
|
||||||
|
return "\n\n".join(blocks)
|
||||||
|
|
||||||
|
def _build_deterministic_answer(self, context: GeneralSummaryContext) -> str:
|
||||||
|
if not context.documents:
|
||||||
|
return "В найденной документации нет достаточной опоры для общего summary по запросу."
|
||||||
|
return "\n".join(item.summary for item in context.documents[:2] if item.summary)
|
||||||
|
|
||||||
|
def trace_input(self, context: GeneralSummaryContext) -> dict[str, object]:
|
||||||
|
return {"query": context.route.normalized_query}
|
||||||
|
|
||||||
|
def trace_output(self, context: GeneralSummaryContext) -> dict[str, object]:
|
||||||
|
return {"answer_length": len(context.answer)}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
"""Workflow-граф v2: буфер шаговых логов и один сброс в trace в конце прогона."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Generic, Sequence, TypeVar
|
||||||
|
|
||||||
|
from app.core.agent.utils.workflow.context import WorkflowContext
|
||||||
|
from app.core.agent.utils.workflow.graph import WorkflowGraph
|
||||||
|
from app.core.agent.utils.workflow.step import WorkflowStep
|
||||||
|
|
||||||
|
|
||||||
|
TContext = TypeVar("TContext", bound=WorkflowContext)
|
||||||
|
|
||||||
|
|
||||||
|
class V2WorkflowGraph(WorkflowGraph[TContext]):
|
||||||
|
"""Не логирует step_started/step_completed по отдельности; сбрасывает буфер в ``workflow_trace_flushed``."""
|
||||||
|
|
||||||
|
async def run(self, context: TContext) -> TContext:
|
||||||
|
trace = context.runtime.trace.module(self._source)
|
||||||
|
trace.log("workflow_started", {"workflow_id": self._workflow_id})
|
||||||
|
steps_buffer: list[dict[str, object]] = []
|
||||||
|
for step in self._steps:
|
||||||
|
inp = step.trace_input(context)
|
||||||
|
request_id = context.runtime.request.request_id
|
||||||
|
await context.runtime.publisher.publish_status(
|
||||||
|
request_id,
|
||||||
|
self._source,
|
||||||
|
f"Шаг workflow: {step.title}.",
|
||||||
|
{"workflow_id": self._workflow_id, "step_id": step.step_id},
|
||||||
|
)
|
||||||
|
context = await step.run(context)
|
||||||
|
out = step.trace_output(context)
|
||||||
|
steps_buffer.append({"step_id": step.step_id, "title": step.title, "input": inp, "output": out})
|
||||||
|
trace.log(
|
||||||
|
"workflow_trace_flushed",
|
||||||
|
{"workflow_id": self._workflow_id, "steps": steps_buffer},
|
||||||
|
)
|
||||||
|
trace.log("workflow_completed", {"workflow_id": self._workflow_id})
|
||||||
|
return context
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
from app.core.agent.runtime.agent_runtime import AgentRuntime
|
||||||
|
from app.core.agent.runtime.execution_context import RuntimeExecutionContext
|
||||||
|
from app.core.agent.runtime.process_registry import ProcessRegistry
|
||||||
|
from app.core.agent.runtime.process_runner import ProcessRunner
|
||||||
|
from app.core.agent.runtime.publisher import RuntimeEventPublisher
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"AgentRuntime",
|
||||||
|
"ProcessRegistry",
|
||||||
|
"ProcessRunner",
|
||||||
|
"RuntimeEventPublisher",
|
||||||
|
"RuntimeExecutionContext",
|
||||||
|
]
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from app.core.api.application.session_service import SessionService
|
||||||
|
from app.core.api.domain.models.agent_request import AgentRequest
|
||||||
|
from app.core.api.domain.models.agent_session import AgentSession
|
||||||
|
from app.core.api.infrastructure.stores.in_memory_request_store import InMemoryRequestStore
|
||||||
|
from app.core.agent.runtime.execution_context import RuntimeExecutionContext
|
||||||
|
from app.core.agent.runtime.process_registry import ProcessRegistry
|
||||||
|
from app.core.agent.runtime.process_runner import ProcessRunner
|
||||||
|
from app.core.agent.runtime.publisher import RuntimeEventPublisher
|
||||||
|
from app.infra.exceptions import AppError
|
||||||
|
from app.infra.observability.module_trace import RequestTraceContext
|
||||||
|
from app.infra.observability.request_trace_logger import RequestTraceLogger
|
||||||
|
from app.schemas.common import ErrorPayload, ModuleName
|
||||||
|
from app.schemas.orchestration import RequestExecutionStatus
|
||||||
|
|
||||||
|
|
||||||
|
class AgentRuntime:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
request_store: InMemoryRequestStore,
|
||||||
|
sessions: SessionService,
|
||||||
|
process_registry: ProcessRegistry,
|
||||||
|
process_runner: ProcessRunner,
|
||||||
|
publisher: RuntimeEventPublisher,
|
||||||
|
trace_logger: RequestTraceLogger,
|
||||||
|
) -> None:
|
||||||
|
self._request_store = request_store
|
||||||
|
self._sessions = sessions
|
||||||
|
self._process_registry = process_registry
|
||||||
|
self._process_runner = process_runner
|
||||||
|
self._publisher = publisher
|
||||||
|
self._trace_logger = trace_logger
|
||||||
|
|
||||||
|
async def run(self, request: AgentRequest, session: AgentSession) -> None:
|
||||||
|
try:
|
||||||
|
process = self._resolve_process(request.process_version)
|
||||||
|
self._start_request(request, session)
|
||||||
|
context = RuntimeExecutionContext(
|
||||||
|
request=request,
|
||||||
|
session=session,
|
||||||
|
publisher=self._publisher,
|
||||||
|
trace=RequestTraceContext(request_id=request.request_id, logger=self._trace_logger),
|
||||||
|
)
|
||||||
|
await self._announce_start(request.request_id, process.version)
|
||||||
|
result = await self._process_runner.run(context, process)
|
||||||
|
request.answer = result.answer
|
||||||
|
await self._publish_result(request)
|
||||||
|
self._complete_request(request, session)
|
||||||
|
except Exception as exc:
|
||||||
|
await self._fail_request(request, exc)
|
||||||
|
|
||||||
|
def _resolve_process(self, version: str):
|
||||||
|
process = self._process_registry.get(version)
|
||||||
|
if process is None:
|
||||||
|
raise AppError("process_not_found", f"Unsupported process version: {version}", ModuleName.AGENT)
|
||||||
|
return process
|
||||||
|
|
||||||
|
def _start_request(self, request: AgentRequest, session: AgentSession) -> None:
|
||||||
|
request.status = RequestExecutionStatus.RUNNING
|
||||||
|
self._request_store.save(request)
|
||||||
|
self._trace_logger.start_request(request, session)
|
||||||
|
|
||||||
|
async def _announce_start(self, request_id: str, process_version: str) -> None:
|
||||||
|
await self._publisher.publish_status(request_id, "runtime", "Запрос принят и поставлен в обработку.")
|
||||||
|
await self._publisher.publish_status(
|
||||||
|
request_id,
|
||||||
|
"runtime",
|
||||||
|
f"Запускаю процесс {process_version}.",
|
||||||
|
{"process_version": process_version},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _publish_result(self, request: AgentRequest) -> None:
|
||||||
|
await self._publisher.publish_user(request.request_id, "agent", request.answer or "")
|
||||||
|
await self._publisher.publish_status(request.request_id, "runtime", "Обработка запроса завершена.")
|
||||||
|
|
||||||
|
def _complete_request(self, request: AgentRequest, session: AgentSession) -> None:
|
||||||
|
session.append_turn(user_message=request.message, assistant_message=request.answer or "")
|
||||||
|
self._sessions.save(session)
|
||||||
|
request.status = RequestExecutionStatus.DONE
|
||||||
|
request.completed_at = datetime.now(timezone.utc)
|
||||||
|
self._request_store.save(request)
|
||||||
|
self._trace_logger.complete_request(request)
|
||||||
|
|
||||||
|
async def _fail_request(self, request: AgentRequest, exc: Exception) -> None:
|
||||||
|
request.status = RequestExecutionStatus.ERROR
|
||||||
|
request.completed_at = datetime.now(timezone.utc)
|
||||||
|
request.error = self._build_error_payload(exc)
|
||||||
|
self._request_store.save(request)
|
||||||
|
self._trace_logger.fail_request(request)
|
||||||
|
await self._publisher.publish_status(
|
||||||
|
request.request_id,
|
||||||
|
"runtime",
|
||||||
|
"Во время обработки запроса произошла ошибка.",
|
||||||
|
{"code": request.error.code},
|
||||||
|
)
|
||||||
|
|
||||||
|
def _build_error_payload(self, exc: Exception) -> ErrorPayload:
|
||||||
|
if isinstance(exc, AppError):
|
||||||
|
return ErrorPayload(code=exc.code, desc=exc.desc, module=exc.module)
|
||||||
|
return ErrorPayload(
|
||||||
|
code="api_runtime_error",
|
||||||
|
desc="Agent request failed unexpectedly.",
|
||||||
|
module=ModuleName.AGENT,
|
||||||
|
)
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
from app.core.api.domain.models.agent_request import AgentRequest
|
||||||
|
from app.core.api.domain.models.agent_session import AgentSession
|
||||||
|
from app.infra.observability.module_trace import RequestTraceContext
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from app.core.agent.runtime.publisher import RuntimeEventPublisher
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class RuntimeExecutionContext:
|
||||||
|
request: AgentRequest
|
||||||
|
session: AgentSession
|
||||||
|
publisher: "RuntimeEventPublisher"
|
||||||
|
trace: RequestTraceContext
|
||||||
|
state: dict[str, Any] = field(default_factory=dict)
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Iterable
|
||||||
|
|
||||||
|
from app.core.agent.processes.base import AgentProcess
|
||||||
|
|
||||||
|
|
||||||
|
class ProcessRegistry:
|
||||||
|
def __init__(self, processes: Iterable[AgentProcess]) -> None:
|
||||||
|
self._items = {process.version: process for process in processes}
|
||||||
|
|
||||||
|
def get(self, version: str) -> AgentProcess | None:
|
||||||
|
return self._items.get(version)
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.core.agent.processes.base import AgentProcess, ProcessResult
|
||||||
|
from app.core.agent.runtime.execution_context import RuntimeExecutionContext
|
||||||
|
|
||||||
|
|
||||||
|
class ProcessRunner:
|
||||||
|
async def run(self, context: RuntimeExecutionContext, process: AgentProcess) -> ProcessResult:
|
||||||
|
return await process.run(context)
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.core.api.domain.events.client_event import ClientEventRecord
|
||||||
|
from app.core.api.infrastructure.streaming.sse_event_channel import SseEventChannel
|
||||||
|
from app.infra.observability.request_trace_logger import RequestTraceLogger
|
||||||
|
from app.schemas.client_events import ClientEventType
|
||||||
|
|
||||||
|
|
||||||
|
class RuntimeEventPublisher:
|
||||||
|
def __init__(self, channel: SseEventChannel, trace_logger: RequestTraceLogger) -> None:
|
||||||
|
self._channel = channel
|
||||||
|
self._trace_logger = trace_logger
|
||||||
|
|
||||||
|
async def publish_status(self, request_id: str, source: str, text: str, payload: dict | None = None) -> None:
|
||||||
|
await self._publish(request_id, ClientEventType.STATUS, source, text, payload)
|
||||||
|
|
||||||
|
async def publish_user(self, request_id: str, source: str, text: str, payload: dict | None = None) -> None:
|
||||||
|
await self._publish(request_id, ClientEventType.USER, source, text, payload)
|
||||||
|
|
||||||
|
async def _publish(
|
||||||
|
self,
|
||||||
|
request_id: str,
|
||||||
|
event_type: ClientEventType,
|
||||||
|
source: str,
|
||||||
|
text: str,
|
||||||
|
payload: dict | None = None,
|
||||||
|
) -> None:
|
||||||
|
event = ClientEventRecord(
|
||||||
|
request_id=request_id,
|
||||||
|
type=event_type,
|
||||||
|
source=source,
|
||||||
|
text=text,
|
||||||
|
payload=payload or {},
|
||||||
|
)
|
||||||
|
self._trace_logger.log_event(event)
|
||||||
|
await self._channel.publish(event)
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
from app.core.agent.utils.llm import AgentLlmService, PromptLoader
|
||||||
|
|
||||||
|
__all__ = ["AgentLlmService", "PromptLoader"]
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
from app.core.agent.utils.llm.prompt_loader import PromptLoader
|
||||||
|
from app.core.agent.utils.llm.service import AgentLlmService
|
||||||
|
|
||||||
|
__all__ = ["AgentLlmService", "PromptLoader"]
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from collections.abc import Iterable
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
|
||||||
|
class PromptLoader:
|
||||||
|
def __init__(self, prompts_path: Path | Iterable[Path] | None = None) -> None:
|
||||||
|
self._paths = self._resolve_paths(prompts_path)
|
||||||
|
self._prompts = self._load_prompts()
|
||||||
|
|
||||||
|
def load(self, name: str) -> str:
|
||||||
|
return str(self._prompts.get(name, "") or "").strip()
|
||||||
|
|
||||||
|
def _load_prompts(self) -> dict[str, str]:
|
||||||
|
merged: dict[str, str] = {}
|
||||||
|
for path in self._paths:
|
||||||
|
if not path.is_file():
|
||||||
|
continue
|
||||||
|
payload = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
continue
|
||||||
|
namespace = str(payload.get("namespace") or "").strip()
|
||||||
|
prompts = payload.get("prompts", payload)
|
||||||
|
if not isinstance(prompts, dict):
|
||||||
|
continue
|
||||||
|
for key, value in prompts.items():
|
||||||
|
prompt_name = f"{namespace}.{key}" if namespace else str(key)
|
||||||
|
merged[prompt_name] = str(value or "")
|
||||||
|
return merged
|
||||||
|
|
||||||
|
def _resolve_paths(self, prompts_path: Path | Iterable[Path] | None) -> tuple[Path, ...]:
|
||||||
|
if prompts_path is None:
|
||||||
|
base = Path(__file__).resolve().parent / "prompts.yml"
|
||||||
|
env_override = os.getenv("AGENT_PROMPTS_DIR", "").strip()
|
||||||
|
raw_path = Path(env_override) if env_override else base
|
||||||
|
return (raw_path / "prompts.yml" if raw_path.is_dir() else raw_path,)
|
||||||
|
if isinstance(prompts_path, Path):
|
||||||
|
return (prompts_path,)
|
||||||
|
return tuple(Path(item) for item in prompts_path)
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from app.modules.agent.observability.module_trace import ModuleTrace
|
from app.core.agent.utils.llm.prompt_loader import PromptLoader
|
||||||
from app.modules.agent.llm.prompt_loader import PromptLoader
|
from app.core.shared.gigachat.client import GigaChatClient
|
||||||
from app.modules.shared.gigachat.client import GigaChatClient
|
from app.infra.observability.module_trace import ModuleTrace
|
||||||
|
|
||||||
LOGGER = logging.getLogger(__name__)
|
LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Shared trace helpers will live here."""
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
from app.core.agent.utils.workflow.context import WorkflowContext
|
||||||
|
from app.core.agent.utils.workflow.graph import WorkflowGraph
|
||||||
|
from app.core.agent.utils.workflow.step import WorkflowStep
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"WorkflowContext",
|
||||||
|
"WorkflowGraph",
|
||||||
|
"WorkflowStep",
|
||||||
|
]
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Protocol
|
||||||
|
|
||||||
|
from app.core.agent.runtime.execution_context import RuntimeExecutionContext
|
||||||
|
|
||||||
|
|
||||||
|
class WorkflowContext(Protocol):
|
||||||
|
runtime: RuntimeExecutionContext
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Generic, Sequence, TypeVar
|
||||||
|
|
||||||
|
from app.core.agent.utils.workflow.context import WorkflowContext
|
||||||
|
from app.core.agent.utils.workflow.step import WorkflowStep
|
||||||
|
|
||||||
|
|
||||||
|
TContext = TypeVar("TContext", bound=WorkflowContext)
|
||||||
|
|
||||||
|
|
||||||
|
class WorkflowGraph(Generic[TContext]):
|
||||||
|
def __init__(self, workflow_id: str, source: str, steps: Sequence[WorkflowStep[TContext]]) -> None:
|
||||||
|
self._workflow_id = workflow_id
|
||||||
|
self._source = source
|
||||||
|
self._steps = tuple(steps)
|
||||||
|
|
||||||
|
async def run(self, context: TContext) -> TContext:
|
||||||
|
trace = context.runtime.trace.module(self._source)
|
||||||
|
trace.log("workflow_started", {"workflow_id": self._workflow_id})
|
||||||
|
for step in self._steps:
|
||||||
|
context = await self._run_step(context, step)
|
||||||
|
trace.log("workflow_completed", {"workflow_id": self._workflow_id})
|
||||||
|
return context
|
||||||
|
|
||||||
|
async def _run_step(self, context: TContext, step: WorkflowStep[TContext]) -> TContext:
|
||||||
|
request_id = context.runtime.request.request_id
|
||||||
|
trace = context.runtime.trace.module(self._source)
|
||||||
|
trace.log(
|
||||||
|
"step_started",
|
||||||
|
{"workflow_id": self._workflow_id, "step_id": step.step_id, "input": step.trace_input(context)},
|
||||||
|
)
|
||||||
|
await context.runtime.publisher.publish_status(
|
||||||
|
request_id,
|
||||||
|
self._source,
|
||||||
|
f"Шаг workflow: {step.title}.",
|
||||||
|
{"workflow_id": self._workflow_id, "step_id": step.step_id},
|
||||||
|
)
|
||||||
|
context = await step.run(context)
|
||||||
|
trace.log(
|
||||||
|
"step_completed",
|
||||||
|
{"workflow_id": self._workflow_id, "step_id": step.step_id, "output": step.trace_output(context)},
|
||||||
|
)
|
||||||
|
return context
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from typing import Any, Generic, TypeVar
|
||||||
|
|
||||||
|
|
||||||
|
TContext = TypeVar("TContext")
|
||||||
|
|
||||||
|
|
||||||
|
class WorkflowStep(ABC, Generic[TContext]):
|
||||||
|
step_id = ""
|
||||||
|
title = ""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def run(self, context: TContext) -> TContext:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def trace_input(self, context: TContext) -> dict[str, Any]:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def trace_output(self, context: TContext) -> dict[str, Any]:
|
||||||
|
return {}
|
||||||
+8
-8
@@ -2,11 +2,11 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
from app.modules.api.domain.models.agent_request import AgentRequest
|
from app.core.api.domain.models.agent_request import AgentRequest
|
||||||
from app.modules.api.infrastructure.ids.request_id_factory import RequestIdFactory
|
from app.core.api.infrastructure.ids.request_id_factory import RequestIdFactory
|
||||||
from app.modules.api.infrastructure.stores.in_memory_request_store import InMemoryRequestStore
|
from app.core.api.infrastructure.stores.in_memory_request_store import InMemoryRequestStore
|
||||||
from app.modules.api.application.session_service import SessionService
|
from app.core.api.application.session_service import SessionService
|
||||||
from app.modules.agent.orchestration.facade import OrchestrationFacade
|
from app.core.agent.runtime import AgentRuntime
|
||||||
|
|
||||||
|
|
||||||
class RequestService:
|
class RequestService:
|
||||||
@@ -15,12 +15,12 @@ class RequestService:
|
|||||||
request_store: InMemoryRequestStore,
|
request_store: InMemoryRequestStore,
|
||||||
request_ids: RequestIdFactory,
|
request_ids: RequestIdFactory,
|
||||||
sessions: SessionService,
|
sessions: SessionService,
|
||||||
orchestration: OrchestrationFacade,
|
runtime: AgentRuntime,
|
||||||
) -> None:
|
) -> None:
|
||||||
self._request_store = request_store
|
self._request_store = request_store
|
||||||
self._request_ids = request_ids
|
self._request_ids = request_ids
|
||||||
self._sessions = sessions
|
self._sessions = sessions
|
||||||
self._orchestration = orchestration
|
self._runtime = runtime
|
||||||
|
|
||||||
async def create(self, session_id: str, message: str, process_version: str) -> AgentRequest:
|
async def create(self, session_id: str, message: str, process_version: str) -> AgentRequest:
|
||||||
session = self._sessions.get(session_id)
|
session = self._sessions.get(session_id)
|
||||||
@@ -31,7 +31,7 @@ class RequestService:
|
|||||||
process_version=process_version,
|
process_version=process_version,
|
||||||
)
|
)
|
||||||
self._request_store.save(request)
|
self._request_store.save(request)
|
||||||
asyncio.create_task(self._orchestration.run(request, session))
|
asyncio.create_task(self._runtime.run(request, session))
|
||||||
return request
|
return request
|
||||||
|
|
||||||
def get(self, request_id: str) -> AgentRequest | None:
|
def get(self, request_id: str) -> AgentRequest | None:
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from app.core.api.application.session_service import SessionService
|
||||||
|
from app.core.api.domain.models.agent_session import AgentSession
|
||||||
|
from app.core.rag.indexing import IndexJob
|
||||||
|
from app.core.rag.module import RagModule
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class BootstrappedAgentSession:
|
||||||
|
session: AgentSession
|
||||||
|
index_job: IndexJob
|
||||||
|
|
||||||
|
|
||||||
|
class SessionBootstrapService:
|
||||||
|
def __init__(self, sessions: SessionService, rag: RagModule) -> None:
|
||||||
|
self._sessions = sessions
|
||||||
|
self._rag = rag
|
||||||
|
|
||||||
|
async def create(self, project_id: str, files: list[dict]) -> BootstrappedAgentSession:
|
||||||
|
rag_session, index_job = await self._rag.create_session(project_id=project_id, files=files)
|
||||||
|
session = self._sessions.create(rag_session_id=rag_session.rag_session_id)
|
||||||
|
return BootstrappedAgentSession(session=session, index_job=index_job)
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.infra.exceptions import AppError
|
||||||
|
from app.core.api.domain.models.agent_session import AgentSession
|
||||||
|
from app.core.api.infrastructure.ids.session_id_factory import SessionIdFactory
|
||||||
|
from app.core.api.infrastructure.stores.in_memory_session_store import InMemorySessionStore
|
||||||
|
from app.schemas.common import ModuleName
|
||||||
|
|
||||||
|
|
||||||
|
class SessionService:
|
||||||
|
def __init__(self, store: InMemorySessionStore, ids: SessionIdFactory) -> None:
|
||||||
|
self._store = store
|
||||||
|
self._ids = ids
|
||||||
|
|
||||||
|
def create(self, rag_session_id: str | None = None) -> AgentSession:
|
||||||
|
session = AgentSession.create(self._ids.create(), rag_session_id=rag_session_id)
|
||||||
|
return self._store.save(session)
|
||||||
|
|
||||||
|
def get(self, session_id: str) -> AgentSession:
|
||||||
|
session = self._store.get(session_id)
|
||||||
|
if session is None:
|
||||||
|
raise AppError("session_not_found", f"Agent session not found: {session_id}", ModuleName.BACKEND)
|
||||||
|
return session
|
||||||
|
|
||||||
|
def save(self, session: AgentSession) -> AgentSession:
|
||||||
|
return self._store.save(session)
|
||||||
+3
-3
@@ -1,8 +1,8 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from app.core.exceptions import AppError
|
from app.infra.exceptions import AppError
|
||||||
from app.modules.api.infrastructure.streaming.sse_encoder import SseEncoder
|
from app.core.api.infrastructure.streaming.sse_encoder import SseEncoder
|
||||||
from app.modules.api.infrastructure.streaming.sse_event_channel import SseEventChannel
|
from app.core.api.infrastructure.streaming.sse_event_channel import SseEventChannel
|
||||||
from app.schemas.common import ModuleName
|
from app.schemas.common import ModuleName
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.core.api.infrastructure.streaming.sse_response_builder import build_sse_response
|
||||||
|
from app.core.rag.module import RagModule
|
||||||
|
from app.core.shared.messaging import EventBus
|
||||||
|
from app.schemas.rag_sessions import RagSessionJobResponse
|
||||||
|
|
||||||
|
|
||||||
|
class RagPublicController:
|
||||||
|
def __init__(self, rag: RagModule) -> None:
|
||||||
|
self._rag = rag
|
||||||
|
|
||||||
|
def get_job(self, rag_session_id: str, index_job_id: str) -> RagSessionJobResponse:
|
||||||
|
job = self._rag.get_session_job(rag_session_id, index_job_id)
|
||||||
|
return RagSessionJobResponse(
|
||||||
|
rag_session_id=rag_session_id,
|
||||||
|
index_job_id=job.index_job_id,
|
||||||
|
status=job.status,
|
||||||
|
indexed_files=job.indexed_files,
|
||||||
|
failed_files=job.failed_files,
|
||||||
|
cache_hit_files=job.cache_hit_files,
|
||||||
|
cache_miss_files=job.cache_miss_files,
|
||||||
|
error=job.error.model_dump(mode="json") if job.error else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def stream_job_events(self, rag_session_id: str, index_job_id: str):
|
||||||
|
channel_id, queue = await self._rag.subscribe_session_job_events(rag_session_id, index_job_id)
|
||||||
|
return build_sse_response(
|
||||||
|
queue,
|
||||||
|
encoder=EventBus.as_sse,
|
||||||
|
unsubscribe=lambda: self._rag.unsubscribe_job_events(channel_id, queue),
|
||||||
|
stop_on_event="terminal",
|
||||||
|
)
|
||||||
+2
-2
@@ -1,7 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from app.core.exceptions import AppError
|
from app.infra.exceptions import AppError
|
||||||
from app.modules.api.application.request_service import RequestService
|
from app.core.api.application.request_service import RequestService
|
||||||
from app.schemas.agent_api import AgentRequestCreateRequest, AgentRequestQueuedResponse, AgentRequestStateResponse
|
from app.schemas.agent_api import AgentRequestCreateRequest, AgentRequestQueuedResponse, AgentRequestStateResponse
|
||||||
from app.schemas.common import ModuleName
|
from app.schemas.common import ModuleName
|
||||||
|
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.core.api.application.session_bootstrap_service import SessionBootstrapService
|
||||||
|
from app.schemas.agent_api import CreateAgentSessionRequest, CreateAgentSessionResponse
|
||||||
|
|
||||||
|
|
||||||
|
class SessionController:
|
||||||
|
def __init__(self, service: SessionBootstrapService) -> None:
|
||||||
|
self._service = service
|
||||||
|
|
||||||
|
async def create_session(self, request: CreateAgentSessionRequest) -> CreateAgentSessionResponse:
|
||||||
|
result = await self._service.create(
|
||||||
|
project_id=request.project_id,
|
||||||
|
files=[item.model_dump() for item in request.files],
|
||||||
|
)
|
||||||
|
session = result.session
|
||||||
|
return CreateAgentSessionResponse(
|
||||||
|
session_id=session.session_id,
|
||||||
|
rag_session_id=session.active_rag_session_id or "",
|
||||||
|
index_job_id=result.index_job.index_job_id,
|
||||||
|
status=result.index_job.status,
|
||||||
|
created_at=session.created_at,
|
||||||
|
)
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.core.api.infrastructure.streaming.sse_response_builder import build_sse_response
|
||||||
|
from app.core.api.application.stream_service import StreamService
|
||||||
|
|
||||||
|
|
||||||
|
class StreamController:
|
||||||
|
def __init__(self, service: StreamService) -> None:
|
||||||
|
self._service = service
|
||||||
|
|
||||||
|
async def stream(self, request_id: str):
|
||||||
|
queue = await self._service.subscribe(request_id)
|
||||||
|
return build_sse_response(
|
||||||
|
queue,
|
||||||
|
encoder=self._service.encode,
|
||||||
|
unsubscribe=lambda: self._service.unsubscribe(request_id, queue),
|
||||||
|
)
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from app.core.api.domain.models.agent_session_message import AgentSessionMessage
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class AgentSession:
|
||||||
|
session_id: str
|
||||||
|
active_rag_session_id: str | None
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
messages: list[AgentSessionMessage] = field(default_factory=list)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def create(cls, session_id: str, rag_session_id: str | None = None) -> "AgentSession":
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
return cls(
|
||||||
|
session_id=session_id,
|
||||||
|
active_rag_session_id=rag_session_id,
|
||||||
|
created_at=now,
|
||||||
|
updated_at=now,
|
||||||
|
)
|
||||||
|
|
||||||
|
def append_turn(self, user_message: str, assistant_message: str, route_result=None) -> None:
|
||||||
|
self._append_message("user", user_message)
|
||||||
|
self._append_message("assistant", assistant_message)
|
||||||
|
self.updated_at = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
def _append_message(self, role: str, text: str) -> None:
|
||||||
|
value = text.strip()
|
||||||
|
if value:
|
||||||
|
self.messages.append(AgentSessionMessage.create(role, value))
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
|
||||||
|
SessionMessageRole = Literal["user", "assistant"]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class AgentSessionMessage:
|
||||||
|
role: SessionMessageRole
|
||||||
|
text: str
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def create(cls, role: SessionMessageRole, text: str) -> "AgentSessionMessage":
|
||||||
|
return cls(role=role, text=text, created_at=datetime.now(timezone.utc))
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user