Фиксирую состояние
This commit is contained in:
@@ -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": []}]
|
||||
@@ -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
|
||||
== "отвечаю на вопрос по существующей документации с опорой на найденные документы"
|
||||
)
|
||||
Reference in New Issue
Block a user