Фиксирую состояние

This commit is contained in:
2026-04-16 11:37:11 +03:00
parent 77851e99a7
commit 2b807623f1
75 changed files with 2065 additions and 79 deletions
@@ -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:
@@ -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
@@ -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:"
@@ -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(
@@ -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"
@@ -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": []}]
+36 -6
View File
@@ -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:
@@ -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"]
@@ -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
== "отвечаю на вопрос по существующей документации с опорой на найденные документы"
)