From 2b807623f1305882f7086f5c9fd1448506d8ae25 Mon Sep 17 00:00:00 2001 From: zosimovaa Date: Thu, 16 Apr 2026 11:37:11 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A4=D0=B8=D0=BA=D1=81=D0=B8=D1=80=D1=83?= =?UTF-8?q?=D1=8E=20=D1=81=D0=BE=D1=81=D1=82=D0=BE=D1=8F=D0=BD=D0=B8=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 3 + .../04. Analitycs artefacts - features.md | 29 ++- .../common-elements/api-contract.md | 7 + _process/doc_rules_v3/common-elements/fr.md | 3 + .../common-elements/tech-use-case.md | 3 + _process/doc_rules_v3/documentation-rules.md | 13 +- _process/doc_rules_v3/global/frontmatter.md | 5 + pyproject.toml | 1 + requirements.txt | 1 + src/app/core/agent/processes/v2/doc_rules.zip | Bin 29271 -> 0 bytes .../processes/v2/intent_router/router.py | 19 +- .../routers/docs_subintent_resolver.py | 25 ++- .../v2/intent_router/routers/fallback.py | 18 ++ .../v2/intent_router/routers/prompts.yml | 3 +- .../v2/intent_router/routers/route_catalog.py | 1 + .../core/agent/processes/v2/router_status.py | 65 +++++++ .../README.md | 9 - .../attribute_resolution.md | 5 - .../path_resolution.md | 7 - .../section_details.md | 3 - .../section_frontmatter.md | 4 - .../section_summary.md | 3 - src/app/core/agent/processes/v2/v2_process.py | 46 +++-- .../workflows/doc_generate_openapi/README.md | 11 ++ .../doc_generate_openapi/__init__.py | 1 + .../workflows/doc_generate_openapi/graph.py | 73 +++++++ .../doc_generate_openapi/prompts/prompts.yml | 35 ++++ .../doc_generate_openapi/steps/__init__.py | 1 + .../steps/build_openapi_operations_step.py | 34 ++++ .../enrich_openapi_from_contract_step.py | 74 +++++++ .../steps/fetch_contract_sections_step.py | 43 +++++ .../steps/finalize_openapi_answer_step.py | 37 ++++ .../steps/generate_openapi_yaml_step.py | 32 ++++ .../steps/openapi_contract_llm_enricher.py | 75 ++++++++ .../steps/openapi_contract_parser.py | 175 +++++++++++++++++ .../steps/openapi_yaml_renderer.py | 129 +++++++++++++ .../steps/retrieval/__init__.py | 1 + .../retrieval/openapi_operation_collector.py | 123 ++++++++++++ .../steps/retrieval/retrieval_policy.py | 75 ++++++++ .../workflow_runtime/__init__.py | 1 + .../workflow_runtime/buffered_graph.py | 43 +++++ .../workflow_runtime/context.py | 23 +++ .../workflow_runtime/context_protocols.py | 26 +++ .../workflow_runtime/models.py | 20 ++ .../steps/step4_prepare_tasks/services.py | 17 +- .../change_evaluator.py | 103 ++++++++++ .../step5_execute_subprocesses/classifier.py | 22 ++- .../steps/step5_execute_subprocesses/step.py | 9 +- .../subprocesses/common/doc_type_policy.py | 60 ++++++ .../prompts/prompts.yml | 2 + .../prompts/prompts.yml | 3 + .../prompts/prompts.yml | 3 + .../workflow_runtime/models.py | 2 + src/app/core/agent/runtime/agent_runtime.py | 46 +++-- src/app/core/agent/utils/process_v2/models.py | 1 + .../plan_resolver/policy_resolver.py | 4 + .../api/controllers/request_controller.py | 9 +- .../core/api/domain/models/agent_request.py | 19 ++ .../core/api/domain/models/request_route.py | 21 ++ src/app/core/application.py | 2 + src/app/core/shared/gigachat/client.py | 5 +- src/app/core/shared/gigachat/errors.py | 4 +- src/app/core/shared/gigachat/settings.py | 2 +- .../core/shared/gigachat/token_provider.py | 5 +- src/app/main.py | 9 + src/app/schemas/agent_api.py | 9 + .../router_only_docs_v2_matrix.yaml | 16 ++ .../cases.yaml | 23 +++ .../suite_07/process_v2_full_chain/cases.yaml | 22 +++ ...t_doc_update_from_feature_v2_happy_path.py | 129 +++++++++++++ .../unit_tests/agent/test_v2_intent_router.py | 18 ++ .../agent/test_v2_openapi_generation.py | 181 ++++++++++++++++++ tests/unit_tests/agent/test_v2_process.py | 42 +++- .../agent/test_v2_retrieval_policy.py | 19 ++ .../unit_tests/api/test_request_controller.py | 32 ++++ 75 files changed, 2065 insertions(+), 79 deletions(-) delete mode 100644 src/app/core/agent/processes/v2/doc_rules.zip create mode 100644 src/app/core/agent/processes/v2/router_status.py delete mode 100644 src/app/core/agent/processes/v2/rules_doc_update_from_feature_v2/README.md delete mode 100644 src/app/core/agent/processes/v2/rules_doc_update_from_feature_v2/attribute_resolution.md delete mode 100644 src/app/core/agent/processes/v2/rules_doc_update_from_feature_v2/path_resolution.md delete mode 100644 src/app/core/agent/processes/v2/rules_doc_update_from_feature_v2/section_details.md delete mode 100644 src/app/core/agent/processes/v2/rules_doc_update_from_feature_v2/section_frontmatter.md delete mode 100644 src/app/core/agent/processes/v2/rules_doc_update_from_feature_v2/section_summary.md create mode 100644 src/app/core/agent/processes/v2/workflows/doc_generate_openapi/README.md create mode 100644 src/app/core/agent/processes/v2/workflows/doc_generate_openapi/__init__.py create mode 100644 src/app/core/agent/processes/v2/workflows/doc_generate_openapi/graph.py create mode 100644 src/app/core/agent/processes/v2/workflows/doc_generate_openapi/prompts/prompts.yml create mode 100644 src/app/core/agent/processes/v2/workflows/doc_generate_openapi/steps/__init__.py create mode 100644 src/app/core/agent/processes/v2/workflows/doc_generate_openapi/steps/build_openapi_operations_step.py create mode 100644 src/app/core/agent/processes/v2/workflows/doc_generate_openapi/steps/enrich_openapi_from_contract_step.py create mode 100644 src/app/core/agent/processes/v2/workflows/doc_generate_openapi/steps/fetch_contract_sections_step.py create mode 100644 src/app/core/agent/processes/v2/workflows/doc_generate_openapi/steps/finalize_openapi_answer_step.py create mode 100644 src/app/core/agent/processes/v2/workflows/doc_generate_openapi/steps/generate_openapi_yaml_step.py create mode 100644 src/app/core/agent/processes/v2/workflows/doc_generate_openapi/steps/openapi_contract_llm_enricher.py create mode 100644 src/app/core/agent/processes/v2/workflows/doc_generate_openapi/steps/openapi_contract_parser.py create mode 100644 src/app/core/agent/processes/v2/workflows/doc_generate_openapi/steps/openapi_yaml_renderer.py create mode 100644 src/app/core/agent/processes/v2/workflows/doc_generate_openapi/steps/retrieval/__init__.py create mode 100644 src/app/core/agent/processes/v2/workflows/doc_generate_openapi/steps/retrieval/openapi_operation_collector.py create mode 100644 src/app/core/agent/processes/v2/workflows/doc_generate_openapi/steps/retrieval/retrieval_policy.py create mode 100644 src/app/core/agent/processes/v2/workflows/doc_generate_openapi/workflow_runtime/__init__.py create mode 100644 src/app/core/agent/processes/v2/workflows/doc_generate_openapi/workflow_runtime/buffered_graph.py create mode 100644 src/app/core/agent/processes/v2/workflows/doc_generate_openapi/workflow_runtime/context.py create mode 100644 src/app/core/agent/processes/v2/workflows/doc_generate_openapi/workflow_runtime/context_protocols.py create mode 100644 src/app/core/agent/processes/v2/workflows/doc_generate_openapi/workflow_runtime/models.py create mode 100644 src/app/core/agent/processes/v2/workflows/doc_update_from_feature_v2/steps/step5_execute_subprocesses/change_evaluator.py create mode 100644 src/app/core/agent/processes/v2/workflows/doc_update_from_feature_v2/subprocesses/common/doc_type_policy.py create mode 100644 src/app/core/api/domain/models/request_route.py create mode 100644 tests/unit_tests/agent/test_v2_openapi_generation.py create mode 100644 tests/unit_tests/api/test_request_controller.py diff --git a/.gitignore b/.gitignore index d72a197..f4921a8 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,9 @@ .venv __pycache__ +# Runtime agent traces (local only; written by RequestTraceLogger) +runtime_traces/ + # Pipeline harness: per-run artifacts (md/json from tests.pipeline_setup_v3/v4) tests/**/test_runs/**/*.md tests/**/test_runs/**/*.json diff --git a/_process/04. Analitycs artefacts - features.md b/_process/04. Analitycs artefacts - features.md index 2e41b24..5cb435e 100644 --- a/_process/04. Analitycs artefacts - features.md +++ b/_process/04. Analitycs artefacts - features.md @@ -91,6 +91,12 @@ Mermaid-диаграмма должна содержать: - `application` - `platform` +Дополнительные метаданные для случаев изменения существующей документации: + +- `action` +- `target_doc_id` +- `target_path` + #### 6.x для `ui_page` Обязательная структура: @@ -117,6 +123,24 @@ Mermaid-диаграмма должна содержать: - Поля описывать списком (не таблицей). - Общие правила (например, read-only, поведение при пустом значении) выносить в общий блок, не дублировать для каждого поля. +Отдельно нужно различать два сценария описания: + +1. Если описывается новая UI-страница или новая самостоятельная UI-форма, раздел оформляется полноценно по шаблону `ui_page`. +- Нужно дать достаточный контекст для разработки и тестирования. +- Нужно подробно описывать структуру формы, состояния отображения, поведение полей, ошибки, empty state и пользовательские действия. + +2. Если описывается доработка уже существующей страницы или существующей UI-формы, не нужно повторно копировать полное описание из действующей документации. +- Нужно учитывать уже существующее описание страницы в документации и аналитике. +- В аналитике нужно явно указать, что именно меняется в существующем сценарии: что добавляется, редактируется или удаляется. +- Нужно указывать точку изменения: в какой существующей странице, форме, блоке или сценарии вносится изменение. +- Нужно ссылаться на существующий документ или раздел, где базовое поведение уже описано. +- Нужно описывать только delta изменений, достаточную для реализации доработки и актуализации документации. +- Полное описание существующей страницы в таком разделе не дублируется. +- Для такой доработки в metadata нужно явно указывать `action: update`. +- Если изменение должно попасть в уже существующий markdown-документ, нужно явно указывать `target_doc_id` и/или `target_path`. +- `target_doc_id` должен совпадать с `id` существующего документа, который требуется обновить. +- Если `target_doc_id`/`target_path` не указаны, агент может ошибочно интерпретировать раздел как создание нового документа. + Нефункциональные требования для `ui_page`: - пользовательская аналитика оформляется таблицей с колонками: @@ -172,6 +196,10 @@ Mermaid-диаграмма должна содержать: - `### Функциональные требования` - `### Нефункциональные требования` +`logic_block` удобно использовать для фиксации точечных изменений существующего сценария, если раздел не описывает новую самостоятельную страницу или новую самостоятельную форму, а только уточняет delta к уже существующей документации. + +Если точечное изменение должно изменить существующий документ другого типа, `logic_block` для этого использовать нельзя. В этом случае metadata раздела должна указывать тип и идентификатор целевого существующего документа, который требуется обновить. + ## Дополнительные правила по слоям - Проверка ролевой модели пользователя обычно выполняется на уровне `ufs`. @@ -182,4 +210,3 @@ Mermaid-диаграмма должна содержать: - Аудит: события, которые фиксируют действия пользователя и позволяют ответить на вопрос «кто, что, когда сделал». - Мониторинг: технические события/метрики для контроля стабильности и поиска сбоев. - diff --git a/_process/doc_rules_v3/common-elements/api-contract.md b/_process/doc_rules_v3/common-elements/api-contract.md index 2070782..3c8def2 100644 --- a/_process/doc_rules_v3/common-elements/api-contract.md +++ b/_process/doc_rules_v3/common-elements/api-contract.md @@ -1,5 +1,7 @@ # API Contract Rules +Этот rule описывает только тело секции `### Контракт`. + ## Обязательные части - request parameters (`header/query/path`) - request body (если применимо) @@ -9,6 +11,11 @@ - timeout - retry/idempotency (если применимо) +## Правила заголовков внутри тела секции +- Не повторять заголовок `Контракт`. +- Запрещено выводить `## Контракт` и `### Контракт` внутри тела секции. +- Если нужны подзаголовки, использовать только уровень ниже родительской секции: `#### Запрос`, `#### Ответ`, `#### Ошибки`, `#### Auth`, `#### Timeout`, `#### Retry/Idempotency`. + ## Табличный формат Для request/response таблицы должны содержать: - название diff --git a/_process/doc_rules_v3/common-elements/fr.md b/_process/doc_rules_v3/common-elements/fr.md index 0e3e206..0608b13 100644 --- a/_process/doc_rules_v3/common-elements/fr.md +++ b/_process/doc_rules_v3/common-elements/fr.md @@ -1,5 +1,7 @@ # Functional Requirements Rules +Этот rule описывает только тело секции `### Функциональные требования`. + ## Формат - `FR.<номер>. <Название>` - Нумерация инкрементальная внутри документа. @@ -9,6 +11,7 @@ - FR не копируют шаги сценария без добавления новой информации. - Для интеграционных шагов FR обязательны. - Если в сценарии есть вызов внешнего API / сервиса / БД, нужен отдельный FR на интеграцию. +- Запрещено повторять заголовок `### Функциональные требования` внутри тела секции. ## FR для интеграционных шагов Для интеграционного FR обязательно раскрывать: diff --git a/_process/doc_rules_v3/common-elements/tech-use-case.md b/_process/doc_rules_v3/common-elements/tech-use-case.md index 9ae3b64..8be4784 100644 --- a/_process/doc_rules_v3/common-elements/tech-use-case.md +++ b/_process/doc_rules_v3/common-elements/tech-use-case.md @@ -1,5 +1,7 @@ # Tech Use Case Rules +Этот rule описывает только тело секции `### Технический use case`. + ## Обязательные части - название - предусловия @@ -14,3 +16,4 @@ - Формат шага: смысловое действие + техническая реализация (endpoint/топик/операция). - Длинные технические детали выносить в FR и ссылаться на FR из шага. - Для интеграционных шагов описание обработки ошибок обязательно. +- Запрещено повторять заголовок `### Технический use case` внутри тела секции. diff --git a/_process/doc_rules_v3/documentation-rules.md b/_process/doc_rules_v3/documentation-rules.md index 9d0b6d9..abe6ac8 100644 --- a/_process/doc_rules_v3/documentation-rules.md +++ b/_process/doc_rules_v3/documentation-rules.md @@ -7,6 +7,10 @@ - Структура документа определяется только template соответствующего типа. - Правила написания конкретного раздела определяются только соответствующим `common-elements` файлом. - Manifest типа документа хранится во frontmatter соответствующего template. +- Генератор секции всегда пишет только тело секции, а не сам заголовок секции. +- Дублирование заголовков запрещено: нельзя повторно выводить заголовок текущей секции внутри ее тела. +- Если template уже содержит `### <Заголовок секции>`, то внутри тела допустимы только подзаголовки более глубокого уровня (`####` и ниже). +- Нельзя повышать уровень заголовка внутри тела секции до `##` или повторять `###` с тем же названием секции. ## 2. Источники требований При генерации документа учитывать: @@ -34,7 +38,14 @@ 5. Применить body template как единственный источник структуры. 5. Проверить чек-лист совместимости с аналитикой (domain/sub_domain, роли слоев, интеграции, ошибки). -## 6. Формат manifest типа документа +## 6. Специальные инварианты для `api_method` +- Во frontmatter обязательно должно присутствовать поле `endpoint`. +- Внутри `## Details` секция `### Контракт` должна присутствовать ровно один раз. +- Внутри тела секции `### Контракт` запрещено повторять заголовки `## Контракт` и `### Контракт`. +- Внутри `### Технический use case` запрещено повторять заголовок `### Технический use case`. +- Внутри `### Функциональные требования` запрещено повторять заголовок `### Функциональные требования`. + +## 7. Формат manifest типа документа Manifest типа документа хранится во frontmatter `templates/.template.md`. Минимальная схема: diff --git a/_process/doc_rules_v3/global/frontmatter.md b/_process/doc_rules_v3/global/frontmatter.md index b0e94e1..7c85ec8 100644 --- a/_process/doc_rules_v3/global/frontmatter.md +++ b/_process/doc_rules_v3/global/frontmatter.md @@ -20,6 +20,11 @@ related_code: [] system_analytics_refs: [] ``` +## Дополнительные обязательные поля по типам документов +- Для `doc_type: api_method` поле `endpoint` обязательно. +- Значение `endpoint` должно содержать HTTP-метод и путь, например: `GET /orders/{orderId}`. +- Если в аналитике endpoint указан в заголовке раздела, use case, контракте или интеграционной схеме, его нужно перенести во frontmatter и не опускать. + ## Body-метаданные для секции изменений Под корнем секции изменений указывать: - `domain` diff --git a/pyproject.toml b/pyproject.toml index f4b411e..18932e3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,6 +11,7 @@ requires-python = ">=3.11" dependencies = [ "fastapi>=0.116", "uvicorn>=0.35", + "python-dotenv>=1.0", "pydantic>=2.11", "langgraph>=0.6", "langgraph-checkpoint-postgres>=2.0", diff --git a/requirements.txt b/requirements.txt index 79e0566..b771587 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,6 @@ fastapi==0.116.1 uvicorn==0.35.0 +python-dotenv==1.0.1 pydantic==2.11.7 langgraph==0.6.7 langgraph-checkpoint-postgres==2.0.23 diff --git a/src/app/core/agent/processes/v2/doc_rules.zip b/src/app/core/agent/processes/v2/doc_rules.zip deleted file mode 100644 index 0b5cb5b469bb125632a935ca74b3371b4948c966..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 29271 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 U(+mK>a{D6_6#yVZ_3lsq4~&C&82|tP diff --git a/src/app/core/agent/processes/v2/intent_router/router.py b/src/app/core/agent/processes/v2/intent_router/router.py index da74027..b38ccfc 100644 --- a/src/app/core/agent/processes/v2/intent_router/router.py +++ b/src/app/core/agent/processes/v2/intent_router/router.py @@ -18,11 +18,12 @@ from app.core.agent.processes.v2.intent_router.modules.scope_resolver import ( 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.docs_subintent_resolver import DocsSubintentResolver 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.utils.process_v2.models import V2RouteResult, V2ScopeType +from app.core.agent.utils.process_v2.models import V2RouteResult, V2ScopeType, V2Subintent from app.core.agent.utils.llm import AgentLlmService if TYPE_CHECKING: @@ -113,6 +114,7 @@ class V2IntentRouter: self._catalog = route_catalog or V2RouteCatalog() self._validator = V2RouteValidator(self._catalog) self._fallback_router = V2FallbackRouter() + self._docs_subintent_resolver = DocsSubintentResolver() 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 @@ -191,6 +193,21 @@ class V2IntentRouter: reason_short="explicit file reference", scope_type=resolution.scope_type, ) + if self._docs_subintent_resolver.resolve(features) == V2Subintent.OPENAPI_GENERATE: + return V2RouteResult( + routing_domain="DOCS", + intent="DOC_EXPLAIN", + subintent=V2Subintent.OPENAPI_GENERATE, + user_query=user_query, + normalized_query=features.normalized_query, + target_terms=features.target_terms, + anchors=anchor_analysis.anchors, + confidence=1.0, + routing_mode="deterministic", + llm_router_used=False, + reason_short="explicit openapi generation request", + scope_type=resolution.scope_type, + ) llm_attempted = self._enable_llm_disambiguation and self._llm_router is not None llm_candidate = self._route_with_llm( features=features, diff --git a/src/app/core/agent/processes/v2/intent_router/routers/docs_subintent_resolver.py b/src/app/core/agent/processes/v2/intent_router/routers/docs_subintent_resolver.py index 498e3c4..7c8a087 100644 --- a/src/app/core/agent/processes/v2/intent_router/routers/docs_subintent_resolver.py +++ b/src/app/core/agent/processes/v2/intent_router/routers/docs_subintent_resolver.py @@ -5,6 +5,16 @@ from app.core.agent.utils.process_v2.models import V2Subintent class DocsSubintentResolver: + _OPENAPI_MARKERS = ( + "openapi", + "swagger", + "спецификац", + "спека", + "contract yaml", + "api yaml", + ) + _GENERATE_MARKERS = ("сгенерируй", "построй", "собери", "generate", "build", "show") + _FORMAT_MARKERS = ("yaml", "json", "xml") _API_ENUM_MARKERS = ( "какие api", "какие эндпоинты", @@ -24,7 +34,11 @@ class DocsSubintentResolver: _LIST_WORD_MARKERS = ("какие", "список", "перечисли", "все", "доступные", "list", "available", "exposed") def resolve(self, features: QueryFeatures) -> str | None: - if features.file_markers or self._has_file_like_anchor(features): + if features.file_markers: + return V2Subintent.FIND_FILES + if self._is_openapi_request(features): + return V2Subintent.OPENAPI_GENERATE + if self._has_file_like_anchor(features): return V2Subintent.FIND_FILES if self._is_api_exposed_request(features): return V2Subintent.API_EXPOSED @@ -47,6 +61,15 @@ class DocsSubintentResolver: for hint in features.target_doc_hints ) or any(token.endswith((".md", ".yaml", ".yml", ".json")) for token in features.file_names) + def _is_openapi_request(self, features: QueryFeatures) -> bool: + query = features.normalized_query.lower() + if any(marker in query for marker in self._OPENAPI_MARKERS): + return True + has_api_words = any(marker in query for marker in self._API_WORD_MARKERS) + has_generate_words = any(marker in query for marker in self._GENERATE_MARKERS) + has_format_words = any(marker in query for marker in self._FORMAT_MARKERS) + return has_api_words and has_generate_words and has_format_words + def _is_api_exposed_request(self, features: QueryFeatures) -> bool: query = features.normalized_query.lower() if features.endpoint_paths: diff --git a/src/app/core/agent/processes/v2/intent_router/routers/fallback.py b/src/app/core/agent/processes/v2/intent_router/routers/fallback.py index 245716a..48df261 100644 --- a/src/app/core/agent/processes/v2/intent_router/routers/fallback.py +++ b/src/app/core/agent/processes/v2/intent_router/routers/fallback.py @@ -62,6 +62,16 @@ class V2FallbackRouter: reason_short="fallback docs update from feature", scope_type=scope_type, ) + if self._has_openapi_signal(features): + return self._build_docs_result( + user_query=user_query, + features=features, + anchors=anchors, + subintent=V2Subintent.OPENAPI_GENERATE, + llm_attempted=llm_attempted, + reason="fallback docs openapi", + scope_type=scope_type, + ) if self._has_api_exposed_signal(features): return self._build_docs_result( user_query=user_query, @@ -136,6 +146,14 @@ class V2FallbackRouter: ) ) + def _has_openapi_signal(self, features: QueryFeatures) -> bool: + query = features.normalized_query.lower() + has_spec = any(marker in query for marker in ("openapi", "swagger", "спецификац", "спека")) + has_format = any(marker in query for marker in ("yaml", "json", "xml")) + has_generate = any(marker in query for marker in ("сгенерируй", "построй", "собери", "generate", "build")) + has_api = any(marker in query for marker in ("api", "эндпоинт", "endpoint", "роут", "route", "метод")) + return has_spec or (has_api and has_generate and has_format) + def _has_api_exposed_signal(self, features: QueryFeatures) -> bool: query = features.normalized_query.lower() has_api = any(marker in query for marker in ("api", "эндпоинт", "endpoint", "роут", "route", "метод")) diff --git a/src/app/core/agent/processes/v2/intent_router/routers/prompts.yml b/src/app/core/agent/processes/v2/intent_router/routers/prompts.yml index e0a4f6d..92b2de0 100644 --- a/src/app/core/agent/processes/v2/intent_router/routers/prompts.yml +++ b/src/app/core/agent/processes/v2/intent_router/routers/prompts.yml @@ -7,6 +7,7 @@ prompts: Основной принцип: - DOCS / DOC_EXPLAIN / FIND_FILES: запрос просит найти файл, документ или путь. - DOCS / DOC_EXPLAIN / API_EXPOSED: запрос просит перечислить доступные API-методы/эндпоинты. + - DOCS / DOC_EXPLAIN / OPENAPI_GENERATE: запрос просит собрать OpenAPI/Swagger спецификацию по API-методам. - DOCS / DOC_EXPLAIN / SUMMARY: запрос просит объяснить документацию, endpoint, архитектуру, процесс или сущность. - DOCS / DOC_UPDATE / FROM_FEATURE: запрос просит обновить документацию по системной аналитике (feature markdown/confluence). - GENERAL / GENERAL_QA / SUMMARY: общий обзорный вопрос без явного запроса к документации. @@ -21,7 +22,7 @@ prompts: { "routing_domain": "GENERAL" | "DOCS", "intent": "GENERAL_QA" | "DOC_EXPLAIN" | "DOC_UPDATE", - "subintent": "SUMMARY" | "FIND_FILES" | "API_EXPOSED" | "FROM_FEATURE", + "subintent": "SUMMARY" | "FIND_FILES" | "API_EXPOSED" | "OPENAPI_GENERATE" | "FROM_FEATURE", "confidence": 0.0-1.0, "reason_short": "короткая причина" } diff --git a/src/app/core/agent/processes/v2/intent_router/routers/route_catalog.py b/src/app/core/agent/processes/v2/intent_router/routers/route_catalog.py index 295928c..e298922 100644 --- a/src/app/core/agent/processes/v2/intent_router/routers/route_catalog.py +++ b/src/app/core/agent/processes/v2/intent_router/routers/route_catalog.py @@ -7,6 +7,7 @@ class V2RouteCatalog: _ALLOWED_ROUTES = ( (V2Domain.DOCS, V2Intent.DOC_EXPLAIN, V2Subintent.FIND_FILES), (V2Domain.DOCS, V2Intent.DOC_EXPLAIN, V2Subintent.API_EXPOSED), + (V2Domain.DOCS, V2Intent.DOC_EXPLAIN, V2Subintent.OPENAPI_GENERATE), (V2Domain.DOCS, V2Intent.DOC_EXPLAIN, V2Subintent.SUMMARY), (V2Domain.DOCS, V2Intent.DOC_UPDATE, V2Subintent.FROM_FEATURE), (V2Domain.GENERAL, V2Intent.GENERAL_QA, V2Subintent.SUMMARY), diff --git a/src/app/core/agent/processes/v2/router_status.py b/src/app/core/agent/processes/v2/router_status.py new file mode 100644 index 0000000..0c45243 --- /dev/null +++ b/src/app/core/agent/processes/v2/router_status.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +from dataclasses import dataclass + +from app.core.agent.utils.process_v2.models import V2Intent, V2RouteResult, V2Subintent + + +@dataclass(frozen=True, slots=True) +class RouterStatusEvent: + text: str + payload: dict[str, object] + + +class V2RouterStatusBuilder: + def build(self, route: V2RouteResult) -> RouterStatusEvent: + subintent_label = self._subintent_label(route.intent, route.subintent) + subintent_comment = self._subintent_comment(route.intent, route.subintent) + return RouterStatusEvent( + text=f"Выбран subintent {route.subintent} - {subintent_comment}", + payload={ + "routing_domain": route.routing_domain, + "intent": route.intent, + "subintent": route.subintent, + "subintent_label": subintent_label, + "subintent_comment": subintent_comment, + "confidence": route.confidence, + "routing_mode": route.routing_mode, + "llm_router_used": route.llm_router_used, + "reason_short": route.reason_short, + }, + ) + + def _subintent_label(self, intent: str, subintent: str) -> str: + labels = { + (V2Intent.DOC_EXPLAIN, V2Subintent.SUMMARY): "объяснение документации", + (V2Intent.DOC_EXPLAIN, V2Subintent.FIND_FILES): "поиск файлов документации", + (V2Intent.DOC_EXPLAIN, V2Subintent.API_EXPOSED): "поиск API-методов", + (V2Intent.DOC_EXPLAIN, V2Subintent.OPENAPI_GENERATE): "генерация OpenAPI-спецификации", + (V2Intent.DOC_UPDATE, V2Subintent.FROM_FEATURE): "обновление документации по аналитике", + (V2Intent.GENERAL_QA, V2Subintent.SUMMARY): "общий ответ по проекту", + } + return labels.get((intent, subintent), str(subintent).lower()) + + def _subintent_comment(self, intent: str, subintent: str) -> str: + comments = { + (V2Intent.DOC_EXPLAIN, V2Subintent.SUMMARY): ( + "отвечаю на вопрос по существующей документации с опорой на найденные документы" + ), + (V2Intent.DOC_EXPLAIN, V2Subintent.FIND_FILES): ( + "ищу конкретные файлы документации, которые соответствуют запросу" + ), + (V2Intent.DOC_EXPLAIN, V2Subintent.API_EXPOSED): ( + "проверяю, опубликован ли API наружу и через какие контракты или endpoint'ы он доступен" + ), + (V2Intent.DOC_EXPLAIN, V2Subintent.OPENAPI_GENERATE): ( + "собираю OpenAPI-спецификацию по существующей документации API" + ), + (V2Intent.DOC_UPDATE, V2Subintent.FROM_FEATURE): ( + "обновляю или создаю документацию по системной аналитике" + ), + (V2Intent.GENERAL_QA, V2Subintent.SUMMARY): ( + "даю общий ответ по проекту, когда запрос не сводится к конкретному документу" + ), + } + return comments.get((intent, subintent), "выполняю сценарий, который лучше всего соответствует запросу") diff --git a/src/app/core/agent/processes/v2/rules_doc_update_from_feature_v2/README.md b/src/app/core/agent/processes/v2/rules_doc_update_from_feature_v2/README.md deleted file mode 100644 index 4c291e7..0000000 --- a/src/app/core/agent/processes/v2/rules_doc_update_from_feature_v2/README.md +++ /dev/null @@ -1,9 +0,0 @@ -# DOC_UPDATE/FROM_FEATURE v2 Rules - -Этот каталог содержит общие rules для всех шагов и подпроцессов workflow `doc_update_from_feature_v2`. - -- `attribute_resolution.md` — правила определения type/id/application/platform. -- `path_resolution.md` — правила резолва путей документации. -- `section_frontmatter.md` — инструкции для frontmatter. -- `section_summary.md` — инструкции для summary. -- `section_details.md` — инструкции для details. diff --git a/src/app/core/agent/processes/v2/rules_doc_update_from_feature_v2/attribute_resolution.md b/src/app/core/agent/processes/v2/rules_doc_update_from_feature_v2/attribute_resolution.md deleted file mode 100644 index e9fe656..0000000 --- a/src/app/core/agent/processes/v2/rules_doc_update_from_feature_v2/attribute_resolution.md +++ /dev/null @@ -1,5 +0,0 @@ -# Attribute Resolution - -1. Приоритет: теги из requirement > metadata документа > LLM fallback. -2. Обязательные атрибуты: `type`, `id`, `application`, `platform`. -3. Если атрибут отсутствует, разрешен fallback через LLM. diff --git a/src/app/core/agent/processes/v2/rules_doc_update_from_feature_v2/path_resolution.md b/src/app/core/agent/processes/v2/rules_doc_update_from_feature_v2/path_resolution.md deleted file mode 100644 index 827021c..0000000 --- a/src/app/core/agent/processes/v2/rules_doc_update_from_feature_v2/path_resolution.md +++ /dev/null @@ -1,7 +0,0 @@ -# Path Resolution - -Путь строится как: - -`docs////.md` - -Нормализация сегментов: lowercase + замена недопустимых символов на `-`. diff --git a/src/app/core/agent/processes/v2/rules_doc_update_from_feature_v2/section_details.md b/src/app/core/agent/processes/v2/rules_doc_update_from_feature_v2/section_details.md deleted file mode 100644 index 4a87491..0000000 --- a/src/app/core/agent/processes/v2/rules_doc_update_from_feature_v2/section_details.md +++ /dev/null @@ -1,3 +0,0 @@ -# Details Rules - -Details содержит детализированное описание поведения, ограничений и сценариев. diff --git a/src/app/core/agent/processes/v2/rules_doc_update_from_feature_v2/section_frontmatter.md b/src/app/core/agent/processes/v2/rules_doc_update_from_feature_v2/section_frontmatter.md deleted file mode 100644 index a2d1a35..0000000 --- a/src/app/core/agent/processes/v2/rules_doc_update_from_feature_v2/section_frontmatter.md +++ /dev/null @@ -1,4 +0,0 @@ -# Frontmatter Rules - -1. Frontmatter всегда в блоке `---`. -2. Должны быть поля id/title/type/application/platform. diff --git a/src/app/core/agent/processes/v2/rules_doc_update_from_feature_v2/section_summary.md b/src/app/core/agent/processes/v2/rules_doc_update_from_feature_v2/section_summary.md deleted file mode 100644 index 7c5e57d..0000000 --- a/src/app/core/agent/processes/v2/rules_doc_update_from_feature_v2/section_summary.md +++ /dev/null @@ -1,3 +0,0 @@ -# Summary Rules - -Summary содержит краткую цель страницы и основные изменения. diff --git a/src/app/core/agent/processes/v2/v2_process.py b/src/app/core/agent/processes/v2/v2_process.py index dc36216..169fd64 100644 --- a/src/app/core/agent/processes/v2/v2_process.py +++ b/src/app/core/agent/processes/v2/v2_process.py @@ -7,12 +7,17 @@ from typing import Any from app.core.agent.processes.base import AgentProcess, ProcessResult from app.core.agent.processes.v2.intent_router import V2IntentRouter +from app.core.agent.processes.v2.router_status import V2RouterStatusBuilder from app.core.agent.processes.v2.workflows.doc_explain_api_exposed.workflow_runtime.context import ( DocExplainApiExposedContext, ) from app.core.agent.processes.v2.workflows.doc_explain_api_exposed.graph import DocExplainApiExposedGraph from app.core.agent.processes.v2.workflows.doc_explain_find_files.workflow_runtime.context import DocExplainFindFilesContext from app.core.agent.processes.v2.workflows.doc_explain_find_files.graph import DocExplainFindFilesGraph +from app.core.agent.processes.v2.workflows.doc_generate_openapi.workflow_runtime.context import ( + DocGenerateOpenApiContext, +) +from app.core.agent.processes.v2.workflows.doc_generate_openapi.graph import DocGenerateOpenApiGraph from app.core.agent.processes.v2.workflows.doc_explain_summary.workflow_runtime.context import DocExplainSummaryContext from app.core.agent.processes.v2.workflows.doc_explain_summary.graph import DocExplainSummaryGraph from app.core.agent.processes.v2.workflows.doc_update_from_feature.graph import DocUpdateFromFeatureGraph @@ -52,6 +57,7 @@ class V2Process(AgentProcess): doc_update_workflow_version: str = "v2", ) -> None: self._router = router or V2IntentRouter() + self._router_status_builder = V2RouterStatusBuilder() gate = evidence_gate or DocsEvidenceGate() self._docs_summary_prompt_name = docs_summary_prompt_name self._general_summary_prompt_name = general_summary_prompt_name @@ -77,6 +83,11 @@ class V2Process(AgentProcess): policy_resolver=policy_resolver, rag_adapter=rag_adapter, ), + (V2Domain.DOCS, V2Intent.DOC_EXPLAIN, V2Subintent.OPENAPI_GENERATE): DocGenerateOpenApiGraph( + llm, + policy_resolver=policy_resolver, + rag_adapter=rag_adapter, + ), (V2Domain.DOCS, V2Intent.DOC_UPDATE, V2Subintent.FROM_FEATURE): doc_update_graph, (V2Domain.GENERAL, V2Intent.GENERAL_QA, V2Subintent.SUMMARY): GeneralQaSummaryGraph( llm, @@ -94,15 +105,19 @@ class V2Process(AgentProcess): context.request.message, rag_session_id=rag_session_id or None, ) + router_status = self._router_status_builder.build(route) + context.request.set_route( + routing_domain=route.routing_domain, + intent=route.intent, + subintent=route.subintent, + subintent_label=str(router_status.payload.get("subintent_label") or route.subintent), + subintent_comment=str(router_status.payload.get("subintent_comment") or ""), + ) await context.publisher.publish_status( context.request.request_id, "process.v2", - f"Запрос принял, переход в {self._subintent_label(route.intent, route.subintent)}.", - { - "routing_domain": route.routing_domain, - "intent": route.intent, - "subintent": route.subintent, - }, + router_status.text, + dict(router_status.payload), ) context.trace.module("process.v2").log( "intent_routed", @@ -163,16 +178,6 @@ class V2Process(AgentProcess): def _log_step(self, context, step: str, payload: dict[str, object]) -> None: context.trace.module("process.v2.pipeline").log(step, payload) - def _subintent_label(self, intent: str, subintent: str) -> str: - labels = { - (V2Intent.DOC_EXPLAIN, V2Subintent.SUMMARY): "объяснение документации", - (V2Intent.DOC_EXPLAIN, V2Subintent.FIND_FILES): "поиск файлов документации", - (V2Intent.DOC_EXPLAIN, V2Subintent.API_EXPOSED): "проверку экспонирования API", - (V2Intent.DOC_UPDATE, V2Subintent.FROM_FEATURE): "обновление документации по аналитике", - (V2Intent.GENERAL_QA, V2Subintent.SUMMARY): "общий ответ по проекту", - } - return labels.get((intent, subintent), str(subintent).lower()) - async def _run_workflow(self, runtime_context, route, rag_session_id: str): workflow = self._workflows.get((route.routing_domain, route.intent, route.subintent)) if workflow is None: @@ -203,6 +208,15 @@ class V2Process(AgentProcess): rag_session_id=rag_session_id, ) ) + if route.subintent == V2Subintent.OPENAPI_GENERATE: + return await workflow.run( + DocGenerateOpenApiContext( + runtime=runtime_context, + route=route, + rag_session_id=rag_session_id, + workflow_llm_enabled=self._workflow_llm_enabled, + ) + ) if route.intent == V2Intent.DOC_UPDATE and route.subintent == V2Subintent.FROM_FEATURE: if self._doc_update_workflow_version == "legacy": return await workflow.run( diff --git a/src/app/core/agent/processes/v2/workflows/doc_generate_openapi/README.md b/src/app/core/agent/processes/v2/workflows/doc_generate_openapi/README.md new file mode 100644 index 0000000..86ad326 --- /dev/null +++ b/src/app/core/agent/processes/v2/workflows/doc_generate_openapi/README.md @@ -0,0 +1,11 @@ +# DOC_EXPLAIN / OPENAPI_GENERATE Workflow + +## LLM Instructions + +Если в workflow будет подключён LLM-шаг для дообогащения OpenAPI-спецификации, придерживайся правил: + +- Поля OpenAPI `summary` и `description` должны быть короткими. +- Каждое поле должно содержать ровно одно предложение. +- В каждом поле должно быть не более 10 слов. +- Не добавляй списки, поясняющие абзацы и несколько предложений в `summary` или `description`. +- Если исходная документация длинная, сожми смысл до короткой формулировки. diff --git a/src/app/core/agent/processes/v2/workflows/doc_generate_openapi/__init__.py b/src/app/core/agent/processes/v2/workflows/doc_generate_openapi/__init__.py new file mode 100644 index 0000000..2aed2ff --- /dev/null +++ b/src/app/core/agent/processes/v2/workflows/doc_generate_openapi/__init__.py @@ -0,0 +1 @@ +"""Workflow for DOC_EXPLAIN / OPENAPI_GENERATE.""" diff --git a/src/app/core/agent/processes/v2/workflows/doc_generate_openapi/graph.py b/src/app/core/agent/processes/v2/workflows/doc_generate_openapi/graph.py new file mode 100644 index 0000000..db4e368 --- /dev/null +++ b/src/app/core/agent/processes/v2/workflows/doc_generate_openapi/graph.py @@ -0,0 +1,73 @@ +from __future__ import annotations + +from app.core.agent.processes.v2.workflows.doc_explain_api_exposed.steps.fetch_rag_rows_step import FetchRagRowsStep +from app.core.agent.processes.v2.workflows.doc_explain_api_exposed.steps.require_rag_session_step import ( + RequireRagSessionStep, +) +from app.core.agent.processes.v2.workflows.doc_explain_api_exposed.steps.resolve_retrieval_plan_step import ( + ResolveRetrievalPlanStep, +) +from app.core.agent.processes.v2.workflows.doc_generate_openapi.steps.build_openapi_operations_step import ( + BuildOpenApiOperationsStep, +) +from app.core.agent.processes.v2.workflows.doc_generate_openapi.steps.enrich_openapi_from_contract_step import ( + EnrichOpenApiFromContractStep, +) +from app.core.agent.processes.v2.workflows.doc_generate_openapi.steps.fetch_contract_sections_step import ( + FetchContractSectionsStep, +) +from app.core.agent.processes.v2.workflows.doc_generate_openapi.steps.finalize_openapi_answer_step import ( + FinalizeOpenApiAnswerStep, +) +from app.core.agent.processes.v2.workflows.doc_generate_openapi.steps.generate_openapi_yaml_step import ( + GenerateOpenApiYamlStep, +) +from app.core.agent.processes.v2.workflows.doc_generate_openapi.steps.openapi_contract_llm_enricher import ( + OpenApiContractLlmEnricher, +) +from app.core.agent.processes.v2.workflows.doc_generate_openapi.steps.openapi_contract_parser import ( + OpenApiContractParser, +) +from app.core.agent.processes.v2.workflows.doc_generate_openapi.steps.retrieval.openapi_operation_collector import ( + OpenApiOperationCollector, +) +from app.core.agent.processes.v2.workflows.doc_generate_openapi.steps.openapi_yaml_renderer import ( + OpenApiYamlRenderer, +) +from app.core.agent.processes.v2.workflows.doc_generate_openapi.workflow_runtime.buffered_graph import ( + DocGenerateOpenApiWorkflowGraph, +) +from app.core.agent.processes.v2.workflows.doc_generate_openapi.workflow_runtime.context import ( + DocGenerateOpenApiContext, +) +from app.core.agent.utils.llm import AgentLlmService +from app.core.agent.utils.process_v2.plan_resolver import RetrievalPlanResolver +from app.core.agent.utils.process_v2.rag_retrieval import V2RagRetrievalAdapter + + +class DocGenerateOpenApiGraph(DocGenerateOpenApiWorkflowGraph[DocGenerateOpenApiContext]): + def __init__( + self, + llm: AgentLlmService, + policy_resolver: RetrievalPlanResolver, + rag_adapter: V2RagRetrievalAdapter, + ) -> None: + super().__init__( + workflow_id="v2.docs_explain.openapi_generate", + source="workflow.v2.openapi_generate", + steps=[ + RequireRagSessionStep( + missing_message="Для генерации OpenAPI нужна активная RAG-сессия проекта с проиндексированной документацией." + ), + ResolveRetrievalPlanStep(policy_resolver), + FetchRagRowsStep(rag_adapter), + BuildOpenApiOperationsStep(OpenApiOperationCollector()), + FetchContractSectionsStep(rag_adapter), + EnrichOpenApiFromContractStep( + parser=OpenApiContractParser(), + llm_enricher=OpenApiContractLlmEnricher(llm), + ), + GenerateOpenApiYamlStep(OpenApiYamlRenderer()), + FinalizeOpenApiAnswerStep(), + ], + ) diff --git a/src/app/core/agent/processes/v2/workflows/doc_generate_openapi/prompts/prompts.yml b/src/app/core/agent/processes/v2/workflows/doc_generate_openapi/prompts/prompts.yml new file mode 100644 index 0000000..34db83b --- /dev/null +++ b/src/app/core/agent/processes/v2/workflows/doc_generate_openapi/prompts/prompts.yml @@ -0,0 +1,35 @@ +namespace: v2_docs_openapi_generate + +prompts: + generate_spec: | + Ты собираешь OpenAPI-спецификацию только по найденной документации проекта. + + Правила для полей OpenAPI: + - `summary` и `description` должны быть короткими. + - Каждое поле должно содержать одно предложение. + - Каждое поле должно содержать не более 10 слов. + - Не пиши в этих полях списки, markdown и длинные пояснения. + + Если исходный текст длинный, сожми его до короткой точной формулировки. + + enrich_operation: | + Ты дообогащаешь одну OpenAPI-операцию только по контракту из документа типа `api_method`. + + Важно: + - Используй только факты из переданного контракта. + - Если поля нет в контракте, оставь его пустым или не заполняй. + - Поля `summary` и `description` должны содержать одно предложение и не более 10 слов. + - Верни только JSON без markdown. + + Верни объект формата: + { + "summary": "string", + "description": "string", + "parameters": [], + "request_schema": {}, + "response_schema": {}, + "responses": { + "200": {"description": "string"} + }, + "security": [] + } diff --git a/src/app/core/agent/processes/v2/workflows/doc_generate_openapi/steps/__init__.py b/src/app/core/agent/processes/v2/workflows/doc_generate_openapi/steps/__init__.py new file mode 100644 index 0000000..3c3b4a4 --- /dev/null +++ b/src/app/core/agent/processes/v2/workflows/doc_generate_openapi/steps/__init__.py @@ -0,0 +1 @@ +"""Steps for DOC_EXPLAIN/OPENAPI_GENERATE workflow.""" diff --git a/src/app/core/agent/processes/v2/workflows/doc_generate_openapi/steps/build_openapi_operations_step.py b/src/app/core/agent/processes/v2/workflows/doc_generate_openapi/steps/build_openapi_operations_step.py new file mode 100644 index 0000000..d87e934 --- /dev/null +++ b/src/app/core/agent/processes/v2/workflows/doc_generate_openapi/steps/build_openapi_operations_step.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +from app.core.agent.processes.v2.workflows.doc_explain_api_exposed.workflow_runtime.pipeline_logging import log_pipeline_step +from app.core.agent.processes.v2.workflows.doc_generate_openapi.steps.retrieval.openapi_operation_collector import ( + OpenApiOperationCollector, +) +from app.core.agent.processes.v2.workflows.doc_generate_openapi.workflow_runtime.context_protocols import ( + OpenApiWorkflowContext, +) +from app.core.agent.utils.workflow import WorkflowStep + + +class BuildOpenApiOperationsStep(WorkflowStep[OpenApiWorkflowContext]): + step_id = "build_openapi_operations" + title = "Сборка API-операций" + + def __init__(self, collector: OpenApiOperationCollector) -> None: + self._collector = collector + + async def run(self, context: OpenApiWorkflowContext) -> OpenApiWorkflowContext: + if context.answer: + return context + context.operations = self._collector.collect(context.retrieved_rows) + payload = { + "mode": "openapi_generate", + "operation_count": len(context.operations), + "path_count": len({item.path for item in context.operations}), + } + context.runtime.trace.module("process.v2.evidence").log("evidence_assembled", payload) + log_pipeline_step(context.runtime, "evidence_assembled", payload) + return context + + def trace_output(self, context: OpenApiWorkflowContext) -> dict[str, object]: + return {"operation_count": len(context.operations)} diff --git a/src/app/core/agent/processes/v2/workflows/doc_generate_openapi/steps/enrich_openapi_from_contract_step.py b/src/app/core/agent/processes/v2/workflows/doc_generate_openapi/steps/enrich_openapi_from_contract_step.py new file mode 100644 index 0000000..f5fab78 --- /dev/null +++ b/src/app/core/agent/processes/v2/workflows/doc_generate_openapi/steps/enrich_openapi_from_contract_step.py @@ -0,0 +1,74 @@ +from __future__ import annotations + +from app.core.agent.processes.v2.workflows.doc_explain_api_exposed.workflow_runtime.pipeline_logging import log_pipeline_step +from app.core.agent.processes.v2.workflows.doc_generate_openapi.steps.openapi_contract_llm_enricher import ( + OpenApiContractLlmEnricher, +) +from app.core.agent.processes.v2.workflows.doc_generate_openapi.steps.openapi_contract_parser import ( + OpenApiContractParser, +) +from app.core.agent.processes.v2.workflows.doc_generate_openapi.workflow_runtime.context import DocGenerateOpenApiContext +from app.core.agent.utils.workflow import WorkflowStep + + +class EnrichOpenApiFromContractStep(WorkflowStep[DocGenerateOpenApiContext]): + step_id = "enrich_openapi_from_contract" + title = "Обогащение OpenAPI по контракту" + + def __init__(self, parser: OpenApiContractParser, llm_enricher: OpenApiContractLlmEnricher) -> None: + self._parser = parser + self._llm_enricher = llm_enricher + + async def run(self, context: DocGenerateOpenApiContext) -> DocGenerateOpenApiContext: + if context.answer or not context.operations: + return context + grouped = _group_contract_rows(context.contract_rows) + enriched: list = [] + request_id = context.runtime.request.request_id + trace = context.runtime.trace.module("workflow.v2.openapi_generate.llm") + for operation in context.operations: + rows = grouped.get(operation.source_path, []) + operation.contract_markdown = _contract_markdown(rows) + operation = self._parser.apply(operation, rows) + if context.workflow_llm_enabled: + operation = await self._llm_enricher.enrich( + operation, + request_id=request_id, + user_query=context.route.user_query, + trace=trace, + ) + enriched.append(operation) + context.operations = enriched + payload = { + "operation_count": len(context.operations), + "contract_row_count": len(context.contract_rows), + "workflow_llm_enabled": context.workflow_llm_enabled, + } + context.runtime.trace.module("process.v2.answer").log("openapi_contract_enriched", payload) + log_pipeline_step(context.runtime, "openapi_contract_enriched", payload) + return context + + def trace_output(self, context: DocGenerateOpenApiContext) -> dict[str, object]: + return {"operation_count": len(context.operations)} + + +def _group_contract_rows(rows: list[dict]) -> dict[str, list[dict]]: + grouped: dict[str, list[dict]] = {} + for row in rows: + path = str(row.get("path") or "").strip() + if not path: + continue + grouped.setdefault(path, []).append(row) + return grouped + + +def _contract_markdown(rows: list[dict]) -> str: + blocks: list[str] = [] + for row in rows: + metadata = dict(row.get("metadata") or {}) + title = str(metadata.get("section_path") or metadata.get("section_title") or "").strip() + content = str(row.get("content") or "").strip() + if not content: + continue + blocks.append(f"## {title}\n{content}") + return "\n\n".join(blocks) diff --git a/src/app/core/agent/processes/v2/workflows/doc_generate_openapi/steps/fetch_contract_sections_step.py b/src/app/core/agent/processes/v2/workflows/doc_generate_openapi/steps/fetch_contract_sections_step.py new file mode 100644 index 0000000..7d1c71a --- /dev/null +++ b/src/app/core/agent/processes/v2/workflows/doc_generate_openapi/steps/fetch_contract_sections_step.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +from app.core.agent.processes.v2.workflows.doc_explain_api_exposed.workflow_runtime.pipeline_logging import log_pipeline_step +from app.core.agent.processes.v2.workflows.doc_generate_openapi.workflow_runtime.context_protocols import ( + OpenApiWorkflowContext, +) +from app.core.agent.utils.process_v2.rag_retrieval import V2RagRetrievalAdapter +from app.core.agent.utils.workflow import WorkflowStep +from app.core.rag.contracts.enums import RagLayer + + +class FetchContractSectionsStep(WorkflowStep[OpenApiWorkflowContext]): + step_id = "fetch_contract_sections" + title = "Получение contract-секций" + + def __init__(self, rag_adapter: V2RagRetrievalAdapter) -> None: + self._rag_adapter = rag_adapter + + async def run(self, context: OpenApiWorkflowContext) -> OpenApiWorkflowContext: + if context.answer or not context.operations: + return context + paths = list(dict.fromkeys(item.source_path for item in context.operations if item.source_path)) + if not paths: + return context + rows = await self._rag_adapter.fetch_exact_paths( + context.rag_session_id, + paths=paths, + layers=[RagLayer.DOCS_DOC_CHUNKS], + ) + context.contract_rows = [row for row in rows if _is_contract_row(row)] + payload = {"contract_row_count": len(context.contract_rows), "path_count": len(paths)} + context.runtime.trace.module("process.v2.evidence").log("contract_rows_loaded", payload) + log_pipeline_step(context.runtime, "contract_rows_loaded", payload) + return context + + def trace_output(self, context: OpenApiWorkflowContext) -> dict[str, object]: + return {"contract_row_count": len(context.contract_rows)} + + +def _is_contract_row(row: dict) -> bool: + metadata = dict(row.get("metadata") or {}) + section_path = str(metadata.get("section_path") or "").lower() + return "контракт" in section_path or "contract" in section_path diff --git a/src/app/core/agent/processes/v2/workflows/doc_generate_openapi/steps/finalize_openapi_answer_step.py b/src/app/core/agent/processes/v2/workflows/doc_generate_openapi/steps/finalize_openapi_answer_step.py new file mode 100644 index 0000000..af42467 --- /dev/null +++ b/src/app/core/agent/processes/v2/workflows/doc_generate_openapi/steps/finalize_openapi_answer_step.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +from app.core.agent.processes.v2.workflows.doc_generate_openapi.workflow_runtime.context import DocGenerateOpenApiContext +from app.core.agent.utils.workflow import WorkflowStep + + +class FinalizeOpenApiAnswerStep(WorkflowStep[DocGenerateOpenApiContext]): + step_id = "finalize_openapi_answer" + title = "Формирование OpenAPI-ответа" + + async def run(self, context: DocGenerateOpenApiContext) -> DocGenerateOpenApiContext: + if context.answer: + return context + if not context.operations: + context.answer = "Не нашёл API-методов в выбранном scope, из которых можно собрать OpenAPI-спецификацию." + context.answer_generated_payload = { + "answer_mode": "insufficient_evidence", + "answer_length": len(context.answer), + } + return context + if not context.openapi_yaml.strip(): + context.answer = "Не удалось собрать YAML OpenAPI по найденным данным." + context.answer_generated_payload = { + "answer_mode": "insufficient_evidence", + "answer_length": len(context.answer), + } + return context + context.answer = f"```yaml\n{context.openapi_yaml.rstrip()}\n```" + context.answer_generated_payload = { + "answer_mode": "openapi_yaml", + "answer_length": len(context.answer), + "operation_count": len(context.operations), + } + return context + + def trace_output(self, context: DocGenerateOpenApiContext) -> dict[str, object]: + return {"answer_length": len(context.answer)} diff --git a/src/app/core/agent/processes/v2/workflows/doc_generate_openapi/steps/generate_openapi_yaml_step.py b/src/app/core/agent/processes/v2/workflows/doc_generate_openapi/steps/generate_openapi_yaml_step.py new file mode 100644 index 0000000..f7a4e75 --- /dev/null +++ b/src/app/core/agent/processes/v2/workflows/doc_generate_openapi/steps/generate_openapi_yaml_step.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +from app.core.agent.processes.v2.workflows.doc_explain_api_exposed.workflow_runtime.pipeline_logging import log_pipeline_step +from app.core.agent.processes.v2.workflows.doc_generate_openapi.steps.openapi_yaml_renderer import OpenApiYamlRenderer +from app.core.agent.processes.v2.workflows.doc_generate_openapi.workflow_runtime.context_protocols import ( + OpenApiWorkflowContext, +) +from app.core.agent.utils.workflow import WorkflowStep + + +class GenerateOpenApiYamlStep(WorkflowStep[OpenApiWorkflowContext]): + step_id = "generate_openapi_yaml" + title = "Сборка YAML-спецификации" + + def __init__(self, renderer: OpenApiYamlRenderer) -> None: + self._renderer = renderer + + async def run(self, context: OpenApiWorkflowContext) -> OpenApiWorkflowContext: + if context.answer or not context.operations: + return context + context.openapi_yaml = self._renderer.render(context.route, context.operations) + payload = { + "mode": "openapi_generate", + "yaml_chars": len(context.openapi_yaml), + "operation_count": len(context.operations), + } + context.runtime.trace.module("process.v2.answer").log("openapi_yaml_generated", payload) + log_pipeline_step(context.runtime, "answer_generated", payload) + return context + + def trace_output(self, context: OpenApiWorkflowContext) -> dict[str, object]: + return {"yaml_chars": len(context.openapi_yaml)} diff --git a/src/app/core/agent/processes/v2/workflows/doc_generate_openapi/steps/openapi_contract_llm_enricher.py b/src/app/core/agent/processes/v2/workflows/doc_generate_openapi/steps/openapi_contract_llm_enricher.py new file mode 100644 index 0000000..f698e8f --- /dev/null +++ b/src/app/core/agent/processes/v2/workflows/doc_generate_openapi/steps/openapi_contract_llm_enricher.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +import asyncio +import json + +from app.core.agent.processes.v2.workflows.doc_generate_openapi.workflow_runtime.models import OpenApiOperation +from app.core.agent.utils.llm import AgentLlmService + + +class OpenApiContractLlmEnricher: + def __init__(self, llm: AgentLlmService, prompt_name: str = "v2_docs_openapi_generate.enrich_operation") -> None: + self._llm = llm + self._prompt_name = prompt_name + + async def enrich(self, operation: OpenApiOperation, *, request_id: str, user_query: str, trace) -> OpenApiOperation: + if not operation.contract_markdown.strip(): + return operation + prompt_input = self._build_prompt_input(operation, user_query) + raw = await asyncio.to_thread( + self._llm.generate, + self._prompt_name, + prompt_input, + log_context=f"agent:{request_id}", + trace=trace, + ) + payload = self._parse_json(raw) + if payload is None: + return operation + return self._merge(operation, payload) + + def _build_prompt_input(self, operation: OpenApiOperation, user_query: str) -> str: + base_payload = { + "method": operation.method, + "path": operation.path, + "summary": operation.summary, + "description": operation.description, + "parameters": operation.parameters, + "request_schema": operation.request_schema, + "response_schema": operation.response_schema, + "responses": operation.responses, + } + return ( + f"Запрос пользователя:\n{user_query}\n\n" + f"Текущая заготовка операции:\n{json.dumps(base_payload, ensure_ascii=False, indent=2)}\n\n" + f"Контракт из документа api_method:\n{operation.contract_markdown}" + ) + + def _parse_json(self, raw: str) -> dict[str, object] | None: + text = str(raw or "").strip() + if text.startswith("```"): + text = text.split("\n", 1)[1].rsplit("```", 1)[0].strip() + try: + value = json.loads(text) + except json.JSONDecodeError: + return None + return value if isinstance(value, dict) else None + + def _merge(self, operation: OpenApiOperation, payload: dict[str, object]) -> OpenApiOperation: + for field in ("summary", "description"): + value = str(payload.get(field) or "").strip() + if value: + setattr(operation, field, value) + if isinstance(payload.get("parameters"), list): + operation.parameters = [dict(item) for item in payload["parameters"] if isinstance(item, dict)] + if isinstance(payload.get("request_schema"), dict): + operation.request_schema = dict(payload["request_schema"]) + if isinstance(payload.get("response_schema"), dict): + operation.response_schema = dict(payload["response_schema"]) + if isinstance(payload.get("responses"), dict): + operation.responses = {str(k): dict(v) for k, v in payload["responses"].items() if isinstance(v, dict)} + if isinstance(payload.get("security"), list): + operation.security = [dict(item) for item in payload["security"] if isinstance(item, dict)] + if str(payload.get("response_status") or "").strip(): + operation.response_status = str(payload["response_status"]).strip() + return operation diff --git a/src/app/core/agent/processes/v2/workflows/doc_generate_openapi/steps/openapi_contract_parser.py b/src/app/core/agent/processes/v2/workflows/doc_generate_openapi/steps/openapi_contract_parser.py new file mode 100644 index 0000000..9fcefb9 --- /dev/null +++ b/src/app/core/agent/processes/v2/workflows/doc_generate_openapi/steps/openapi_contract_parser.py @@ -0,0 +1,175 @@ +from __future__ import annotations + +from app.core.agent.processes.v2.workflows.doc_generate_openapi.workflow_runtime.models import OpenApiOperation + + +class OpenApiContractParser: + _BODY_MARKERS = ("body", "json", "request body", "тело", "body json") + _QUERY_MARKERS = ("query", "querystring", "строка запроса") + _HEADER_MARKERS = ("header", "заголов", "headers") + _COOKIE_MARKERS = ("cookie", "cookies") + + def apply(self, operation: OpenApiOperation, rows: list[dict]) -> OpenApiOperation: + request_properties: dict[str, object] = {} + request_required: list[str] = [] + response_properties: dict[str, object] = {} + response_required: list[str] = [] + responses = dict(operation.responses) + + for row in rows: + metadata = dict(row.get("metadata") or {}) + title = str(metadata.get("section_title") or "").strip().lower() + table = _parse_markdown_table(str(row.get("content") or "")) + if "входные параметры" in title: + for item in table: + self._apply_input_row(operation, item, request_properties, request_required) + elif "выходные параметры" in title: + for item in table: + self._apply_output_row(item, response_properties, response_required) + elif "ошиб" in title: + self._apply_error_rows(responses, table) + elif "метаданные вызова" in title: + self._apply_call_metadata(operation, str(row.get("content") or "")) + + if request_properties: + operation.request_schema = _object_schema(request_properties, request_required) + if response_properties: + operation.response_schema = _object_schema(response_properties, response_required) + if responses: + operation.responses = responses + return operation + + def _apply_input_row( + self, + operation: OpenApiOperation, + item: dict[str, str], + request_properties: dict[str, object], + request_required: list[str], + ) -> None: + name = item.get("параметр") or item.get("field") or item.get("name") or item.get("поле") or "" + place = item.get("где передается") or item.get("where") or item.get("in") or "" + if not name: + return + schema = _schema_from_row(item) + if _is_body_place(place): + request_properties[name] = schema + if _is_required(item): + request_required.append(name) + return + parameter = { + "name": name, + "in": _parameter_place(place, path=operation.path), + "required": _is_required(item) or ("{" + name + "}") in operation.path, + "schema": schema, + } + description = item.get("описание") or item.get("description") or "" + if description: + parameter["description"] = description + operation.parameters.append(parameter) + + def _apply_output_row( + self, + item: dict[str, str], + response_properties: dict[str, object], + response_required: list[str], + ) -> None: + name = item.get("поле") or item.get("field") or item.get("name") or "" + if not name: + return + response_properties[name] = _schema_from_row(item) + if _is_required(item): + response_required.append(name) + + def _apply_error_rows(self, responses: dict[str, dict[str, object]], table: list[dict[str, str]]) -> None: + for item in table: + status = item.get("status") or item.get("http") or item.get("код") or "" + error = item.get("error") or item.get("code") or item.get("ошибка") or "" + if not status: + continue + responses[str(status)] = {"description": error or f"HTTP {status}"} + + def _apply_call_metadata(self, operation: OpenApiOperation, content: str) -> None: + lowered = content.lower() + if "auth:" not in lowered: + return + auth_value = lowered.split("auth:", 1)[1].splitlines()[0].strip() + if auth_value and auth_value not in {"false", "none", "public", "no"}: + operation.security = [{"bearerAuth": []}] + + +def _parse_markdown_table(content: str) -> list[dict[str, str]]: + lines = [line.strip() for line in str(content or "").splitlines() if line.strip()] + if len(lines) < 3 or "|" not in lines[0]: + return [] + headers = [_normalize_header(part) for part in lines[0].strip("|").split("|")] + items: list[dict[str, str]] = [] + for row in lines[2:]: + if "|" not in row: + continue + values = [part.strip() for part in row.strip("|").split("|")] + if len(values) != len(headers): + continue + items.append(dict(zip(headers, values))) + return items + + +def _normalize_header(value: str) -> str: + return str(value or "").strip().lower() + + +def _schema_from_row(item: dict[str, str]) -> dict[str, object]: + schema_type = _map_type(item.get("тип") or item.get("type") or "") + schema: dict[str, object] = {"type": schema_type} + example = item.get("пример") or item.get("example") or "" + description = item.get("описание") or item.get("description") or "" + if description: + schema["description"] = description + if example: + schema["example"] = example + return schema + + +def _map_type(raw: str) -> str: + value = str(raw or "").strip().lower() + if value in {"int", "integer", "number", "long"}: + return "integer" + if value in {"decimal", "float", "double"}: + return "number" + if value in {"bool", "boolean"}: + return "boolean" + if value in {"array", "list"}: + return "array" + if value in {"object", "json"}: + return "object" + return "string" + + +def _object_schema(properties: dict[str, object], required: list[str]) -> dict[str, object]: + payload: dict[str, object] = {"type": "object", "properties": properties} + required_values = [item for item in dict.fromkeys(required) if item in properties] + if required_values: + payload["required"] = required_values + return payload + + +def _is_required(item: dict[str, str]) -> bool: + value = str(item.get("обязательность") or item.get("required") or "").strip().lower() + return value in {"yes", "true", "required", "да", "mandatory", "обязательно"} + + +def _is_body_place(place: str) -> bool: + lowered = str(place or "").strip().lower() + return any(marker in lowered for marker in OpenApiContractParser._BODY_MARKERS) + + +def _parameter_place(place: str, *, path: str) -> str: + lowered = str(place or "").strip().lower() + if any(marker in lowered for marker in OpenApiContractParser._QUERY_MARKERS): + return "query" + if any(marker in lowered for marker in OpenApiContractParser._HEADER_MARKERS): + return "header" + if any(marker in lowered for marker in OpenApiContractParser._COOKIE_MARKERS): + return "cookie" + if any(part.startswith("{") and part.endswith("}") for part in path.split("/")): + return "path" + return "query" diff --git a/src/app/core/agent/processes/v2/workflows/doc_generate_openapi/steps/openapi_yaml_renderer.py b/src/app/core/agent/processes/v2/workflows/doc_generate_openapi/steps/openapi_yaml_renderer.py new file mode 100644 index 0000000..67e59f0 --- /dev/null +++ b/src/app/core/agent/processes/v2/workflows/doc_generate_openapi/steps/openapi_yaml_renderer.py @@ -0,0 +1,129 @@ +from __future__ import annotations + +import yaml + +from app.core.agent.processes.v2.workflows.doc_generate_openapi.workflow_runtime.models import OpenApiOperation +from app.core.agent.utils.process_v2.models import V2RouteResult + + +class OpenApiYamlRenderer: + _METHOD_ORDER = ("get", "post", "put", "patch", "delete", "head", "options") + + def render(self, route: V2RouteResult, operations: list[OpenApiOperation]) -> str: + document = { + "openapi": "3.0.3", + "info": { + "title": self._title(route), + "version": "1.0.0", + }, + "tags": self._tags(operations), + "paths": self._paths(operations), + } + return yaml.safe_dump(document, sort_keys=False, allow_unicode=True) + + def _title(self, route: V2RouteResult) -> str: + if route.anchors.process_subdomain and route.anchors.process_domain: + return f"OpenAPI for {route.anchors.process_domain}/{route.anchors.process_subdomain}" + if route.anchors.process_domain: + return f"OpenAPI for {route.anchors.process_domain}" + return "Project API" + + def _tags(self, operations: list[OpenApiOperation]) -> list[dict[str, str]]: + values = sorted({tag for item in operations for tag in item.tags if tag and tag != "default"}) + return [{"name": tag} for tag in values] + + def _paths(self, operations: list[OpenApiOperation]) -> dict[str, dict[str, object]]: + grouped: dict[str, dict[str, object]] = {} + for operation in sorted(operations, key=self._sort_key): + methods = grouped.setdefault(operation.path, {}) + methods[operation.method.lower()] = self._operation_payload(operation) + return grouped + + def _sort_key(self, operation: OpenApiOperation) -> tuple[str, int]: + try: + method_index = self._METHOD_ORDER.index(operation.method.lower()) + except ValueError: + method_index = len(self._METHOD_ORDER) + return operation.path, method_index + + def _operation_payload(self, operation: OpenApiOperation) -> dict[str, object]: + summary = self._short_sentence(operation.summary, fallback=f"{operation.method} {operation.path}") + description = self._short_sentence(operation.description, fallback=summary) + responses = operation.responses or { + operation.response_status: self._response_payload(summary, operation), + } + responses = self._merge_primary_response_schema(responses, operation) + payload: dict[str, object] = { + "summary": summary, + "description": description, + "operationId": self._operation_id(operation), + "responses": responses, + } + if operation.tags and operation.tags != ["default"]: + payload["tags"] = list(operation.tags) + if operation.parameters: + payload["parameters"] = list(operation.parameters) + if operation.security: + payload["security"] = list(operation.security) + if operation.request_schema is not None: + payload["requestBody"] = { + "required": True, + "content": { + "application/json": { + "schema": operation.request_schema, + } + }, + } + return payload + + def _operation_id(self, operation: OpenApiOperation) -> str: + slug = operation.path.strip("/").replace("/", "_").replace("{", "").replace("}", "") or "root" + return f"{operation.method.lower()}_{slug}" + + def _response_payload(self, summary: str, operation: OpenApiOperation) -> dict[str, object]: + payload: dict[str, object] = {"description": summary or "Successful response"} + if operation.response_schema is not None: + payload["content"] = { + "application/json": { + "schema": operation.response_schema, + } + } + return payload + + def _merge_primary_response_schema( + self, + responses: dict[str, dict[str, object]], + operation: OpenApiOperation, + ) -> dict[str, dict[str, object]]: + if operation.response_schema is None: + return responses + primary = dict(responses.get(operation.response_status) or {}) + if "content" not in primary: + primary["content"] = { + "application/json": { + "schema": operation.response_schema, + } + } + if "description" not in primary: + primary["description"] = operation.summary or f"HTTP {operation.response_status}" + merged = dict(responses) + merged[operation.response_status] = primary + return merged + + def _short_sentence(self, text: str, *, fallback: str) -> str: + raw = str(text or "").strip() or fallback + first_sentence = self._first_sentence(raw) + words = [word for word in first_sentence.split() if word] + shortened = " ".join(words[:10]).strip() + return shortened or fallback + + def _first_sentence(self, text: str) -> str: + value = str(text or "").replace("\n", " ").strip() + if not value: + return "" + for marker in (".", "!", "?", ";"): + idx = value.find(marker) + if idx > 0: + value = value[:idx] + break + return value.strip(" -:,") diff --git a/src/app/core/agent/processes/v2/workflows/doc_generate_openapi/steps/retrieval/__init__.py b/src/app/core/agent/processes/v2/workflows/doc_generate_openapi/steps/retrieval/__init__.py new file mode 100644 index 0000000..b549d8a --- /dev/null +++ b/src/app/core/agent/processes/v2/workflows/doc_generate_openapi/steps/retrieval/__init__.py @@ -0,0 +1 @@ +"""Retrieval helpers for DOC_EXPLAIN/OPENAPI_GENERATE workflow.""" diff --git a/src/app/core/agent/processes/v2/workflows/doc_generate_openapi/steps/retrieval/openapi_operation_collector.py b/src/app/core/agent/processes/v2/workflows/doc_generate_openapi/steps/retrieval/openapi_operation_collector.py new file mode 100644 index 0000000..f957b7a --- /dev/null +++ b/src/app/core/agent/processes/v2/workflows/doc_generate_openapi/steps/retrieval/openapi_operation_collector.py @@ -0,0 +1,123 @@ +from __future__ import annotations + +import re + +from app.core.agent.processes.v2.workflows.doc_generate_openapi.workflow_runtime.models import OpenApiOperation + + +class OpenApiOperationCollector: + _METHODS = ("GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS") + _METHOD_PATH_RE = re.compile(r"\b(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\s+(/[-a-zA-Z0-9_./{}]+)") + + def collect(self, rows: list[dict]) -> list[OpenApiOperation]: + operations: dict[tuple[str, str], OpenApiOperation] = {} + for row in rows: + operation = self._operation_from_row(row) + if operation is None: + continue + key = (operation.method, operation.path) + current = operations.get(key) + operations[key] = operation if current is None else self._merge(current, operation) + return [operations[key] for key in sorted(operations)] + + def _operation_from_row(self, row: dict) -> OpenApiOperation | None: + metadata = dict(row.get("metadata") or {}) + method, path = self._resolve_method_path(row, metadata) + if not method or not path: + return None + summary = self._summary(row, metadata, method, path) + description = self._description(row, metadata, summary) + tags = self._tags(metadata) + return OpenApiOperation( + method=method, + path=path, + summary=summary, + description=description, + tags=tags, + request_schema=self._schema_dict(metadata.get("request_schema")), + response_schema=self._schema_dict(metadata.get("response_schema")), + parameters=self._parameters(path, metadata), + response_status=str(metadata.get("response_status") or metadata.get("status_code") or "200"), + source_path=str(row.get("path") or ""), + ) + + def _resolve_method_path(self, row: dict, metadata: dict[str, object]) -> tuple[str, str]: + method = str(metadata.get("http_method") or metadata.get("method") or "").strip().upper() + endpoint_texts = [metadata.get("endpoint"), row.get("title"), row.get("content")] + path = "" + for value in endpoint_texts: + parsed_method, parsed_path = self._extract_method_path(value) + if parsed_method and not method: + method = parsed_method + if parsed_path: + path = parsed_path + break + if not method and path: + method = "GET" + return method, path + + def _extract_method_path(self, value: object) -> tuple[str, str]: + text = str(value or "").strip() + if not text: + return "", "" + match = self._METHOD_PATH_RE.search(text) + if match: + return match.group(1).upper(), match.group(2).strip().lower() + return "", text.lower() if text.startswith("/") else "" + + def _summary(self, row: dict, metadata: dict[str, object], method: str, path: str) -> str: + raw = str(metadata.get("summary_text") or metadata.get("summary") or row.get("title") or "").strip() + cleaned = self._METHOD_PATH_RE.sub("", raw).strip(" -:\n\t") + return cleaned or f"{method} {path}" + + def _description(self, row: dict, metadata: dict[str, object], summary: str) -> str: + raw = str(metadata.get("description") or row.get("content") or "").strip() + return raw or summary + + def _tags(self, metadata: dict[str, object]) -> list[str]: + domain = str(metadata.get("domain") or "").strip() + subdomain = str(metadata.get("subdomain") or "").strip() + if domain and subdomain: + return [f"{domain}/{subdomain}"] + if domain: + return [domain] + return ["default"] + + def _schema_dict(self, value: object) -> dict[str, object] | None: + return dict(value) if isinstance(value, dict) and value else None + + def _parameters(self, path: str, metadata: dict[str, object]) -> list[dict[str, object]]: + explicit = self._explicit_parameters(metadata.get("parameters")) + if explicit: + return explicit + return [self._path_parameter(name) for name in self._path_param_names(path)] + + def _explicit_parameters(self, value: object) -> list[dict[str, object]]: + if not isinstance(value, list): + return [] + return [dict(item) for item in value if isinstance(item, dict)] + + def _path_param_names(self, path: str) -> list[str]: + return re.findall(r"{([^{}]+)}", path) + + def _path_parameter(self, name: str) -> dict[str, object]: + return { + "name": name, + "in": "path", + "required": True, + "schema": {"type": "string"}, + } + + def _merge(self, current: OpenApiOperation, candidate: OpenApiOperation) -> OpenApiOperation: + current.summary = current.summary if len(current.summary) >= len(candidate.summary) else candidate.summary + current.description = ( + current.description if len(current.description) >= len(candidate.description) else candidate.description + ) + current.tags = list(dict.fromkeys([*current.tags, *candidate.tags])) + current.parameters = current.parameters or candidate.parameters + current.request_schema = current.request_schema or candidate.request_schema + current.response_schema = current.response_schema or candidate.response_schema + if current.response_status == "200" and candidate.response_status != "200": + current.response_status = candidate.response_status + current.source_path = current.source_path or candidate.source_path + return current diff --git a/src/app/core/agent/processes/v2/workflows/doc_generate_openapi/steps/retrieval/retrieval_policy.py b/src/app/core/agent/processes/v2/workflows/doc_generate_openapi/steps/retrieval/retrieval_policy.py new file mode 100644 index 0000000..6dcba45 --- /dev/null +++ b/src/app/core/agent/processes/v2/workflows/doc_generate_openapi/steps/retrieval/retrieval_policy.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +from app.core.agent.utils.process_v2.models import V2Intent, V2RouteResult, V2Subintent +from app.core.rag.contracts.enums import RagLayer +from app.core.rag.retrieval.session_retriever import RetrievalPlan + + +class DocGenerateOpenApiRetrievalPolicy: + _LAYERS = [RagLayer.DOCS_DOCUMENT_CATALOG] + _API_PREFIXES = ["docs/api/", "docs/endpoints/", "docs/methods/", "api/", "endpoints/", "methods/"] + + def supports(self, route: V2RouteResult) -> bool: + return route.intent == V2Intent.DOC_EXPLAIN and route.subintent == V2Subintent.OPENAPI_GENERATE + + def resolve(self, route: V2RouteResult) -> RetrievalPlan: + return RetrievalPlan( + profile="openapi_generate", + layers=list(self._LAYERS), + limit=1000, + filters=self._filters(route), + ) + + def _filters(self, route: V2RouteResult) -> dict[str, object]: + filters: dict[str, object] = { + "metadata.type": "api_method", + "prefer_path_prefixes": list(self._API_PREFIXES), + "target_doc_hints": list(route.anchors.target_doc_hints), + "prefer_like_patterns": self._like_patterns(route), + } + query_signals = self._query_signals(route) + if query_signals: + filters["query_signals"] = query_signals + if route.anchors.process_domain: + filters["metadata.domain"] = route.anchors.process_domain + if route.anchors.process_subdomain: + filters["metadata.subdomain"] = route.anchors.process_subdomain + return filters + + def _like_patterns(self, route: V2RouteResult) -> list[str]: + raw = list(route.target_terms) + raw.extend(route.anchors.endpoint_paths) + raw.extend(route.anchors.target_doc_hints) + raw.extend(candidate.value for candidate in route.anchors.candidate_apis) + raw.extend(candidate.value for candidate in route.anchors.candidate_domains) + raw.extend(candidate.value for candidate in route.anchors.candidate_subdomains) + return [f"%{item.lower()}%" for item in _unique(raw)] + + def _query_signals(self, route: V2RouteResult) -> list[str]: + raw = list(route.target_terms) + raw.extend(route.anchors.endpoint_paths) + blocked = { + "openapi", + "swagger", + "yaml", + "json", + "xml", + "api", + "endpoint", + "method", + "эндпоинт", + "метод", + } + return [item for item in _unique(raw) if item.lower() not in blocked] + + +def _unique(items: list[str]) -> list[str]: + out: list[str] = [] + seen: set[str] = set() + for item in items: + value = str(item or "").strip() + if not value or value in seen: + continue + seen.add(value) + out.append(value) + return out diff --git a/src/app/core/agent/processes/v2/workflows/doc_generate_openapi/workflow_runtime/__init__.py b/src/app/core/agent/processes/v2/workflows/doc_generate_openapi/workflow_runtime/__init__.py new file mode 100644 index 0000000..c2256b0 --- /dev/null +++ b/src/app/core/agent/processes/v2/workflows/doc_generate_openapi/workflow_runtime/__init__.py @@ -0,0 +1 @@ +"""Runtime helpers for the DOC_EXPLAIN/OPENAPI_GENERATE workflow.""" diff --git a/src/app/core/agent/processes/v2/workflows/doc_generate_openapi/workflow_runtime/buffered_graph.py b/src/app/core/agent/processes/v2/workflows/doc_generate_openapi/workflow_runtime/buffered_graph.py new file mode 100644 index 0000000..963bf26 --- /dev/null +++ b/src/app/core/agent/processes/v2/workflows/doc_generate_openapi/workflow_runtime/buffered_graph.py @@ -0,0 +1,43 @@ +"""Buffered graph for DOC_EXPLAIN/OPENAPI_GENERATE workflow.""" + +from __future__ import annotations + +from typing import TypeVar + +from app.core.agent.utils.workflow.context import WorkflowContext +from app.core.agent.utils.workflow.graph import WorkflowGraph + +TContext = TypeVar("TContext", bound=WorkflowContext) + + +class DocGenerateOpenApiWorkflowGraph(WorkflowGraph[TContext]): + 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) + await self._publish_step_status(context, step, phase="before", input_context=context) + next_context = await step.run(context) + out = step.trace_output(next_context) + await self._publish_step_status( + next_context, + step, + phase="after", + input_context=context, + output_context=next_context, + ) + trace.log( + "workflow_step_traced", + { + "workflow_id": self._workflow_id, + "step": {"id": step.step_id, "title": step.title}, + "input": inp, + "output": out, + }, + ) + steps_buffer.append({"step_id": step.step_id, "title": step.title, "input": inp, "output": out}) + context = next_context + trace.log("workflow_trace_flushed", {"workflow_id": self._workflow_id, "steps": steps_buffer}) + trace.log("workflow_completed", {"workflow_id": self._workflow_id}) + return context diff --git a/src/app/core/agent/processes/v2/workflows/doc_generate_openapi/workflow_runtime/context.py b/src/app/core/agent/processes/v2/workflows/doc_generate_openapi/workflow_runtime/context.py new file mode 100644 index 0000000..b2da908 --- /dev/null +++ b/src/app/core/agent/processes/v2/workflows/doc_generate_openapi/workflow_runtime/context.py @@ -0,0 +1,23 @@ +from __future__ import annotations + +from dataclasses import dataclass, field + +from app.core.agent.processes.v2.workflows.doc_generate_openapi.workflow_runtime.models import OpenApiOperation +from app.core.agent.runtime.execution_context import RuntimeExecutionContext +from app.core.agent.utils.process_v2.models import V2RouteResult +from app.core.rag.retrieval.session_retriever import RetrievalPlan + + +@dataclass(slots=True) +class DocGenerateOpenApiContext: + runtime: RuntimeExecutionContext + route: V2RouteResult + rag_session_id: str + workflow_llm_enabled: bool = True + retrieval_plan: RetrievalPlan | None = None + retrieved_rows: list[dict] = field(default_factory=list) + contract_rows: list[dict] = field(default_factory=list) + operations: list[OpenApiOperation] = field(default_factory=list) + openapi_yaml: str = "" + answer: str = "" + answer_generated_payload: dict[str, object] | None = None diff --git a/src/app/core/agent/processes/v2/workflows/doc_generate_openapi/workflow_runtime/context_protocols.py b/src/app/core/agent/processes/v2/workflows/doc_generate_openapi/workflow_runtime/context_protocols.py new file mode 100644 index 0000000..f0888db --- /dev/null +++ b/src/app/core/agent/processes/v2/workflows/doc_generate_openapi/workflow_runtime/context_protocols.py @@ -0,0 +1,26 @@ +"""Context protocols for the DOC_EXPLAIN/OPENAPI_GENERATE workflow.""" + +from __future__ import annotations + +from typing import Protocol + +from app.core.agent.processes.v2.workflows.doc_generate_openapi.workflow_runtime.models import OpenApiOperation +from app.core.agent.runtime.execution_context import RuntimeExecutionContext +from app.core.agent.utils.process_v2.models import V2RouteResult +from app.core.rag.retrieval.session_retriever import RetrievalPlan + + +class RetrievalWorkflowContext(Protocol): + runtime: RuntimeExecutionContext + route: V2RouteResult + rag_session_id: str + retrieval_plan: RetrievalPlan | None + retrieved_rows: list[dict] + answer: str + answer_generated_payload: dict[str, object] | None + + +class OpenApiWorkflowContext(RetrievalWorkflowContext, Protocol): + contract_rows: list[dict] + operations: list[OpenApiOperation] + openapi_yaml: str diff --git a/src/app/core/agent/processes/v2/workflows/doc_generate_openapi/workflow_runtime/models.py b/src/app/core/agent/processes/v2/workflows/doc_generate_openapi/workflow_runtime/models.py new file mode 100644 index 0000000..136f561 --- /dev/null +++ b/src/app/core/agent/processes/v2/workflows/doc_generate_openapi/workflow_runtime/models.py @@ -0,0 +1,20 @@ +from __future__ import annotations + +from dataclasses import dataclass, field + + +@dataclass(slots=True) +class OpenApiOperation: + method: str + path: str + summary: str + description: str + tags: list[str] = field(default_factory=list) + request_schema: dict[str, object] | None = None + response_schema: dict[str, object] | None = None + parameters: list[dict[str, object]] = field(default_factory=list) + response_status: str = "200" + responses: dict[str, dict[str, object]] = field(default_factory=dict) + security: list[dict[str, list[str]]] = field(default_factory=list) + contract_markdown: str = "" + source_path: str = "" diff --git a/src/app/core/agent/processes/v2/workflows/doc_update_from_feature_v2/steps/step4_prepare_tasks/services.py b/src/app/core/agent/processes/v2/workflows/doc_update_from_feature_v2/steps/step4_prepare_tasks/services.py index b504dc9..c873612 100644 --- a/src/app/core/agent/processes/v2/workflows/doc_update_from_feature_v2/steps/step4_prepare_tasks/services.py +++ b/src/app/core/agent/processes/v2/workflows/doc_update_from_feature_v2/steps/step4_prepare_tasks/services.py @@ -2,6 +2,7 @@ from __future__ import annotations from typing import TYPE_CHECKING +from app.core.agent.processes.v2.workflows.doc_update_from_feature.steps.docs_state_loader import DocsState from app.core.agent.processes.v2.workflows.doc_update_from_feature_v2.steps.step5_execute_subprocesses.path_resolver import ( DocsPathResolver, ) @@ -50,6 +51,7 @@ class RequirementTaskBuilder: def build(self, context: DocUpdateFromFeatureV2Context) -> list[RequirementTaskContext]: known_paths = {str(row.get("path") or "") for row in context.docs_catalog_rows} + docs_state = DocsState.from_rows(context.docs_catalog_rows) tasks: list[RequirementTaskContext] = [] for index, requirement in enumerate(context.requirements): task = RequirementTaskContext( @@ -60,7 +62,8 @@ class RequirementTaskBuilder: metadata=dict(requirement.metadata), ) self._attribute_resolver.resolve(context, task, rules_text="") - task.path = self._path_resolver.resolve( + self._resolve_target(task, docs_state) + task.path = task.target_path or self._path_resolver.resolve( application=task.application, platform=task.platform, doc_type=task.doc_type, @@ -72,6 +75,18 @@ class RequirementTaskBuilder: tasks.append(task) return tasks + def _resolve_target(self, task: RequirementTaskContext, docs_state: DocsState) -> None: + target_path = str(task.metadata.get("target_path") or task.metadata.get("path") or "").strip() + if target_path: + task.target_path = target_path + target_doc_id = str(task.metadata.get("target_doc_id") or task.metadata.get("target_id") or "").strip() + if not target_doc_id and str(task.metadata.get("action") or "").strip().lower() == "update": + target_doc_id = task.doc_id + if target_doc_id: + task.target_doc_id = target_doc_id + if not task.target_path: + task.target_path = docs_state.by_doc_id.get(target_doc_id, "") + class RequirementTaskOrderer: _PLATFORM_PRIORITY = {"pprb": 0, "ufs": 1, "web": 2} diff --git a/src/app/core/agent/processes/v2/workflows/doc_update_from_feature_v2/steps/step5_execute_subprocesses/change_evaluator.py b/src/app/core/agent/processes/v2/workflows/doc_update_from_feature_v2/steps/step5_execute_subprocesses/change_evaluator.py new file mode 100644 index 0000000..05d7fc0 --- /dev/null +++ b/src/app/core/agent/processes/v2/workflows/doc_update_from_feature_v2/steps/step5_execute_subprocesses/change_evaluator.py @@ -0,0 +1,103 @@ +from __future__ import annotations + +import json +from dataclasses import dataclass, field + +from app.core.agent.processes.v2.workflows.doc_update_from_feature_v2.subprocesses.common.doc_type_policy import ( + DocTypePolicyRegistry, +) +from app.core.agent.processes.v2.workflows.doc_update_from_feature_v2.subprocesses.common.rules_catalog import ( + RulesCatalog, +) +from app.core.agent.processes.v2.workflows.doc_update_from_feature_v2.workflow_runtime.models import ( + RequirementTaskContext, + RuleDocument, +) +from app.core.agent.utils.llm import AgentLlmService + + +@dataclass(slots=True) +class ChangeAssessment: + decision: str + reason: str = "" + target_sections: list[str] = field(default_factory=list) + missing_points: list[str] = field(default_factory=list) + + def needs_update(self) -> bool: + return self.decision == "needs_update" + + +class DocumentChangeNeedEvaluator: + def __init__(self, llm: AgentLlmService, policies: DocTypePolicyRegistry | None = None) -> None: + self._llm = llm + self._policies = policies or DocTypePolicyRegistry() + + def assess( + self, + task: RequirementTaskContext, + *, + current_content: str, + rule_documents: list[RuleDocument], + shared_context: dict[str, object], + ) -> ChangeAssessment: + catalog = RulesCatalog.from_documents(rule_documents) + policy = self._policies.load(catalog, task.doc_type) + template = policy.template + payload = { + "task": { + "section_key": task.section_key, + "heading": task.heading, + "doc_type": task.doc_type, + "doc_id": task.doc_id, + "path": task.path, + "domain": task.domain, + "sub_domain": task.subdomain, + "application": task.application, + "platform": task.platform, + "requirement_text": task.body, + }, + "current_document": current_content, + "template": { + "doc_type": template.doc_type, + "source_name": template.source_name, + "template_text": template.template_text, + "sections": [ + {"title": item.title, "level": item.level, "rule_path": item.rule_path} + for item in template.sections + ], + }, + "doc_type_policy": policy.prompt_payload(), + "global_rules": catalog.global_text(), + "shared_context": shared_context, + } + raw = self._llm.generate( + "v2_docs_update_v2.assess_existing_document_change", + json.dumps(payload, ensure_ascii=False, indent=2), + log_context="workflow.v2.docs_update.from_feature_v2.assess_change", + ) + return self._parse(raw) + + def _parse(self, raw: str) -> ChangeAssessment: + try: + parsed = json.loads(str(raw or "").strip()) + except Exception: + return ChangeAssessment(decision="needs_update", reason="LLM assessment parse failed.") + if not isinstance(parsed, dict): + return ChangeAssessment(decision="needs_update", reason="LLM assessment returned non-object payload.") + decision = str(parsed.get("decision") or "").strip().lower() + if decision not in {"up_to_date", "needs_update"}: + return ChangeAssessment(decision="needs_update", reason="LLM assessment returned unknown decision.") + reason = str(parsed.get("reason") or "").strip() + target_sections = self._string_list(parsed.get("target_sections")) + missing_points = self._string_list(parsed.get("missing_points")) + return ChangeAssessment( + decision=decision, + reason=reason, + target_sections=target_sections, + missing_points=missing_points, + ) + + def _string_list(self, value: object) -> list[str]: + if not isinstance(value, list): + return [] + return [str(item).strip() for item in value if str(item).strip()] diff --git a/src/app/core/agent/processes/v2/workflows/doc_update_from_feature_v2/steps/step5_execute_subprocesses/classifier.py b/src/app/core/agent/processes/v2/workflows/doc_update_from_feature_v2/steps/step5_execute_subprocesses/classifier.py index 87cfd24..47bfc7e 100644 --- a/src/app/core/agent/processes/v2/workflows/doc_update_from_feature_v2/steps/step5_execute_subprocesses/classifier.py +++ b/src/app/core/agent/processes/v2/workflows/doc_update_from_feature_v2/steps/step5_execute_subprocesses/classifier.py @@ -11,7 +11,25 @@ class DeleteIntentHeuristic: re.compile(r"\bудал(?:ить|ение|яем|яется|ен[аоы]?|ить страницу|ить документ)\b"), re.compile(r"\bдекомисс"), ) + _NEGATIVE_MARKERS = ( + re.compile(r"\bотсутств"), + re.compile(r"\bнедоступ"), + re.compile(r"\bdisabled\b"), + re.compile(r"\bне\s+(?:нужно|требуется|должен|должна|должны|предусмотрен[аоы]?|отображается)\b"), + re.compile(r"\bбез возможности\b"), + ) + _INLINE_CODE_RE = re.compile(r"`[^`]*`") + _FRAGMENT_SPLIT_RE = re.compile(r"[\n\r]+|(?<=[.!?])\s+") def is_delete(self, text: str) -> bool: - lowered = (text or "").lower() - return any(pattern.search(lowered) for pattern in self._PATTERNS) + normalized = self._INLINE_CODE_RE.sub(" ", text or "").lower() + for fragment in self._iter_fragments(normalized): + if not any(pattern.search(fragment) for pattern in self._PATTERNS): + continue + if any(marker.search(fragment) for marker in self._NEGATIVE_MARKERS): + continue + return True + return False + + def _iter_fragments(self, text: str) -> list[str]: + return [fragment.strip() for fragment in self._FRAGMENT_SPLIT_RE.split(text) if fragment.strip()] diff --git a/src/app/core/agent/processes/v2/workflows/doc_update_from_feature_v2/steps/step5_execute_subprocesses/step.py b/src/app/core/agent/processes/v2/workflows/doc_update_from_feature_v2/steps/step5_execute_subprocesses/step.py index 4d42a01..48cb93c 100644 --- a/src/app/core/agent/processes/v2/workflows/doc_update_from_feature_v2/steps/step5_execute_subprocesses/step.py +++ b/src/app/core/agent/processes/v2/workflows/doc_update_from_feature_v2/steps/step5_execute_subprocesses/step.py @@ -28,7 +28,14 @@ class ExecuteRequirementSubprocessesStep(WorkflowStep[DocUpdateFromFeatureV2Cont def _execute_all(self, context: DocUpdateFromFeatureV2Context) -> None: for task in context.requirement_tasks: - self._change_executor.execute(context, task) + try: + self._change_executor.execute(context, task) + except Exception as exc: + task.issues.append(str(exc)) + context.issues.append( + f"Task failed for {task.section_key or task.doc_id or task.heading}: " + f"{task.path or task.target_path or ''}: {exc}" + ) def trace_input(self, context: DocUpdateFromFeatureV2Context) -> dict[str, Any]: return { diff --git a/src/app/core/agent/processes/v2/workflows/doc_update_from_feature_v2/subprocesses/common/doc_type_policy.py b/src/app/core/agent/processes/v2/workflows/doc_update_from_feature_v2/subprocesses/common/doc_type_policy.py new file mode 100644 index 0000000..25e3184 --- /dev/null +++ b/src/app/core/agent/processes/v2/workflows/doc_update_from_feature_v2/subprocesses/common/doc_type_policy.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +from dataclasses import dataclass +from dataclasses import field + +from app.core.agent.processes.v2.workflows.doc_update_from_feature_v2.subprocesses.common.rules_catalog import ( + RulesCatalog, +) +from app.core.agent.processes.v2.workflows.doc_update_from_feature_v2.subprocesses.common.template_models import ( + TemplateSpec, +) +from app.core.agent.processes.v2.workflows.doc_update_from_feature_v2.subprocesses.common.template_registry import ( + TemplateRegistry, +) + + +@dataclass(slots=True) +class RuleSnippet: + path: str + text: str + + +@dataclass(slots=True) +class DocTypePolicy: + template: TemplateSpec + required_common_elements: list[RuleSnippet] = field(default_factory=list) + + def prompt_payload(self) -> dict[str, object]: + return { + "doc_type": self.template.doc_type, + "template": { + "doc_type": self.template.doc_type, + "source_name": self.template.source_name, + "template_text": self.template.template_text, + "title_level": self.template.title_level, + "required_common_elements": list(self.template.required_common_elements), + "special_rules": list(self.template.special_rules), + "sections": [ + {"title": item.title, "level": item.level, "rule_path": item.rule_path} + for item in self.template.sections + ], + }, + "required_common_element_rules": [ + {"path": item.path, "text": item.text} + for item in self.required_common_elements + ], + } + + +class DocTypePolicyRegistry: + def __init__(self, templates: TemplateRegistry | None = None) -> None: + self._templates = templates or TemplateRegistry() + + def load(self, catalog: RulesCatalog, doc_type: str) -> DocTypePolicy: + template = self._templates.load(catalog, doc_type) + required_common_elements = [ + RuleSnippet(path=path, text=catalog.rule_text(path)) + for path in template.required_common_elements + ] + return DocTypePolicy(template=template, required_common_elements=required_common_elements) diff --git a/src/app/core/agent/processes/v2/workflows/doc_update_from_feature_v2/subprocesses/create_doc/steps/step1_generate_frontmatter/prompts/prompts.yml b/src/app/core/agent/processes/v2/workflows/doc_update_from_feature_v2/subprocesses/create_doc/steps/step1_generate_frontmatter/prompts/prompts.yml index fcd3109..6e7c2e5 100644 --- a/src/app/core/agent/processes/v2/workflows/doc_update_from_feature_v2/subprocesses/create_doc/steps/step1_generate_frontmatter/prompts/prompts.yml +++ b/src/app/core/agent/processes/v2/workflows/doc_update_from_feature_v2/subprocesses/create_doc/steps/step1_generate_frontmatter/prompts/prompts.yml @@ -6,3 +6,5 @@ prompts: Не добавляй другие секции и не добавляй пояснений. Используй requirement_text, template, global_rules, frontmatter_rules и shared_context. Обязательно заполни id, title, doc_type, status, domain, sub_domain, related_docs. + Для `doc_type: api_method` обязательно заполни поле `endpoint`. + Если endpoint встречается в аналитике, use case, интеграционной схеме, контракте или shared_context, перенеси его во frontmatter. diff --git a/src/app/core/agent/processes/v2/workflows/doc_update_from_feature_v2/subprocesses/create_doc/steps/step2_generate_sections/prompts/prompts.yml b/src/app/core/agent/processes/v2/workflows/doc_update_from_feature_v2/subprocesses/create_doc/steps/step2_generate_sections/prompts/prompts.yml index 1f354e6..18b56dc 100644 --- a/src/app/core/agent/processes/v2/workflows/doc_update_from_feature_v2/subprocesses/create_doc/steps/step2_generate_sections/prompts/prompts.yml +++ b/src/app/core/agent/processes/v2/workflows/doc_update_from_feature_v2/subprocesses/create_doc/steps/step2_generate_sections/prompts/prompts.yml @@ -9,3 +9,6 @@ prompts: Если для секции есть структура в rule_text, заполни ее полностью, без заглушек и без фраз вида "описано отдельно". Если source_fragment содержит "Не выявлены", это не запрещает детализировать интеграционный FR из use case, contract и shared_context. Не ссылайся на rule-файлы в тексте документа. + Никогда не повторяй заголовок target_section внутри ответа. + Не выводи заголовки того же уровня или выше, чем уровень target_section. + Для `### Контракт` запрещено выводить `## Контракт` и `### Контракт`; допускаются только подзаголовки глубже, например `#### Запрос` и `#### Ответ`. diff --git a/src/app/core/agent/processes/v2/workflows/doc_update_from_feature_v2/subprocesses/edit_doc/steps/step2_generate_sections/prompts/prompts.yml b/src/app/core/agent/processes/v2/workflows/doc_update_from_feature_v2/subprocesses/edit_doc/steps/step2_generate_sections/prompts/prompts.yml index 015840b..714431d 100644 --- a/src/app/core/agent/processes/v2/workflows/doc_update_from_feature_v2/subprocesses/edit_doc/steps/step2_generate_sections/prompts/prompts.yml +++ b/src/app/core/agent/processes/v2/workflows/doc_update_from_feature_v2/subprocesses/edit_doc/steps/step2_generate_sections/prompts/prompts.yml @@ -7,3 +7,6 @@ prompts: Строго следуй template.template_text, target_section.rule_text и global_rules. Используй source_fragment как приоритетный источник фактов и сохраняй согласованность с current_content. Не оставляй заглушки, само-ссылки и фразы вида "описано отдельно". + Никогда не повторяй заголовок target_section внутри ответа. + Не выводи заголовки того же уровня или выше, чем уровень target_section. + Для `### Контракт` запрещено выводить `## Контракт` и `### Контракт`; допускаются только подзаголовки глубже, например `#### Запрос` и `#### Ответ`. diff --git a/src/app/core/agent/processes/v2/workflows/doc_update_from_feature_v2/workflow_runtime/models.py b/src/app/core/agent/processes/v2/workflows/doc_update_from_feature_v2/workflow_runtime/models.py index 2a8abb5..1bcf16e 100644 --- a/src/app/core/agent/processes/v2/workflows/doc_update_from_feature_v2/workflow_runtime/models.py +++ b/src/app/core/agent/processes/v2/workflows/doc_update_from_feature_v2/workflow_runtime/models.py @@ -42,6 +42,8 @@ class RequirementTaskContext: subdomain: str = "" action: DocAction = DocAction.CREATE path: str = "" + target_doc_id: str = "" + target_path: str = "" issues: list[str] = field(default_factory=list) diff --git a/src/app/core/agent/runtime/agent_runtime.py b/src/app/core/agent/runtime/agent_runtime.py index fc7ed46..22baee3 100644 --- a/src/app/core/agent/runtime/agent_runtime.py +++ b/src/app/core/agent/runtime/agent_runtime.py @@ -11,6 +11,7 @@ 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.core.shared.gigachat.errors import GigaChatError from app.infra.exceptions import AppError from app.infra.observability.module_trace import RequestTraceContext from app.infra.observability.request_trace_logger import RequestTraceLogger @@ -53,7 +54,6 @@ class AgentRuntime: 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 request.changeset = list(result.changeset) @@ -86,17 +86,8 @@ class AgentRuntime: 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._safe_publish_status(request_id, "runtime", "Запрос принят и поставлен в обработку.") - await self._safe_publish_status( - request_id, - "runtime", - f"Запускаю процесс {process_version}.", - {"process_version": process_version}, - ) - async def _publish_result(self, request: AgentRequest) -> None: - await self._safe_publish_status(request.request_id, "runtime", "Обработка запроса завершена.") + route_payload = request.route.as_payload() if request.route is not None else None try: await self._publisher.publish_result( request.request_id, @@ -107,6 +98,7 @@ class AgentRuntime: "answer": request.answer or "", "changeset": [item.model_dump(mode="json") for item in request.changeset], "apply_changeset": request.apply_changeset, + "route": route_payload, }, ) except Exception: @@ -137,7 +129,10 @@ class AgentRuntime: request.request_id, "runtime", request.error.desc, - {"error": request.error.model_dump(mode="json")}, + { + "error": request.error.model_dump(mode="json"), + "route": request.route.as_payload() if request.route is not None else None, + }, ) except Exception: LOGGER.exception("failed to publish error event: request_id=%s", request.request_id) @@ -145,12 +140,39 @@ class AgentRuntime: def _build_error_payload(self, exc: Exception) -> ErrorPayload: if isinstance(exc, AppError): return ErrorPayload(code=exc.code, desc=exc.desc, module=exc.module) + if isinstance(exc, GigaChatError): + return self._build_gigachat_error_payload(exc) return ErrorPayload( code="api_runtime_error", desc="Agent request failed unexpectedly.", module=ModuleName.AGENT, ) + def _build_gigachat_error_payload(self, exc: GigaChatError) -> ErrorPayload: + if exc.status_code == 402: + return ErrorPayload( + code="llm_payment_required", + desc="GigaChat недоступен: провайдер вернул 402 Payment Required. Проверьте баланс или настройки биллинга.", + module=ModuleName.AGENT, + ) + if exc.status_code == 401: + return ErrorPayload( + code="llm_auth_failed", + desc="GigaChat недоступен: ошибка авторизации провайдера. Проверьте токен и настройки доступа.", + module=ModuleName.AGENT, + ) + if exc.status_code == 429: + return ErrorPayload( + code="llm_rate_limited", + desc="GigaChat временно недоступен: превышен лимит запросов. Повторите попытку позже.", + module=ModuleName.AGENT, + ) + return ErrorPayload( + code="llm_provider_error", + desc=str(exc) or "GigaChat request failed.", + module=ModuleName.AGENT, + ) + async def _safe_publish_status(self, request_id: str, source: str, text: str, payload: dict | None = None) -> None: try: await self._publisher.publish_status(request_id, source, text, payload) diff --git a/src/app/core/agent/utils/process_v2/models.py b/src/app/core/agent/utils/process_v2/models.py index 51e9751..fc86cb0 100644 --- a/src/app/core/agent/utils/process_v2/models.py +++ b/src/app/core/agent/utils/process_v2/models.py @@ -20,6 +20,7 @@ class V2Subintent: SUMMARY = "SUMMARY" FIND_FILES = "FIND_FILES" API_EXPOSED = "API_EXPOSED" + OPENAPI_GENERATE = "OPENAPI_GENERATE" FROM_FEATURE = "FROM_FEATURE" diff --git a/src/app/core/agent/utils/process_v2/plan_resolver/policy_resolver.py b/src/app/core/agent/utils/process_v2/plan_resolver/policy_resolver.py index a450c68..01079e4 100644 --- a/src/app/core/agent/utils/process_v2/plan_resolver/policy_resolver.py +++ b/src/app/core/agent/utils/process_v2/plan_resolver/policy_resolver.py @@ -10,6 +10,9 @@ from app.core.agent.processes.v2.workflows.doc_explain_api_exposed.steps.retriev from app.core.agent.processes.v2.workflows.doc_explain_find_files.steps.retrieval.retrieval_policy import ( DocExplainFindFilesRetrievalPolicy, ) +from app.core.agent.processes.v2.workflows.doc_generate_openapi.steps.retrieval.retrieval_policy import ( + DocGenerateOpenApiRetrievalPolicy, +) from app.core.agent.processes.v2.workflows.doc_explain_summary.steps.retrieval.retrieval_policy import ( DocExplainSummaryRetrievalPolicy, ) @@ -38,5 +41,6 @@ class V2RetrievalPolicyResolver: GeneralQaSummaryRetrievalPolicy(), DocExplainFindFilesRetrievalPolicy(), DocExplainApiExposedRetrievalPolicy(), + DocGenerateOpenApiRetrievalPolicy(), DocExplainSummaryRetrievalPolicy(), ) diff --git a/src/app/core/api/controllers/request_controller.py b/src/app/core/api/controllers/request_controller.py index 716b94d..0c52f26 100644 --- a/src/app/core/api/controllers/request_controller.py +++ b/src/app/core/api/controllers/request_controller.py @@ -2,7 +2,12 @@ from __future__ import annotations from app.infra.exceptions import AppError 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, + RouteSelectionResponse, +) from app.schemas.common import ModuleName @@ -23,6 +28,7 @@ class RequestController: item = self._service.get(request_id) if item is None: raise AppError("request_not_found", f"Agent request not found: {request_id}", ModuleName.BACKEND) + route = RouteSelectionResponse(**item.route.as_payload()) if item.route is not None else None return AgentRequestStateResponse( request_id=item.request_id, session_id=item.session_id, @@ -31,6 +37,7 @@ class RequestController: answer=item.answer, changeset=item.changeset, apply_changeset=item.apply_changeset, + route=route, error=item.error, created_at=item.created_at, completed_at=item.completed_at, diff --git a/src/app/core/api/domain/models/agent_request.py b/src/app/core/api/domain/models/agent_request.py index b7d10db..3823728 100644 --- a/src/app/core/api/domain/models/agent_request.py +++ b/src/app/core/api/domain/models/agent_request.py @@ -4,6 +4,7 @@ from dataclasses import dataclass from dataclasses import field from datetime import datetime, timezone +from app.core.api.domain.models.request_route import RequestRoute from app.schemas.common import ErrorPayload from app.schemas.changeset import ChangeItem from app.schemas.orchestration import RequestExecutionStatus @@ -21,6 +22,7 @@ class AgentRequest: answer: str | None = None changeset: list[ChangeItem] = field(default_factory=list) apply_changeset: bool = False + route: RequestRoute | None = None error: ErrorPayload | None = None @classmethod @@ -39,3 +41,20 @@ class AgentRequest: status=RequestExecutionStatus.QUEUED, created_at=datetime.now(timezone.utc), ) + + def set_route( + self, + *, + routing_domain: str, + intent: str, + subintent: str, + subintent_label: str, + subintent_comment: str, + ) -> None: + self.route = RequestRoute( + routing_domain=routing_domain, + intent=intent, + subintent=subintent, + subintent_label=subintent_label, + subintent_comment=subintent_comment, + ) diff --git a/src/app/core/api/domain/models/request_route.py b/src/app/core/api/domain/models/request_route.py new file mode 100644 index 0000000..38b9cd2 --- /dev/null +++ b/src/app/core/api/domain/models/request_route.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True, slots=True) +class RequestRoute: + routing_domain: str + intent: str + subintent: str + subintent_label: str + subintent_comment: str + + def as_payload(self) -> dict[str, str]: + return { + "routing_domain": self.routing_domain, + "intent": self.intent, + "subintent": self.subintent, + "subintent_label": self.subintent_label, + "subintent_comment": self.subintent_comment, + } diff --git a/src/app/core/application.py b/src/app/core/application.py index 70294c0..b7fdfe6 100644 --- a/src/app/core/application.py +++ b/src/app/core/application.py @@ -72,6 +72,8 @@ class ModularApplication: / "agent/processes/v2/workflows/doc_update_from_feature_v2/subprocesses/create_doc/steps/step2_generate_sections/prompts/prompts.yml", Path(__file__).resolve().parent / "agent/processes/v2/workflows/doc_update_from_feature_v2/subprocesses/edit_doc/steps/step2_generate_sections/prompts/prompts.yml", + Path(__file__).resolve().parent + / "agent/processes/v2/workflows/doc_generate_openapi/prompts/prompts.yml", Path(__file__).resolve().parent / "agent/processes/v2/intent_router/routers/prompts.yml", ] ) diff --git a/src/app/core/shared/gigachat/client.py b/src/app/core/shared/gigachat/client.py index f3ea41e..1fad3b9 100644 --- a/src/app/core/shared/gigachat/client.py +++ b/src/app/core/shared/gigachat/client.py @@ -116,7 +116,10 @@ class GigaChatClient: else: if response.status_code < 400: return response - last_error = GigaChatError(f"GigaChat {operation_name} error {response.status_code}: {response.text}") + last_error = GigaChatError( + f"GigaChat {operation_name} error {response.status_code}: {response.text}", + status_code=response.status_code, + ) if not self._is_retryable_status(response.status_code): raise last_error if attempt == retries: diff --git a/src/app/core/shared/gigachat/errors.py b/src/app/core/shared/gigachat/errors.py index 8e90ea6..183311f 100644 --- a/src/app/core/shared/gigachat/errors.py +++ b/src/app/core/shared/gigachat/errors.py @@ -1,2 +1,4 @@ class GigaChatError(OSError): - pass + def __init__(self, message: str, *, status_code: int | None = None) -> None: + super().__init__(message) + self.status_code = status_code diff --git a/src/app/core/shared/gigachat/settings.py b/src/app/core/shared/gigachat/settings.py index 654a9f6..1bd0aae 100644 --- a/src/app/core/shared/gigachat/settings.py +++ b/src/app/core/shared/gigachat/settings.py @@ -20,6 +20,6 @@ class GigaChatSettings: scope=os.getenv("GIGACHAT_SCOPE", "GIGACHAT_API_PERS"), credentials=os.getenv("GIGACHAT_TOKEN", "").strip(), ssl_verify=os.getenv("GIGACHAT_SSL_VERIFY", "true").lower() in {"1", "true", "yes"}, - model=os.getenv("GIGACHAT_MODEL", "GigaChat-Pro"), + model=os.getenv("GIGACHAT_MODEL", "GigaChat-2"), embedding_model=os.getenv("GIGACHAT_EMBEDDING_MODEL", "Embeddings"), ) diff --git a/src/app/core/shared/gigachat/token_provider.py b/src/app/core/shared/gigachat/token_provider.py index 804ca4e..9f73348 100644 --- a/src/app/core/shared/gigachat/token_provider.py +++ b/src/app/core/shared/gigachat/token_provider.py @@ -61,7 +61,10 @@ class GigaChatTokenProvider: raise GigaChatError(f"GigaChat auth request failed: {exc}") from exc if response.status_code >= 400: - raise GigaChatError(f"GigaChat auth error {response.status_code}: {response.text}") + raise GigaChatError( + f"GigaChat auth error {response.status_code}: {response.text}", + status_code=response.status_code, + ) payload = response.json() token = payload.get("access_token") diff --git a/src/app/main.py b/src/app/main.py index b06529a..ae6bf67 100644 --- a/src/app/main.py +++ b/src/app/main.py @@ -1,4 +1,7 @@ import logging +from pathlib import Path + +from dotenv import load_dotenv from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware @@ -13,7 +16,13 @@ def _configure_logging() -> None: configure_logging() +def _load_environment() -> None: + # Load repo-local .env for local uvicorn runs before application settings are instantiated. + load_dotenv(Path(__file__).resolve().parents[2] / ".env") + + _configure_logging() +_load_environment() def create_app() -> FastAPI: diff --git a/src/app/schemas/agent_api.py b/src/app/schemas/agent_api.py index 5244d4c..998a200 100644 --- a/src/app/schemas/agent_api.py +++ b/src/app/schemas/agent_api.py @@ -9,6 +9,14 @@ from app.schemas.changeset import ChangeItem from app.schemas.common import ErrorPayload +class RouteSelectionResponse(BaseModel): + routing_domain: str = Field(min_length=1) + intent: str = Field(min_length=1) + subintent: str = Field(min_length=1) + subintent_label: str = Field(min_length=1) + subintent_comment: str = Field(min_length=1) + + class CreateAgentSessionRequest(BaseModel): project_id: str = Field(min_length=1) files: list[FileSnapshot] @@ -43,6 +51,7 @@ class AgentRequestStateResponse(BaseModel): answer: str | None = None changeset: list[ChangeItem] = Field(default_factory=list) apply_changeset: bool = False + route: RouteSelectionResponse | None = None error: ErrorPayload | None = None created_at: datetime completed_at: datetime | None = None diff --git a/tests/pipeline_setup_v4/cases/suite_01/process_v2_intent_router/router_only_docs_v2_matrix.yaml b/tests/pipeline_setup_v4/cases/suite_01/process_v2_intent_router/router_only_docs_v2_matrix.yaml index 030d93c..40fcadf 100644 --- a/tests/pipeline_setup_v4/cases/suite_01/process_v2_intent_router/router_only_docs_v2_matrix.yaml +++ b/tests/pipeline_setup_v4/cases/suite_01/process_v2_intent_router/router_only_docs_v2_matrix.yaml @@ -162,6 +162,22 @@ cases: intent: GENERAL_QA sub_intent: SUMMARY + - id: v2-router-openapi-01-project + query: "Сгенерируй OpenAPI спецификацию по API проекта" + expected: + router: + domain: DOCS + intent: DOC_EXPLAIN + sub_intent: OPENAPI_GENERATE + + - id: v2-router-openapi-02-domain + query: "Сгенерируй YAML OpenAPI по API домена runtime" + expected: + router: + domain: DOCS + intent: DOC_EXPLAIN + sub_intent: OPENAPI_GENERATE + - id: v2-router-find-files-01-health query: "В каком файле описан `/health`?" expected: diff --git a/tests/pipeline_setup_v4/cases/suite_03/process_v2_retrieval_policy_resolver/cases.yaml b/tests/pipeline_setup_v4/cases/suite_03/process_v2_retrieval_policy_resolver/cases.yaml index cb7a37a..9fe6297 100644 --- a/tests/pipeline_setup_v4/cases/suite_03/process_v2_retrieval_policy_resolver/cases.yaml +++ b/tests/pipeline_setup_v4/cases/suite_03/process_v2_retrieval_policy_resolver/cases.yaml @@ -219,6 +219,29 @@ cases: layers: [D1_DOCUMENT_CATALOG, D0_DOC_CHUNKS] limit: 8 + - id: docs-openapi-domain-scope + route: + routing_domain: DOCS + intent: DOC_EXPLAIN + subintent: OPENAPI_GENERATE + user_query: "Сгенерируй OpenAPI для runtime" + normalized_query: "сгенерируй openapi для runtime" + target_terms: ["runtime"] + anchors: + process_domain: runtime + process_subdomain: health + endpoint_paths: [] + target_doc_hints: [] + expected: + plan: + profile: openapi_generate + layers: [D1_DOCUMENT_CATALOG] + limit: 1000 + filters: + metadata.type: api_method + metadata.domain: runtime + metadata.subdomain: health + - id: find-files-stays-file-lookup-on-mixed-signals route: routing_domain: DOCS diff --git a/tests/pipeline_setup_v4/cases/suite_07/process_v2_full_chain/cases.yaml b/tests/pipeline_setup_v4/cases/suite_07/process_v2_full_chain/cases.yaml index a3b0dd3..af739b8 100644 --- a/tests/pipeline_setup_v4/cases/suite_07/process_v2_full_chain/cases.yaml +++ b/tests/pipeline_setup_v4/cases/suite_07/process_v2_full_chain/cases.yaml @@ -178,3 +178,25 @@ cases: contains_all: - "не найден" - "документ" + + - id: full-t09-openapi-health + query: "Сгенерируй YAML OpenAPI для /health" + expected: + router: + domain: DOCS + intent: DOC_EXPLAIN + sub_intent: OPENAPI_GENERATE + route: + anchors: + endpoint_paths_contains: + - "/health" + retrieval_plan: + profile: openapi_generate + pipeline: + answer_mode: openapi_yaml + llm: + non_empty: true + contains_all: + - "```yaml" + - "openapi:" + - "paths:" diff --git a/tests/unit_tests/agent/test_doc_update_from_feature_v2_happy_path.py b/tests/unit_tests/agent/test_doc_update_from_feature_v2_happy_path.py index 2c30c09..5e13959 100644 --- a/tests/unit_tests/agent/test_doc_update_from_feature_v2_happy_path.py +++ b/tests/unit_tests/agent/test_doc_update_from_feature_v2_happy_path.py @@ -20,6 +20,9 @@ from app.core.agent.processes.v2.workflows.doc_update_from_feature_v2.steps.step from app.core.agent.processes.v2.workflows.doc_update_from_feature_v2.steps.step5_execute_subprocesses.classifier import ( DeleteIntentHeuristic, ) +from app.core.agent.processes.v2.workflows.doc_update_from_feature_v2.steps.step5_execute_subprocesses.step import ( + ExecuteRequirementSubprocessesStep, +) from app.core.agent.processes.v2.workflows.doc_update_from_feature_v2.subprocesses.common.source_sections import ( RequirementSourceSections, ) @@ -37,6 +40,7 @@ from app.core.agent.processes.v2.workflows.doc_update_from_feature_v2.workflow_r ) from app.core.agent.processes.v2.workflows.doc_update_from_feature_v2.workflow_runtime.models import ( AnalyticsMeta, + RequirementTaskContext, RequirementUnit, ) @@ -192,6 +196,83 @@ def test_task_builder_uses_create_when_path_is_new_and_no_delete_markers() -> No assert tasks[0].path == "docs/orders/web/ui_page/orders.ui.list.md" +def test_task_builder_does_not_treat_absent_delete_button_as_doc_delete() -> None: + context = DocUpdateFromFeatureV2Context( + runtime=SimpleNamespace(), + route=SimpleNamespace(), + rag_session_id="", + analytics_meta=AnalyticsMeta( + application="orders_web", + domain="orders", + subdomain="detail_read", + ), + requirements=[ + RequirementUnit( + section_key="6.2", + heading="Модальное окно детальной информации по заказу", + body=( + "### Требования к UI\n" + "- В нижней части модального окна отображается единственная action-кнопка `Закрыть`.\n" + "- Другие action-кнопки (`Редактировать`, `Сохранить`, `Удалить`) отсутствуют." + ), + metadata={ + "id": "orders.ui.detail", + "doc_type": "ui_page", + "application": "orders_web", + "platform": "web", + }, + ) + ], + ) + + tasks = RequirementTaskBuilder(_UnexpectedLlmCall()).build(context) + + assert len(tasks) == 1 + assert tasks[0].action.value == "create" + assert tasks[0].path == "docs/orders/web/ui_page/orders.ui.detail.md" + + +def test_task_builder_updates_existing_doc_by_target_doc_id() -> None: + context = DocUpdateFromFeatureV2Context( + runtime=SimpleNamespace(), + route=SimpleNamespace(), + rag_session_id="sid", + analytics_meta=AnalyticsMeta( + application="orders_web", + domain="orders", + subdomain="detail_read", + ), + docs_catalog_rows=[ + { + "path": "docs/orders/web/ui_page/orders.ui.list.md", + "metadata": {"id": "orders.ui.list", "doc_type": "ui_page"}, + } + ], + requirements=[ + RequirementUnit( + section_key="6.1", + heading="Изменения на форме списка заказов", + body="### Функциональные требования\n- Добавить двойной клик по строке.", + metadata={ + "id": "orders.ui.list", + "doc_type": "ui_page", + "application": "orders_web", + "platform": "web", + "action": "update", + "target_doc_id": "orders.ui.list", + }, + ) + ], + ) + + tasks = RequirementTaskBuilder(_UnexpectedLlmCall()).build(context) + + assert len(tasks) == 1 + assert tasks[0].target_doc_id == "orders.ui.list" + assert tasks[0].path == "docs/orders/web/ui_page/orders.ui.list.md" + assert tasks[0].action.value == "update" + + def test_task_builder_normalizes_data_entity_to_db_table() -> None: context = DocUpdateFromFeatureV2Context( runtime=SimpleNamespace(), @@ -339,6 +420,54 @@ doc_type: db_table assert spec.doc_type == "db_table" +def test_execute_step_continues_after_single_task_failure() -> None: + class _FakeExecutor: + def __init__(self) -> None: + self.calls: list[str] = [] + + def execute(self, context, task): # noqa: ANN001, ANN201 + self.calls.append(task.doc_id) + if task.doc_id == "broken-doc": + raise RuntimeError("boom") + context.changeset.append(SimpleNamespace(op=SimpleNamespace(value="create"), path=task.path, reason=task.heading)) + + step = ExecuteRequirementSubprocessesStep(_UnexpectedLlmCall()) + fake_executor = _FakeExecutor() + step._change_executor = fake_executor + context = DocUpdateFromFeatureV2Context( + runtime=SimpleNamespace(), + route=SimpleNamespace(), + rag_session_id="", + requirement_tasks=[ + RequirementTaskContext( + index=0, + section_key="6.1", + heading="Broken task", + body="", + metadata={}, + doc_id="broken-doc", + path="docs/orders/web/ui_page/broken-doc.md", + ), + RequirementTaskContext( + index=1, + section_key="6.2", + heading="Healthy task", + body="", + metadata={}, + doc_id="healthy-doc", + path="docs/orders/web/ui_page/healthy-doc.md", + ), + ], + ) + + asyncio.run(step.run(context)) + + assert fake_executor.calls == ["broken-doc", "healthy-doc"] + assert len(context.issues) == 1 + assert "broken-doc" in context.issues[0] + assert len(context.changeset) == 1 + + def test_load_rules_step_includes_bundled_db_table_template() -> None: step = LoadRulesStep(rules_root=Path("_process/doc_rules_v3")) context = DocUpdateFromFeatureV2Context( diff --git a/tests/unit_tests/agent/test_v2_intent_router.py b/tests/unit_tests/agent/test_v2_intent_router.py index 7fc3612..98944e6 100644 --- a/tests/unit_tests/agent/test_v2_intent_router.py +++ b/tests/unit_tests/agent/test_v2_intent_router.py @@ -149,6 +149,24 @@ def test_router_routes_explicit_feature_doc_build_to_doc_update_without_llm() -> assert len(llm.calls) == 0 +def test_router_routes_openapi_generation_request_to_new_subintent() -> None: + result = V2IntentRouter(llm=FakeLlm(_llm_response("DOCS", "DOC_EXPLAIN", "SUMMARY"))).route( + "Сгенерируй OpenAPI спецификацию по API проекта" + ) + + assert result.intent == "DOC_EXPLAIN" + assert result.subintent == "OPENAPI_GENERATE" + + +def test_router_keeps_endpoint_anchor_for_openapi_generation() -> None: + result = V2IntentRouter(llm=FakeLlm(_llm_response("DOCS", "DOC_EXPLAIN", "SUMMARY"))).route( + "Сгенерируй YAML OpenAPI для /health" + ) + + assert result.subintent == "OPENAPI_GENERATE" + assert result.anchors.endpoint_paths == ["/health"] + + def test_target_terms_extractor_does_not_treat_absolute_filesystem_path_as_endpoint() -> None: analysis = V2TargetTermsExtractor().extract( "Собери документацию по /Users/alex/Dev_projects_v2/ai driven app process/v2/test_doc/features/order_list.md" diff --git a/tests/unit_tests/agent/test_v2_openapi_generation.py b/tests/unit_tests/agent/test_v2_openapi_generation.py new file mode 100644 index 0000000..b4475b1 --- /dev/null +++ b/tests/unit_tests/agent/test_v2_openapi_generation.py @@ -0,0 +1,181 @@ +from __future__ import annotations + +import yaml + +from app.core.agent.processes.v2.workflows.doc_generate_openapi.steps.openapi_contract_parser import ( + OpenApiContractParser, +) +from app.core.agent.processes.v2.workflows.doc_generate_openapi.steps.openapi_yaml_renderer import OpenApiYamlRenderer +from app.core.agent.processes.v2.workflows.doc_generate_openapi.steps.retrieval.openapi_operation_collector import ( + OpenApiOperationCollector, +) +from app.core.agent.utils.process_v2.models import V2RouteAnchors, V2RouteResult + + +def _route(*, domain: str | None = None, subdomain: str | None = None) -> V2RouteResult: + return V2RouteResult( + routing_domain="DOCS", + intent="DOC_EXPLAIN", + subintent="OPENAPI_GENERATE", + user_query="Сгенерируй openapi", + normalized_query="сгенерируй openapi", + anchors=V2RouteAnchors(process_domain=domain, process_subdomain=subdomain), + ) + + +def test_openapi_collector_extracts_operations_and_tags() -> None: + rows = [ + { + "path": "docs/api/orders.md", + "title": "POST /orders", + "content": "Создание заказа", + "metadata": { + "type": "api_method", + "endpoint": "POST /orders", + "http_method": "post", + "domain": "orders", + "subdomain": "checkout", + "request_schema": {"type": "object", "properties": {"customer_id": {"type": "string"}}}, + "response_schema": {"type": "object", "properties": {"order_id": {"type": "string"}}}, + }, + } + ] + + operation = OpenApiOperationCollector().collect(rows)[0] + + assert operation.method == "POST" + assert operation.path == "/orders" + assert operation.tags == ["orders/checkout"] + assert operation.request_schema is not None + assert operation.response_schema is not None + + +def test_openapi_renderer_builds_yaml_document() -> None: + rows = [ + { + "path": "docs/api/health.md", + "title": "GET /health", + "content": "Проверка здоровья сервиса", + "metadata": { + "type": "api_method", + "endpoint": "GET /health", + "http_method": "get", + "domain": "runtime", + "response_schema": { + "type": "object", + "properties": {"status": {"type": "string"}}, + }, + }, + }, + { + "path": "docs/api/orders.md", + "title": "POST /orders/{order_id}", + "content": "Обновление заказа", + "metadata": { + "type": "api_method", + "endpoint": "POST /orders/{order_id}", + "http_method": "post", + "domain": "orders", + "request_schema": {"type": "object", "properties": {"comment": {"type": "string"}}}, + "response_schema": {"type": "object", "properties": {"ok": {"type": "boolean"}}}, + }, + }, + ] + + operations = OpenApiOperationCollector().collect(rows) + payload = yaml.safe_load(OpenApiYamlRenderer().render(_route(), operations)) + + assert payload["openapi"] == "3.0.3" + assert "/health" in payload["paths"] + assert "get" in payload["paths"]["/health"] + assert payload["paths"]["/orders/{order_id}"]["post"]["parameters"][0]["name"] == "order_id" + assert payload["paths"]["/orders/{order_id}"]["post"]["requestBody"]["content"]["application/json"]["schema"][ + "type" + ] == "object" + + +def test_openapi_renderer_limits_summary_and_description_to_short_sentence() -> None: + rows = [ + { + "path": "docs/api/orders.md", + "title": "POST /orders", + "content": ( + "Создает новый заказ для клиента и валидирует входные данные. " + "После этого публикует событие." + ), + "metadata": { + "type": "api_method", + "endpoint": "POST /orders", + "http_method": "post", + "summary": "Создает новый заказ для клиента и валидирует входные данные перед сохранением", + }, + } + ] + + operations = OpenApiOperationCollector().collect(rows) + payload = yaml.safe_load(OpenApiYamlRenderer().render(_route(), operations)) + method = payload["paths"]["/orders"]["post"] + + assert len(method["summary"].split()) <= 10 + assert len(method["description"].split()) <= 10 + assert "." not in method["summary"] + assert "." not in method["description"] + + +def test_contract_parser_builds_request_response_and_errors_from_contract_sections() -> None: + row = { + "path": "docs/api/invoices.md", + "title": "POST /billing/invoices", + "content": "Создает инвойс", + "metadata": { + "type": "api_method", + "endpoint": "POST /billing/invoices", + "http_method": "post", + }, + } + operation = OpenApiOperationCollector().collect([row])[0] + contract_rows = [ + { + "path": "docs/api/invoices.md", + "content": "- Method: POST\n- Auth: USER\n- Idempotency: false", + "metadata": {"section_path": "Details > Контракт > Метаданные вызова", "section_title": "Метаданные вызова"}, + }, + { + "path": "docs/api/invoices.md", + "content": ( + "| Параметр | Где передается | Тип | Обязательность | Описание |\n" + "| --- | --- | --- | --- | --- |\n" + "| amount | body | decimal | yes | Сумма |\n" + "| trace_id | header | string | no | Идентификатор трассировки |\n" + ), + "metadata": {"section_path": "Details > Контракт > Входные параметры", "section_title": "Входные параметры"}, + }, + { + "path": "docs/api/invoices.md", + "content": ( + "| Поле | Тип | Обязательность | Описание |\n" + "| --- | --- | --- | --- |\n" + "| invoice_id | string | yes | Идентификатор инвойса |\n" + ), + "metadata": {"section_path": "Details > Контракт > Выходные параметры", "section_title": "Выходные параметры"}, + }, + { + "path": "docs/api/invoices.md", + "content": ( + "| status | error |\n" + "| --- | --- |\n" + "| 400 | invalid_amount |\n" + ), + "metadata": {"section_path": "Details > Контракт > Ошибки", "section_title": "Ошибки"}, + }, + ] + + operation = OpenApiContractParser().apply(operation, contract_rows) + payload = yaml.safe_load(OpenApiYamlRenderer().render(_route(), [operation])) + method = payload["paths"]["/billing/invoices"]["post"] + + assert method["requestBody"]["content"]["application/json"]["schema"]["properties"]["amount"]["type"] == "number" + assert method["parameters"][0]["in"] == "header" + assert method["responses"]["200"]["content"]["application/json"]["schema"]["properties"]["invoice_id"]["type"] == "string" + assert method["responses"]["400"]["description"] == "invalid_amount" + assert method["security"] == [{"bearerAuth": []}] diff --git a/tests/unit_tests/agent/test_v2_process.py b/tests/unit_tests/agent/test_v2_process.py index f466966..d431b9d 100644 --- a/tests/unit_tests/agent/test_v2_process.py +++ b/tests/unit_tests/agent/test_v2_process.py @@ -266,12 +266,42 @@ def test_v2_process_publishes_router_status_message() -> None: assert runtime.publisher.status_events router_event = runtime.publisher.status_events[0] assert router_event["source"] == "process.v2" - assert router_event["text"] == "Запрос принял, переход в объяснение документации." - assert router_event["payload"] == { - "routing_domain": "DOCS", - "intent": "DOC_EXPLAIN", - "subintent": "SUMMARY", - } + assert ( + router_event["text"] + == "Выбран subintent SUMMARY - отвечаю на вопрос по существующей документации с опорой на найденные документы" + ) + payload = router_event["payload"] + assert payload["routing_domain"] == "DOCS" + assert payload["intent"] == "DOC_EXPLAIN" + assert payload["subintent"] == "SUMMARY" + assert payload["subintent_label"] == "объяснение документации" + assert ( + payload["subintent_comment"] + == "отвечаю на вопрос по существующей документации с опорой на найденные документы" + ) + assert "confidence" in payload + assert "routing_mode" in payload + assert "llm_router_used" in payload + assert "reason_short" in payload + + +def test_v2_process_persists_route_on_request() -> None: + llm = FakeLlm("Краткое объяснение по документации.") + adapter = FakeRagAdapter(summary_rows=_SUMMARY_ROWS, file_rows=[]) + process = _v2_process(llm, adapter) + runtime = _context("Что делает endpoint /health?") + + asyncio.run(process.run(runtime)) + + assert runtime.request.route is not None + assert runtime.request.route.routing_domain == "DOCS" + assert runtime.request.route.intent == "DOC_EXPLAIN" + assert runtime.request.route.subintent == "SUMMARY" + assert runtime.request.route.subintent_label == "объяснение документации" + assert ( + runtime.request.route.subintent_comment + == "отвечаю на вопрос по существующей документации с опорой на найденные документы" + ) def test_v2_process_blocks_generic_docs_answer_without_target_doc() -> None: diff --git a/tests/unit_tests/agent/test_v2_retrieval_policy.py b/tests/unit_tests/agent/test_v2_retrieval_policy.py index 3f43234..73cd9de 100644 --- a/tests/unit_tests/agent/test_v2_retrieval_policy.py +++ b/tests/unit_tests/agent/test_v2_retrieval_policy.py @@ -133,3 +133,22 @@ def test_policy_keeps_general_routes_in_general_profile() -> None: assert plan.profile == "general_qa_grounded_summary" assert plan.layers == ["D1_DOCUMENT_CATALOG", "D0_DOC_CHUNKS"] assert "path_prefixes" not in plan.filters + + +def test_policy_maps_openapi_generation_to_catalog_with_scope_filters() -> None: + plan = V2RetrievalPolicyResolver().resolve( + _route( + subintent=V2Subintent.OPENAPI_GENERATE, + endpoint_paths=["/send"], + process_domain="messaging", + process_subdomain="manual_send", + target_doc_hints=["docs/api/send-message-endpoint.md"], + ) + ) + + assert plan.profile == "openapi_generate" + assert plan.layers == ["D1_DOCUMENT_CATALOG"] + assert plan.filters["metadata.type"] == "api_method" + assert plan.filters["metadata.domain"] == "messaging" + assert plan.filters["metadata.subdomain"] == "manual_send" + assert "%/send%" in plan.filters["prefer_like_patterns"] diff --git a/tests/unit_tests/api/test_request_controller.py b/tests/unit_tests/api/test_request_controller.py new file mode 100644 index 0000000..2810917 --- /dev/null +++ b/tests/unit_tests/api/test_request_controller.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +from types import SimpleNamespace + +from app.core.api.controllers.request_controller import RequestController +from app.core.api.domain.models.agent_request import AgentRequest +from app.schemas.orchestration import RequestExecutionStatus + + +def test_get_request_returns_route_selection() -> None: + request = AgentRequest.create("req-1", "sess-1", "Объясни /health", "v2") + request.status = RequestExecutionStatus.DONE + request.set_route( + routing_domain="DOCS", + intent="DOC_EXPLAIN", + subintent="SUMMARY", + subintent_label="объяснение документации", + subintent_comment="отвечаю на вопрос по существующей документации с опорой на найденные документы", + ) + controller = RequestController(SimpleNamespace(get=lambda _request_id: request)) + + response = controller.get_request("req-1") + + assert response.route is not None + assert response.route.routing_domain == "DOCS" + assert response.route.intent == "DOC_EXPLAIN" + assert response.route.subintent == "SUMMARY" + assert response.route.subintent_label == "объяснение документации" + assert ( + response.route.subintent_comment + == "отвечаю на вопрос по существующей документации с опорой на найденные документы" + )